Skip to content

ICAP server

An icap:// address on --listen serves ICAP (RFC 3507), the protocol a proxy speaks to hand an HTTP request or response to a scanner before letting it through. It is a drop-in for a c-icap container running the virus_scan service against ClamAV: same port, same service names, same X-Infection-Found header, so the proxy in front of it keeps its configuration.

Terminal window
exav --listen icap://0.0.0.0:1344 -d /var/lib/exav # the default services
exav --listen icap://0.0.0.0:1344/avscan -d /var/lib/exav # only that one

ICAP is a TCP protocol, so an icap:// address is a host:port; a socket path there is refused rather than bound somewhere no proxy could reach it.

An ICAP service name is the path of the URL a client is configured with, so it goes where it already lives — paste the icap://… line out of a squid.conf unchanged and exav answers on exactly that:

Terminal window
exav --listen icap://scanner:1344/avscan -d /var/lib/exav

With no path, exav answers on all three names a c-icap virus_scan deployment does — avscan, srv_clamav, virus_scan — so it stands in for one without your having to find out which the proxy asks for. Naming a service replaces that set rather than adding to it: a deployment that asks for one name must not keep answering on two it never configured. An unrecognised service gets 404 ICAP Service not found.

For the one shape a path cannot express — two proxies whose configurations disagree about the name, pointed at one exav — repeat the option:

Terminal window
exav --listen 'icap://0.0.0.0:1344?service=avscan&service=srv_clamav'

A comma will not do it: --listen separates addresses with commas, so one inside a value ends the address. That is also why the key is singular — each occurrence names exactly one service.

An address is a listener, not a mode, so the two protocols compose:

Command What listens
exav --listen clamd://0.0.0.0:3310 clamd only
exav --listen icap://0.0.0.0:1344 ICAP only
exav --listen clamd://0.0.0.0:3310 --listen icap://0.0.0.0:1344 both, in one process, over one loaded database

One process for both is what replaces a c-icap + clamav pair with a single container. It is also the cheaper arrangement: a full signature set costs seconds and gigabytes to load, and the second container mostly exists to avoid paying that twice.

Under the worker pool the ICAP listener runs in a dedicated child of the same supervisor. It shares the warmed database copy-on-write, and a signature reload re-forks it along with the scan workers — so the swap needs no in-process machinery. It gets its own process rather than a pool slot because ICAP connections are keep-alive and long-lived, and a handful of idle proxy connections would otherwise occupy every worker and starve the clamd listener too.

As a container, adding an icap:// address to EXAV_LISTEN adds the ICAP listener to the image’s default clamd one; the signature bootstrap, auto-update and hot-reload feed both:

Terminal window
docker run -d -p 3310:3310 -p 1344:1344 \
-e EXAV_LISTEN=clamd://0.0.0.0:3310,icap://0.0.0.0:1344 \
-v exav-db:/var/lib/exav ghcr.io/sylvinus/exav

For ICAP and nothing else, name the listener instead of taking the image’s default command:

Terminal window
docker run -d -p 1344:1344 -v exav-db:/var/lib/exav \
ghcr.io/sylvinus/exav --listen icap://0.0.0.0:1344 --auto-update
icap_enable on
icap_preview_enable on
icap_preview_size 4096
icap_service exav_resp respmod_precache icap://127.0.0.1:1344/avscan bypass=off
icap_service exav_req reqmod_precache icap://127.0.0.1:1344/avscan bypass=off
adaptation_access exav_resp allow all
adaptation_access exav_req allow all

bypass=off is the setting that matters: with bypass=on, a scanner that is down means traffic flows unscanned.

The mapping is keyed on the verdict’s categoryCLEAN, INFECTED, PARTIAL — not on the individual verdicts behind it, so a partial condition added to the engine arrives here already mapped, and on the blocking side.

Category ICAP response Headers
CLEAN 204 No Content when the client sent Allow: 204, else 200 echoing the message back byte for byte
INFECTED 200 OK + a block page X-Infection-Found: Type=0; Resolution=2; Threat=<signature>;
PARTIAL 200 OK + a block page X-Exav-Status: PARTIAL, X-Exav-Category: <condition>, X-Exav-Reason: <detail> — plus X-Infection-Found: … Threat=Heuristics.Exav.<Condition>; under the default --icap-infection-header blocks

X-Exav-Category carries the condition, and the threat name is derived from it:

X-Exav-Category Threat name in X-Infection-Found
LIMITS-EXCEEDED Heuristics.Exav.LimitsExceeded
UNSCANNABLE Heuristics.Exav.Unscannable
PASSWORD-PROTECTED Heuristics.Exav.PasswordProtected

X-Exav-Status is PARTIAL for every one of them. Blocking them is the default, not the only option — --partial-as hands those objects to the client instead. A detection is never affected by it.

Every non-clean verdict blocks, and every block says so in the c-icap vocabulary. This is the ICAP face of exav’s never a silent clean invariant, and it takes both halves because ICAP clients come in two kinds:

  • A proxy acts on the message. It sees a 200 carrying a replacement body instead of the object and stops it, whatever the headers say.
  • A scan wrapper acts on the headers alone. It hands a file over, greps the response for X-Infection-Found, and calls a 200 without that header clean.

