Skip to content

Daemon mode

--listen clamd://… loads the signature database once and serves scans over a socket, so callers pay no per-scan load cost. The wire protocol is a subset of ClamAV’s clamd protocol, so existing tooling (clamdscan, milters, clamd client libraries) talks to exav unchanged.

The daemon refuses to start without signatures, so fetch them first (see Signatures). A prebuilt .exavdb starts much faster than raw .cvd files: see what it costs.

Terminal window
exav --listen /run/clamav/clamd.ctl -d /var/lib/exav # a Unix socket
exav --listen 0.0.0.0:3310 -d /var/lib/exav # TCP instead
exav --listen clamd://0.0.0.0:3310 -d /var/lib/exav # the same, spelled out

The protocol travels in the address, and with no scheme it is clamd. A leading / is a socket path; anything else is host:port. Nothing listens without --listen. Creating a socket under /run/clamav needs root or the clamav user; as yourself, use a path such as $XDG_RUNTIME_DIR/exav.sock.

Then scan through it:

Terminal window
exav --connect /run/clamav/clamd.ctl /data # exav as its own client
exav --connect /run/clamav/clamd.ctl --send-as contents /data # send the bytes
exav --connect /run/clamav/clamd.ctl --send-as fd /data # send descriptors
clamdscan --stream file.bin # the official ClamAV client
clamdscan --fdpass file.bin # fd-passing also works

--listen accepts connections and --connect makes one. clamdscan takes the address from its clamd.conf (LocalSocket, or TCPSocket and TCPAddr).

By default the client sends paths, which the daemon opens itself, so the daemon has to be able to see them. --send-as contents sends the bytes (INSTREAM) and --send-as fd sends an open descriptor (FILDES, Unix socket only); either way the daemon needs no access to the path, which suits a container, a remote scanner or a daemon running as another user. - (stdin) is always sent as contents.

A directory names its whole tree in all three modes, as it does for clamdscan. The client walks it and sends one request per file, so --exclude / --include apply to the tree and each request has one reply. The parts of a byte-split archive (x.001, x.002, …) are sent together so the daemon can rejoin them (see split archives). By path, a directory holding such parts goes as one CONTSCAN, and the client’s filters and --no-recursive do not apply to it; with contents or fd, the parts of each directory go in one EXINSTREAM MULTI request.

The socket is created with mode 0600: only the user running the daemon can connect. Anyone the mode admits can submit scans and read verdicts.

Terminal window
exav --listen 'clamd:///run/clamav/clamd.ctl?mode=660' -d /var/lib/exav

?mode= takes octal permission bits, the value clamd.conf calls LocalSocketMode. Prefer a group (660, with the daemon’s group shared with its clients) over 666. The mode is applied at creation, whatever umask the daemon inherited, and a value that is not a workable mode is refused.

The mode belongs to the address because it is a property of that socket. A host:port has no file to set permissions on, so asking for one there is refused; bind a TCP listener to the interface that should reach it and firewall the rest. The other address options are listed in what belongs to one listener.

Supported commands: PING, VERSION, VERSIONCOMMANDS, STATS, RELOAD, SCAN / CONTSCAN / MULTISCAN / ALLMATCHSCAN <path>, INSTREAM, FILDES (fd passing), IDSESSION / SESSION / END, SHUTDOWN / QUIT (below), and the exav extensions EXINSTREAM, EXINSTREAM MULTI (below) and SCANURL <url>. SCANURL scans an http(s):// object through range requests (see scanning over HTTP) and is honoured, and listed in VERSIONCOMMANDS, only in a build with http-scan (--features http) and with --allow-http-scan. Commands may be z- or n-framed.

EXINSTREAM takes INSTREAM’s framing and answers one line of JSON with the verdict fields, for instance {"v":1,"status":"FOUND","signature":"…","location":"a.zip/b.exe"}, or {"v":1,"status":"PARTIAL","category":"LIMITS-EXCEEDED","reason":"…"}.

