carlos / platform Public

docs: the scanner host, store_scanning, and the scanner's real variables

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 53e0376468db4fbcd1315f46674307b272bd412c parent 88eadd0
5 files changed, +217 −9
  • docs/site/cli.md +9 −9
  • docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md +2 −0
  • infra/modules/README.md +21 −0
  • infra/modules/region-host/README.md +4 −0
  • infra/modules/scanner-host/README.md +181 −0

CI failed — run details

diff --git a/docs/site/cli.md b/docs/site/cli.md
index a1e2ffbbe..1b4edfd33 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -1070,7 +1070,7 @@ otherwise leave the denial in place with no way to clear it.
numbers — how many objects were scanned, and how many were not. They are not
complements. An object too large to stream past the engine was never judged at
all, and a bucket full of them could report zero infections while nothing had
-actually been looked at. A sweep still in progress says so and says which
+actually been looked at. An archive that expands past the engine's limits is counted as not scanned, the same as an object too large to stream. A sweep still in progress says so and says which
numbers are stale; a first sweep that has not finished claims no coverage at
all. Signature-database age is printed, and called out when it is old, because
a clean verdict from three-month-old definitions is a weaker claim than a
@@ -1084,16 +1084,18 @@ how many it carried forward, so the two are never confused.
objects are recorded and still readable. `carlos store status` says which of
the two you have.
-The operator side is six environment variables on the console:
+The operator side is six environment variables on the console. Setting `CARLOS_CLAMD_ADDR` wires an engine. It does not switch scanning on. The switch is `carlos system antivirus enable`.
| variable | default | meaning |
| --- | --- | --- |
-| `CARLOS_STORE_SCANNER` | unset | `clamav` is the only engine this build accepts. Unset means no scanning at all. |
-| `CARLOS_CLAMD_SOCKET` | `/run/clamav/clamd.ctl` | the unix socket to reach `clamd` on |
-| `CARLOS_CLAMD_MAX_BYTES` | 25 MB | objects larger than this are recorded as skipped without being fetched; mirror `clamd`'s own `StreamMaxLength` |
+| `CARLOS_CLAMD_ADDR` | unset | where to reach `clamd`: `host:port`, or a socket path beginning `/`. Unset means no scanning at all. A [scanner host](https://github.com/carlosframework/platform/tree/main/infra/modules/scanner-host) module output gives you this value. |
+| `CARLOS_SCAN_TAG_KEY` | unset | required once an address is set, and the console refuses to boot without it. It signs every verdict, so keep it: without it, stored verdicts cannot be verified. |
+| `CARLOS_CLAMD_MAX_BYTES` | 25 MiB | objects larger than this are recorded as skipped without being fetched. Set it equal to `clamd`'s own `StreamMaxLength`. |
| `CARLOS_CLAMD_MAX_SIG_AGE` | 168h | how old the signature database may get before `scan status` warns |
| `CARLOS_STORE_SCAN_BUDGET` | 60s | how much scanning one app gets per hourly tick |
-| `CARLOS_STORE_SCAN_INTERVAL` | 24h | the floor before a *completed* sweep is run again. A sweep still in progress ignores it — that is how a bucket bigger than one budget ever finishes. |
+| `CARLOS_STORE_SCAN_INTERVAL` | 24h | the floor before a *completed* sweep is run again. A sweep still in progress ignores it. That is how a bucket bigger than one budget ever finishes. |
+
+`CARLOS_CLAMD_SOCKET` is still accepted as another name for `CARLOS_CLAMD_ADDR`. `CARLOS_STORE_SCANNER=clamav` is deprecated: it still wires the default socket `/run/clamav/clamd.ctl` and seeds the switch once, and the console logs a warning on every boot while it is set. `CARLOS_SCAN_TAG_KEY_PREVIOUS` is optional and holds the old key while you rotate.
The budget arithmetic is worth stating rather than discovering. An hourly tick
at 60s is 24 minutes of scanning per app per day; at a few hundred objects a
@@ -1102,9 +1104,7 @@ The default is honest for small and medium stores. A deployment with large ones
raises the budget or shortens the tick, and `scan status` says when a sweep has
spanned more than one tick rather than implying its numbers are current.
-Setting `CARLOS_STORE_SCANNER` without `CARLOS_STORE_BACKEND` is refused at
-boot — a scanner with no object stores to scan is a misconfiguration, and one
-loud failure beats a pass that silently finds nothing forever.
+Setting `CARLOS_CLAMD_ADDR` without `CARLOS_STORE_BACKEND` is refused at boot. A scanner with no object stores to scan is a misconfiguration, and one loud failure beats a pass that silently finds nothing forever.
Wiring an engine is not the same as switching scanning on. The deployment-wide
switch is `carlos system antivirus` — see [carlos system](#carlos-system).
diff --git a/docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md b/docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md
index 8a2cdd57c..0f164be27 100644
--- a/docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md
+++ b/docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md
@@ -1,3 +1,5 @@
+> **2026-10-05:** steps 2 and 4 are superseded by docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md. clamd runs on its own box, the switch is carlos system antivirus enable, and CARLOS_STORE_SCANNER is deprecated. Steps 5 to 8 and the watch list still stand.
+
# Rolling object-storage scanning out to Carloku
**Not TDD tasks — an ordered operational sequence.** Do not start it until the
diff --git a/infra/modules/README.md b/infra/modules/README.md
index 8526879fe..3553d493e 100644
--- a/infra/modules/README.md
+++ b/infra/modules/README.md
@@ -115,6 +115,27 @@ only the fleet, with *no* bucket or parameter grants; statelessness is
imposed, not promised). Generalise it only when a second deployment
wants one.
+## scanner-host
+
+One box that runs clamd for the console's object-storage virus scanning, on its own host so signature updates and scan CPU stay off the control plane. The console streams each object to it over TCP 3310. The box reads no buckets and no parameters: its role has Session Manager and an explicit Deny on SSM parameter reads. The security group admits 3310 from the console box's group and nothing else, and there is no SSH.
+
+Port 3310 is open only while a self-test has passed against the clamd process running now. A replaced box gets a new private IP, so update the console's `CARLOS_CLAMD_ADDR` after any `-replace`. The module's `README.md` has the readiness check, four acceptance checks to run before wiring a console, and what a clean verdict does not cover.
+
+| Variable | Description |
+|---|---|
+| `name` | Name for the instance, its security group, its IAM role and its instance profile (e.g. `carlos-flagship-scanner`). |
+| `ami_id` | Debian arm64 AMI. Pass the console box's own, `module.<host>.ami_id`. Ignored after creation: a newer image never replaces the box. |
+| `client_security_group_id` | Security group of the one box allowed to reach clamd: the console's. clamd has no authentication, so this group is the whole fence. |
+| `region` | AWS region the box runs in. The box fetches its SSM agent package from that region's bucket. |
+| `instance_type` | arm64 instance type. Default `t4g.medium`. A `t4g.small` works only with `concurrent_database_reload = false`. |
+| `stream_max_bytes` | clamd's `StreamMaxLength`, in bytes. Default `26214400` (25 MiB), between 8 MiB and 100 MiB. The console's `CARLOS_CLAMD_MAX_BYTES` must equal it. |
+| `memory_max` | clamd's cgroup ceiling (systemd `MemoryMax`). Default `3G`. |
+| `memory_high` | clamd's throttle point (systemd `MemoryHigh`). Default `2560M`. |
+| `concurrent_database_reload` | Keep scanning while new signatures load, at the cost of holding both databases in memory. Default `true`. |
+| `subnet_id` | Subnet to launch in. Null (default) is the default subnet of the default VPC, and is refused unless the client security group is in the default VPC. The subnet needs a route out. |
+
+Outputs: `clamd_addr` (set as the console's `CARLOS_CLAMD_ADDR`), `stream_max_bytes` (set as `CARLOS_CLAMD_MAX_BYTES`), `instance_id`, `security_group_id`.
+
## region-remote-host
What an off-cloud CARLOS host (customer fleets and tito's hq)
diff --git a/infra/modules/region-host/README.md b/infra/modules/region-host/README.md
index b10c3a8c0..bf3c5d067 100644
--- a/infra/modules/region-host/README.md
+++ b/infra/modules/region-host/README.md
@@ -102,6 +102,10 @@ platform-update script, re-run `carlos bootstrap` so any new unit is
installed and armed; a stage relay that is not armed fails silently, which
looks exactly like a deploy that did nothing.
+## Object-storage scanning
+
+`store_scanning = true` lets this host's role read and tag objects under `store_bucket_prefix` (`s3:GetObject`, `s3:GetObjectTagging` and `s3:PutObjectTagging`), which is what the console's virus scanner needs. It is off by default, so a deployment that has not switched scanning on plans no change and its console still never reads tenant data. Set it on the console's box only, and only on a deployment that runs a [scanner-host](../scanner-host/README.md). It requires `store_bucket_prefix`.
+
## Checking it worked
```sh
diff --git a/infra/modules/scanner-host/README.md b/infra/modules/scanner-host/README.md
new file mode 100644
index 000000000..708b6b094
--- /dev/null
+++ b/infra/modules/scanner-host/README.md
@@ -0,0 +1,181 @@
+# scanner-host: a clamd box for a deployment's console
+
+> 🤖 Written by an LLM (Claude), per factor X.
+
+This module makes one small box that runs clamd, the ClamAV scanning daemon, so the console can virus-scan the objects in app stores. The console reads each object from S3 and streams it to this box. The box reads nothing itself. Its role has Session Manager and nothing else, with an explicit Deny on every SSM parameter read, so a compromised clamd cannot fetch a tenant object, the scan tag key or the release key. There is no SSH. You reach the box over SSM Session Manager.
+
+```hcl
+module "scanner" {
+ source = "git::https://github.com/carlosframework/platform.git//infra/modules/scanner-host?ref=main"
+ name = "carlos-flagship-scanner"
+ ami_id = module.host_ie.ami_id
+ client_security_group_id = module.host_ie.security_group_id
+ region = "eu-west-1"
+}
+```
+
+Pass the console box's own AMI, so the two boxes are built from the same base. Pass the console box's security group as `client_security_group_id`. It is the only thing allowed to reach clamd, which authenticates nobody, so that group is the whole fence. The module creates the scanner's security group in the same VPC. It admits TCP 3310 from that one group and never from a CIDR.
+
+## If you replace the box, the address changes
+
+A replaced box gets a new private IP, and the console does not follow it. After any replacement, update `CARLOS_CLAMD_ADDR` on the console and restart it. Until you do, every sweep fails on its tick, and `carlos store scan status` and `carlos system antivirus status` say so. The console itself stays up.
+
+Replacement is always explicit. A newer AMI, an edited init script or a new `stream_max_bytes` does nothing to a running box. To apply any of them:
+
+```sh
+tofu apply -replace=module.<name>.aws_instance.scanner
+```
+
+## Wiring the console
+
+The module has two outputs the console needs. Each becomes a console environment variable.
+
+| Output | Console variable |
+|---|---|
+| `clamd_addr` (`<private_ip>:3310`) | `CARLOS_CLAMD_ADDR` |
+| `stream_max_bytes` | `CARLOS_CLAMD_MAX_BYTES` |
+
+The console also needs `CARLOS_SCAN_TAG_KEY`. It signs every verdict the console writes, and the console refuses to boot with an address and no key. Generate 32 random bytes as hex, once per deployment. Keep the only copy in your secret store, never in git, tofu state or a transcript. If you lose it, every stored verdict becomes unverifiable.
+
+The console's own role also needs `store_scanning = true` on its `region-host`, which lets it read and tag store objects. See that module's README.
+
+Wire the console only after the box has passed the readiness check and the four acceptance checks below. Nothing the console can ask tells a tested engine from an untested one. The console must also run a build that records an archive over the engine's limits as not scanned. A console without that change would record such an archive as infected.
+
+Wiring an address does not switch scanning on. Switch it on with `carlos system antivirus enable`.
+
+## How the port is kept shut
+
+Port 3310 is open only while a self-test has passed against the clamd process that is running now. A failed test, a clamd restart or a ClamAV package upgrade closes the port until the test passes again. The console cannot check this for you, because `antivirus status` and `enable` only ask clamd for its version, and an engine that signs a truncated scan as clean answers that perfectly well.
+
+To get there, clamd never listens on TCP. It listens on the unix socket `/run/clamav/clamd.ctl` only. `scanner-tcp.socket` listens on the box's private IPv4 address on port 3310 and hands each connection to `scanner-tcp.service`, which carries it to the unix socket. Both units are bound to `scanner-selftest.service`, which is bound to `clamav-daemon.service`. When the self-test unit goes inactive, the port closes and connections in flight are cut.
+
+The self-test builds ten archive fixtures, streams each to clamd, and requires the right class of answer. An archive that expands past the limits must answer with a limit hit. An `OK` there is a failure. It runs every time clamd starts, on whatever ClamAV version is installed at that moment.
+
+## Why the two limits are equal and fixed
+
+clamd runs with `MaxFileSize 100M`, `MaxScanSize 100M` and `AlertExceedsMax yes`. These are constants in this release, not variables.
+
+`AlertExceedsMax yes` makes clamd answer `Heuristics.Limits.Exceeded.<limit> FOUND` when an archive expands past a limit. The console records that as not scanned. With the default `no`, clamd scans part of the archive and answers `OK`.
+
+That is not enough on its own. ClamAV 1.4.3 trims a zip member that expands past `MaxFileSize`, scans the trimmed copy, and raises no limit. With the two limits equal, the trimmed copy is as large as the whole scan budget, so scanning it always trips `MaxScanSize`, which does alert. The fixtures and expected answers in the self-test are sized for 100 MiB, so changing the limit means deriving both from it.
+
+Measured on 2026-10-05 against Debian trixie's clamd (ClamAV 1.4.3), with `StreamMaxLength` at 25M and every fixture streamed with `clamdscan --stream`. Each member is a repeating 16 KiB random block, distinct per member, so clamd's clean cache cannot short-circuit it. "Limit" means a `Heuristics.Limits.Exceeded.*` reply.
+
+| Fixture | `MaxFileSize 25M`, `MaxScanSize 100M` | Both 100M (this module) | Self-test requires |
+|---|---|---|---|
+| small clean file | OK | OK | OK |
+| EICAR | infected | infected | infected |
+| zip, one member expanding to 500 MiB | **OK** (trimmed, silent) | Limit | Limit |
+| zip, a zip inside holding that member | **OK** (silent) | Limit | Limit |
+| zip, 30 distinct 20 MiB members | Limit | Limit | Limit |
+| gzip of 500 MiB | Limit | Limit | Limit |
+| tar.gz, 5 x 40 MiB | Limit | Limit | Limit |
+| bzip2, xz, tar.xz of the same | Limit | Limit | not in self-test |
+| zip, 500 MiB member then EICAR | infected | Limit | Limit |
+| zip, 60 MiB member then EICAR | infected | infected | infected |
+| zip, one 60 MiB member | **OK** (trimmed, silent) | OK (fully scanned) | OK |
+
+The first column is what an unequal pair does: three fixtures were signed clean over content nobody read.
+
+`stream_max_bytes` is the one limit you can change. It is clamd's `StreamMaxLength`, the largest object it accepts on a stream. It defaults to 26214400 (25 MiB) and must be between 8 MiB and 100 MiB. The floor keeps the self-test's fixtures, about 5 MB each, inside the stream. The ceiling keeps a whole stream inside the scan budget. The console's `CARLOS_CLAMD_MAX_BYTES` must equal it. Use the `stream_max_bytes` output rather than typing the number twice.
+
+## What a clean verdict does not cover
+
+A zip member written with zip64 local headers is not extracted at all by ClamAV 1.4.3 (`local header - stream out of file`), and EICAR inside one reads `OK` under every configuration. Encrypted archives are the same. This is parser coverage, not a limit, and no clamd setting changes it. A clean verdict means clamd found nothing in what it could open. It does not mean the object is safe.
+
+## Checking that a box is ready
+
+First boot takes several minutes. It installs packages, downloads about 110 MB of signatures and runs the self-test. In an SSM session on the box:
+
+```sh
+cloud-init status --wait
+cat /var/lib/scanner-host/ready
+systemctl is-active scanner-selftest.service scanner-tcp.socket
+journalctl -u scanner-selftest.service --no-pager | tail -14
+```
+
+You should see `status: done`, a ClamAV version line, `active` twice, and then ten `pass` lines and `scanner-selftest: PASS`. The init script also writes each step to the serial console, ending in `SCANNER-HOST READY <version>` or `SCANNER-HOST FAILED: <reason>`. To read it without a session:
+
+```sh
+aws ec2 get-console-output --instance-id <scanner-instance-id> --latest --output text | grep 'SCANNER-HOST'
+```
+
+If the box failed, fix the module and replace the box. Do not patch the box.
+
+## Acceptance checks
+
+Run all four on the first real box in a deployment, before the console is wired, and record the results. Each one proves a part of the invariant that first boot cannot. Run the commands as root in an SSM session on the box, except the third.
+
+**1. Killing clamd closes the port, and it reopens only after the self-test passes.**
+
+```sh
+systemctl kill -s KILL clamav-daemon.service
+sleep 3
+echo "after kill: $(ss -ltnH 'sport = :3310' | wc -l) listeners, selftest $(systemctl is-active scanner-selftest.service)"
+for i in $(seq 1 60); do [ "$(systemctl is-active scanner-tcp.socket)" = active ] && break; sleep 10; done
+echo "recovered: socket $(systemctl is-active scanner-tcp.socket), selftest $(systemctl is-active scanner-selftest.service), $(ss -ltnH 'sport = :3310' | wc -l) listeners"
+```
+
+Expected: `after kill: 0 listeners`, with the self-test anything but `active`. Then `recovered: socket active, selftest active, 1 listeners`. If the port is still open right after the kill, or never reopens, the unit dependencies do not work on this systemd. Fix the module and replace the box.
+
+**2. A failed self-test keeps the port shut.** The script honours `SCANNER_SELFTEST_FORCE_FAIL=1`. Set it in a drop-in for this check only.
+
+```sh
+mkdir -p /etc/systemd/system/scanner-selftest.service.d
+printf '[Service]\nEnvironment=SCANNER_SELFTEST_FORCE_FAIL=1\n' > /etc/systemd/system/scanner-selftest.service.d/99-force-fail.conf
+systemctl daemon-reload
+systemctl restart clamav-daemon.service
+sleep 90
+systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket
+ss -ltnH 'sport = :3310' | wc -l
+
+rm /etc/systemd/system/scanner-selftest.service.d/99-force-fail.conf
+systemctl daemon-reload
+systemctl restart clamav-daemon.service
+for i in $(seq 1 60); do [ "$(systemctl is-active scanner-tcp.socket)" = active ] && break; sleep 10; done
+systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket
+ss -ltnH 'sport = :3310' | wc -l
+```
+
+Expected from the first print: `active`, `failed`, then `inactive` or `failed`, then `0`. Expected from the second: `active` three times, then `1`.
+
+**3. The scan protocol works through the proxy, from the console's box.** Run this in bash in an SSM session on the console box. `/dev/tcp` is a bash feature. It streams the EICAR test string, a harmless standard test file that every engine flags, to the scanner.
+
+```bash
+exec 3<>/dev/tcp/<scanner-private-ip>/3310
+printf 'zINSTREAM\0\0\0\0\104X5O!P%%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\0\0\0\0' >&3
+timeout 20 head -c 200 <&3 | tr -d '\0'; echo
+```
+
+`\0\0\0\104` is the 68-byte chunk length (octal 104 is 68). The four zero bytes after the payload end the stream. Expected: `stream: Eicar-Test-Signature FOUND`. Also confirm the fence from a box that is not the console's: the same connect attempt should time out.
+
+**4. The box comes back from a reboot with the port open.** First boot cannot show this, because `basic.target` and `sockets.target` are already reached by the time the init script runs. A systemd ordering cycle would only appear on a later boot.
+
+```sh
+aws ec2 reboot-instances --instance-ids <scanner-instance-id>
+```
+
+Wait five minutes, then in an SSM session on the box:
+
+```sh
+systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket
+ss -ltnH 'sport = :3310' | wc -l
+journalctl -b --no-pager | grep -ci "ordering cycle"
+systemd-analyze verify /etc/systemd/system/scanner-selftest.service /etc/systemd/system/scanner-tcp.socket /etc/systemd/system/scanner-tcp.service 2>&1 | grep -v '^$' | head
+```
+
+Expected: `active` three times, `1`, `0`, and nothing from `systemd-analyze verify` about these three units.
+
+## Sizing
+
+The default is `t4g.medium`. clamd holds about 1.5 GB of signatures in memory, and about twice that for a short time while it loads a fresh set. A `t4g.small` or smaller cannot hold two databases at once. `tofu plan` refuses that pairing unless you set `concurrent_database_reload = false`, which pauses scanning during each reload.
+
+clamd runs under a cgroup ceiling (`memory_max`, default `3G`, with a throttle at `memory_high`, default `2560M`). Under memory pressure the kernel kills clamd, never the SSM agent, which is the only way onto the box. systemd restarts clamd, and the self-test re-runs before the port reopens. The root volume is 20 GiB, encrypted gp3.
+
+## Network
+
+The box needs a route out. It downloads packages, the SSM agent (from the regional bucket for `region`) and signature updates. With `subnet_id` null, the module uses the default subnet of the default VPC, and refuses at plan time unless the client security group is in the default VPC. Otherwise pass a `subnet_id` in the client group's VPC. Egress is open. There is no ingress except TCP 3310 from the client group.
+
+## Variables and outputs
+
+Variables are described in `variables.tf`. Outputs: `clamd_addr`, `stream_max_bytes`, `instance_id`, `security_group_id`.