So a partial block carries X-Infection-Found too. What keeps it distinguishable from a database hit is the threat name, not the header’s presence: Heuristics. is the prefix ClamAV puts on a policy block rather than a database entry, and .Exav. says which scanner synthesised it. No signature database ships a name that collides, X-Exav-Category still carries the exact condition, and an analyst reading an incident log can tell the two apart at a glance.

The header’s format is byte-for-byte what c-icap’s virus_scan emits, because deployed clients parse it by hand.

The block page is an HTTP 403 Forbidden with a short HTML body naming the signature or the verdict. Blocking a REQMOD works the same way — the client is handed an HTTP response to serve instead of forwarding the request, which is RFC 3507’s request satisfaction.

If you want the header to mean a database hit and nothing else

Section titled “If you want the header to mean a database hit and nothing else”

--icap-infection-header detections restores the strict split: only a signature match carries X-Infection-Found, and a partial object blocks with the X-Exav-* trio alone.

Blocking an object nobody could look inside is the default, and it is the right default for a proxy. It is not right for everyone: an upload service usually cares more about not rejecting a user’s password-protected ZIP than about the small chance that ZIP was hostile. c-icap + ClamAV makes exactly that trade — virus_scan.MaxObjectSize passes an oversized object, and ClamAV calls an encrypted archive clean unless asked otherwise — silently, and without being asked.

--partial-as makes it a decision instead of an accident. It is not an ICAP flag: the same policy decides a one-shot scan’s exit code and a clamd reply, so one deployment answers the question once.

Terminal window
exav --listen icap://0.0.0.0:1344 --partial-as ok # all three
exav --listen icap://0.0.0.0:1344 --partial-as password-protected=ok # just this one
exav --listen icap://0.0.0.0:1344 --partial-as limits-exceeded=ok,unscannable=ok
Value Effect on an ICAP response
partial (default) Blocks, carrying X-Exav-Status: PARTIAL and the category.
found Blocks as an ordinary detection named Heuristics.* — a hit to any client.
error Blocks the same way partial does. ICAP has no exit code, so the two differ only in what the status header says.
ok Delivered: a 204 No Content, with the headers still saying what was skipped. Includes any category a later exav grows.
a per-condition list Only the named conditions take that value; the rest keep the default. Conditions are limits-exceeded, unscannable, password-protected.

A condition exav does not recognise is a startup error, not a policy that silently never fires.

A detection is never passed. This setting covers only the objects exav could not fully examine; a signature match blocks under every value.

And a pass is never silent — which is what keeps the never a silent clean invariant intact rather than switched off:

  • the listener says so at startup, so a container’s logs answer “are we delivering unscanned files?” without anyone reconstructing its command line;
  • every passed object is logged with the verdict and the reason;
  • the response still carries X-Exav-Status, X-Exav-Category and X-Exav-Reason, so a client that reads them knows what it was handed.

It never carries X-Infection-Found, whatever --icap-infection-header says. The object is being delivered, so claiming an infection would be false — and it would make the header-only clients below block it anyway, which is the opposite of what you asked for.

Behind a script that shells out to an ICAP client

Section titled “Behind a script that shells out to an ICAP client”

A common shape — matrix-content-scanner is one — is a wrapper that runs c-icap-client, greps the response, and turns it into an exit code:

Terminal window
resp=$(c-icap-client -i "$ICAP_HOST" -p 1344 -f "$1" \
-s 'srv_clamav?allow204=on&force=on&sizelimit=off&mode=simple' -v 2>&1)
echo "$resp" | grep -q "X-Infection-Found" && exit 4 # infected
exit 0 # clean

Nothing about it needs changing to point at exav. The service name, the per-request options in the query string, the 204 on a clean object and the X-Infection-Found on a block are all what that script already expects — and with the default policy above, an encrypted archive reaches it as a block rather than as a silent exit 0.

The one setting such a deployment will want to think about is the pass policy, because for this client it decides whether a file is accepted:

Terminal window
# Behave like the c-icap + ClamAV container it replaces: deliver what could not
# be examined, block only what actually matched.
exav --listen icap://0.0.0.0:1344 --partial-as ok

Size needs no setting at all. There is no ICAP object ceiling to raise: an object is buffered the same way a clamd stream is and scanned whatever it weighs.

Size ceilings: read this before translating your config

Section titled “Size ceilings: read this before translating your config”

virus_scan.MaxObjectSize in c-icap means anything larger is not scanned and passes. A silent clean, driven by a number in a config file.

exav has no ICAP equivalent to translate it to, and does not need one. An encapsulated body is buffered exactly as a clamd stream is: in memory while it is small, spilled to a temp file past that, then scanned from wherever it landed. A connection therefore costs the same 16 MB whatever a client sends, so how large an object may be is a scan question rather than a memory one — and the scan answer is --max-input-bytes (clamscan --max-filesize), which is unset by default and applies identically to clamscan, the daemon and ICAP.

