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.
Run it
Section titled “Run it”exav --listen icap://0.0.0.0:1344 -d /var/lib/exav # the default servicesexav --listen icap://0.0.0.0:1344/avscan -d /var/lib/exav # only that oneICAP 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.
The service name is the path
Section titled “The service name is the path”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:
exav --listen icap://scanner:1344/avscan -d /var/lib/exavWith 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:
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:
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/exavFor ICAP and nothing else, name the listener instead of taking the image’s default command:
docker run -d -p 1344:1344 -v exav-db:/var/lib/exav \ ghcr.io/sylvinus/exav --listen icap://0.0.0.0:1344 --auto-updatePoint Squid at it
Section titled “Point Squid at it”icap_enable onicap_preview_enable onicap_preview_size 4096icap_service exav_resp respmod_precache icap://127.0.0.1:1344/avscan bypass=officap_service exav_req reqmod_precache icap://127.0.0.1:1344/avscan bypass=offadaptation_access exav_resp allow alladaptation_access exav_req allow allbypass=off is the setting that matters: with bypass=on, a scanner that is
down means traffic flows unscanned.
Verdict mapping
Section titled “Verdict mapping”The mapping is keyed on the verdict’s category — CLEAN, 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
200carrying 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 a200without 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.
Delivering what exav could not examine
Section titled “Delivering what exav could not examine”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.
exav --listen icap://0.0.0.0:1344 --partial-as ok # all threeexav --listen icap://0.0.0.0:1344 --partial-as password-protected=ok # just this oneexav --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-CategoryandX-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:
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 # infectedexit 0 # cleanNothing 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:
# 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 okSize 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.
Translating a c-icap configuration
Section titled “Translating a c-icap configuration”| 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:
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 anRSTthrows 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.
Caching and the ISTag
Section titled “Caching and the ISTag”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.
What OPTIONS advertises
Section titled “What OPTIONS advertises”ICAP/1.0 200 OKServer: exav/0.1.0ISTag: "exav-ae0b21d4572e711f"Methods: REQMOD, RESPMODService: exav/0.1.0 ICAP serviceAllow: 204Preview: 4096Max-Connections: 100Options-TTL: 3600Transfer-Preview: *Connection: keep-aliveEncapsulated: null-body=0Framing, 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.
Not implemented yet
Section titled “Not implemented yet”- 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 throughX-Infection-Found/X-Exav-Category.206 Partial Content: exav returns a full replacement body instead.