carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Scanner Host Rollout Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Object-storage virus scanning switched on, with no member granted, on carloku.com and then on the Tito platform, each with clamd on its own box in the home region.
6
7**Architecture:** A new `scanner-host` OpenTofu module builds a clamd box whose TCP port is open only while a self-test has passed against the running clamd. `region-host` gains an opt-in read-and-tag grant for the console. One small Go change records clamd's limit replies as unscanned. Then a release, and an operational sequence per deployment: apply, acceptance checks on the box, console roll, key, wiring, enable, proof.
8
9**Tech Stack:** Go (`internal/clamd`), OpenTofu 1.12 with `tofu test` and `mock_provider`, Debian trixie, ClamAV 1.4, systemd (`systemd-socket-proxyd`), AWS (EC2, IAM, SSM), the `carlos` and `amadan` CLIs.
10
11**Spec:** `docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md` (draft 6). Read it first; section numbers below (§) refer to it.
12
13## Global Constraints
14
15- **Rulings D1–D8 in spec §2 are fixed.** Do not reopen them.
16- `MaxFileSize` and `MaxScanSize` are both `100M`, a constant, not a variable (§3.2).
17- The invariant (§3.2): TCP 3310 is open only while a self-test has passed against the clamd process running now. clamd itself never listens on TCP.
18- The scanner box's role: Session Manager only, explicit Deny on the four parameter-read actions, no S3, no KMS (§3.1).
19- `CARLOS_STORE_SCANNER` stays unset everywhere. It is deprecated.
20- The scan tag key never appears in git, tofu state, argv, a terminal or a transcript (§6.1).
21- **Nothing live changes without Paul's go for that step.** Every `tofu apply`, console roll, env edit, restart, `antivirus enable`, merge, mirror push and release is preceded by showing him exactly what will run. Tasks 6–12 are marked **GATED**, as are the merge and mirror steps of Task 5.
22- Every tofu apply is `-target`ed. Never apply a whole root (flagship has standing `user_data` drift that restarts boxes; Tito's main cannot plan).
23- Carloku first, then Tito. Tito waits for #201 phase 2 to have landed.
24- User-facing prose (READMEs, `docs/site`, release notes) goes through the `copy-review` skill before it is written to a file. No em dashes in copy: full stop and a new sentence.
25- No GitHub issues. Track on an amadan discussion (`amadan` skill for the house rules).
26- Go: use the default build cache. Scratch goes under `$TMPDIR/scanner-host/`, never `/tmp`. Delete it at the end.
27- AWS profiles: `keymail` is the flagship account (console box `i-092c0c1eea75723cb`, eu-west-1, bucket `carlos-flagship-271376211898`). `titogo` is Tito's (console box `i-025a0b70f3d93304d`, eu-west-1). Assumed-role profiles need the Bash sandbox off for that command.
28- Commits end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
29
30## Review Focus
31
321. **An engine upgrade that changes limit behaviour.** A later ClamAV package could answer `OK` for an over-limit archive. Expected: the port closes and stays closed. Pinned by Task 7's forced-fail acceptance check and by the self-test running on every clamd start (Task 3).
332. **A reboot of the scanner box.** Expected: it comes back scanning with no hand. First boot cannot show an ordering cycle. Pinned by Task 7's reboot check.
343. **A signature name that merely contains the limit phrase.** `Win.Heuristics.Limits.Exceeded.Fake FOUND` must stay infected. Pinned by Task 1's tests.
354. **A console restarted on the old binary with the scanner wired.** Expected: wiring stops before any variable is written. Pinned by Task 8's `/proc/<pid>/exe` check and Task 9's precondition.
365. **A rerun of the key step.** Expected: the existing key survives; a second run cannot replace it and orphan every verdict. Pinned by `Overwrite:false` in Task 9.
37
38## File Structure
39
40Platform repo (this worktree, branch `worktree-scanner-host`):
41
42| File | Responsibility |
43|---|---|
44| `internal/clamd/clamd.go` (modify) | `parseReply`: limit replies become size-exceeded or error |
45| `internal/clamd/clamd_test.go` (modify) | tests for the above |
46| `infra/modules/region-host/variables.tf`, `main.tf` (modify) | `store_scanning` and the `-store-scan` policy |
47| `infra/modules/region-host/tests/store_scanning.tftest.hcl` (create) | plan-time tests for the grant |
48| `infra/modules/scanner-host/{versions,variables,main,outputs}.tf` (create) | the box, its group, its role |
49| `infra/modules/scanner-host/scanner-init.sh.tftpl` (create) | first-boot script |
50| `infra/modules/scanner-host/files/scanner-selftest` (create) | the self-test |
51| `infra/modules/scanner-host/files/scanner-selftest.service`, `scanner-tcp.socket`, `scanner-tcp.service` (create) | the gate |
52| `infra/modules/scanner-host/tests/scanner_host.tftest.hcl` (create) | plan-time tests |
53| `infra/modules/scanner-host/README.md` (create), `infra/modules/README.md`, `infra/modules/region-host/README.md`, `docs/site/cli.md`, `docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md` (modify) | docs |
54
55Other repos: `~/github.com/carlosframework/carloku-infrastructure` (`region-ie.tf`, new `scanner.tf`), `~/github.com/teamtito/carlos-infrastructure` (`object-store.tf`, new `scanner.tf`).
56
57---
58
59### Task 1: clamd client records limit replies as unscanned
60
61**Files:**
62- Modify: `internal/clamd/clamd.go` (`parseReply`, near line 268; the `StatusSizeExceeded` comment near line 38)
63- Test: `internal/clamd/clamd_test.go`
64
65**Interfaces:**
66- Consumes: `replyFixture(t, reply string) Result` (existing test helper), `signatureOf(reply string) string` (existing).
67- Produces: no new names. `parseReply` now returns `StatusSizeExceeded` for limit heuristics other than scan time, and `StatusError` for `Heuristics.Limits.Exceeded.MaxScanTime`. `objstore.verdictFor` is unchanged and already maps these to `VerdictSkipped` and `VerdictFailed`.
68
69- [ ] **Step 1: Write the failing tests.** Append to `internal/clamd/clamd_test.go`:
70
71```go
72// With AlertExceedsMax on, clamd reports an archive it could only partly scan
73// as a FOUND whose signature names the limit. That is not an infection and it
74// is not clean: nothing was judged, so it is the same status as an object too
75// large to stream. Recording it clean would sign a verdict over bytes nobody
76// read; recording it infected would make enforce block large honest archives.
77func TestLimitHeuristicIsSizeExceededNotInfected(t *testing.T) {
78 for _, limit := range []string{"MaxScanSize", "MaxFileSize", "MaxFiles", "MaxRecursion"} {
79 reply := "stream: Heuristics.Limits.Exceeded." + limit + " FOUND\x00"
80 res := replyFixture(t, reply)
81 if res.Status != StatusSizeExceeded {
82 t.Errorf("%s: status = %v, want size-exceeded", limit, res.Status)
83 }
84 if res.Signature != "" {
85 t.Errorf("%s: a limit is not a signature, got %q", limit, res.Signature)
86 }
87 if !strings.Contains(res.Raw, limit) {
88 t.Errorf("%s: the engine's own words did not survive: %q", limit, res.Raw)
89 }
90 }
91}
92
93// Older engines name the limit without saying which one.
94func TestBareLimitHeuristicIsSizeExceeded(t *testing.T) {
95 res := replyFixture(t, "stream: Heuristics.Limits.Exceeded FOUND\x00")
96 if res.Status != StatusSizeExceeded {
97 t.Fatalf("status = %v, want size-exceeded", res.Status)
98 }
99}
100
101// A scan that ran out of TIME depends on load, not on the bytes, so it must be
102// retried. StatusError becomes a failed verdict, which every sweep re-issues;
103// size-exceeded becomes skipped, which is carried forward until the object
104// changes. Filing a timeout under skipped would exclude the object for good.
105func TestScanTimeLimitIsAnErrorSoItIsRetried(t *testing.T) {
106 res := replyFixture(t, "stream: Heuristics.Limits.Exceeded.MaxScanTime FOUND\x00")
107 if res.Status != StatusError {
108 t.Fatalf("status = %v, want error", res.Status)
109 }
110 if !strings.Contains(res.Raw, "MaxScanTime") {
111 t.Fatalf("the engine's own words did not survive: %q", res.Raw)
112 }
113}
114
115// The match is on the SIGNATURE, as a prefix. A real signature that happens to
116// carry the phrase elsewhere in its name is still an infection, and so is one
117// that only begins with something similar.
118func TestLookalikeSignaturesAreStillInfected(t *testing.T) {
119 for _, sig := range []string{
120 "Win.Heuristics.Limits.Exceeded.Fake",
121 "Heuristics.Limits.ExceededButNot",
122 "Heuristics.Phishing.Email.SpoofedDomain",
123 "Eicar-Signature",
124 } {
125 res := replyFixture(t, "stream: "+sig+" FOUND\x00")
126 if res.Status != StatusInfected {
127 t.Errorf("%s: status = %v, want infected", sig, res.Status)
128 }
129 if res.Signature != sig {
130 t.Errorf("signature = %q, want %q", res.Signature, sig)
131 }
132 }
133}
134```
135
136- [ ] **Step 2: Run them and watch them fail.**
137
138Run: `go test ./internal/clamd -run 'Limit|Lookalike' -count=1`
139Expected: FAIL. `TestLimitHeuristicIsSizeExceededNotInfected`, `TestBareLimitHeuristicIsSizeExceeded` and `TestScanTimeLimitIsAnErrorSoItIsRetried` report `status = infected`. `TestLookalikeSignaturesAreStillInfected` passes already; it is there to stay passing.
140
141- [ ] **Step 3: Implement.** In `internal/clamd/clamd.go`, add beside the timeout constants:
142
143```go
144// clamd's names for "I stopped before the end". With AlertExceedsMax yes it
145// reports them as FOUND, which parseReply must not read as an infection.
146const (
147 limitHeuristic = "Heuristics.Limits.Exceeded"
148 limitHeuristicScanTime = "Heuristics.Limits.Exceeded.MaxScanTime"
149)
150
151// isLimitHeuristic reports whether sig names a scan limit rather than malware.
152// A prefix on the signature, with the dot, so neither a longer first component
153// nor a real signature carrying the phrase elsewhere can match.
154func isLimitHeuristic(sig string) bool {
155 return sig == limitHeuristic || strings.HasPrefix(sig, limitHeuristic+".")
156}
157```
158
159and replace the `FOUND` case of `parseReply`:
160
161```go
162 case strings.HasSuffix(reply, " FOUND"):
163 sig := signatureOf(reply)
164 switch {
165 case sig == limitHeuristicScanTime:
166 // Load-dependent, so retried: an error becomes a failed verdict,
167 // which every sweep re-issues.
168 res.Status = StatusError
169 case isLimitHeuristic(sig):
170 // Deterministic for these bytes and this config, and nothing was
171 // judged: the same fact as an object too large to stream.
172 res.Status = StatusSizeExceeded
173 default:
174 res.Status = StatusInfected
175 res.Signature = sig
176 }
177```
178
179Extend the `StatusSizeExceeded` doc comment with one sentence: `It also covers clamd's limit heuristics (an archive that expands past MaxScanSize, MaxFileSize, MaxFiles or MaxRecursion), which arrive as FOUND when AlertExceedsMax is on.`
180
181- [ ] **Step 4: Run the package.**
182
183Run: `go test ./internal/clamd -count=1` then `go vet ./internal/clamd`
184Expected: `ok`, no vet output.
185
186- [ ] **Step 5: Run what consumes it.**
187
188Run: `go test ./internal/objstore -run 'Clam|Scan|Verdict' -count=1`
189Expected: `ok` (no behaviour change there; this proves nothing asserted the old mapping).
190
191- [ ] **Step 6: Commit.**
192
193```bash
194git add internal/clamd/clamd.go internal/clamd/clamd_test.go
195git commit -m "clamd: a limit heuristic is unscanned, and a scan timeout is retried" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
196```
197
198---
199
200### Task 2: `region-host` grants the console read-and-tag, opt-in
201
202**Files:**
203- Modify: `infra/modules/region-host/variables.tf` (after `store_user_path`), `infra/modules/region-host/main.tf` (after `host_store`; and the `host_store` comment)
204- Create: `infra/modules/region-host/tests/store_scanning.tftest.hcl`
205
206**Interfaces:**
207- Produces: variable `store_scanning` (bool, default `false`); resource `aws_iam_role_policy.host_store_scan[0]` named `"${var.iam_name}-store-scan"`. Task 7 targets `module.host_ie.aws_iam_role_policy.host_store_scan`.
208
209- [ ] **Step 1: Write the failing test.** Create `infra/modules/region-host/tests/store_scanning.tftest.hcl`:
210
211```hcl
212# Plan-time tests for the scanning grant. mock_provider means no AWS account
213# and no credentials: these assert what the module WOULD create.
214mock_provider "aws" {}
215
216override_data {
217 target = data.aws_vpcs.default
218 values = { ids = ["vpc-0aaaaaaaaaaaaaaaa"] }
219}
220
221variables {
222 name = "carlos-test-ie"
223 sg_description = "carlos test host"
224 iam_name = "carlos-test-host"
225 instance_type = "t4g.small"
226 root_volume_size = 20
227 user_data = "#!/bin/sh\n"
228 deployment_bucket_arn = "arn:aws:s3:::carlos-test-platform"
229 data_bucket_arn = "arn:aws:s3:::carlos-test-ie"
230 ssm_parameter_arn = "arn:aws:ssm:eu-west-1:123456789012:parameter/carlos/*"
231 store_bucket_prefix = "carlos-store-test-"
232}
233
234run "off_by_default" {
235 command = plan
236 assert {
237 condition = length(aws_iam_role_policy.host_store_scan) == 0
238 error_message = "store_scanning defaults to false and must create nothing"
239 }
240}
241
242run "on_grants_exactly_read_and_tag" {
243 command = plan
244 variables {
245 store_scanning = true
246 }
247 assert {
248 condition = length(aws_iam_role_policy.host_store_scan) == 1
249 error_message = "store_scanning = true must create the scan policy"
250 }
251 assert {
252 condition = aws_iam_role_policy.host_store_scan[0].name == "carlos-test-host-store-scan"
253 error_message = "the policy is named <iam_name>-store-scan"
254 }
255 assert {
256 condition = toset(jsondecode(aws_iam_role_policy.host_store_scan[0].policy).Statement[0].Action) == toset([
257 "s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging",
258 ])
259 error_message = "exactly the three actions the scanner calls, no more"
260 }
261 assert {
262 condition = jsondecode(aws_iam_role_policy.host_store_scan[0].policy).Statement[0].Resource == "arn:aws:s3:::carlos-store-test-*/*"
263 error_message = "scoped to objects under the store prefix"
264 }
265}
266
267run "refused_without_a_store_prefix" {
268 command = plan
269 variables {
270 store_scanning = true
271 store_bucket_prefix = ""
272 }
273 expect_failures = [aws_iam_role_policy.host_store_scan]
274}
275```
276
277- [ ] **Step 2: Run it and watch it fail.**
278
279```bash
280export TF_DATA_DIR="$TMPDIR/scanner-host/tf-region-host"
281tofu -chdir=infra/modules/region-host init -backend=false -input=false
282tofu -chdir=infra/modules/region-host test
283```
284Expected: FAIL, `Reference to undeclared resource ... "aws_iam_role_policy" "host_store_scan"`. (The first `init` downloads the AWS provider into `TF_DATA_DIR`, on disk, not `/tmp`. If a mock data source makes an unrelated precondition fail, add an `override_data` for it in this file and say so in the commit; do not weaken the module.)
285
286- [ ] **Step 3: Implement.** In `variables.tf`, after `store_user_path`:
287
288```hcl
289variable "store_scanning" {
290 description = "Let this host's role READ and TAG objects under store_bucket_prefix, which is what the console's virus scanner needs (s3:GetObject, s3:GetObjectTagging, s3:PutObjectTagging). Off by default: without it the console provisions stores and never reads tenant data. Set it only on the box that runs the console, and only on a deployment that runs a scanner (infra/modules/scanner-host). Requires store_bucket_prefix."
291 type = bool
292 default = false
293}
294```
295
296In `main.tf`, directly after the `host_store` resource:
297
298```hcl
299# ---- object storage scanning (opt-in) --------------------------------------
300#
301# The one grant that lets this role read tenant object bytes, so it is its own
302# policy behind its own switch: a deployment that has not turned scanning on
303# plans no change here, and the sentence in host_store above ("it does not
304# read tenant data") stays true for it.
305#
306# Exactly the calls the scanner makes. HeadObject is authorised by GetObject;
307# ListObjectsV2 is already in host_store. Store buckets use SSE-S3, so there is
308# no KMS statement. PutObjectTagging is how a verdict is written; the store
309# user boundary deliberately lacks it, so an app cannot write one itself.
310resource "aws_iam_role_policy" "host_store_scan" {
311 count = var.store_scanning ? 1 : 0
312 name = "${var.iam_name}-store-scan"
313 role = aws_iam_role.host.id
314 policy = jsonencode({
315 Version = "2012-10-17"
316 Statement = [
317 {
318 Effect = "Allow"
319 Action = ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging"]
320 Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*"
321 },
322 ]
323 })
324 lifecycle {
325 precondition {
326 condition = var.store_bucket_prefix != ""
327 error_message = "store_scanning needs store_bucket_prefix: with no prefix there are no object stores on this deployment to scan, and the grant would name every bucket in the account."
328 }
329 }
330}
331```
332
333In the `host_store` comment, change `No GetObject: the console provisions stores, it does not read tenant data, and the economics sweep sizes buckets with ListBucket.` to add: ` (Scanning is the exception, and it is a separate policy behind store_scanning, below.)`
334
335- [ ] **Step 4: Run the tests and the formatter.**
336
337Run: `tofu -chdir=infra/modules/region-host test` then `tofu fmt -check -recursive infra/modules/region-host`
338Expected: `3 passed, 0 failed`; fmt prints nothing.
339
340- [ ] **Step 5: Commit.** Confirm `git status --short infra/` shows no `.terraform` or lock file, then:
341
342```bash
343git add infra/modules/region-host/variables.tf infra/modules/region-host/main.tf infra/modules/region-host/tests/store_scanning.tftest.hcl
344git commit -m "region-host: store_scanning, the console's opt-in read-and-tag grant" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
345```
346
347---
348
349### Task 3: the `scanner-host` module
350
351**Files:**
352- Create: `infra/modules/scanner-host/versions.tf`, `variables.tf`, `main.tf`, `outputs.tf`, `scanner-init.sh.tftpl`, `files/scanner-selftest`, `files/scanner-selftest.service`, `files/scanner-tcp.socket`, `files/scanner-tcp.service`, `tests/scanner_host.tftest.hcl`
353
354**Interfaces:**
355- Consumes: nothing from earlier tasks.
356- Produces: module inputs `name`, `ami_id`, `client_security_group_id`, `region`, `instance_type`, `stream_max_bytes`, `memory_max`, `memory_high`, `concurrent_database_reload`, `subnet_id`; outputs `clamd_addr`, `instance_id`, `security_group_id`, `stream_max_bytes`. On the box: `/usr/local/sbin/scanner-selftest`, units `scanner-selftest.service`, `scanner-tcp.socket`, `scanner-tcp.service`, marker `/var/lib/scanner-host/ready`, console-log lines `SCANNER-HOST READY` / `SCANNER-HOST FAILED: <reason>`, and the environment switch `SCANNER_SELFTEST_FORCE_FAIL=1`. Tasks 7, 9, 11 and 12 use these names.
357
358- [ ] **Step 1: Write the failing tests.** Create `infra/modules/scanner-host/tests/scanner_host.tftest.hcl`:
359
360```hcl
361# Plan-time tests. mock_provider: no account, no credentials.
362mock_provider "aws" {}
363
364override_data {
365 target = data.aws_security_group.client
366 values = { vpc_id = "vpc-0aaaaaaaaaaaaaaaa" }
367}
368
369override_data {
370 target = data.aws_vpc.client
371 values = { default = true }
372}
373
374variables {
375 name = "carlos-test-scanner"
376 ami_id = "ami-0123456789abcdef0"
377 client_security_group_id = "sg-0123456789abcdef0"
378 region = "eu-west-1"
379}
380
381run "the_fence" {
382 command = plan
383 assert {
384 condition = length(aws_security_group.scanner.ingress) == 1
385 error_message = "exactly one way in"
386 }
387 assert {
388 condition = alltrue([
389 for i in aws_security_group.scanner.ingress :
390 i.from_port == 3310 && i.to_port == 3310 && i.protocol == "tcp"
391 && i.security_groups == toset(["sg-0123456789abcdef0"])
392 && try(length(i.cidr_blocks), 0) == 0 && try(length(i.ipv6_cidr_blocks), 0) == 0
393 ])
394 error_message = "3310 from the client security group only, never a CIDR"
395 }
396 assert {
397 condition = aws_security_group.scanner.vpc_id == "vpc-0aaaaaaaaaaaaaaaa"
398 error_message = "the group lands in the client group's VPC"
399 }
400}
401
402run "the_box" {
403 command = plan
404 assert {
405 condition = aws_instance.scanner.metadata_options[0].http_tokens == "required"
406 error_message = "IMDSv2 only"
407 }
408 assert {
409 condition = aws_instance.scanner.root_block_device[0].encrypted
410 error_message = "encrypted root volume"
411 }
412 assert {
413 condition = aws_instance.scanner.instance_type == "t4g.medium"
414 error_message = "t4g.medium by default"
415 }
416 assert {
417 # base64 is 4 characters per 3 bytes, so 21800 characters is under 16 KiB.
418 condition = length(base64gzip(local.init_script)) < 21800
419 error_message = "EC2 refuses user_data over 16 KiB, and the script is sent gzipped to stay well under it"
420 }
421}
422
423run "the_role_cannot_read_parameters" {
424 command = plan
425 assert {
426 condition = jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Effect == "Deny"
427 error_message = "an explicit Deny, because the managed SSM policy allows GetParameter* on *"
428 }
429 assert {
430 condition = toset(jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Action) == toset([
431 "ssm:GetParameter", "ssm:GetParameters", "ssm:GetParameterHistory", "ssm:GetParametersByPath",
432 ])
433 error_message = "all four parameter-read actions"
434 }
435 assert {
436 condition = jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Resource == "*"
437 error_message = "on every parameter: this box is allowed none"
438 }
439}
440
441run "the_engine_config" {
442 command = plan
443 assert {
444 condition = strcontains(local.init_script, "setkey MaxFileSize 100M") && strcontains(local.init_script, "setkey MaxScanSize 100M")
445 error_message = "both limits 100M: equal is what makes a trimmed member trip the scan budget"
446 }
447 assert {
448 condition = strcontains(local.init_script, "setkey AlertExceedsMax yes")
449 error_message = "limits must alert"
450 }
451 assert {
452 condition = strcontains(local.init_script, "setkey StreamMaxLength 26214400")
453 error_message = "StreamMaxLength is stream_max_bytes"
454 }
455 assert {
456 condition = !strcontains(local.init_script, "setkey TCPSocket") && !strcontains(local.init_script, "setkey TCPAddr")
457 error_message = "clamd itself never listens on TCP; the gated proxy does"
458 }
459 assert {
460 condition = output.stream_max_bytes == 26214400
461 error_message = "the console's CARLOS_CLAMD_MAX_BYTES comes from this output"
462 }
463}
464
465run "small_box_with_concurrent_reload_is_refused" {
466 command = plan
467 variables {
468 instance_type = "t4g.small"
469 }
470 expect_failures = [aws_instance.scanner]
471}
472
473run "small_box_without_concurrent_reload_is_fine" {
474 command = plan
475 variables {
476 instance_type = "t4g.small"
477 concurrent_database_reload = false
478 }
479 assert {
480 condition = strcontains(local.init_script, "setkey ConcurrentDatabaseReload no")
481 error_message = "the reload setting reaches clamd.conf"
482 }
483}
484
485run "stream_limit_too_small_for_the_fixtures" {
486 command = plan
487 variables {
488 stream_max_bytes = 1048576
489 }
490 expect_failures = [var.stream_max_bytes]
491}
492
493run "stream_limit_above_the_scan_budget" {
494 command = plan
495 variables {
496 stream_max_bytes = 209715200
497 }
498 expect_failures = [var.stream_max_bytes]
499}
500
501run "another_vpc_needs_a_subnet" {
502 command = plan
503 override_data {
504 target = data.aws_vpc.client
505 values = { default = false }
506 }
507 expect_failures = [aws_instance.scanner]
508}
509```
510
511- [ ] **Step 2: Run it and watch it fail.**
512
513```bash
514export TF_DATA_DIR="$TMPDIR/scanner-host/tf-scanner-host"
515tofu -chdir=infra/modules/scanner-host init -backend=false -input=false
516tofu -chdir=infra/modules/scanner-host test
517```
518Expected: FAIL at init or test: the module has no configuration yet.
519
520- [ ] **Step 3: `versions.tf` and `variables.tf`.**
521
522`versions.tf`:
523
524```hcl
525terraform {
526 required_version = ">= 1.10"
527 required_providers {
528 aws = {
529 source = "hashicorp/aws"
530 version = ">= 6.0"
531 }
532 }
533}
534```
535
536`variables.tf`:
537
538```hcl
539variable "name" {
540 description = "Name for the instance, its security group, its IAM role and its instance profile (e.g. \"carlos-flagship-scanner\")."
541 type = string
542}
543
544variable "ami_id" {
545 description = "Debian arm64 AMI. Pass the console box's own image, module.<host>.ami_id, so the two are built from the same base. Ignored after creation: a newer image never replaces the box by itself."
546 type = string
547}
548
549variable "client_security_group_id" {
550 description = "Security group of the ONE box allowed to reach clamd: the console's. clamd has no authentication at all, so this group is the whole fence. The scanner's own group is created in this group's VPC."
551 type = string
552}
553
554variable "region" {
555 description = "AWS region the box runs in (e.g. \"eu-west-1\"). The SSM agent package is served per region and the box must fetch its own."
556 type = string
557}
558
559variable "instance_type" {
560 description = "EC2 instance type (arm64 Graviton). clamd holds about 1.5 GB of signatures resident and briefly about twice that while reloading them, so t4g.medium is the default. A t4g.small works only with concurrent_database_reload = false."
561 type = string
562 default = "t4g.medium"
563}
564
565variable "stream_max_bytes" {
566 description = "Largest object clamd accepts on a stream (StreamMaxLength), in bytes. Set the console's CARLOS_CLAMD_MAX_BYTES to this module's stream_max_bytes output: the two must be equal. Between 8 MiB and 100 MiB: the box's self-test streams fixtures of about 5 MB, and a whole stream must fit the 100 MiB scan budget."
567 type = number
568 default = 26214400
569 validation {
570 condition = var.stream_max_bytes >= 8388608 && var.stream_max_bytes <= 104857600
571 error_message = "stream_max_bytes must be between 8388608 (8 MiB) and 104857600 (100 MiB)."
572 }
573}
574
575variable "memory_max" {
576 description = "clamd's cgroup ceiling (systemd MemoryMax). Under memory pressure the kernel kills clamd, never the SSM agent that is the only way onto the box."
577 type = string
578 default = "3G"
579}
580
581variable "memory_high" {
582 description = "clamd's throttle point (systemd MemoryHigh), about 85% of memory_max."
583 type = string
584 default = "2560M"
585}
586
587variable "concurrent_database_reload" {
588 description = "Keep scanning while freshclam's new signatures load, at the cost of holding both databases in memory at once. False pauses scanning during each reload and fits a smaller box."
589 type = bool
590 default = true
591}
592
593variable "subnet_id" {
594 description = "Subnet to launch in. Null (the default) is the default subnet of the default VPC, and is refused unless the client security group lives in the default VPC. The subnet needs a route to the internet: the box fetches signatures and packages."
595 type = string
596 default = null
597}
598```
599
600- [ ] **Step 4: `main.tf` and `outputs.tf`.**
601
602`main.tf`:
603
604```hcl
605# 🤖 A CARLOS scanner host: one box running clamd for a deployment's console.
606#
607# The console reads each object from S3 and streams it here. This box reads
608# nothing for itself: its role has Session Manager and nothing else, so a
609# compromised clamd cannot fetch a tenant object or a key.
610#
611# What makes it more than "install clamav" is in scanner-init.sh.tftpl and
612# files/: TCP 3310 is open only while a self-test has passed against the clamd
613# process running now. See README.md.
614
615data "aws_security_group" "client" {
616 id = var.client_security_group_id
617}
618
619data "aws_vpc" "client" {
620 id = data.aws_security_group.client.vpc_id
621}
622
623locals {
624 # Fixed in this release. The self-test's fixtures and expected answers are
625 # sized for it; making it a variable means deriving both.
626 scan_limit = "100M"
627
628 small_box = can(regex("^t4g\\.(nano|micro|small)$", var.instance_type))
629
630 init_script = templatefile("${path.module}/scanner-init.sh.tftpl", {
631 region = var.region
632 stream_max_bytes = var.stream_max_bytes
633 scan_limit = local.scan_limit
634 memory_max = var.memory_max
635 memory_high = var.memory_high
636 concurrent_reload = var.concurrent_database_reload ? "yes" : "no"
637 selftest_b64 = base64encode(file("${path.module}/files/scanner-selftest"))
638 selftest_unit_b64 = base64encode(file("${path.module}/files/scanner-selftest.service"))
639 tcp_socket_b64 = base64encode(file("${path.module}/files/scanner-tcp.socket"))
640 tcp_service_b64 = base64encode(file("${path.module}/files/scanner-tcp.service"))
641 })
642}
643
644resource "aws_security_group" "scanner" {
645 name = var.name
646 description = "clamd, reachable from the console box only"
647 vpc_id = data.aws_security_group.client.vpc_id
648
649 # The whole fence. clamd authenticates nobody, so nothing but the console's
650 # own group may reach it, and never a CIDR.
651 ingress {
652 description = "clamd INSTREAM from the console"
653 from_port = 3310
654 to_port = 3310
655 protocol = "tcp"
656 security_groups = [var.client_security_group_id]
657 }
658
659 # Signatures (freshclam) and packages (apt). No SSH ingress exists: admin
660 # access is SSM Session Manager.
661 egress {
662 from_port = 0
663 to_port = 0
664 protocol = "-1"
665 cidr_blocks = ["0.0.0.0/0"]
666 }
667
668 tags = { Name = var.name }
669}
670
671resource "aws_iam_role" "scanner" {
672 name = var.name
673 assume_role_policy = jsonencode({
674 Version = "2012-10-17"
675 Statement = [{
676 Effect = "Allow"
677 Principal = { Service = "ec2.amazonaws.com" }
678 Action = "sts:AssumeRole"
679 }]
680 })
681}
682
683resource "aws_iam_role_policy_attachment" "scanner_ssm" {
684 role = aws_iam_role.scanner.name
685 policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
686}
687
688# AmazonSSMManagedInstanceCore grants ssm:GetParameter* on "*". Left alone,
689# this box could read the deployment's scan tag key and its release key. An
690# explicit Deny beats the managed Allow and names only the parameter-read
691# actions, so Session Manager is untouched. Same shape as region-host's
692# host_ssm_parameter_scope, with no parameter allowed at all.
693resource "aws_iam_role_policy" "no_parameters" {
694 name = "${var.name}-no-parameters"
695 role = aws_iam_role.scanner.id
696 policy = jsonencode({
697 Version = "2012-10-17"
698 Statement = [
699 {
700 Effect = "Deny"
701 Action = [
702 "ssm:GetParameter",
703 "ssm:GetParameters",
704 "ssm:GetParameterHistory",
705 "ssm:GetParametersByPath",
706 ]
707 Resource = "*"
708 },
709 ]
710 })
711}
712
713resource "aws_iam_instance_profile" "scanner" {
714 name = var.name
715 role = aws_iam_role.scanner.name
716}
717
718resource "aws_instance" "scanner" {
719 ami = var.ami_id
720 instance_type = var.instance_type
721 subnet_id = var.subnet_id
722 vpc_security_group_ids = [aws_security_group.scanner.id]
723 iam_instance_profile = aws_iam_instance_profile.scanner.name
724
725 # Gzipped: the script carries the self-test and three units, and EC2 caps
726 # user_data at 16 KiB. cloud-init unpacks it.
727 user_data_base64 = base64gzip(local.init_script)
728 user_data_replace_on_change = false
729
730 metadata_options {
731 http_endpoint = "enabled"
732 http_tokens = "required"
733 http_put_response_hop_limit = 1
734 }
735
736 root_block_device {
737 volume_size = 20
738 volume_type = "gp3"
739 encrypted = true
740 }
741
742 tags = { Name = var.name }
743
744 # A newer image or an edited script never replaces the box by itself.
745 # Changing either, including stream_max_bytes, is an explicit
746 # `tofu apply -replace=module.<name>.aws_instance.scanner`. A replaced box
747 # has a new private IP: update the console's CARLOS_CLAMD_ADDR.
748 lifecycle {
749 ignore_changes = [ami, user_data, user_data_base64]
750
751 precondition {
752 condition = var.subnet_id != null || data.aws_vpc.client.default
753 error_message = "The client security group is not in the default VPC, so the scanner cannot use the default subnet: its security group and its instance would be in different VPCs. Pass subnet_id naming a subnet in the client group's VPC."
754 }
755
756 precondition {
757 condition = !(local.small_box && var.concurrent_database_reload)
758 error_message = "A t4g.small or smaller cannot hold two signature databases at once. Either use t4g.medium, or set concurrent_database_reload = false (scanning pauses during each reload)."
759 }
760 }
761}
762```
763
764`outputs.tf`:
765
766```hcl
767output "clamd_addr" {
768 description = "Where the console reaches clamd: set it as CARLOS_CLAMD_ADDR. A private address, reachable only from the client security group. It changes if the box is replaced."
769 value = "${aws_instance.scanner.private_ip}:3310"
770}
771
772output "stream_max_bytes" {
773 description = "clamd's StreamMaxLength in bytes: set it as CARLOS_CLAMD_MAX_BYTES on the console."
774 value = var.stream_max_bytes
775}
776
777output "instance_id" {
778 description = "EC2 instance id of the scanner box, for SSM."
779 value = aws_instance.scanner.id
780}
781
782output "security_group_id" {
783 description = "Security group id of the scanner box."
784 value = aws_security_group.scanner.id
785}
786```
787
788- [ ] **Step 5: the self-test.** Create `infra/modules/scanner-host/files/scanner-selftest` (mode 0755 in git: `chmod +x` before `git add`):
789
790```bash
791#!/bin/bash
792# 🤖 scanner-selftest: prove, on THIS engine, that clamd never answers OK for
793# an archive it could only partly scan.
794#
795# scanner-selftest.service runs this every time clamd starts, and the TCP
796# listener is bound to that unit, so a failure here keeps port 3310 shut. It
797# talks to clamd on its unix socket: the port it guards is not open yet.
798#
799# Why it exists: with ClamAV's packaged limits, a zip member that expands past
800# MaxFileSize is trimmed and the trimmed copy is scanned and answered OK, with
801# no limit raised. The box is configured so that cannot happen (both limits
802# equal, AlertExceedsMax on). This checks that the configuration still does
803# what it did when it was chosen, on whatever version is installed today.
804#
805# Fixtures are sized for a 100 MiB scan limit. Members are written from a temp
806# file so their zip headers are ordinary; streamed members get zip64 headers,
807# which clamd does not open at all.
808set -u
809sock=/run/clamav/clamd.ctl
810
811if [ "${SCANNER_SELFTEST_FORCE_FAIL:-}" = 1 ]; then
812 echo "scanner-selftest: FAIL (forced, for the acceptance check)"
813 exit 1
814fi
815
816d=$(mktemp -d /var/tmp/scanner-selftest.XXXXXX) || exit 1
817trap 'rm -rf "$d"' EXIT
818echo "LocalSocket $sock" > "$d/clamdscan.conf"
819
820python3 - "$d" <<'PY' || { echo "scanner-selftest: FAIL (could not build fixtures)"; exit 1; }
821import io, gzip, os, random, sys, tarfile, zipfile
822d = sys.argv[1]
823fx = os.path.join(d, 'fx')
824os.mkdir(fx)
825EICAR = rb'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'
826
827def block(seed):
828 # 1 MiB: a 16 KiB random block repeated. It compresses about 140 to 1 and
829 # differs per seed, so clamd's clean cache cannot wave a member through.
830 r = random.Random(seed)
831 return bytes(r.getrandbits(8) for _ in range(16384)) * 64
832
833def bigfile(name, mb, seed):
834 p = os.path.join(d, name)
835 blk = block(seed)
836 with open(p, 'wb') as f:
837 for _ in range(mb):
838 f.write(blk)
839 return p
840
841def mkzip(name, build):
842 with zipfile.ZipFile(os.path.join(fx, name), 'w', zipfile.ZIP_DEFLATED) as z:
843 build(z)
844
845open(os.path.join(fx, '01-clean.txt'), 'wb').write(b'hello world\n')
846open(os.path.join(fx, '02-eicar.txt'), 'wb').write(EICAR)
847big = bigfile('big.bin', 500, 1)
848mid = bigfile('mid.bin', 60, 5)
849mkzip('03-zip-one-500.zip', lambda z: z.write(big, 'big.bin'))
850with zipfile.ZipFile(os.path.join(d, 'inner.zip'), 'w', zipfile.ZIP_DEFLATED) as z:
851 z.write(big, 'big.bin')
852mkzip('04-zip-nested-500.zip', lambda z: z.write(os.path.join(d, 'inner.zip'), 'inner.zip'))
853def many(z):
854 for i in range(30):
855 z.write(bigfile('m.bin', 20, 100 + i), 'm%02d.bin' % i)
856mkzip('05-zip-30x20.zip', many)
857with gzip.open(os.path.join(fx, '06-gzip-500.gz'), 'wb') as g:
858 blk = block(2)
859 for _ in range(500):
860 g.write(blk)
861with tarfile.open(os.path.join(fx, '07-targz-5x40.tar.gz'), 'w:gz') as t:
862 for i in range(5):
863 data = block(200 + i) * 40
864 ti = tarfile.TarInfo('t%d.bin' % i)
865 ti.size = len(data)
866 t.addfile(ti, io.BytesIO(data))
867def big_then_eicar(z):
868 z.write(big, 'big.bin')
869 z.writestr('eicar.com', EICAR)
870mkzip('08-zip-500-then-eicar.zip', big_then_eicar)
871def mid_then_eicar(z):
872 z.write(mid, 'mid.bin')
873 z.writestr('eicar.com', EICAR)
874mkzip('09-zip-60-then-eicar.zip', mid_then_eicar)
875mkzip('10-zip-one-60.zip', lambda z: z.write(mid, 'mid.bin'))
876PY
877
878# What each fixture must answer. "limit" is the one that matters: an OK there
879# is a truncated scan signed clean.
880want() {
881 case $1 in
882 01 | 10) echo ok ;;
883 02 | 09) echo infected ;;
884 *) echo limit ;;
885 esac
886}
887
888fail=0
889for f in "$d"/fx/*; do
890 n=$(basename "$f")
891 exp=$(want "${n%%-*}")
892 reply=$(clamdscan --config-file="$d/clamdscan.conf" --stream --no-summary "$f" 2>&1 | tail -1)
893 case $reply in
894 *"Heuristics.Limits.Exceeded"*" FOUND") got=limit ;;
895 *" FOUND") got=infected ;;
896 *": OK") got=ok ;;
897 *) got=error ;;
898 esac
899 if [ "$got" = "$exp" ]; then
900 echo "scanner-selftest: pass $n ($got)"
901 else
902 echo "scanner-selftest: FAIL $n want $exp, got $got: $reply"
903 fail=1
904 fi
905done
906
907if [ $fail -eq 0 ]; then
908 echo "scanner-selftest: PASS"
909else
910 echo "scanner-selftest: FAIL"
911fi
912exit $fail
913```
914
915- [ ] **Step 6: the three units.**
916
917`files/scanner-selftest.service`:
918
919```ini
920# 🤖 Runs the self-test against the clamd that is running now. BindsTo makes it
921# go inactive the moment clamd stops or restarts, and the TCP listener is bound
922# to THIS unit, so the port follows the test, not the boot.
923[Unit]
924Description=Prove this clamd never answers OK for an over-limit archive
925BindsTo=clamav-daemon.service
926After=clamav-daemon.service
927
928[Service]
929Type=oneshot
930RemainAfterExit=yes
931ExecStart=/usr/local/sbin/scanner-selftest
932TimeoutStartSec=1800
933
934[Install]
935WantedBy=multi-user.target
936```
937
938`files/scanner-tcp.socket` (`@PRIVATE_IP@` is replaced at first boot):
939
940```ini
941# 🤖 clamd on TCP, for the console. Open only behind a passed self-test.
942#
943# DefaultDependencies=no is load-bearing. A socket unit is ordered before
944# sockets.target by default, sockets.target before basic.target, and every
945# service (the self-test included) after basic.target. A socket that must also
946# come AFTER the self-test closes that loop, and systemd breaks an ordering
947# cycle by dropping a job, which could leave this port shut after a reboot.
948# So it opts out, restores what it still needs, and is started by
949# multi-user.target and by clamd's Wants=, never by sockets.target.
950[Unit]
951Description=clamd on TCP, open only behind a passed self-test
952DefaultDependencies=no
953After=sysinit.target
954Requires=scanner-selftest.service
955After=scanner-selftest.service
956BindsTo=scanner-selftest.service
957Conflicts=shutdown.target
958Before=shutdown.target
959
960[Socket]
961ListenStream=@PRIVATE_IP@:3310
962
963[Install]
964WantedBy=multi-user.target
965```
966
967`files/scanner-tcp.service`:
968
969```ini
970# 🤖 Carries each TCP connection to clamd's unix socket. Bound to the self-test
971# like the socket is, so connections in flight are cut when the test's unit
972# goes inactive.
973[Unit]
974Description=Proxy TCP 3310 to clamd's unix socket
975Requires=scanner-selftest.service
976After=scanner-selftest.service
977BindsTo=scanner-selftest.service
978
979[Service]
980ExecStart=/usr/lib/systemd/systemd-socket-proxyd /run/clamav/clamd.ctl
981DynamicUser=yes
982SupplementaryGroups=clamav
983PrivateTmp=yes
984NoNewPrivileges=yes
985```
986
987- [ ] **Step 7: the first-boot script.** Create `infra/modules/scanner-host/scanner-init.sh.tftpl`. It is a `templatefile`: `$${...}` would be needed for a literal bash `${...}`, so the bash below uses `$name` and `$(...)` forms only. Keep it that way.
988
989```bash
990#!/bin/bash
991# 🤖 First-boot init for a CARLOS scanner host (Debian arm64). Rendered by
992# infra/modules/scanner-host. Each step says where it got to on the serial
993# console, so `aws ec2 get-console-output` answers "how far did it get".
994#
995# The order is the design:
996# 1. SSM agent, because with no SSH it is the only way in to fix the rest.
997# 2. Packages installed but NOT started.
998# 3. Config, the self-test and the gate units.
999# 4. First signatures, alone, before anything else touches the database.
1000# 5. Start: clamd, then the self-test, then (only on a pass) the port.
1001# 6. Verify from the outside of the gate, and say READY or FAILED.
1002set -euo pipefail
1003
1004say() { echo "scanner-init: $*" | tee /dev/console; }
1005fail() { say "SCANNER-HOST FAILED: $*"; exit 1; }
1006trap 'fail "line $LINENO"' ERR
1007export DEBIAN_FRONTEND=noninteractive
1008
1009# ---- 1. the way in ---------------------------------------------------------
1010say "1/6 SSM agent"
1011apt-get update -q
1012apt-get install -qy curl unattended-upgrades
1013curl -fsSL -o /var/tmp/amazon-ssm-agent.deb \
1014 "https://s3.${region}.amazonaws.com/amazon-ssm-${region}/latest/debian_arm64/amazon-ssm-agent.deb"
1015dpkg -i /var/tmp/amazon-ssm-agent.deb
1016rm -f /var/tmp/amazon-ssm-agent.deb
1017systemctl enable --now amazon-ssm-agent
1018systemctl enable --now unattended-upgrades
1019
1020# ---- 2. packages, not started ----------------------------------------------
1021# Debian's postinst starts clamav-freshclam and the daemon. Neither may run
1022# before step 3 has configured it, and freshclam's service would fight the
1023# foreground run in step 4 for the database lock.
1024say "2/6 clamav packages"
1025printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d
1026chmod +x /usr/sbin/policy-rc.d
1027apt-get install -qy clamav-daemon clamav-freshclam clamdscan python3 iproute2
1028rm -f /usr/sbin/policy-rc.d
1029
1030# ---- 3. config, self-test, gate --------------------------------------------
1031say "3/6 config and gate"
1032conf=/etc/clamav/clamd.conf
1033# clamd has no include directory: replace each key in place.
1034setkey() {
1035 sed -i "/^$1[[:space:]]/d" "$conf"
1036 echo "$1 $2" >> "$conf"
1037}
1038setkey StreamMaxLength ${stream_max_bytes}
1039setkey AlertExceedsMax yes
1040setkey MaxFileSize ${scan_limit}
1041setkey MaxScanSize ${scan_limit}
1042setkey ConcurrentDatabaseReload ${concurrent_reload}
1043# Debian 13 mounts /tmp as tmpfs: extraction of up to the scan limit per scan
1044# belongs on disk, not in memory.
1045setkey TemporaryDirectory /var/tmp
1046# clamd itself never listens on TCP. The gated proxy does.
1047sed -i '/^TCPSocket[[:space:]]/d;/^TCPAddr[[:space:]]/d' "$conf"
1048
1049echo '${selftest_b64}' | base64 -d > /usr/local/sbin/scanner-selftest
1050chmod 0755 /usr/local/sbin/scanner-selftest
1051echo '${selftest_unit_b64}' | base64 -d > /etc/systemd/system/scanner-selftest.service
1052echo '${tcp_service_b64}' | base64 -d > /etc/systemd/system/scanner-tcp.service
1053
1054tok=$(curl -fsS -X PUT http://169.254.169.254/latest/api/token \
1055 -H 'X-aws-ec2-metadata-token-ttl-seconds: 300')
1056ip=$(curl -fsS -H "X-aws-ec2-metadata-token: $tok" \
1057 http://169.254.169.254/latest/meta-data/local-ipv4)
1058case $ip in
1059 [0-9]*.[0-9]*.[0-9]*.[0-9]*) ;;
1060 *) fail "no private IPv4 from instance metadata: '$ip'" ;;
1061esac
1062echo '${tcp_socket_b64}' | base64 -d | sed "s/@PRIVATE_IP@/$ip/" \
1063 > /etc/systemd/system/scanner-tcp.socket
1064grep -q "^ListenStream=$ip:3310\$" /etc/systemd/system/scanner-tcp.socket \
1065 || fail "the listener is not bound to the private address"
1066
1067mkdir -p /etc/systemd/system/clamav-daemon.service.d
1068cat > /etc/systemd/system/clamav-daemon.service.d/10-scanner.conf <<EOF
1069# Every start of clamd, an automatic restart included, asks for the gate again.
1070[Unit]
1071Wants=scanner-tcp.socket
1072
1073# The kernel kills clamd under memory pressure, never the SSM agent.
1074[Service]
1075MemoryMax=${memory_max}
1076MemoryHigh=${memory_high}
1077Restart=on-failure
1078EOF
1079systemctl daemon-reload
1080
1081# ---- 4. first signatures ---------------------------------------------------
1082say "4/6 first signatures"
1083# NotifyClamd would complain that no clamd is running yet. It is not.
1084sed '/^NotifyClamd/d' /etc/clamav/freshclam.conf > /run/freshclam-first.conf
1085freshclam --config-file=/run/freshclam-first.conf
1086rm -f /run/freshclam-first.conf
1087for db in main daily bytecode; do
1088 ls /var/lib/clamav/$db.c[vl]d >/dev/null 2>&1 || fail "no $db signature database after freshclam"
1089done
1090systemctl enable --now clamav-freshclam
1091
1092# ---- 5. start, in the order the units enforce ------------------------------
1093say "5/6 clamd, self-test, listener (several minutes)"
1094systemctl enable clamav-daemon.service scanner-selftest.service scanner-tcp.socket
1095systemctl start clamav-daemon.socket clamav-daemon.service
1096# Blocks until the self-test has finished. Fails if it failed.
1097systemctl start scanner-tcp.socket \
1098 || fail "self-test or listener did not start: $(systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket | tr '\n' ' ')"
1099
1100# ---- 6. verify from outside the gate ---------------------------------------
1101say "6/6 verify"
1102[ "$(systemctl is-active scanner-selftest.service)" = active ] || fail "self-test is not active"
1103[ "$(systemctl is-active scanner-tcp.socket)" = active ] || fail "listener is not active"
1104bound=$(ss -ltnH 'sport = :3310' | awk '{print $4}' | sort -u | tr '\n' ' ')
1105[ "$bound" = "$ip:3310 " ] || fail "3310 is bound to '$bound', want only $ip:3310"
1106exec 3<>"/dev/tcp/$ip/3310"
1107printf 'zVERSION\0' >&3
1108ver=$(timeout 20 head -c 200 <&3 | tr -d '\0')
1109exec 3>&-
1110case $ver in
1111 ClamAV*) ;;
1112 *) fail "no VERSION answer through the listener: '$ver'" ;;
1113esac
1114mkdir -p /var/lib/scanner-host
1115echo "$ver" > /var/lib/scanner-host/ready
1116say "SCANNER-HOST READY $ver"
1117```
1118
1119- [ ] **Step 8: Run the tests and the formatter.**
1120
1121Run: `tofu -chdir=infra/modules/scanner-host test` then `tofu fmt -check -recursive infra/modules/scanner-host` then `bash -n infra/modules/scanner-host/files/scanner-selftest`
1122Expected: `9 passed, 0 failed`; fmt and `bash -n` print nothing. If an assertion fails because a mocked attribute is null or unknown at plan time (the `ingress` block attributes are the likely one), adjust the assertion's `try(...)`, never the module's behaviour, and say which in the commit.
1123
1124- [ ] **Step 9: Check the rendered script is valid bash and has no stray template syntax.**
1125
1126```bash
1127mkdir -p "$TMPDIR/scanner-host/render"
1128cat > "$TMPDIR/scanner-host/render/main.tf" <<'EOF'
1129output "script" {
1130 value = templatefile("${path.module}/../../../infra/modules/scanner-host/scanner-init.sh.tftpl", {
1131 region = "eu-west-1", stream_max_bytes = 26214400, scan_limit = "100M",
1132 memory_max = "3G", memory_high = "2560M", concurrent_reload = "yes",
1133 selftest_b64 = "QQ==", selftest_unit_b64 = "QQ==", tcp_socket_b64 = "QQ==", tcp_service_b64 = "QQ==",
1134 })
1135}
1136EOF
1137```
1138
1139Adjust the relative path so it resolves to this worktree's template (an absolute path is fine), then:
1140
1141Run: `tofu -chdir="$TMPDIR/scanner-host/render" init -input=false >/dev/null && tofu -chdir="$TMPDIR/scanner-host/render" apply -auto-approve >/dev/null && tofu -chdir="$TMPDIR/scanner-host/render" output -raw script > "$TMPDIR/scanner-host/render/init.sh" && bash -n "$TMPDIR/scanner-host/render/init.sh" && grep -c 'setkey MaxFileSize 100M' "$TMPDIR/scanner-host/render/init.sh"`
1142Expected: `1`, and no bash syntax error.
1143
1144- [ ] **Step 10: Commit.** Confirm no `.terraform` directory or lock file is staged.
1145
1146```bash
1147chmod +x infra/modules/scanner-host/files/scanner-selftest
1148git add infra/modules/scanner-host
1149git commit -m "scanner-host: a clamd box whose port is open only behind a passed self-test" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1150```
1151
1152---
1153
1154### Task 4: docs, through copy review
1155
1156**Files:**
1157- Create: `infra/modules/scanner-host/README.md`
1158- Modify: `infra/modules/README.md` (new `## scanner-host` section), `infra/modules/region-host/README.md` (one paragraph on `store_scanning`), `docs/site/cli.md` (the operator table near line 1088 and the "What `status` will and will not claim" paragraph near line 1071), `docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md` (a dated note at the top)
1159
1160**Interfaces:**
1161- Consumes: every name Task 3 produces.
1162
1163- [ ] **Step 1: Draft, do not write.** Use the `humane-docs` skill for the drafting voice. Content each piece must carry:
1164 - **`scanner-host/README.md`**: what the box is and that it reads nothing itself; the usage block (`module "scanner"` with `ami_id = module.host_ie.ami_id`, `client_security_group_id = module.host_ie.security_group_id`, `region`); the two outputs the console needs and the variables they become (`clamd_addr` to `CARLOS_CLAMD_ADDR`, `stream_max_bytes` to `CARLOS_CLAMD_MAX_BYTES`) plus `CARLOS_SCAN_TAG_KEY`; replacement changes the private IP (spec §3.6), first; the invariant in one paragraph (§3.2); why the two limits are equal and fixed, with the table from §3.4; what a clean verdict does not cover (zip64 and encrypted members); how to check a box is ready (`cloud-init status --wait`, `/var/lib/scanner-host/ready`, `systemctl is-active scanner-selftest.service scanner-tcp.socket`); the four acceptance checks from §3.3 with their commands (Task 7 has them); sizing; that the subnet needs a route out.
1165 - **`infra/modules/README.md`**: a short `## scanner-host` section in the style of the `deployment-buckets` one, with the variable table and outputs.
1166 - **`region-host/README.md`**: `store_scanning` in three sentences: what it grants, that it is off by default, that it goes on the console's box only.
1167 - **`docs/site/cli.md`**: the operator table is stale. Replace the `CARLOS_STORE_SCANNER` and `CARLOS_CLAMD_SOCKET` rows with `CARLOS_CLAMD_ADDR` (`host:port`, or a socket path beginning `/`) and `CARLOS_SCAN_TAG_KEY` (required once an address is set; signs every verdict), say the switch is `carlos system antivirus enable`, and change "six environment variables" to the true count. In the `status` paragraph add one sentence: an archive that expands past the engine's limits is counted as not scanned, the same as an object too large to stream.
1168 - **Rollout plan note**, at the top: `> **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.` (Internal plan text, not user-facing: no copy review for this one.)
1169
1170- [ ] **Step 2: Copy review.** Invoke the `copy-review` skill with every drafted string except the rollout-plan note. Wait for Paul's edits. Apply them verbatim.
1171
1172- [ ] **Step 3: Write the files** with the approved text.
1173
1174- [ ] **Step 4: Verify the docs gate.**
1175
1176Run: `go test ./internal/docsite -count=1`
1177Expected: `ok`.
1178
1179- [ ] **Step 5: Commit.**
1180
1181```bash
1182git add infra/modules/scanner-host/README.md infra/modules/README.md infra/modules/region-host/README.md docs/site/cli.md docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md
1183git commit -m "docs: the scanner host, store_scanning, and the scanner's real variables" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1184```
1185
1186---
1187
1188### Task 5: gate, review, discussion, merge
1189
1190**Files:** none new.
1191
1192- [ ] **Step 1: Full gate.**
1193
1194Run: `go build ./... && go vet ./... && go test ./... -count=1`
1195Expected: all `ok`. If the activator's socket tests fail on path length or `/tmp` fills, use the `-exec` wrapper recorded in memory (`gate-tmp-exec-wrapper`); do not skip packages. Report any failure with its output.
1196
1197- [ ] **Step 2: Cross-compile, as CI does.**
1198
1199Run: `GOOS=darwin GOARCH=arm64 go build ./... && GOOS=windows GOARCH=amd64 go build ./...`
1200Expected: no output.
1201
1202- [ ] **Step 3: Open the tracking discussion.** Invoke the `amadan` skill, then create a discussion on `carlos/platform` titled `Scanner host: object-storage scanning on Carloku and Tito`, linking the spec, this plan and the branch, and recording the two follow-ups from spec §11 (the zip64 and encrypted-archive disclosure; a configurable scan limit). Note its number; later tasks post progress there.
1203
1204- [ ] **Step 4: Astra code review.** The session model is Opus, so the reviewer is Astra via `codex` (memory `codex-second-reader` has the working command; set `model_reasoning_effort="high"` and read the header line). Commit a brief as `docs/superpowers/specs/CODEX-REVIEW-BRIEF-SCANNER-HOST-CODE.md` asking it to review `origin/main..HEAD` against the spec, with special attention to: the systemd dependency design in `files/`, the first-boot script's failure paths, the `parseReply` change, and the IAM statements. Commit findings unedited as `CODEX-FINDINGS-SCANNER-HOST-CODE.md`. Verify every citation before accepting a finding (`receiving-code-review` skill). Fix, re-run the affected tests, and repeat until the verdict is accept.
1205
1206- [ ] **Step 5: Push the branch.** `git push origin worktree-scanner-host`, then `amadan ci status carlos/platform` until green.
1207
1208- [ ] **Step 6 (GATED): merge.** Show Paul the diffstat and the review verdict. On his go: `amadan branch merge -squash` per the `amadan` skill and memory `carlos-platform-forge`. Record the main SHA in the discussion.
1209
1210- [ ] **Step 7 (GATED): move the GitHub mirror.** Only a fast-forward:
1211
1212```bash
1213git fetch https://github.com/carlosframework/platform.git main
1214git merge-base --is-ancestor FETCH_HEAD origin/main && git push https://github.com/carlosframework/platform.git origin/main:refs/heads/main
1215```
1216Expected: the push succeeds. If the ancestor check fails, stop and tell Paul; never force.
1217
1218---
1219
1220### Task 6 (GATED): release v0.24.1
1221
1222> **2026-10-06:** v0.23.0 had already been cut (3ca43d3) before the scanner merged, so the scanner first shipped in v0.24.0. v0.24.0's first boot failed on AppArmor: freshclam may only read its config under `/etc/clamav`. The fix ships in v0.24.1, which is the pin used below.
1223
1224Carloku pins modules to release tags, so the modules reach it through a release. The release carries everything on main since v0.22.2, not only this work, so **whether and when to cut it is Paul's call**. Ask before starting.
1225
1226- [ ] **Step 1:** List what the release contains: `git log --oneline v0.22.2..origin/main`. Show Paul. Wait for his go.
1227- [ ] **Step 2:** Follow `README.md` "Releasing the platform" exactly (steps 1 to 9 and the public-side verification), with memory `platform-release-recipe` for the traps. Release notes go through `copy-review`. The operators' section says: object-storage scanning needs a scanner host (`infra/modules/scanner-host`) and `store_scanning` on the console's `region-host`; a console must run this release before it is pointed at a scanner host, because older consoles record an over-limit archive as infected.
1228- [ ] **Step 3:** Push the tag to the GitHub mirror: `git push https://github.com/carlosframework/platform.git v0.24.1`.
1229- [ ] **Step 4:** Do **not** roll boxes as part of this plan. The box binary is unaffected; only consoles need the new build (Tasks 8 and 12). If Paul wants the fleet on v0.24.1 too, that is the README's last step and his separate go.
1230
1231---
1232
1233### Task 7 (GATED): Carloku, the scanner box
1234
1235**Files:**
1236- `~/github.com/carlosframework/carloku-infrastructure/region-ie.tf` (modify: `host_ie`'s `?ref=`, add `store_scanning = true`)
1237- `~/github.com/carlosframework/carloku-infrastructure/scanner.tf` (create)
1238- `~/github.com/carlosframework/carloku-infrastructure/outputs.tf` (modify)
1239
1240- [ ] **Step 1: Branch and edit.** In that repo: `git checkout -b scanner-host origin/main`. In `region-ie.tf`, change `host_ie`'s source to `?ref=v0.24.1` (that module block only; the other three regions stay on v0.21.0) and add, after `store_bucket_prefix`:
1241
1242```hcl
1243 # Object-storage virus scanning (platform spec 2026-10-04): lets THIS box's
1244 # role read and tag store objects. The console is the only thing that scans.
1245 store_scanning = true
1246```
1247
1248Create `scanner.tf`:
1249
1250```hcl
1251# The flagship's scanner host: clamd for the console's object-storage virus
1252# scanning, on its own box so its signatures and CPU stay off the control
1253# plane. Reachable from the IE host's security group only. The module's README
1254# has the readiness and acceptance checks; run them before wiring the console.
1255module "scanner" {
1256 source = "git::https://github.com/carlosframework/platform.git//infra/modules/scanner-host?ref=v0.24.1"
1257 name = "carlos-flagship-scanner"
1258 ami_id = module.host_ie.ami_id
1259 client_security_group_id = module.host_ie.security_group_id
1260 region = "eu-west-1"
1261}
1262```
1263
1264Append to `outputs.tf`:
1265
1266```hcl
1267output "scanner_clamd_addr" {
1268 description = "CARLOS_CLAMD_ADDR for the flagship console."
1269 value = module.scanner.clamd_addr
1270}
1271
1272output "scanner_stream_max_bytes" {
1273 description = "CARLOS_CLAMD_MAX_BYTES for the flagship console."
1274 value = module.scanner.stream_max_bytes
1275}
1276
1277output "scanner_instance_id" {
1278 description = "The scanner box, for SSM."
1279 value = module.scanner.instance_id
1280}
1281```
1282
1283- [ ] **Step 2: Plan, targeted.** (Sandbox off for the `keymail` profile.)
1284
1285```bash
1286AWS_PROFILE=keymail tofu init -upgrade -input=false
1287AWS_PROFILE=keymail tofu plan -input=false -out="$TMPDIR/scanner-host/flagship.plan" \
1288 -target=module.scanner \
1289 -target='module.host_ie.aws_iam_role_policy.host_store_scan'
1290```
1291Expected: creations only: the scanner's security group, role, policy attachment, `no_parameters` policy, instance profile and instance, plus `host_store_scan[0]`. **No `update in-place`, no `replace`, no `user_data` line on any existing box.** If there is one, stop.
1292
1293- [ ] **Step 3: Show Paul the plan** (`tofu show "$TMPDIR/scanner-host/flagship.plan"`), and separately the full untargeted plan's summary line so he sees what the ref bump leaves pending on `host_ie`. Wait for his go.
1294
1295- [ ] **Step 4: Apply that plan file.** `AWS_PROFILE=keymail tofu apply "$TMPDIR/scanner-host/flagship.plan"`. Record the three outputs. Commit the branch, push, open a PR on carloku-infrastructure listing what was applied and what the bump leaves unapplied.
1296
1297- [ ] **Step 5: Wait for first boot.** Create the SSM helper used from here on:
1298
1299```bash
1300mkdir -p "$TMPDIR/scanner-host"
1301cat > "$TMPDIR/scanner-host/ssm-run.sh" <<'EOF'
1302#!/bin/sh
1303# usage: ssm-run.sh <profile> <region> <instance-id> '<command>'
1304# Runs one shell command on a box and prints its stdout, stderr and status.
1305set -eu
1306p=$1; r=$2; i=$3; c=$4
1307id=$(aws --profile "$p" --region "$r" ssm send-command --instance-ids "$i" \
1308 --document-name AWS-RunShellScript --parameters "$(jq -nc --arg c "$c" '{commands:[$c]}')" \
1309 --query Command.CommandId --output text)
1310aws --profile "$p" --region "$r" ssm wait command-executed --command-id "$id" --instance-id "$i" 2>/dev/null || true
1311aws --profile "$p" --region "$r" ssm get-command-invocation --command-id "$id" --instance-id "$i" \
1312 --query '[Status,StandardOutputContent,StandardErrorContent]' --output text
1313EOF
1314chmod +x "$TMPDIR/scanner-host/ssm-run.sh"
1315```
1316
1317Then poll the serial console until it says one or the other (first boot takes several minutes: packages, about 300 MB of signatures, the self-test):
1318
1319Run: `aws --profile keymail --region eu-west-1 ec2 get-console-output --instance-id <scanner-id> --latest --output text | grep 'SCANNER-HOST'`
1320Expected: `SCANNER-HOST READY ClamAV 1.4.x/<sig>/<date>`, with the date today or yesterday. On `SCANNER-HOST FAILED: <reason>`, read the `scanner-init:` lines above it and fix the module (back to Task 3), not the box.
1321
1322- [ ] **Step 6: Readiness.**
1323
1324Run: `"$TMPDIR/scanner-host/ssm-run.sh" keymail eu-west-1 <scanner-id> '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; aa-status 2>/dev/null | grep -c /usr/sbin/clamd; journalctl -k --no-pager | grep -c "apparmor=\"DENIED\".*clamd"'`
1325Expected: `status: done`; the VERSION line; `active` twice; ten `pass` lines and `scanner-selftest: PASS`; then `1` or more (clamd's AppArmor profile is loaded) and `0` (it denied clamd nothing, so the `/var/tmp` local include took).
1326
1327Then prove a package reconfigure leaves `clamd.conf` alone (debconf is frozen off; with it on, the postinst would rewrite `StreamMaxLength`):
1328
1329Run: `... ssm-run.sh keymail eu-west-1 <scanner-id> 'debconf-show clamav-daemon | grep "clamav-daemon/debconf"; dpkg-reconfigure -fnoninteractive clamav-daemon; grep -E "^(StreamMaxLength|MaxFileSize|MaxScanSize|AlertExceedsMax|TemporaryDirectory) " /etc/clamav/clamd.conf; systemctl restart clamav-daemon.service; for i in $(seq 1 60); do [ "$(systemctl is-active scanner-tcp.socket)" = active ] && break; sleep 10; done; echo "after reconfigure: socket $(systemctl is-active scanner-tcp.socket), selftest $(systemctl is-active scanner-selftest.service), $(ss -ltnH "sport = :3310" | wc -l) listeners"'`
1330Expected: a line ending `clamav-daemon/debconf: false`; these five lines, in any order: `StreamMaxLength 25M`, `AlertExceedsMax yes`, `MaxFileSize 100M`, `MaxScanSize 100M`, `TemporaryDirectory /var/tmp` (init appends them in that order, so expect that one, but any order is a pass); then `after reconfigure: socket active, selftest active, 1 listeners`. The reconfigure moves Debian's `extend.conf` drop-in aside (postinst, debconf off), so the restart also proves clamd starts on the module's own drop-in. Anything else: stop, fix in Task 3, replace the box.
1331
1332- [ ] **Step 7: Acceptance check, kill.** The port follows the test.
1333
1334Run: `... ssm-run.sh keymail eu-west-1 <scanner-id> '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"'`
1335Expected: `after kill: 0 listeners, selftest` 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 dependency design is wrong on this systemd: stop, fix in Task 3, replace the box.
1336
1337- [ ] **Step 8: Acceptance check, forced fail.** A failed test keeps the port shut.
1338
1339Run, in one command: write `/etc/systemd/system/scanner-selftest.service.d/99-force-fail.conf` containing `[Service]` and `Environment=SCANNER_SELFTEST_FORCE_FAIL=1`; `systemctl daemon-reload`; `systemctl restart clamav-daemon.service`; wait 90 seconds; print `systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket` and the listener count; then remove the drop-in, `daemon-reload`, `systemctl restart clamav-daemon.service`, wait until `scanner-tcp.socket` is active (up to 10 minutes), and print the same again.
1340Expected first print: `active`, `failed`, `inactive` (or `failed`), `0` listeners. Second: `active` three times, `1` listener.
1341
1342- [ ] **Step 9: Acceptance check, the protocol through the proxy, from the console box.**
1343
1344Run: `... ssm-run.sh keymail eu-west-1 i-092c0c1eea75723cb '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'`
1345(`\0\0\0\104` is the 68-byte chunk length, octal 104 = 68; the four zero bytes after the payload end the stream.)
1346Expected: `stream: Eicar-Test-Signature FOUND`. If the nested quoting fights you, send the same bytes with a three-line `python3` socket script instead; the bytes are what matter. Also confirm the fence from somewhere that is not the console box: the same connect attempt from another fleet box times out.
1347
1348- [ ] **Step 10: Acceptance check, reboot.**
1349
1350Run: `aws --profile keymail --region eu-west-1 ec2 reboot-instances --instance-ids <scanner-id>`, wait five minutes, then `... ssm-run.sh keymail eu-west-1 <scanner-id> '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'`
1351Expected: `active` three times, `1`, `0`, and nothing from `systemd-analyze verify` about these three units.
1352
1353- [ ] **Step 11: Record** all four results in the discussion, with the VERSION line.
1354
1355---
1356
1357### Task 8 (GATED): Carloku, the console on the new build
1358
1359- [ ] **Step 1:** A clean checkout of the `v0.24.1` tag (scratch clone under `~/.claude/tmp/carlos-build/`, as memory `platform-roll-recipe` describes). `git status --porcelain` must print nothing.
1360- [ ] **Step 2:** Show Paul the command. On his go, from `carloku-infrastructure`: `GOFLAGS=-buildvcs=false scripts/roll-console.sh <checkout> flagship`.
1361Expected: it builds arm64, ships, promotes, adopts, restarts, and its own check prints the running executable as `.../versions/<sha>/console` for the SHA it built. **If that check fails, stop: nothing gets wired.**
1362- [ ] **Step 3:** `carlos system antivirus status --console https://console.carloku.com`
1363Expected: it answers, and says no engine is wired. (Wiring is Task 9.)
1364
1365---
1366
1367### Task 9 (GATED): Carloku, key and wiring
1368
1369- [ ] **Step 1: Preconditions, both.** Task 7's readiness and four acceptance checks are recorded as passed. Task 8's executable check passed for the build with the Task 1 change: `git merge-base --is-ancestor <task-1-commit-on-main> v0.24.1` exits 0.
1370
1371- [ ] **Step 2: Find the console's environment file.**
1372
1373Run: `... ssm-run.sh keymail eu-west-1 i-092c0c1eea75723cb 'systemctl cat console.service | grep -i EnvironmentFile; systemctl show -p User --value console.service'`
1374Expected: one `EnvironmentFile=` path (written `<envfile>` below) and the unit's user.
1375
1376- [ ] **Step 3: Generate the key straight into SSM.** It never touches argv, a file or the terminal:
1377
1378```bash
1379openssl rand -hex 32 \
1380 | jq -Rc '{Name:"/carlos/scan-tag-key",Type:"SecureString",Value:.,Overwrite:false,Description:"Signs every object-storage scan verdict. Losing it makes every stored verdict unverifiable."}' \
1381 | aws --profile keymail --region eu-west-1 ssm put-parameter --cli-input-json file:///dev/stdin
1382```
1383Expected: `"Version": 1`. `Overwrite:false` means a rerun fails with `ParameterAlreadyExists` instead of replacing the key; that failure is correct, leave the key alone.
1384
1385- [ ] **Step 4: Show Paul** the three variables (the key as a name only), the file, and that the next step restarts the console for a few seconds. Wait for his go.
1386
1387- [ ] **Step 5: Write the variables on the box.** The box fetches the key itself, so it never crosses the SSM command channel:
1388
1389```sh
1390set -eu
1391f=<envfile>
1392cp -p "$f" "$f.bak-scan-$(date +%Y%m%d)"
1393key=$(aws ssm get-parameter --region eu-west-1 --name /carlos/scan-tag-key --with-decryption --query Parameter.Value --output text)
1394[ ${#key} -eq 64 ] || { echo "key is not 64 hex chars"; exit 1; }
1395grep -v '^CARLOS_CLAMD_ADDR=\|^CARLOS_CLAMD_MAX_BYTES=\|^CARLOS_SCAN_TAG_KEY=\|^CARLOS_CLAMD_SOCKET=' "$f" > "$f.new"
1396printf 'CARLOS_CLAMD_ADDR=%s\nCARLOS_CLAMD_MAX_BYTES=%s\nCARLOS_SCAN_TAG_KEY=%s\n' '<scanner_clamd_addr>' '<scanner_stream_max_bytes>' "$key" >> "$f.new"
1397chmod --reference="$f" "$f.new"; chown --reference="$f" "$f.new"; mv "$f.new" "$f"
1398grep -c '^CARLOS_CLAMD_ADDR=\|^CARLOS_CLAMD_MAX_BYTES=\|^CARLOS_SCAN_TAG_KEY=' "$f"
1399grep -c '^CARLOS_STORE_SCANNER=' "$f" || true
1400```
1401Expected: `3`, then `0`. Nothing prints the key.
1402
1403- [ ] **Step 6: Restart and check.**
1404
1405Run: `... ssm-run.sh keymail eu-west-1 i-092c0c1eea75723cb 'systemctl restart console; sleep 8; systemctl is-active console; readlink /proc/$(systemctl show -p MainPID --value console)/exe; journalctl -u console --since "-1min" --no-pager | grep -i "clamd\|scan\|fatal" | tail -5'`
1406Expected: `active`; the same `versions/<sha>/console`; no `fatal` line.
1407**If it is not active:** `cp -p <envfile>.bak-scan-<date> <envfile> && systemctl reset-failed console && systemctl restart console`, confirm `active`, stop, and tell Paul with the journal lines.
1408
1409- [ ] **Step 7: Status, then enable.**
1410
1411Run: `carlos system antivirus status --console https://console.carloku.com`
1412Expected: engine wired, answering, signature date today or yesterday, policy off.
1413Show Paul. On his go: `carlos system antivirus enable --console https://console.carloku.com`, then `status` again.
1414Expected: policy on. No app is granted, so the hourly pass does nothing.
1415
1416---
1417
1418### Task 10 (GATED): Carloku, the proof
1419
1420On a throwaway app Paul's account owns. Ask Paul which account to create it under, and confirm before creating and before destroying.
1421
1422- [ ] **Step 1: Make the app and its store.** Create an app (name `scan-proof-<date>`), then `carlos store create --app <app>` and `carlos store status --app <app>` to learn the bucket name.
1423- [ ] **Step 2: Build the fixtures locally** by running the fixture half of `infra/modules/scanner-host/files/scanner-selftest` (the `python3` heredoc) into `$TMPDIR/scanner-host/fx`. Upload five with the operator profile: `01-clean.txt`, `02-eicar.txt`, `03-zip-one-500.zip`, `08-zip-500-then-eicar.zip`, `09-zip-60-then-eicar.zip`:
1424`aws --profile keymail s3 cp "$TMPDIR/scanner-host/fx/<file>" s3://<bucket>/proof/<file>`
1425- [ ] **Step 3: Request, grant, sweep.**
1426
1427```
1428carlos store scan request --app <app> --plaintext
1429carlos store scan grant --app <app>
1430carlos store scan sweep --app <app>
1431carlos store scan status --app <app>
1432```
1433Expected (spec §7 steps 1 and 2): a completed sweep; 5 objects; `01` clean; `02` and `09` infected; `03` and `08` skipped and counted unscanned. **If `03` or `08` reads clean, stop the rollout:** `carlos system antivirus disable`, and tell Paul.
1434
1435- [ ] **Step 4: The tags.** For `01` and `03`: `aws --profile keymail s3api get-object-tagging --bucket <bucket> --key proof/<file>` and `aws --profile keymail s3api head-object --bucket <bucket> --key proof/<file> --query ETag`.
1436Expected: `carlos-scan`, `carlos-scan-version`, `carlos-scan-at` present; the version tag carries the object's current ETag; `01` says clean, `03` says skipped.
1437
1438- [ ] **Step 5: A rewrite invalidates the verdict.** Overwrite `proof/01-clean.txt` with different bytes, `sweep`, `status`.
1439Expected: the fresh count goes up by one; it is re-scanned, not carried forward. **If carried forward, stop the rollout.**
1440
1441- [ ] **Step 6: Enforce survives the engine going away.** `carlos store scan enforce --app <app> on`. Then, with Paul's go (two more console restarts): on the box, back up `<envfile>`, remove the `CARLOS_CLAMD_ADDR` line, restart; run `carlos store scan enforce --app <app> off`.
1442Expected: it succeeds with no engine built. Restore the line from the backup, restart, `antivirus status` shows the engine wired again.
1443
1444- [ ] **Step 7: Teardown.** Destroy the app.
1445Expected: `aws --profile keymail s3 ls s3://carlos-flagship-271376211898/apps/ --recursive | grep '<app>.*scan.json'` prints nothing, and the app name can be created again at once (create it, then destroy it).
1446
1447- [ ] **Step 8:** Record every result in the discussion. Scanning stays enabled on Carloku with no member granted.
1448
1449---
1450
1451### Task 11 (GATED): Tito, the scanner box and the grant
1452
1453Do not start until Task 10 has passed and #201 phase 2 has landed (check memory `tito-console-store-boundary` and the teamtito repo). Tito is a third party: every step here is shown to Paul first.
1454
1455**Files:** `~/github.com/teamtito/carlos-infrastructure/object-store.tf` (modify), `scanner.tf` (create), `outputs.tf` (modify)
1456
1457- [ ] **Step 1: Branch from the line that can plan.** `git fetch origin`, then branch `scanner-host` from the tip the last targeted apply ran from (`store-boundary-201`, or whatever has superseded it; `main` cannot plan while the Canada box is only on `region-ca`). Say which tip in the PR.
1458
1459- [ ] **Step 2: Edit.** In `object-store.tf`, add to `host_ie_object_store`'s `Statement` list:
1460
1461```hcl
1462 {
1463 # Object-storage virus scanning (platform spec 2026-10-04): the console
1464 # reads each object to stream it to the scanner host, and writes the
1465 # verdict as tags. Inert until a member requests scanning for a store
1466 # and an operator grants it.
1467 Sid = "StoreScan"
1468 Effect = "Allow"
1469 Action = ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging"]
1470 Resource = [
1471 "arn:aws:s3:::carlos-store-tito-*/*",
1472 "arn:aws:s3:::titogo-uploads-665106592142/*",
1473 ]
1474 },
1475```
1476
1477Create `scanner.tf` as in Task 7 Step 1 with `name = "carlos-tito-scanner"`, and the three outputs.
1478
1479- [ ] **Step 3: Check the box can read its key.** In `region-ie.tf`, read `host_ie`'s `ssm_parameter_arn`. If `/carlos/scan-tag-key` falls outside it, the console box could not fetch the key in Task 12; add that one parameter ARN in the smallest way that file allows and include the resource in the targeted plan. Say so to Paul.
1480
1481- [ ] **Step 4: Plan, targeted.**
1482
1483```bash
1484AWS_PROFILE=titogo tofu init -upgrade -input=false
1485AWS_PROFILE=titogo tofu plan -input=false -out="$TMPDIR/scanner-host/tito.plan" \
1486 -target=module.scanner -target=aws_iam_role_policy.host_ie_object_store
1487```
1488Expected: the scanner's six resources created; `host_ie_object_store` updated in place with exactly the `StoreScan` statement added (read the policy diff: any other difference is drift from #201, stop and ask). No change to any instance.
1489
1490- [ ] **Step 5:** Show Paul the plan. On his go, apply the plan file, push the branch, open the PR.
1491- [ ] **Step 6:** Task 7 Steps 5 to 11 on Tito's scanner box (profile `titogo`, console box `i-025a0b70f3d93304d`): first boot, readiness, and all four acceptance checks.
1492
1493---
1494
1495### Task 12 (GATED): Tito, console, key, wiring, enable
1496
1497- [ ] **Step 1:** Roll Tito's console to the same build as Task 8: `GOFLAGS=-buildvcs=false scripts/roll-console.sh <checkout> tito`, with Paul's go. Its executable check must pass. Before rolling, diff Tito's console environment against the flagship's for variables the new build requires (the 2026-09-11 lesson): `... ssm-run.sh` on each console box, `cut -d= -f1 <envfile> | sort`, and compare.
1498- [ ] **Step 2:** Task 9 Steps 1 to 7 in Tito's account (profile `titogo`, region `eu-west-1`, `/carlos/scan-tag-key`, console `https://carlos.tito.io`). A **new** key: never copy the flagship's.
1499- [ ] **Step 3:** Stop at `antivirus status` (wired, answering, fresh signatures) and `antivirus enable`. **No throwaway app, no grant, no sweep on Tito** (spec §8).
1500- [ ] **Step 4:** Record in the discussion.
1501
1502---
1503
1504### Task 13: close out
1505
1506- [ ] **Step 1:** Post the final state to the discussion: both deployments enabled, no store granted, the two follow-ups open.
1507- [ ] **Step 2:** Update memory: a `scanner-host-rollout` entry (what is live, box ids, where the keys are, the replacement rule, the follow-ups) and a pointer line in `MEMORY.md`; correct `tito-console-store-boundary` if Tito's policy changed.
1508- [ ] **Step 3:** Delete `$TMPDIR/scanner-host/` and the scratch clone if this plan made it. Remove `.scratch-codex-review.sh` from the worktree. Leave nothing under `/tmp`.
1509- [ ] **Step 4:** Write the handoff (`handoff` skill).
1510