enhancement: enable icap preview mode and fix client according to the spec (#8062)

* enhancement: enable icap preview mode and use a forked icap client which fixes tcp socket keepalive

* enhancement: make use of human time for the icap timout config option

* enhancement: update icap-client

* enhancement: bump icap client library and deprecate ANTIVIRUS_ICAP_TIMEOUT env

* chore: vendor icap library

* enhancement: set preview size only if greater than 0

* Update services/antivirus/pkg/config/config.go

Co-authored-by: Martin <github@diemattels.at>

* enhancement: add changelog

---------

Co-authored-by: Martin <github@diemattels.at>
This commit is contained in:
Florian Schade
2024-01-05 20:52:26 +01:00
committed by GitHub
parent 1cbf0ba5f3
commit ac8676fff4
29 changed files with 986 additions and 1207 deletions

View File

@@ -0,0 +1,9 @@
Enhancement: Update antivirus service
We update the antivirus icap client library and optimize the antivirus scanning service.
ANTIVIRUS_ICAP_TIMEOUT is now deprecated and ANTIVIRUS_ICAP_SCAN_TIMEOUT should be used instead.
ANTIVIRUS_ICAP_SCAN_TIMEOUT supports human durations like `1s`, `1m`, `1h` and `1d`.
https://github.com/owncloud/ocis/pull/8062
https://github.com/owncloud/ocis/issues/6764

2
go.mod
View File

@@ -353,3 +353,5 @@ require (
replace github.com/go-micro/plugins/v4/store/nats-js-kv => github.com/kobergj/plugins/v4/store/nats-js-kv v0.0.0-20231207143248-4d424e3ae348
replace github.com/studio-b12/gowebdav => github.com/aduffeck/gowebdav v0.0.0-20231215102054-212d4a4374f6
replace github.com/egirna/icap-client => github.com/fschade/icap-client v0.0.0-20240105150744-9c2d8aff3ef2

4
go.sum
View File

@@ -1065,8 +1065,6 @@ github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/egirna/icap v0.0.0-20181108071049-d5ee18bd70bc h1:6IxmRbXV8WXVkcYcTzkU219A3UZeNMX/e6X2sve1wXA=
github.com/egirna/icap v0.0.0-20181108071049-d5ee18bd70bc/go.mod h1:FdVN2WHg7zOHhJ7kZQdDorfFhIfqZaHttjAzDDvAXHE=
github.com/egirna/icap-client v0.1.1 h1:UURZRA7+36bBmMgJZHB+W4d3hfp20pDUA/QL794C0ck=
github.com/egirna/icap-client v0.1.1/go.mod h1:6yHhnak1cKRyhDoRxnzlRKJbOTXPgh9Oe3tOs7Sq3vw=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -1114,6 +1112,8 @@ github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6
github.com/foxcpp/go-mockdns v1.0.0/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtVlwxvzXTQ4=
github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss=
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
github.com/fschade/icap-client v0.0.0-20240105150744-9c2d8aff3ef2 h1:PJERPsceXsS4uTJuDvHy/4rgrZyZWttbKesaacKmXiI=
github.com/fschade/icap-client v0.0.0-20240105150744-9c2d8aff3ef2/go.mod h1:Curjbe9P7SKWAtoXuu/huL8VnqzuBzetEpEPt9TLToE=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=

View File

@@ -2,6 +2,7 @@ package config
import (
"context"
"time"
)
// Config combines all available configuration parts.
@@ -70,9 +71,10 @@ type ClamAV struct {
Socket string `yaml:"socket" env:"ANTIVIRUS_CLAMAV_SOCKET" desc:"The socket clamav is running on. Note the default value is an example which needs adaption according your OS."`
}
// ICAP provides configuration option for ICAP
// ICAP provides configuration options for icap
type ICAP struct {
Timeout int64 `yaml:"timeout" env:"ANTIVIRUS_ICAP_TIMEOUT" desc:"Timeout for the ICAP client."`
URL string `yaml:"url" env:"ANTIVIRUS_ICAP_URL" desc:"URL of the ICAP server."`
Service string `yaml:"service" env:"ANTIVIRUS_ICAP_SERVICE" desc:"The name of the ICAP service."`
DeprecatedTimeout int64 `yaml:"timeout" env:"ANTIVIRUS_ICAP_TIMEOUT" desc:"Timeout for the ICAP client." deprecationVersion:"5.0" removalVersion:"6.0" deprecationInfo:"Changing the envvar type for consistency reasons." deprecationReplacement:"ANTIVIRUS_ICAP_SCAN_TIMEOUT"`
Timeout time.Duration `yaml:"scan_timeout" env:"ANTIVIRUS_ICAP_SCAN_TIMEOUT" desc:"Scan timeout for the ICAP client. Defaults to '5m' (5 minutes). See the Environment Variable Types description for more details."`
URL string `yaml:"url" env:"ANTIVIRUS_ICAP_URL" desc:"URL of the ICAP server."`
Service string `yaml:"service" env:"ANTIVIRUS_ICAP_SERVICE" desc:"The name of the ICAP service."`
}

View File

@@ -1,6 +1,8 @@
package defaults
import (
"time"
"github.com/owncloud/ocis/v2/services/antivirus/pkg/config"
)
@@ -35,7 +37,7 @@ func DefaultConfig() *config.Config {
ICAP: config.ICAP{
URL: "icap://127.0.0.1:1344",
Service: "avscan",
Timeout: 300,
Timeout: 5 * time.Minute,
},
},
}

View File

@@ -2,6 +2,8 @@ package parser
import (
"errors"
"fmt"
"time"
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
"github.com/owncloud/ocis/v2/services/antivirus/pkg/config"
@@ -34,5 +36,10 @@ func ParseConfig(cfg *config.Config) error {
// Validate validates our little config
func Validate(cfg *config.Config) error {
if cfg.Scanner.ICAP.DeprecatedTimeout != 0 {
cfg.Scanner.ICAP.Timeout = time.Duration(cfg.Scanner.ICAP.DeprecatedTimeout) * time.Second
fmt.Println("ANTIVIRUS_ICAP_TIMEOUT is deprecated, use ANTIVIRUS_ICAP_SCAN_TIMEOUT instead")
}
return nil
}

View File

@@ -1,7 +1,6 @@
package scanners
import (
"io"
"time"
"github.com/dutchcoders/go-clamd"
@@ -20,16 +19,16 @@ type ClamAV struct {
}
// Scan to fulfill Scanner interface
func (s ClamAV) Scan(file io.Reader) (ScanResult, error) {
ch, err := s.clamd.ScanStream(file, make(chan bool))
func (s ClamAV) Scan(in Input) (Result, error) {
ch, err := s.clamd.ScanStream(in.Body, make(chan bool))
if err != nil {
return ScanResult{}, err
return Result{}, err
}
r := <-ch
return ScanResult{
return Result{
Infected: r.Status == clamd.RES_FOUND,
Description: r.Description,
Scantime: time.Now(),
ScanTime: time.Now(),
}, nil
}

View File

@@ -1,13 +1,15 @@
package scanners
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"path"
"regexp"
"time"
"github.com/cs3org/reva/v2/pkg/mime"
ic "github.com/egirna/icap-client"
)
@@ -21,49 +23,71 @@ func NewICAP(icapURL string, icapService string, timeout time.Duration) (ICAP, e
endpoint.Scheme = "icap"
endpoint.Path = icapService
return ICAP{
client: &ic.Client{
Timeout: timeout,
},
endpoint: endpoint.String(),
}, nil
client, err := ic.NewClient(
ic.WithICAPConnectionTimeout(timeout),
)
return ICAP{client: client, url: *endpoint}, nil
}
// ICAP is a Scanner talking to an ICAP server
// ICAP is responsible for scanning files using an ICAP server
type ICAP struct {
client *ic.Client
endpoint string
client ic.Client
url url.URL
}
// Scan to fulfill Scanner interface
func (s ICAP) Scan(file io.Reader) (ScanResult, error) {
sr := ScanResult{}
// Scan scans a file using the ICAP server
func (s ICAP) Scan(in Input) (Result, error) {
ctx := context.TODO()
result := Result{}
httpReq, err := http.NewRequest(http.MethodGet, "http://localhost", file)
httpReq, err := http.NewRequest(http.MethodPost, in.Url, in.Body)
if err != nil {
return sr, err
return result, err
}
req, err := ic.NewRequest(ic.MethodREQMOD, s.endpoint, httpReq, nil)
if err != nil {
return sr, err
httpReq.ContentLength = in.Size
if mt := mime.Detect(path.Ext(in.Name) == "", in.Name); mt != "" {
httpReq.Header.Set("Content-Type", mt)
}
resp, err := s.client.Do(req)
optReq, err := ic.NewRequest(ctx, ic.MethodOPTIONS, s.url.String(), nil, nil)
if err != nil {
return sr, err
return result, err
}
// TODO: make header configurable. See oc10 documentation: https://doc.owncloud.com/server/10.12/admin_manual/configuration/server/virus-scanner-support.html
if data, infected := resp.Header["X-Infection-Found"]; infected {
sr.Infected = infected
re := regexp.MustCompile(`Threat=(.*);`)
match := re.FindStringSubmatch(fmt.Sprint(data))
optRes, err := s.client.Do(optReq)
if err != nil {
return result, err
}
if len(match) > 1 {
sr.Description = match[1]
req, err := ic.NewRequest(ctx, ic.MethodREQMOD, s.url.String(), httpReq, nil)
if err != nil {
return result, err
}
if optRes.PreviewBytes > 0 {
err = req.SetPreview(optRes.PreviewBytes)
if err != nil {
return result, err
}
}
return sr, nil
res, err := s.client.Do(req)
if err != nil {
return result, err
}
// TODO: make header configurable. See oc10 documentation: https://doc.owncloud.com/server/10.12/admin_manual/configuration/server/virus-scanner-support.html
if data, infected := res.Header["X-Infection-Found"]; infected {
result.Infected = infected
match := regexp.MustCompile(`Threat=(.*);`).FindStringSubmatch(fmt.Sprint(data))
if len(match) > 1 {
result.Description = match[1]
}
}
return result, nil
}

View File

@@ -1,34 +1,21 @@
package scanners
import (
"fmt"
"io"
"time"
"github.com/owncloud/ocis/v2/services/antivirus/pkg/config"
)
// ScanResult is the common scan result to all scanners
type ScanResult struct {
// The Result is the common scan result to all scanners
type Result struct {
Infected bool
Scantime time.Time
ScanTime time.Time
Description string
}
// Scanner is an abstraction for the actual virus scan
type Scanner interface {
Scan(file io.Reader) (ScanResult, error)
}
// New returns a new scanner from config
func New(c *config.Config) (Scanner, error) {
switch c.Scanner.Type {
default:
return nil, fmt.Errorf("unknown av scanner: '%s'", c.Scanner.Type)
case "clamav":
return NewClamAV(c.Scanner.ClamAV.Socket), nil
case "icap":
return NewICAP(c.Scanner.ICAP.URL, c.Scanner.ICAP.Service, time.Duration(c.Scanner.ICAP.Timeout)*time.Second)
}
// The Input is the common input to all scanners
type Input struct {
Body io.Reader
Size int64
Url string
Name string
}

View File

@@ -16,14 +16,15 @@ import (
"github.com/cs3org/reva/v2/pkg/events"
"github.com/cs3org/reva/v2/pkg/events/stream"
"github.com/cs3org/reva/v2/pkg/rhttp"
"go.opentelemetry.io/otel/trace"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/antivirus/pkg/config"
"github.com/owncloud/ocis/v2/services/antivirus/pkg/scanners"
"go.opentelemetry.io/otel/trace"
)
var (
// ErrFatal is returned when a fatal error occurs and we want to exit.
// ErrFatal is returned when a fatal error occurs, and we want to exit.
ErrFatal = errors.New("fatal error")
// ErrEvent is returned when something went wrong with a specific event.
ErrEvent = errors.New("event error")
@@ -31,18 +32,27 @@ var (
// Scanner is an abstraction for the actual virus scan
type Scanner interface {
Scan(file io.Reader) (scanners.ScanResult, error)
Scan(body scanners.Input) (scanners.Result, error)
}
// NewAntivirus returns a service implementation for Service.
func NewAntivirus(c *config.Config, l log.Logger, tp trace.TracerProvider) (Antivirus, error) {
av := Antivirus{c: c, l: l, tp: tp, client: rhttp.GetHTTPClient(rhttp.Insecure(true))}
var scanner Scanner
var err error
av.s, err = scanners.New(c)
if err != nil {
return av, err
switch c.Scanner.Type {
default:
return Antivirus{}, fmt.Errorf("unknown av scanner: '%s'", c.Scanner.Type)
case "clamav":
scanner = scanners.NewClamAV(c.Scanner.ClamAV.Socket)
case "icap":
scanner, err = scanners.NewICAP(c.Scanner.ICAP.URL, c.Scanner.ICAP.Service, c.Scanner.ICAP.Timeout)
}
if err != nil {
return Antivirus{}, err
}
av := Antivirus{c: c, l: l, tp: tp, s: scanner, client: rhttp.GetHTTPClient(rhttp.Insecure(true))}
switch o := events.PostprocessingOutcome(c.InfectedFileHandling); o {
case events.PPOutcomeContinue, events.PPOutcomeAbort, events.PPOutcomeDelete:
@@ -199,11 +209,11 @@ func (av Antivirus) processEvent(e events.Event, s events.Publisher) error {
}
// process the scan
func (av Antivirus) process(ev events.StartPostprocessingStep) (scanners.ScanResult, error) {
func (av Antivirus) process(ev events.StartPostprocessingStep) (scanners.Result, error) {
if ev.Filesize == 0 || (0 < av.m && av.m < ev.Filesize) {
av.l.Info().Str("uploadid", ev.UploadID).Uint64("limit", av.m).Uint64("filesize", ev.Filesize).Msg("Skipping file to be virus scanned because its file size is higher than the defined limit.")
return scanners.ScanResult{
Scantime: time.Now(),
return scanners.Result{
ScanTime: time.Now(),
}, nil
}
@@ -218,12 +228,12 @@ func (av Antivirus) process(ev events.StartPostprocessingStep) (scanners.ScanRes
}
if err != nil {
av.l.Error().Err(err).Str("uploadid", ev.UploadID).Msg("error downloading file")
return scanners.ScanResult{}, err
return scanners.Result{}, err
}
defer rrc.Close()
av.l.Debug().Str("uploadid", ev.UploadID).Msg("Downloaded file successfully, starting virusscan")
res, err := av.s.Scan(rrc)
res, err := av.s.Scan(scanners.Input{Body: rrc, Size: int64(ev.Filesize), Url: ev.URL, Name: ev.Filename})
if err != nil {
av.l.Error().Err(err).Str("uploadid", ev.UploadID).Msg("error scanning file")
}

View File

@@ -6,7 +6,7 @@ env:
- GO111MODULE=ON
go:
- 1.12.x
- 1.21.x
git:
depth: 1

View File

@@ -19,64 +19,53 @@ go get -u github.com/egirna/icap-client
```go
import ic "github.com/egirna/icap-client"
```
**Making a simple RESPMOD call**
```go
req, err := ic.NewRequest(ic.MethodRESPMOD, "icap://<host>:<port>/<path>", httpReq, httpResp)
req, err := ic.NewRequest(context.Background(), MethodRESPMOD, "icap://<host>:<port>/<path>", httpReq, httpResp)
if err != nil {
log.Fatal(err)
}
client := &ic.Client{
Timeout: 5 * time.Second,
}
client, err := ic.NewClient(
ic.WithICAPConnectionTimeout(5 * time.Second),
)
if err != nil {
log.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
if err != nil {
log.Fatal(err)
}
```
**Note**: ``httpReq`` & ``httpResp`` here are ``*http.Response`` & ``*http.Request`` respectively
**Note**: `httpReq` & `httpResp` here are `*http.Response` & `*http.Request` respectively
**Setting preview obtained from OPTIONS call**
```go
optReq, err := ic.NewRequest(ic.MethodOPTIONS, "icap://<host>:<port>/<path>", nil, nil)
req, err := ic.NewRequest(context.Background(), ic.MethodOPTIONS, "icap://<host>:<port>/<path>", nil, nil)
if err != nil {
log.Fatal(err)
return
}
client := &ic.Client{
Timeout: 5 * time.Second,
client, err := ic.NewClient(
ic.WithICAPConnectionTimeout(5 * time.Second),
)
if err != nil {
log.Fatal(err)
}
req.SetPreview(optReq.PreviewBytes)
// do something with req(ICAP *Request)
```
**DEBUG Mode**
Turn on debug mode to inspect detailed & verbose logs to debug your code during development
```go
ic.SetDebugMode(true)
```
By default the icap-client will dump the debugging logs to the standard output(stdout), but you can always add your custom writer
By default, the icap-client will dump the debugging logs to the standard output(stdout),
but you can always add your custom writer
```go
f, _ := os.OpenFile("logs.txt", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
@@ -88,7 +77,7 @@ For more details, see the [docs](https://godoc.org/github.com/egirna/icap-client
### Contributing
This package is still WIP, so totally open to suggestions. See the contributions guide [here](CONTRIBUTING.md).
This package is still WIP, so totally open to suggestions. See the contribution guide [here](CONTRIBUTING.md).
### License

View File

@@ -1,115 +1,89 @@
package icapclient
import (
"bufio"
"bytes"
"errors"
"net/http"
"strconv"
"time"
"strings"
)
// Client represents the icap client who makes the icap server calls
type Client struct {
scktDriver *Driver
Timeout time.Duration
conn Conn
}
// Do makes does everything required to make a call to the ICAP server
func (c *Client) Do(req *Request) (*Response, error) {
if c.scktDriver == nil { // create a new socket driver if one wasn't explicitly created
port, err := strconv.Atoi(req.URL.Port())
if err != nil {
return nil, err
}
c.scktDriver = NewDriver(req.URL.Hostname(), port)
// NewClient creates a new icap client
func NewClient(options ...ConfigOption) (Client, error) {
config := DefaultConfig()
for _, option := range options {
option(&config)
}
c.setDefaultTimeouts() // assinging default timeouts if not set already
if req.ctx != nil { // connect with the given context if context is set
if err := c.scktDriver.ConnectWithContext(*req.ctx); err != nil {
return nil, err
}
} else {
if err := c.scktDriver.Connect(); err != nil {
return nil, err
}
}
defer c.scktDriver.Close() // closing the socket connection
req.SetDefaultRequestHeaders() // assigning default headers if not set already
logDebug("The request headers: ")
dumpDebug(req.Header)
d, err := DumpRequest(req) // getting the byte representation of the ICAP request
conn, err := NewICAPConn(config.ICAPConn)
if err != nil {
return nil, err
return Client{}, err
}
if err := c.scktDriver.Send(d); err != nil { // sending the entire TCP message of the ICAP client to the server connected
return nil, err
}
resp, err := c.scktDriver.Receive() // taking the response
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusContinue && !req.bodyFittedInPreview && req.previewSet { // this block suggests that the ICAP request contained preview body bytes and whole body did not fit in the preview, so the serber responded with 100 Continue and the client is to send the remaining body bytes only
logDebug("Making request for the rest of the remaining body bytes after preview, as received 100 Continue from the server...")
return c.DoRemaining(req)
}
return resp, nil
return Client{
conn: conn,
}, nil
}
// DoRemaining requests an ICAP server with the remaining body bytes which did not fit in the preview in the original request
func (c *Client) DoRemaining(req *Request) (*Response, error) {
// Do is the main function of the client that makes the ICAP request
func (c *Client) Do(req Request) (Response, error) {
var err error
// establish connection to the icap server
err = c.conn.Connect(req.ctx, req.URL.Host)
if err != nil {
return Response{}, err
}
defer func() {
err = errors.Join(err, c.conn.Close())
}()
req.setDefaultRequestHeaders()
// convert the request to icap message
message, err := toICAPRequest(req)
if err != nil {
return Response{}, err
}
// send the icap message to the server
dataRes, err := c.conn.Send(message)
if err != nil {
return Response{}, err
}
resp, err := toClientResponse(bufio.NewReader(strings.NewReader(string(dataRes))))
if err != nil {
return Response{}, err
}
// check if the message is fully done scanning or if it needs to be sent another chunk
done := !(resp.StatusCode == http.StatusContinue && !req.bodyFittedInPreview && req.previewSet)
if done {
return resp, nil
}
// get the remaining body bytes
data := req.remainingPreviewBytes
if !bodyAlreadyChunked(string(data)) { // if the body is not already chunke, then add the basic hexa body bytes notation
ds := string(data)
addHexaBodyByteNotations(&ds)
data = []byte(ds)
if !bodyIsChunked(string(data)) {
data = []byte(addHexBodyByteNotations(string(data)))
}
if err := c.scktDriver.Send(data); err != nil {
return nil, err
// hydrate the icap message with closing doubleCRLF suffix
if !bytes.HasSuffix(data, []byte(doubleCRLF)) {
data = append(data, []byte(crlf)...)
}
resp, err := c.scktDriver.Receive()
// send the remaining body bytes to the server
dataRes, err = c.conn.Send(data)
if err != nil {
return nil, err
return Response{}, err
}
return resp, nil
}
// SetDriver sets a new socket driver with the client
func (c *Client) SetDriver(d *Driver) {
c.scktDriver = d
}
func (c *Client) setDefaultTimeouts() {
if c.Timeout == 0 {
c.Timeout = defaultTimeout
}
if c.scktDriver.DialerTimeout == 0 {
c.scktDriver.DialerTimeout = c.Timeout
}
if c.scktDriver.ReadTimeout == 0 {
c.scktDriver.ReadTimeout = c.Timeout
}
if c.scktDriver.WriteTimeout == 0 {
c.scktDriver.WriteTimeout = c.Timeout
}
return toClientResponse(bufio.NewReader(strings.NewReader(string(dataRes))))
}

33
vendor/github.com/egirna/icap-client/config.go generated vendored Normal file
View File

@@ -0,0 +1,33 @@
package icapclient
import (
"time"
)
// Config is the shared configuration for the icap client library
type Config struct {
ICAPConn ICAPConnConfig
}
// DefaultConfig returns the default configuration for the icap client library
func DefaultConfig() Config {
return Config{
ICAPConn: ICAPConnConfig{
Timeout: 15 * time.Second,
},
}
}
// ConfigOption is a function that configures the icap client
type ConfigOption func(*Config)
// WithICAPConnectionTimeout sets the timeout for the connection to the icap server
func WithICAPConnectionTimeout(timeout time.Duration) ConfigOption {
return func(cfg *Config) {
if timeout <= 0 {
return
}
cfg.ICAPConn.Timeout = timeout
}
}

141
vendor/github.com/egirna/icap-client/conn.go generated vendored Normal file
View File

@@ -0,0 +1,141 @@
package icapclient
import (
"bytes"
"context"
"io"
"net"
"sync"
"syscall"
"time"
)
// ICAPConnConfig is the configuration for the icap connection
type ICAPConnConfig struct {
// Timeout is the maximum amount of time a connection will be kept open
Timeout time.Duration
}
// ICAPConn is the one responsible for driving the transport layer operations. We have to explicitly deal with the connection because the ICAP protocol is aware of keep alive and reconnects.
type ICAPConn struct {
tcp net.Conn
mu sync.Mutex
timeout time.Duration
}
// NewICAPConn creates a new connection to the icap server
func NewICAPConn(conf ICAPConnConfig) (*ICAPConn, error) {
return &ICAPConn{
timeout: conf.Timeout,
}, nil
}
// Connect connects to the icap server
func (c *ICAPConn) Connect(ctx context.Context, address string) error {
dialer := net.Dialer{Timeout: c.timeout}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return err
}
c.tcp = conn
if dialer.Timeout == 0 {
return nil
}
deadline := time.Now().UTC().Add(dialer.Timeout)
if err := c.tcp.SetReadDeadline(deadline); err != nil {
return err
}
if err := c.tcp.SetWriteDeadline(deadline); err != nil {
return err
}
return nil
}
// Send sends a request to the icap server
func (c *ICAPConn) Send(in []byte) ([]byte, error) {
if !c.ok() {
return nil, syscall.EINVAL
}
c.mu.Lock()
defer c.mu.Unlock()
errChan := make(chan error)
resChan := make(chan []byte)
go func() {
// send the message to the server
_, err := c.tcp.Write(in)
if err != nil {
errChan <- err
}
}()
go func() {
data := make([]byte, 0)
for {
tmp := make([]byte, 1096)
// read the response from the server
n, err := c.tcp.Read(tmp)
// something went wrong while reading from the server,
// send the error and exit the routine to prevent
// sending the response to resChan
if err != nil && err != io.EOF {
errChan <- err
return
}
// EOF detected, an entire message is received
if err == io.EOF || n == 0 {
break
}
data = append(data, tmp[:n]...)
// explicitly breaking because the Read blocks for 100 continue message
// fixMe: still unclear why this is happening, find out and fix it
if bytes.Equal(data, []byte(icap100ContinueMsg)) {
break
}
// EOF detected, 0 Double crlf indicates the end of the message
if bytes.HasSuffix(data, []byte("0\r\n\r\n")) {
break
}
// EOF detected, 204 no modifications and Double crlf indicate the end of the message
if bytes.Contains(data, []byte(icap204NoModsMsg)) {
break
}
}
resChan <- data
}()
select {
case err := <-errChan:
return nil, err
case res := <-resChan:
return res, nil
}
}
// Close closes the tcp connection
func (c *ICAPConn) Close() error {
if !c.ok() {
return syscall.EINVAL
}
return c.tcp.Close()
}
func (c *ICAPConn) ok() bool { return c != nil && c.tcp != nil }

View File

@@ -1,57 +0,0 @@
package icapclient
import "time"
// the icap request methods
const (
MethodOPTIONS = "OPTIONS"
MethodRESPMOD = "RESPMOD"
MethodREQMOD = "REQMOD"
)
// the error messages
const (
ErrInvalidScheme = "the url scheme must be icap://"
ErrMethodNotRegistered = "the requested method is not registered"
ErrInvalidHost = "the requested host is invalid"
ErrConnectionNotOpen = "no open connection to close"
ErrInvalidTCPMsg = "invalid tcp message"
ErrREQMODWithNoReq = "http request cannot be nil for method REQMOD"
ErrREQMODWithResp = "http response must be nil for method REQMOD"
ErrRESPMODWithNoResp = "http response cannot be nil for method RESPMOD"
)
// general constants required for the package
const (
SchemeICAP = "icap"
ICAPVersion = "ICAP/1.0"
HTTPVersion = "HTTP/1.1"
SchemeHTTPReq = "http_request"
SchemeHTTPResp = "http_response"
CRLF = "\r\n"
DoubleCRLF = "\r\n\r\n"
LF = "\n"
bodyEndIndicator = CRLF + "0" + CRLF
fullBodyEndIndicatorPreviewMode = "; ieof" + DoubleCRLF
icap100ContinueMsg = "ICAP/1.0 100 Continue" + DoubleCRLF
icap204NoModsMsg = "ICAP/1.0 204 No modifications"
defaultChunkLength = 512
defaultTimeout = 15 * time.Second
)
// Common ICAP headers
const (
PreviewHeader = "Preview"
MethodsHeader = "Methods"
AllowHeader = "Allow"
EncapsulatedHeader = "Encapsulated"
TransferPreviewHeader = "Transfer-Preview"
ServiceHeader = "Service"
ISTagHeader = "ISTag"
OptBodyTypeHeader = "Opt-body-type"
MaxConnectionsHeader = "Max-Connections"
OptionsTTLHeader = "Options-TTL"
ServiceIDHeader = "Service-ID"
TransferIgnoreHeader = "Transfer-Ignore"
TransferCompleteHeader = "Transfer-Complete"
)

View File

@@ -1,55 +0,0 @@
package icapclient
import (
"io"
"log"
"os"
"github.com/davecgh/go-spew/spew"
)
// the debug mode determiner & the writer to the write the debug output to
var (
DEBUG = false
debugWriter io.Writer
logger *log.Logger
)
const (
debugPrefix = "icap-client says: "
)
// SetDebugMode sets the debug mode for the entire package depending on the bool
func SetDebugMode(debug bool) {
DEBUG = debug
if DEBUG { // setting os.Stdout as the default debug writer if debug mode is enabled & also the debug prefix
debugWriter = os.Stdout
logger = log.New(debugWriter, debugPrefix, log.LstdFlags)
}
}
// SetDebugOutput sets writer to write the debug outputs (default: os.Stdout)
func SetDebugOutput(w io.Writer) {
debugWriter = w
logger.SetOutput(debugWriter)
}
func logDebug(a ...interface{}) {
if DEBUG {
logger.Println(a...)
}
}
func logfDebug(s string, a ...interface{}) {
if DEBUG {
logger.Printf(s, a...)
}
}
func dumpDebug(a ...interface{}) {
if DEBUG {
spew.Fdump(debugWriter, a...)
}
}

View File

@@ -1,73 +1,4 @@
//Package icapclient is a client package for the ICAP protocol
// Package icapclient is a client package for the ICAP protocol
//
// Here is a basic example:
// package main
//
// import (
// "fmt"
// "log"
// "net/http"
// "time"
//
// ic "github.com/egirna/icap-client"
// )
//
// func main() {
// /* preparing the http request required for the RESPMOD */
// httpReq, err := http.NewRequest(http.MethodGet, "http://localhost:8000/sample.pdf", nil)
//
// if err != nil {
// log.Fatal(err)
// }
//
// /* making the http client & making the request call to get the response needed for the icap RESPMOD call */
// httpClient := &http.Client{}
//
// httpResp, err := httpClient.Do(httpReq)
//
// if err != nil {
// log.Fatal(err)
// }
//
// /* making a icap request with OPTIONS method */
// optReq, err := ic.NewRequest(ic.MethodOPTIONS, "icap://127.0.0.1:1344/respmod", nil, nil)
//
// if err != nil {
// log.Fatal(err)
// return
// }
//
// /* making the icap client responsible for making the requests */
// client := &ic.Client{
// Timeout: 5 * time.Second,
// }
//
// /* making the OPTIONS request call */
// optResp, err := client.Do(optReq)
//
// if err != nil {
// log.Fatal(err)
// return
// }
//
// /* making a icap request with RESPMOD method */
// req, err := ic.NewRequest(ic.MethodRESPMOD, "icap://127.0.0.1:1344/respmod", httpReq, httpResp)
//
// if err != nil {
// log.Fatal(err)
// }
//
// req.SetPreview(optResp.PreviewBytes) // setting the preview bytes obtained from the OPTIONS call
//
// /* making the RESPMOD request call */
// resp, err := client.Do(req)
//
// if err != nil {
// log.Fatal(err)
// }
//
// fmt.Println(resp.StatusCode)
//
// }
// See https://github.com/egirna/icap-client/examples.
package icapclient

View File

@@ -1,99 +0,0 @@
package icapclient
import (
"bufio"
"context"
"errors"
"fmt"
"strings"
"time"
)
// Driver os the one responsible for driving the transport layer operations
type Driver struct {
Host string
Port int
DialerTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
tcp *transport
}
// NewDriver is the factory function for Driver
func NewDriver(host string, port int) *Driver {
return &Driver{
Host: host,
Port: port,
}
}
// Connect fires up a tcp socket connection with the icap server
func (d *Driver) Connect() error {
d.tcp = &transport{
network: "tcp",
addr: fmt.Sprintf("%s:%d", d.Host, d.Port),
timeout: d.DialerTimeout,
readTimeout: d.ReadTimeout,
writeTimeout: d.WriteTimeout,
}
return d.tcp.dial()
}
// ConnectWithContext connects to the server satisfying the context
func (d *Driver) ConnectWithContext(ctx context.Context) error {
d.tcp = &transport{
network: "tcp",
addr: fmt.Sprintf("%s:%d", d.Host, d.Port),
timeout: d.DialerTimeout,
readTimeout: d.ReadTimeout,
writeTimeout: d.WriteTimeout,
}
return d.tcp.dialWithContext(ctx)
}
// Close closes the socket connection
func (d *Driver) Close() error {
if d.tcp == nil {
return errors.New(ErrConnectionNotOpen)
}
return d.tcp.close()
}
// Send sends a request to the icap server
func (d *Driver) Send(data []byte) error {
_, err := d.tcp.write(data)
if err != nil {
return err
}
return nil
}
// Receive returns the respone from the tcp socket connection
func (d *Driver) Receive() (*Response, error) {
msg, err := d.tcp.read()
if err != nil {
return nil, err
}
resp, err := ReadResponse(bufio.NewReader(strings.NewReader(msg)))
if err != nil {
return nil, err
}
logDebug("The final *ic.Response from tcp messages...")
dumpDebug(resp)
return resp, nil
}

View File

@@ -1,124 +0,0 @@
package icapclient
import (
"bytes"
"io/ioutil"
"net/http"
"os"
"strconv"
)
// SetPreview sets the preview bytes in the icap header
func (r *Request) SetPreview(maxBytes int) error {
bodyBytes := []byte{}
previewBytes := 0
// receiving the body bites to determine the preview bytes depending on the request ICAP method
if r.Method == MethodREQMOD {
if r.HTTPRequest == nil {
return nil
}
if r.HTTPRequest.Body != nil {
var err error
bodyBytes, err = ioutil.ReadAll(r.HTTPRequest.Body)
if err != nil {
return err
}
defer r.HTTPRequest.Body.Close()
}
}
if r.Method == MethodRESPMOD {
if r.HTTPResponse == nil {
return nil
}
if r.HTTPResponse.Body != nil {
var err error
bodyBytes, err = ioutil.ReadAll(r.HTTPResponse.Body)
if err != nil {
return err
}
defer r.HTTPResponse.Body.Close()
}
}
previewBytes = len(bodyBytes)
if previewBytes > 0 { // if the preview byte is 0 or less, there is no question of the body fitting insides
r.bodyFittedInPreview = true
}
if previewBytes > maxBytes { // if the preview bytes is greater than what was mentioned by the ICAP Server(did not fit in the body)
previewBytes = maxBytes
r.bodyFittedInPreview = false
r.remainingPreviewBytes = bodyBytes[maxBytes:] // storing the rest of the body byte which were not sent as preview for further operations
}
// returning the body back to the http message depending on the request method
if r.Method == MethodREQMOD {
r.HTTPRequest.Body = ioutil.NopCloser(bytes.NewReader(bodyBytes))
}
if r.Method == MethodRESPMOD {
r.HTTPResponse.Body = ioutil.NopCloser(bytes.NewReader(bodyBytes))
}
// finally assinging the preview informations including setting the header
r.Header.Set("Preview", strconv.Itoa(previewBytes))
r.PreviewBytes = previewBytes
r.previewSet = true
return nil
}
// SetDefaultRequestHeaders assigns some of the headers with its default value if they are not set already
func (r *Request) SetDefaultRequestHeaders() {
if _, exists := r.Header["Allow"]; !exists {
r.Header.Add("Allow", "204") // assigning 204 by default if Allow not provided
}
if _, exists := r.Header["Host"]; !exists {
hostName, _ := os.Hostname()
r.Header.Add("Host", hostName)
}
}
// ExtendHeader extends the current ICAP Request header with a new header
func (r *Request) ExtendHeader(hdr http.Header) error {
for header, values := range hdr {
if header == PreviewHeader && r.previewSet {
continue
}
if header == EncapsulatedHeader {
continue
}
for _, value := range values {
if header == PreviewHeader {
pb, err := strconv.Atoi(value)
if err != nil {
return err
}
if err := r.SetPreview(pb); err != nil {
return err
}
continue
}
r.Header.Add(header, value)
}
}
return nil
}

441
vendor/github.com/egirna/icap-client/icap_client.go generated vendored Normal file
View File

@@ -0,0 +1,441 @@
package icapclient
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httputil"
"regexp"
"strconv"
"strings"
)
// the icap request methods
const (
MethodOPTIONS = "OPTIONS"
MethodRESPMOD = "RESPMOD"
MethodREQMOD = "REQMOD"
)
// shared errors
var (
// ErrNoContext is used when no context is provided
ErrNoContext = errors.New("no context provided")
// ErrInvalidScheme is used when the url scheme is not icap://
ErrInvalidScheme = errors.New("the url scheme must be icap://")
// ErrMethodNotAllowed is used when the method is not allowed
ErrMethodNotAllowed = errors.New("the requested method is not registered")
// ErrInvalidHost is used when the host is invalid
ErrInvalidHost = errors.New("the requested host is invalid")
// ErrInvalidTCPMsg is used when the tcp message is invalid
ErrInvalidTCPMsg = errors.New("invalid tcp message")
// ErrREQMODWithoutReq is used when the request is nil for REQMOD method
ErrREQMODWithoutReq = errors.New("http request cannot be nil for method REQMOD")
// ErrREQMODWithResp is used when the response is not nil for REQMOD method
ErrREQMODWithResp = errors.New("http response must be nil for method REQMOD")
// ErrRESPMODWithoutResp is used when the response is nil for RESPMOD method
ErrRESPMODWithoutResp = errors.New("http response cannot be nil for method RESPMOD")
)
// general constants required for the package
const (
schemeICAP = "icap"
icapVersion = "ICAP/1.0"
httpVersion = "HTTP/1.1"
schemeHTTPReq = "http_request"
schemeHTTPResp = "http_response"
crlf = "\r\n"
doubleCRLF = "\r\n\r\n"
lf = "\n"
bodyEndIndicator = crlf + "0" + crlf
fullBodyEndIndicatorPreviewMode = "; ieof" + doubleCRLF
icap100ContinueMsg = "ICAP/1.0 100 Continue" + doubleCRLF
icap204NoModsMsg = "ICAP/1.0 204 Unmodified"
)
// Common ICAP headers
const (
previewHeader = "Preview"
encapsulatedHeader = "Encapsulated"
)
// Conn represents the connection to the icap server
type Conn interface {
io.Closer
Connect(ctx context.Context, address string) error
Send(in []byte) ([]byte, error)
}
// Response represents the icap server response data
type Response struct {
StatusCode int
Status string
PreviewBytes int
Header http.Header
ContentRequest *http.Request
ContentResponse *http.Response
}
// getStatusWithCode prepares the status code and status text from two given strings
func getStatusWithCode(str1, str2 string) (int, string, error) {
statusCode, err := strconv.Atoi(str1)
if err != nil {
return 0, "", err
}
status := strings.TrimSpace(str2)
return statusCode, status, nil
}
// getHeaderValue parses the header and its value from a tcp message string
func getHeaderValue(str string) (string, string) {
headerValues := strings.SplitN(str, ":", 2)
header := headerValues[0]
if len(headerValues) >= 2 {
return header, strings.TrimSpace(headerValues[1])
}
return header, ""
}
// isRequestLine determines if the tcp message string is a request line, i.e., the first line of the message or not
func isRequestLine(str string) bool {
return strings.Contains(str, icapVersion) || strings.Contains(str, httpVersion)
}
// setEncapsulatedHeaderValue generates the Encapsulated values and assigns to the ICAP request string
func setEncapsulatedHeaderValue(icapReqStr string, httpReqStr, httpRespStr string) string {
encVal := " "
if strings.HasPrefix(icapReqStr, MethodOPTIONS) {
switch {
// the most common case for OPTIONS method, no Encapsulated body
case httpReqStr == "" && httpRespStr == "":
encVal += "null-body=0"
// if there is an Encapsulated body
default:
encVal += "opt-body=0"
}
}
if strings.HasPrefix(icapReqStr, MethodREQMOD) || strings.HasPrefix(icapReqStr, MethodRESPMOD) {
// looking for the match of the string \r\n\r\n,
// as that is the expression that separates each block, i.e., headers and bodies
re := regexp.MustCompile(doubleCRLF)
// getting the offsets of the matches, tells us the starting/ending point of headers and bodies
reqIndices := re.FindAllStringIndex(httpReqStr, -1)
// is needed to calculate the response headers by adding the last offset of the request block
reqEndsAt := 0
if reqIndices != nil {
encVal += "req-hdr=0"
reqEndsAt = reqIndices[0][1]
switch {
// indicating there is a body present for the request block, as length would have been 1 for a single match of \r\n\r\n
case len(reqIndices) > 1:
encVal += fmt.Sprintf(", req-body=%d", reqIndices[0][1]) // assigning the starting point of the body
reqEndsAt = reqIndices[1][1]
case httpRespStr == "":
encVal += fmt.Sprintf(", null-body=%d", reqIndices[0][1])
}
if httpRespStr != "" {
encVal += ", "
}
}
respIndices := re.FindAllStringIndex(httpRespStr, -1)
if respIndices != nil {
encVal += fmt.Sprintf("res-hdr=%d", reqEndsAt)
switch {
case len(respIndices) > 1:
encVal += fmt.Sprintf(", res-body=%d", reqEndsAt+respIndices[0][1])
default:
encVal += fmt.Sprintf(", null-body=%d", reqEndsAt+respIndices[0][1])
}
}
}
// formatting the ICAP request Encapsulated header with the value
return fmt.Sprintf(icapReqStr, encVal)
}
// replaceRequestURIWithActualURL replaces just the escaped portion of the url with the entire URL in the dumped request message
func replaceRequestURIWithActualURL(str string, uri, url string) string {
if uri == "" {
uri = "/"
}
return strings.Replace(str, uri, url, 1)
}
// addFullBodyInPreviewIndicator adds 0; ieof\r\n\r\n which indicates the entire body fitted in the preview
func addFullBodyInPreviewIndicator(str string) string {
return strings.TrimSuffix(str, doubleCRLF) + fullBodyEndIndicatorPreviewMode
}
// splitBodyAndHeader separates header and body from a http message
func splitBodyAndHeader(str string) (string, string, bool) {
ss := strings.SplitN(str, doubleCRLF, 2)
if len(ss) < 2 || ss[1] == "" {
return "", "", false
}
headerStr := ss[0]
bodyStr := ss[1]
return headerStr, bodyStr, true
}
// bodyIsChunked determines if the http body is already chunked from the origin server or not
func bodyIsChunked(str string) bool {
_, bodyStr, ok := splitBodyAndHeader(str)
if !ok {
return false
}
return regexp.MustCompile(`\r\n0(\r\n)+$`).MatchString(bodyStr)
}
// parsePreviewBodyBytes parses the preview portion of the body and only keeps that in the message
func parsePreviewBodyBytes(str string, pb int) string {
headerStr, bodyStr, ok := splitBodyAndHeader(str)
if !ok {
return str
}
return headerStr + doubleCRLF + bodyStr[:pb]
}
// addHexBodyByteNotations adds the hexadecimal byte notations to the string,
// for example, Hello World, becomes
// b
// Hello World
// 0
func addHexBodyByteNotations(str string) string {
return fmt.Sprintf("%x%s%s%s", len([]byte(str)), crlf, str, bodyEndIndicator)
}
// addHeaderAndBody merges the header and body of the http message
func addHeaderAndBody(headerStr, bodyStr string) string {
return headerStr + doubleCRLF + bodyStr
}
// toICAPRequest returns the given request in its ICAP/1.x wire
func toICAPRequest(req Request) ([]byte, error) {
// Making the ICAP message block
reqStr := fmt.Sprintf("%s %s %s%s", req.Method, req.URL.String(), icapVersion, crlf)
for headerName, values := range req.Header {
for _, value := range values {
reqStr += fmt.Sprintf("%s: %s%s", headerName, value, crlf)
}
}
// will populate the Encapsulated header value after making the http Request & Response messages
reqStr += "Encapsulated: %s" + crlf
reqStr += crlf
// build the HTTP Request message block
httpReqStr := ""
if req.HTTPRequest != nil {
b, err := httputil.DumpRequestOut(req.HTTPRequest, true)
if err != nil {
return nil, err
}
httpReqStr += string(b)
httpReqStr = replaceRequestURIWithActualURL(httpReqStr, req.HTTPRequest.URL.EscapedPath(), req.HTTPRequest.URL.String())
if req.Method == MethodREQMOD {
if req.previewSet {
httpReqStr = parsePreviewBodyBytes(httpReqStr, req.PreviewBytes)
}
if !bodyIsChunked(httpReqStr) {
headerStr, bodyStr, ok := splitBodyAndHeader(httpReqStr)
if ok {
bodyStr = addHexBodyByteNotations(bodyStr)
httpReqStr = addHeaderAndBody(headerStr, bodyStr)
}
}
}
// if the HTTP Request message block doesn't end with a \r\n\r\n,
// then going to add one by force for better calculation of byte offsets
if httpReqStr != "" {
for !strings.HasSuffix(httpReqStr, doubleCRLF) {
httpReqStr += crlf
}
}
}
// build the HTTP Response message block
httpRespStr := ""
if req.HTTPResponse != nil {
b, err := httputil.DumpResponse(req.HTTPResponse, true)
if err != nil {
return nil, err
}
httpRespStr += string(b)
if req.previewSet {
httpRespStr = parsePreviewBodyBytes(httpRespStr, req.PreviewBytes)
}
if !bodyIsChunked(httpRespStr) {
headerStr, bodyStr, ok := splitBodyAndHeader(httpRespStr)
if ok {
bodyStr = addHexBodyByteNotations(bodyStr)
httpRespStr = addHeaderAndBody(headerStr, bodyStr)
}
}
if httpRespStr != "" && !strings.HasSuffix(httpRespStr, doubleCRLF) { // if the HTTP Response message block doesn't end with a \r\n\r\n, then going to add one by force for better calculation of byte offsets
httpRespStr += crlf
}
}
if encVal := req.Header.Get(encapsulatedHeader); encVal != "" {
reqStr = fmt.Sprintf(reqStr, encVal)
} else {
//populating the Encapsulated header of the ICAP message portion
reqStr = setEncapsulatedHeaderValue(reqStr, httpReqStr, httpRespStr)
}
// determining if the http message needs the full body fitted in the preview portion indicator or not
if httpRespStr != "" && req.previewSet && req.bodyFittedInPreview {
httpRespStr = addFullBodyInPreviewIndicator(httpRespStr)
}
if req.Method == MethodREQMOD && req.previewSet && req.bodyFittedInPreview {
httpReqStr = addFullBodyInPreviewIndicator(httpReqStr)
}
data := []byte(reqStr + httpReqStr + httpRespStr)
return data, nil
}
// toClientResponse reads an ICAP message and returns a Response
func toClientResponse(b *bufio.Reader) (Response, error) {
resp := Response{
Header: make(map[string][]string),
}
scheme := ""
httpMsg := ""
for currentMsg, err := b.ReadString('\n'); err == nil || currentMsg != ""; currentMsg, err = b.ReadString('\n') { // keep reading the buffer message which is the http response message
// if the current message line if the first line of the message portion(request line)
if isRequestLine(currentMsg) {
ss := strings.Split(currentMsg, " ")
// must contain 3 words, for example, "ICAP/1.0 200 OK" or "GET /something HTTP/1.1"
if len(ss) < 3 {
return Response{}, fmt.Errorf("%w: %s", ErrInvalidTCPMsg, currentMsg)
}
// preparing the scheme below
if ss[0] == icapVersion {
scheme = schemeICAP
resp.StatusCode, resp.Status, err = getStatusWithCode(ss[1], strings.Join(ss[2:], " "))
if err != nil {
return Response{}, err
}
continue
}
if ss[0] == httpVersion {
scheme = schemeHTTPResp
httpMsg = ""
}
// http request message scheme version should always be at the end,
// for example, GET /something HTTP/1.1
if strings.TrimSpace(ss[2]) == httpVersion {
scheme = schemeHTTPReq
httpMsg = ""
}
}
// preparing the header for ICAP & contents for the HTTP messages below
if scheme == schemeICAP {
// ignore the CRLF and the LF, shouldn't be counted
if currentMsg == lf || currentMsg == crlf {
continue
}
header, val := getHeaderValue(currentMsg)
if header == previewHeader {
pb, _ := strconv.Atoi(val)
resp.PreviewBytes = pb
}
resp.Header.Add(header, val)
}
if scheme == schemeHTTPReq {
httpMsg += strings.TrimSpace(currentMsg) + crlf
bufferEmpty := b.Buffered() == 0
// a crlf indicates the end of the HTTP message and the buffer check is just in case the buffer ended with one last message instead of a crlf
if currentMsg == crlf || bufferEmpty {
request, err := http.ReadRequest(bufio.NewReader(strings.NewReader(httpMsg)))
if err != nil {
return Response{}, err
}
resp.ContentRequest = request
continue
}
}
if scheme == schemeHTTPResp {
httpMsg += strings.TrimSpace(currentMsg) + crlf
bufferEmpty := b.Buffered() == 0
if currentMsg == crlf || bufferEmpty {
response, err := http.ReadResponse(bufio.NewReader(strings.NewReader(httpMsg)), resp.ContentRequest)
if err != nil {
return Response{}, err
}
resp.ContentResponse = response
continue
}
}
}
return resp, nil
}

View File

@@ -1,9 +0,0 @@
package icapclient
var (
registeredMethods = map[string]bool{
MethodOPTIONS: true,
MethodRESPMOD: true,
MethodREQMOD: true,
}
)

View File

@@ -1,179 +0,0 @@
package icapclient
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// getStatusWithCode prepares the status code and status text from two given strings
func getStatusWithCode(str1, str2 string) (int, string, error) {
statusCode, err := strconv.Atoi(str1)
if err != nil {
return 0, "", err
}
status := strings.TrimSpace(str2)
return statusCode, status, nil
}
// getHeaderVal parses the header and its value from a tcp message string
func getHeaderVal(str string) (string, string) {
headerVals := strings.SplitN(str, ":", 2)
header := headerVals[0]
val := ""
if len(headerVals) >= 2 {
val = strings.TrimSpace(headerVals[1])
}
return header, val
}
// isRequestLine determines if the tcp message string is a request line, i.e the first line of the message or not
func isRequestLine(str string) bool {
return strings.Contains(str, ICAPVersion) || strings.Contains(str, HTTPVersion)
}
// setEncapsulatedHeaderValue generates the Encapsulated values and assigns to the ICAP request string
func setEncapsulatedHeaderValue(icapReqStr *string, httpReqStr, httpRespStr string) {
encpVal := " "
if strings.HasPrefix(*icapReqStr, MethodOPTIONS) { // if the request method is OPTIONS
if httpReqStr == "" && httpRespStr == "" { // the most common case for OPTIONS method, no Encapsulated body
encpVal += "null-body=0"
} else {
encpVal += "opt-body=0" // if there is an Encapsulated body
}
}
if strings.HasPrefix(*icapReqStr, MethodREQMOD) || strings.HasPrefix(*icapReqStr, MethodRESPMOD) { // if the request method is RESPMOD or REQMOD
re := regexp.MustCompile(DoubleCRLF) // looking for the match of the string \r\n\r\n, as that is the expression that seperates each blocks, i.e headers and bodies
reqIndices := re.FindAllStringIndex(httpReqStr, -1) // getting the offsets of the matches, tells us the starting/ending point of headers and bodies
reqEndsAt := 0 // this is needed to calculate the response headers by adding the last offset of the request block
if reqIndices != nil {
encpVal += "req-hdr=0"
reqEndsAt = reqIndices[0][1]
if len(reqIndices) > 1 { // indicating there is a body present for the request block, as length would have been 1 for a single match of \r\n\r\n
encpVal += fmt.Sprintf(", req-body=%d", reqIndices[0][1]) // assigning the starting point of the body
reqEndsAt = reqIndices[1][1]
} else if httpRespStr == "" {
encpVal += fmt.Sprintf(", null-body=%d", reqIndices[0][1])
}
if httpRespStr != "" {
encpVal += ", "
}
}
respIndices := re.FindAllStringIndex(httpRespStr, -1)
if respIndices != nil {
encpVal += fmt.Sprintf("res-hdr=%d", reqEndsAt)
if len(respIndices) > 1 {
encpVal += fmt.Sprintf(", res-body=%d", reqEndsAt+respIndices[0][1])
} else {
encpVal += fmt.Sprintf(", null-body=%d", reqEndsAt+respIndices[0][1])
}
}
}
*icapReqStr = fmt.Sprintf(*icapReqStr, encpVal) // formatting the ICAP request Encapsulated header with the value
}
// replaceRequestURIWithActualURL replaces the just the escaped portion of the url with the entire URL in the dumped request message
func replaceRequestURIWithActualURL(str *string, uri, url string) {
if uri == "" {
uri = "/"
}
*str = strings.Replace(*str, uri, url, 1)
}
// addFullBodyInPreviewIndicator adds 0; ieof\r\n\r\n which indicates the entire body fitted in preview
func addFullBodyInPreviewIndicator(str *string) {
*str = strings.TrimSuffix(*str, DoubleCRLF)
*str += fullBodyEndIndicatorPreviewMode
}
// splitBodyAndHeader separates header and body from a http message
func splitBodyAndHeader(str string) (string, string, bool) {
ss := strings.SplitN(str, DoubleCRLF, 2)
if len(ss) < 2 || ss[1] == "" {
return "", "", false
}
headerStr := ss[0]
bodyStr := ss[1]
return headerStr, bodyStr, true
}
// bodyAlreadyChunked determines if the http body is already chunked from the origin server or not
func bodyAlreadyChunked(str string) bool {
_, bodyStr, ok := splitBodyAndHeader(str)
if !ok {
return false
}
r := regexp.MustCompile("\\r\\n0(\\r\\n)+$")
return r.MatchString(bodyStr)
}
// parsePreviewBodyBytes parses the preview portion of the body and only keeps that in the message
func parsePreviewBodyBytes(str *string, pb int) {
headerStr, bodyStr, ok := splitBodyAndHeader(*str)
if !ok {
return
}
bodyStr = bodyStr[:pb]
*str = headerStr + DoubleCRLF + bodyStr
}
// addHexaBodyByteNotations adds the hexadecimal byte notaions in the messages
// for example: Hello World, becomes
// b
// Hello World
// 0
func addHexaBodyByteNotations(bodyStr *string) {
bodyBytes := []byte(*bodyStr)
*bodyStr = fmt.Sprintf("%x%s%s%s", len(bodyBytes), CRLF, *bodyStr, bodyEndIndicator)
}
// mergeHeaderAndBody merges the header and body of the http message togather
func mergeHeaderAndBody(src *string, headerStr, bodyStr string) {
*src = headerStr + DoubleCRLF + bodyStr
}
func chunkBodyByBytes(bdyByte []byte, cl int) []byte {
newBytes := []byte{}
for i := 0; i < len(bdyByte); i += cl {
end := i + cl
if end > len(bdyByte) {
end = len(bdyByte)
}
newBytes = append(newBytes, []byte(fmt.Sprintf("%x\r\n", len(bdyByte[i:end]))+string(bdyByte[i:end]))...)
}
newBytes = append(newBytes, []byte(bodyEndIndicator)...)
return newBytes
}

View File

@@ -1,11 +1,15 @@
package icapclient
import (
"bytes"
"context"
"fmt"
"errors"
"io"
"net/http"
"net/http/httputil"
"net/url"
"os"
"slices"
"strconv"
"strings"
)
@@ -18,143 +22,202 @@ type Request struct {
HTTPResponse *http.Response
ChunkLength int
PreviewBytes int
ctx *context.Context
ctx context.Context
previewSet bool
bodyFittedInPreview bool
remainingPreviewBytes []byte
}
// NewRequest is the factory function for Request
func NewRequest(method, urlStr string, httpReq *http.Request, httpResp *http.Response) (*Request, error) {
method = strings.ToUpper(method)
// NewRequest returns a new Request given a context, method, url, http request and http response
// todo: method iota
func NewRequest(ctx context.Context, method, urlStr string, httpReq *http.Request, httpResp *http.Response) (Request, error) {
u, err := url.Parse(urlStr)
if err != nil {
return nil, err
return Request{}, err
}
req := &Request{
Method: method,
req := Request{
Method: strings.ToUpper(method),
URL: u,
Header: make(map[string][]string),
HTTPRequest: httpReq,
HTTPResponse: httpResp,
ctx: ctx,
}
if err := req.Validate(); err != nil {
return nil, err
if err := req.validate(); err != nil {
return Request{}, err
}
return req, nil
}
// DumpRequest returns the given request in its ICAP/1.x wire
// representation.
func DumpRequest(req *Request) ([]byte, error) {
// SetPreview sets the preview bytes in the icap header
// todo: defer close error
func (r *Request) SetPreview(maxBytes int) error {
var bodyBytes []byte
var previewBytes int
var err error
// Making the ICAP message block
// receiving the body bites to determine the preview bytes depending on the request ICAP method
if r.Method == MethodREQMOD {
if r.HTTPRequest == nil {
return nil
}
reqStr := fmt.Sprintf("%s %s %s%s", req.Method, req.URL.String(), ICAPVersion, CRLF)
if r.HTTPRequest.Body != nil {
b, err := io.ReadAll(r.HTTPRequest.Body)
if err != nil {
return err
}
bodyBytes = b
for headerName, vals := range req.Header {
for _, val := range vals {
reqStr += fmt.Sprintf("%s: %s%s", headerName, val, CRLF)
defer func() {
err = errors.Join(err, r.HTTPRequest.Body.Close())
}()
}
}
reqStr += "Encapsulated: %s" + CRLF // will populate the Encapsulated header value after making the http Request & Response messages
reqStr += CRLF
// Making the HTTP Request message block
httpReqStr := ""
if req.HTTPRequest != nil {
b, err := httputil.DumpRequestOut(req.HTTPRequest, true)
if err != nil {
return nil, err
if r.Method == MethodRESPMOD {
if r.HTTPResponse == nil {
return nil
}
httpReqStr += string(b)
replaceRequestURIWithActualURL(&httpReqStr, req.HTTPRequest.URL.EscapedPath(), req.HTTPRequest.URL.String())
if req.Method == MethodREQMOD {
if req.previewSet {
parsePreviewBodyBytes(&httpReqStr, req.PreviewBytes)
if r.HTTPResponse.Body != nil {
b, err := io.ReadAll(r.HTTPResponse.Body)
if err != nil {
return err
}
bodyBytes = b
if !bodyAlreadyChunked(httpReqStr) {
headerStr, bodyStr, ok := splitBodyAndHeader(httpReqStr)
if ok {
addHexaBodyByteNotations(&bodyStr)
mergeHeaderAndBody(&httpReqStr, headerStr, bodyStr)
defer func() {
err = errors.Join(err, r.HTTPResponse.Body.Close())
}()
}
}
previewBytes = len(bodyBytes)
// if the preview byte is 0 or less, there is no question of the body-fitting insides
if previewBytes > 0 {
r.bodyFittedInPreview = true
}
// if the preview bytes are greater than what was mentioned by the ICAP Server (did not fit in the body)
if previewBytes > maxBytes {
previewBytes = maxBytes
r.bodyFittedInPreview = false
// storing the rest of the body byte which was not sent as preview for further operations
r.remainingPreviewBytes = bodyBytes[maxBytes:]
}
// set the body to the http message depending on the request method
if r.Method == MethodREQMOD {
r.HTTPRequest.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
if r.Method == MethodRESPMOD {
r.HTTPResponse.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
// assign preview byte information to the header
r.Header.Set("Preview", strconv.Itoa(previewBytes))
r.PreviewBytes = previewBytes
r.previewSet = true
return err
}
// setDefaultRequestHeaders is called by the client before sending the request
// to the ICAP server to ensure all required headers are set
func (r *Request) setDefaultRequestHeaders() {
if _, exists := r.Header["Allow"]; !exists {
r.Header.Add("Allow", "204") // assigning 204 by default if Allow not provided
}
if _, exists := r.Header["Host"]; !exists {
hostName, _ := os.Hostname()
r.Header.Add("Host", hostName)
}
}
// extendHeader extends the current ICAP Request header with a new header
func (r *Request) extendHeader(hdr http.Header) error {
for header, values := range hdr {
if header == previewHeader && r.previewSet {
continue
}
if header == encapsulatedHeader {
continue
}
for _, value := range values {
if header == previewHeader {
pb, err := strconv.Atoi(value)
if err != nil {
return err
}
err = r.SetPreview(pb)
if err != nil {
return err
}
continue
}
r.Header.Add(header, value)
}
if httpReqStr != "" { // if the HTTP Request message block doesn't end with a \r\n\r\n, then going to add one by force for better calculation of byte offsets
for !strings.HasSuffix(httpReqStr, DoubleCRLF) {
httpReqStr += CRLF
}
}
}
// Making the HTTP Response message block
httpRespStr := ""
if req.HTTPResponse != nil {
b, err := httputil.DumpResponse(req.HTTPResponse, true)
if err != nil {
return nil, err
}
httpRespStr += string(b)
if req.previewSet {
parsePreviewBodyBytes(&httpRespStr, req.PreviewBytes)
}
if !bodyAlreadyChunked(httpRespStr) {
headerStr, bodyStr, ok := splitBodyAndHeader(httpRespStr)
if ok {
addHexaBodyByteNotations(&bodyStr)
mergeHeaderAndBody(&httpRespStr, headerStr, bodyStr)
}
}
if httpRespStr != "" && !strings.HasSuffix(httpRespStr, DoubleCRLF) { // if the HTTP Response message block doesn't end with a \r\n\r\n, then going to add one by force for better calculation of byte offsets
httpRespStr += CRLF
}
}
if encpVal := req.Header.Get(EncapsulatedHeader); encpVal != "" {
reqStr = fmt.Sprintf(reqStr, encpVal)
} else {
//populating the Encapsulated header of the ICAP message portion
setEncapsulatedHeaderValue(&reqStr, httpReqStr, httpRespStr)
}
// determining if the http message needs the full body fitted in the preview portion indicator or not
if httpRespStr != "" && req.previewSet && req.bodyFittedInPreview {
addFullBodyInPreviewIndicator(&httpRespStr)
}
if req.Method == MethodREQMOD && req.previewSet && req.bodyFittedInPreview {
addFullBodyInPreviewIndicator(&httpReqStr)
}
data := []byte(reqStr + httpReqStr + httpRespStr)
return data, nil
return nil
}
// SetContext sets a context for the ICAP request
func (r *Request) SetContext(ctx context.Context) { // TODO: make context take control over the whole operation
r.ctx = &ctx
// validate checks if the ICAP request is valid or not
func (r *Request) validate() error {
var err error
// check if the ICAP request has a context
if r.ctx == nil {
err = errors.Join(err, ErrNoContext)
}
// check if the ICAP request method is allowed
if methodAllowed := slices.Contains([]string{
MethodOPTIONS,
MethodRESPMOD,
MethodREQMOD,
}, r.Method); !methodAllowed {
err = errors.Join(err, ErrMethodNotAllowed)
}
// check if the ICAP url is valid and contains all required fields
{
if r.URL.Scheme != schemeICAP {
err = errors.Join(err, ErrInvalidScheme)
}
if r.URL.Host == "" {
err = errors.Join(err, ErrInvalidHost)
}
}
// check if the ICAP request method is aligned with the http messages
{
if r.Method == MethodREQMOD && r.HTTPRequest == nil {
err = errors.Join(err, ErrREQMODWithoutReq)
}
if r.Method == MethodREQMOD && r.HTTPResponse != nil {
err = errors.Join(err, ErrREQMODWithResp)
}
if r.Method == MethodRESPMOD && r.HTTPResponse == nil {
err = errors.Join(err, ErrRESPMODWithoutResp)
}
}
return err
}

View File

@@ -1,123 +0,0 @@
package icapclient
import (
"bufio"
"errors"
"net/http"
"strconv"
"strings"
)
// Response represents the icap server response data
type Response struct {
StatusCode int
Status string
PreviewBytes int
Header http.Header
ContentRequest *http.Request
ContentResponse *http.Response
}
var (
optionValues = map[string]bool{
PreviewHeader: true,
MethodsHeader: true,
AllowHeader: true,
TransferPreviewHeader: true,
ServiceHeader: true,
ISTagHeader: true,
OptBodyTypeHeader: true,
MaxConnectionsHeader: true,
OptionsTTLHeader: true,
ServiceIDHeader: true,
TransferIgnoreHeader: true,
TransferCompleteHeader: true,
}
)
// ReadResponse converts a Reader to a icapclient Response
func ReadResponse(b *bufio.Reader) (*Response, error) {
resp := &Response{
Header: make(map[string][]string),
}
scheme := ""
httpMsg := ""
for currentMsg, err := b.ReadString('\n'); err == nil || currentMsg != ""; currentMsg, err = b.ReadString('\n') { // keep reading the buffer message which is the http response message
if isRequestLine(currentMsg) { // if the current message line if the first line of the message portion(request line)
ss := strings.Split(currentMsg, " ")
if len(ss) < 3 { // must contain 3 words, for example: "ICAP/1.0 200 OK" or "GET /something HTTP/1.1"
return nil, errors.New(ErrInvalidTCPMsg + ":" + currentMsg)
}
// preparing the scheme below
if ss[0] == ICAPVersion {
scheme = SchemeICAP
resp.StatusCode, resp.Status, err = getStatusWithCode(ss[1], strings.Join(ss[2:], " "))
if err != nil {
return nil, err
}
continue
}
if ss[0] == HTTPVersion {
scheme = SchemeHTTPResp
httpMsg = ""
}
if strings.TrimSpace(ss[2]) == HTTPVersion { // for a http request message if the scheme version is always at last, for example: GET /something HTTP/1.1
scheme = SchemeHTTPReq
httpMsg = ""
}
}
// preparing the header for ICAP & contents for HTTP messages below
if scheme == SchemeICAP {
if currentMsg == LF || currentMsg == CRLF { // don't want to count the Line Feed as header
continue
}
header, val := getHeaderVal(currentMsg)
if header == PreviewHeader {
pb, _ := strconv.Atoi(val)
resp.PreviewBytes = pb
}
resp.Header.Add(header, val)
}
if scheme == SchemeHTTPReq {
httpMsg += strings.TrimSpace(currentMsg) + CRLF
bufferEmpty := b.Buffered() == 0
if currentMsg == CRLF || bufferEmpty { // a CRLF indicates the end of a http message and the buffer check is just in case the buffer eneded with one last message instead of a CRLF
var erR error
resp.ContentRequest, erR = http.ReadRequest(bufio.NewReader(strings.NewReader(httpMsg)))
if erR != nil {
return nil, erR
}
continue
}
}
if scheme == SchemeHTTPResp {
httpMsg += strings.TrimSpace(currentMsg) + CRLF
bufferEmpty := b.Buffered() == 0
if currentMsg == CRLF || bufferEmpty {
var erR error
resp.ContentResponse, erR = http.ReadResponse(bufio.NewReader(strings.NewReader(httpMsg)), resp.ContentRequest)
if erR != nil {
return nil, erR
}
continue
}
}
}
return resp, nil
}

View File

@@ -36,7 +36,7 @@ func startTestServer() {
log.Println("Starting ICAP test server...")
signal.Notify(stop, syscall.SIGKILL, syscall.SIGINT, syscall.SIGQUIT)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)
go func() {
if err := icap.ListenAndServe(fmt.Sprintf(":%d", port), nil); err != nil {
@@ -79,8 +79,6 @@ func respmodHandler(w icap.ResponseWriter, req *icap.Request) {
return
}
// log.Println("The preview data: ", string(req.Preview))
buf := &bytes.Buffer{}
if _, err := io.Copy(buf, req.Response.Body); err != nil {
@@ -124,8 +122,6 @@ func reqmodHandler(w icap.ResponseWriter, req *icap.Request) {
return
}
// log.Println("The preview data: ", string(req.Preview))
fileURL := req.Request.RequestURI
status := 0

View File

@@ -1,123 +0,0 @@
package icapclient
import (
"context"
"io"
"net"
"strings"
"time"
)
// transport represents the transport layer data
type transport struct {
network string
addr string
timeout time.Duration
readTimeout time.Duration
writeTimeout time.Duration
sckt net.Conn
}
// dial fires up a tcp socket
func (t *transport) dial() error {
sckt, err := net.DialTimeout(t.network, t.addr, t.timeout)
if err != nil {
return err
}
if err := sckt.SetReadDeadline(time.Now().UTC().Add(t.readTimeout)); err != nil {
return err
}
if err := sckt.SetWriteDeadline(time.Now().UTC().Add(t.writeTimeout)); err != nil {
return err
}
t.sckt = sckt
return nil
}
// dialWithContext fires up a tcp socket
func (t *transport) dialWithContext(ctx context.Context) error {
sckt, err := (&net.Dialer{
Timeout: t.timeout,
}).DialContext(ctx, t.network, t.addr)
if err != nil {
return err
}
if err := sckt.SetReadDeadline(time.Now().UTC().Add(t.readTimeout)); err != nil {
return err
}
if err := sckt.SetWriteDeadline(time.Now().UTC().Add(t.writeTimeout)); err != nil {
return err
}
t.sckt = sckt
return nil
}
// Write writes data to the server
func (t *transport) write(data []byte) (int, error) {
logDebug("Dumping the message being sent to the server...")
dumpDebug(string(data))
return t.sckt.Write(data)
}
// Read reads data from server
func (t *transport) read() (string, error) {
data := make([]byte, 0)
logDebug("Dumping messages received from the server...")
for {
tmp := make([]byte, 1096)
n, err := t.sckt.Read(tmp)
if err != nil {
if err == io.EOF {
logDebug("End of file detected from EOF error")
break
}
return "", err
}
if n == 0 {
logDebug("End of file detected by 0 bytes")
break
}
data = append(data, tmp[:n]...)
if string(data) == icap100ContinueMsg { // explicitly breaking because the Read blocks for 100 continue message // TODO: find out why
logDebug("Stopping because got 100 Continue from the server")
break
}
if strings.HasSuffix(string(data), "0\r\n\r\n") {
logDebug("End of the file detected by 0 Double CRLF indicator")
break
}
if strings.Contains(string(data), icap204NoModsMsg) {
logDebug("End of file detected by 204 no modifications and Double CRLF at the end")
break
}
dumpDebug(string(tmp))
}
return string(data), nil
}
// close closes the tcp connection
func (t *transport) close() error {
return t.sckt.Close()
}

View File

@@ -1,63 +0,0 @@
package icapclient
import (
"errors"
"net/http"
"net/url"
)
// validMethod validates the ICAP method
func validMethod(method string) (bool, error) {
if _, registered := registeredMethods[method]; !registered {
return false, errors.New(ErrMethodNotRegistered)
}
return true, nil
}
// validURL validates the Server URL provided
func validURL(url *url.URL) (bool, error) {
if url.Scheme != SchemeICAP {
return false, errors.New(ErrInvalidScheme)
}
if url.Host == "" {
return false, errors.New(ErrInvalidHost)
}
return true, nil
}
// validMethodWithHTTP validates if the ICAP request method and the http messages are alligned or not
func validMethodWithHTTP(httpReq *http.Request, httpResp *http.Response, method string) (bool, error) {
if method == MethodREQMOD && httpReq == nil {
return false, errors.New(ErrREQMODWithNoReq)
}
if method == MethodREQMOD && httpResp != nil {
return false, errors.New(ErrREQMODWithResp)
}
if method == MethodRESPMOD && httpResp == nil {
return false, errors.New(ErrRESPMODWithNoResp)
}
return true, nil
}
// Validate validates the ICAP request
func (r *Request) Validate() error {
if valid, err := validMethod(r.Method); !valid {
return err
}
if valid, err := validURL(r.URL); !valid {
return err
}
if valid, err := validMethodWithHTTP(r.HTTPRequest, r.HTTPResponse, r.Method); !valid {
return err
}
return nil
}

5
vendor/modules.txt vendored
View File

@@ -763,8 +763,8 @@ github.com/dutchcoders/go-clamd
# github.com/egirna/icap v0.0.0-20181108071049-d5ee18bd70bc
## explicit
github.com/egirna/icap
# github.com/egirna/icap-client v0.1.1
## explicit; go 1.12
# github.com/egirna/icap-client v0.1.1 => github.com/fschade/icap-client v0.0.0-20240105150744-9c2d8aff3ef2
## explicit; go 1.21
github.com/egirna/icap-client
# github.com/emirpasic/gods v1.18.1
## explicit; go 1.2
@@ -2318,3 +2318,4 @@ stash.kopano.io/kgol/oidc-go
stash.kopano.io/kgol/rndm
# github.com/go-micro/plugins/v4/store/nats-js-kv => github.com/kobergj/plugins/v4/store/nats-js-kv v0.0.0-20231207143248-4d424e3ae348
# github.com/studio-b12/gowebdav => github.com/aduffeck/gowebdav v0.0.0-20231215102054-212d4a4374f6
# github.com/egirna/icap-client => github.com/fschade/icap-client v0.0.0-20240105150744-9c2d8aff3ef2