spec: scanner host draft 6, closes Astra review 5 (socket unit boot-ordering cycle)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+57
−4
docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md+22 −4docs/superpowers/specs/CODEX-FINDINGS-SCANNER-HOST-R5.md+35 −0
diff --git a/docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md b/docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md| index 86dad3ec9..5a2df4cf9 100644 |
| --- a/docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md |
| +++ b/docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md |
| @@ -1,12 +1,15 @@ |
| # Scanner host: getting object-storage scanning running on Carloku and Tito |
| -**Status:** draft 5. Closes Astra review 1 (`CODEX-FINDINGS-SCANNER-HOST.md`, |
| +**Status:** draft 6. Closes Astra review 1 (`CODEX-FINDINGS-SCANNER-HOST.md`, |
| findings 1–5, all accepted), review 2 (`CODEX-FINDINGS-SCANNER-HOST-R2.md`, |
| findings 6–8: 7 and 8 accepted, 6 declined with the reason in §4.3) and |
| review 3 (`CODEX-FINDINGS-SCANNER-HOST-R3.md`, finding 9, accepted and tested |
| against a real engine, §3.4) and review 4 (`CODEX-FINDINGS-SCANNER-HOST-R4.md`, |
| -findings 10 and 11, both accepted; 10 reshaped how the box listens, §3.2). |
| -Accepted findings are marked **(codex N)**. |
| +findings 10 and 11, both accepted; 10 reshaped how the box listens, §3.2) and |
| +review 5 (`CODEX-FINDINGS-SCANNER-HOST-R5.md`, finding 12, accepted). Review 5 |
| +found the gating design sound at spec level once 12 is fixed, and left restart |
| +timing, connection cut-off and EICAR transport to the real-box acceptance |
| +checks in §3.3. Accepted findings are marked **(codex N)**. |
| **Builds on:** `2026-09-01-object-storage-virus-scanning-design.md` (the sweep, |
| verdict tags, enforcement) and `2026-09-02-scan-on-demand-design.md` §9 (clamd |
| off the console box, reachable over TCP). |
| @@ -110,6 +113,16 @@ So clamd itself never listens on TCP. Three units: |
| the port cannot open before the test passes, and it closes, cutting |
| connections in flight, when the test's unit goes inactive. |
| + The socket unit sets **`DefaultDependencies=no`**. A socket unit is ordered |
| + `Before=sockets.target` by default, `sockets.target` comes before |
| + `basic.target`, and every service (the self-test included) comes after |
| + `basic.target`; a socket that must also come *after* the self-test closes |
| + that loop, and systemd breaks an ordering cycle by dropping a job, which |
| + could leave the port shut after a reboot. So the unit opts out, restores what |
| + it still needs by hand (`After=sysinit.target`, `Conflicts=shutdown.target`, |
| + `Before=shutdown.target`), and is started by `WantedBy=multi-user.target` and |
| + clamd's `Wants=`, never by `sockets.target`. **(codex 12)** |
| + |
| `clamd.conf` keys, each rewritten idempotently (clamd has no include |
| directory): |
| @@ -182,7 +195,12 @@ console is wired (§6.2), and recorded in the README: |
| for this check): clamd comes up, 3310 **stays closed**, the unit shows |
| failed; remove the override and restart: it opens; |
| - an INSTREAM scan of EICAR through `<private ip>:3310` from the console box |
| - answers `Eicar-Test-Signature FOUND`, so the proxy carries the protocol. |
| + answers `Eicar-Test-Signature FOUND`, so the proxy carries the protocol; |
| +- **reboot the box**: it comes back with the self-test passed and 3310 open, |
| + `systemd-analyze verify` on the three units is clean, and `journalctl -b` |
| + has no `ordering cycle` line. First boot cannot show this, because |
| + `basic.target` and `sockets.target` are already reached by the time |
| + `user_data` runs. **(codex 12)** |
| ### 3.4 What the limits do, measured |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-SCANNER-HOST-R5.md b/docs/superpowers/specs/CODEX-FINDINGS-SCANNER-HOST-R5.md| new file mode 100644 |
| index 000000000..6417cdc74 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-SCANNER-HOST-R5.md |
| @@ -0,0 +1,35 @@ |
| +| Finding | Disposition | Reason | |
| +|---|---|---| |
| +| 10 | **Closed for the original bypass** | The listener now requires a successful self-test, and both listener and proxy are bound to that test’s lifetime. §6.2 also requires scanner-side readiness before wiring. A separate boot-ordering defect remains below. | |
| +| 11 | **Closed** | Both scan limits are fixed at 100 MiB; §3.5 bounds the configurable stream limit so the specified fixtures fit. | |
| + |
| +Reviewed draft 5 at `edee53d4`. No files changed or live-box tests performed. |
| + |
| +12. **P2 — §§3.2–3.3: The gated socket creates a boot-ordering cycle with systemd’s default dependencies.** |
| + |
| + The [specified socket dependency](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/scanner-host/docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md:109) orders `scanner-tcp.socket` after `scanner-selftest.service`, but neither unit disables default dependencies. On reboot, this produces the following cycle, where each arrow means “must start before”: |
| + |
| + ```text |
| + scanner-tcp.socket |
| + → sockets.target |
| + → basic.target |
| + → scanner-selftest.service |
| + → scanner-tcp.socket |
| + ``` |
| + |
| + **Code evidence:** systemd adds `Before=sockets.target` to sockets in [`src/core/socket.c:262`](https://github.com/systemd/systemd/blob/v257/src/core/socket.c#L262), adds `After=basic.target` to services in [`src/core/service.c:712`](https://github.com/systemd/systemd/blob/v257/src/core/service.c#L712), and orders `basic.target` after `sockets.target` in [`units/basic.target:13`](https://github.com/systemd/systemd/blob/v257/units/basic.target#L13). |
| + |
| + Systemd resolves an ordering cycle by deleting a dispensable job or rejecting the transaction; it cannot execute the specified startup sequence intact. See [`src/core/transaction.c:351–395`](https://github.com/systemd/systemd/blob/v257/src/core/transaction.c#L351). Consequently, the scanner can remain unavailable after reboot. Initial cloud-init startup and §3.3’s kill/restart checks can miss this because `basic.target` and `sockets.target` have already completed. |
| + |
| + **Fix:** Specify `DefaultDependencies=no` for `scanner-tcp.socket`, restore its initialization and shutdown dependencies explicitly, and arrange activation through clamd or `multi-user.target` without ordering it before `sockets.target`. Retain the self-test dependencies. Add a reboot acceptance check and verify the complete boot transaction for ordering cycles. |
| + |
| +The other requested mechanisms do not produce additional findings: |
| + |
| +- **Self-test state:** `Type=oneshot` delays ordered dependents until successful completion. `RemainAfterExit=yes` retains that success as `active (exited)`; it neither overrides `BindsTo=` nor preserves success across reboot. A failed test cannot authorize the listener. [Service semantics](https://manpages.debian.org/trixie/systemd/systemd.service.5.en.html). |
| +- **Clamd restart and package restart:** `BindsTo=` plus `After=` stops dependents when clamd becomes inactive or fails; explicit restarts also propagate. Applying this to **both** TCP units removes the listener and terminates the proxy holding established connections. `Requires=` alone does not cover unexpected deactivation. [Dependency semantics](https://manpages.debian.org/trixie/systemd/systemd.unit.5.en.html). |
| +- **Automatic recovery:** With the default `RestartMode=normal`, automatic restart creates a new start transaction with dependency restart propagation. That transaction processes `Wants=scanner-tcp.socket`, which pulls in the self-test again. This is supported by [`service.c:2405–2418`](https://github.com/systemd/systemd/blob/v257/src/core/service.c#L2405) and [`transaction.c:937–947`](https://github.com/systemd/systemd/blob/v257/src/core/transaction.c#L937). |
| +- **INSTREAM:** `systemd-socket-proxyd` forwards bidirectional stream bytes between TCP and Unix sockets. The client’s [INSTREAM implementation](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/scanner-host/internal/clamd/clamd.go:195) uses byte framing, without descriptor passing, so the proxy supports its protocol. [Proxy semantics](https://manpages.debian.org/trixie/systemd/systemd-socket-proxyd.8.en.html). |
| + |
| +After fixing the boot cycle, the gating design is sound at spec level. Actual restart timing, connection termination and EICAR transport belong to the real-box acceptance checks. |
| + |
| +**Verdict: accept with fixes.** |
| \ No newline at end of file |