STATS carries the clamd fields clamdtop parses, then two exav lines: how much this process scanned, how long it took, and (with --profile) which matcher the time went to. See finding where the CPU went.

By default a file that could not be fully scanned comes back as … ERROR carrying LIMITS-EXCEEDED, UNSCANNABLE or PASSWORD-PROTECTED, because an unknown word would be read as OK by existing clients; --partial-as changes that (see on the clamd wire).

The deprecated STREAM (separate port) command is not implemented; INSTREAM replaces it. SHUTDOWN (and its alias QUIT) is refused by default so a client cannot stop the daemon; send the process a signal instead, or pass --allow-shutdown to honour it.

Validated against clamdscan (--fdpass, --stream, --multiscan), the Python clamd library, and raw socat.

An archive spread across big.zip.001, .002, .003 is one file cut at arbitrary byte offsets. No part holds the archive whole, so scanning them one at a time reports three clean files and never looks at what the archive holds. (The first part of a 7z set is PARTIAL on its own: the header that says how to decode it is in the last part.)

CONTSCAN and MULTISCAN rejoin such sets per directory and scan the archive they make. The reply is still one line per file, and every part carries the archive’s verdict, a FOUND replacing a part’s own PARTIAL:

/data/big.zip.001: Eicar-Signature FOUND (in big.zip)
/data/big.zip.002: Eicar-Signature FOUND (in big.zip)

A part of an infected archive is the file you have to act on. A set with a gap in its numbering comes back UNSCANNABLE: those bytes belong to an archive nothing can read any more. A missing last part cannot be seen from the names.

EXINSTREAM MULTI does the same for streamed input, with several files in one request:

EXINSTREAM MULTI
<u32 name_len><name> <u32 len><data>…<u32 0> per file
<u32 0> ends the request

Names are labels; nothing opens or resolves them as paths. The reply is one line of JSON, {"v":1,"files":[…]}, with one entry per file carrying the same verdict fields as a single EXINSTREAM, plus "set" when the verdict came from a rejoined archive. One request carries at most 1024 files with names up to 4096 bytes, and holds at most 256 MiB in RAM and 8 GiB in temporary files across them; past any of these the request is answered with a JSON error. A file the daemon could not hold in full has what it held scanned and is answered LIMITS-EXCEEDED unless something is found; the rest of the request is still scanned. The reply carries "truncated": true when the request ended before its terminator, so some files never arrived.

RAR .partN and ZIP .zNN volumes are not rejoined this way: each carries its own headers, so concatenating them produces garbage that still looks like an archive. Each is scanned on its own.

SCAN <path> sends only the path, and the daemon scans the file as the CLI would. An INSTREAM body is buffered before it is scanned: in memory up to --spill-threshold-bytes (16 MiB), then in a temporary file up to --max-spill-bytes (2 GiB), with --max-total-spill-bytes bounding what one process holds at once. A stream past those is scanned as far as it was held and answered LIMITS-EXCEEDED. See buffering a stream.

When the daemon has its answer before the stream ends, it reads and discards up to 64 MiB of the rest before replying, so the reply is not lost to a reset connection; the ICAP guide explains why. ICAP bodies are buffered the same way.

By default the daemon runs a prefork pool of one worker process per CPU core (--workers threads selects the in-process thread model). Workers are forked after the database loads, so they share it copy-on-write. Each handles one job at a time under kernel-enforced limits:

Flag Default Bounds
--max-scan-secs 120 s wall clock (and CPU time, RLIMIT_CPU)
--max-process-bytes 2 G address space (RLIMIT_AS)
--max-jobs-per-worker 1000 jobs before a worker is recycled (bounds leaks)

The 2 G is lowered at startup to what the machine can back, and what a scan holds is kept inside it: one object at most a quarter, since an image decoded from it and its grey copy can take as much again twice, and the whole scan whatever leaves room for those. Sizing a server works this through for a 4 GiB machine.