That is the difference an operator feels: one file gets one answer, whichever listener it arrives at.

If you are copying virus_scan.MaxObjectSize 5M across, the honest translation is usually nothing at all — exav will scan the objects c-icap was skipping. If you do want a size limit, --max-input-bytes 5M sets it everywhere, and past it an object is LIMITS-EXCEEDED rather than quietly clean. To keep c-icap’s answer as well as its number, add --partial-as limits-exceeded=ok; the difference from c-icap is then only that exav says so, in the log and in X-Exav-Category.

c-icap directive exav setting Default
Port 1344 --listen icap://0.0.0.0:1344 (nothing listens without it)
Service / ServiceAlias the path of the --listen address, or a repeated ?service= avscan, srv_clamav, virus_scan
MaxServers x ThreadsPerChild ?max-connections= on the address 100
MaxKeepAliveRequests --icap-max-requests 100
KeepAliveTimeout --icap-idle-secs 600
virus_scan.MaxObjectSize’s size --max-input-bytes (all surfaces) (unset)
virus_scan.MaxObjectSize’s pass --partial-as (all surfaces) block
(preview size, per service) --icap-preview-bytes 4096
(none) --icap-transfer-preview *
(none) --icap-options-ttl-secs 3600
(none) --icap-max-header-bytes 65536
(none) --icap-infection-header blocks

Every flag has an EXAV_* variable spelled the same way — --icap-max-requests reads EXAV_ICAP_MAX_REQUESTS. A flag beats the variable; both beat the default. Pipelining on needs no setting — multiple requests per connection always work.

Two settings ride on the address rather than on a flag: the service names above, and the connection cap — which bounds this listener, and which the clamd one reads under the same name:

Terminal window
exav --listen 'icap://0.0.0.0:1344/avscan?max-connections=200'

Draining, and why an over-limit body is still read

Section titled “Draining, and why an over-limit body is still read”

Sometimes exav knows its answer before the client has finished sending — the object went past --max-input-bytes, or a budget refused it. The bytes still arriving are of no further use. The question is what to do with them, and the obvious answer is wrong:

  • Answer immediately and close. Closing a socket that still has unread bytes in its receive queue makes the kernel send an RST, and an RST throws away whatever the peer has not read yet — including the response just written. The client sees a connection reset and no verdict at all. The one thing exav had to say is the thing that gets lost.
  • Read the rest and throw it away — “draining” — then answer. The receive queue is empty, the close is clean, the verdict arrives, and the connection is even left at the next request boundary so it can be reused.

Draining is the right move, but it costs time proportional to whatever the client chooses to send, and a client that never stops would hold a thread for ever — every individual read arrives inside the socket timeout, so the timeout never fires. So there is a bound, 64 MB, where exav stops being polite: past it it answers anyway and closes, accepting that this particular client may lose the response. It is a constant rather than a flag, because the number that matters is “more than any real client sends after being told to stop”, and there is no deployment that wants a different one.

It only comes up when something stopped exav early, which with the default unlimited --max-input-bytes means almost never. The clamd listener does the same thing for INSTREAM, at the same bound.

ICAP clients cache adaptation results against the ISTag the server reports. exav derives it from the loaded signature set — database version, build time and signature count — so a signature reload changes the tag and every cached verdict downstream is invalidated. A tag that stayed put across a reload would keep serving verdicts from a database that no longer exists.

Signatures hot-reload the same way as in daemon mode: the data directory is polled and a change swaps the database in, without dropping connections.

ICAP/1.0 200 OK
Server: exav/0.1.0
ISTag: "exav-ae0b21d4572e711f"
Methods: REQMOD, RESPMOD
Service: exav/0.1.0 ICAP service
Allow: 204
Preview: 4096
Max-Connections: 100
Options-TTL: 3600
Transfer-Preview: *
Connection: keep-alive
Encapsulated: null-body=0

Framing, and why the feature is on by default

Section titled “Framing, and why the feature is on by default”

ICAP’s framing resembles HTTP’s without being it: an HTTP chunked decoder has no 0; ieof, and one that reads that marker as an ordinary end-of-body cannot tell a complete small object from the head of a large one. So exav parses the request line, the header block, the Encapsulated offset list and the chunked body itself, against std alone — no HTTP library is involved.

Serving ICAP therefore adds no dependency to the binary, which is why the icap feature is on by default: it costs a build nothing and binds nothing until an icap:// address asks it to.

  • Trickling (SendPercentData / StartSendPercentDataAfter): c-icap can dribble part of a body to the client while scanning, so a slow download does not time out. exav buffers the whole object first.
  • ICAP-level ACLs (icap_access, acl): restrict access with the bind address and a firewall.
  • TLS (ICAPS): terminate it in front, or keep the hop on a loopback or private network.
  • X-Violations-Found: the older multi-violation reporting header. exav reports one verdict per object through X-Infection-Found / X-Exav-Category.
  • 206 Partial Content: exav returns a full replacement body instead.