A worker past its limit answers its client, exits and is replaced, without touching the rest of the pool. The in-engine limits (scan bytes, ratio, recursion, emulation steps) fire first.

--log FILE records one line per scan the daemon serves, appended and flushed as it goes:

Terminal window
exav --listen /run/clamav/clamd.ctl --log /var/log/exav/exav.log -d /var/lib/exav

The daemon refuses to start if it cannot open the file for appending, so its directory must exist and be writable by the daemon. The packaged unit makes /var/log read-only, so with systemctl edit exav add LogsDirectory=exav (which creates /var/log/exav for the unit’s user) along with the --log flag on ExecStart.

The daemon’s results otherwise go only to the client that asked, so this is the operator’s record. A path scan is logged under its path, a streamed scan as stream:, a passed descriptor as fd:. PING, VERSION and STATS are not logged. The file is opened for appending and each line is written in a single call, so lines from different workers never interleave.

SIGHUP reopens the file, as it does for clamd, so logrotate can move it:

/var/log/exav/exav.log {
weekly
postrotate
systemctl kill -s HUP exav.service
endscript
}

The daemon refreshes signatures without a restart on any of:

  • the clamd RELOAD command (what freshclam’s NotifyClamd sends; the reply is RELOADING);
  • SIGUSR2 (kill -USR2 <pid>, systemctl reload), as for clamd;
  • a change on disk at whatever -d points at (see below);
  • a successful fetch by the built-in updater.

On each, the supervisor loads the new database and forks a new pool from it. Workers of the old pool finish the job they are serving before they exit, so a reload never cuts a scan short; ICAP requests in progress likewise finish, and idle ICAP connections are closed. The old pool gets --max-scan-secs plus 5 seconds for this (10 minutes when --max-scan-secs is 0). A process still there after that, held by an open IDSESSION or a client that never finishes its request, is stopped and logged; a worker stopped in the middle of a job answers it with ERROR. Under --workers threads, and for ICAP served alone, the new database is swapped in instead: a connection already open keeps the one it started with. If the new source is empty or corrupt, the swap is refused and the daemon keeps serving the current database.

SIGHUP does not reload (it reopens the log). SIGTERM and SIGINT stop the daemon.

Every ~10 seconds the supervisor checks the -d source and reloads when it changed, as clamd’s SelfCheck does. It compares the name, size, modification time and inode of every entry with what it saw when the serving database was read, so a new or renamed file counts even when it carries the same timestamp as the rest (some filesystems stamp from a coarse clock), and a database replaced by one with an older mtime is still picked up. It watches whichever form -d names:

  • a directory of signature files: the whole tree, subdirectories included;
  • a single database file (-d db.exavdb): that file, which an atomic swap replaces.

A file rewritten in place with the same size within one tick of the filesystem’s clock looks unchanged; send RELOAD after such a write.

Polling rather than kernel events is what makes it work across Docker volumes, bind mounts and NFS, where inotify events do not propagate. The dual-container pattern relies on it (the updater renames a new .cvd into the shared volume and the scanner’s next poll reloads), and a prebuilt-database deployment reloads on swap too, with no explicit RELOAD.

A systemd unit (packaging/exav.service, also shipped in the .deb) runs exav in place of clamav-daemon on the same socket, reading the raw signatures freshclam keeps in /var/lib/clamav. It runs as the clamav user, which the package does not create, so install it where ClamAV is installed:

Terminal window
sudo systemctl disable --now clamav-daemon.socket clamav-daemon.service
sudo systemctl enable --now exav
clamdscan --ping 1

Stopping clamav-daemon.socket matters on Debian and Ubuntu: while it holds the socket, the first connection starts clamd again. systemctl reload exav sends SIGUSR2.

See Migrating from ClamAV for the full swap, and the Docker guide for the containerised daemon.