All green. Report:
**Verified / implemented (final pass of `15_agent_mtls`)**
- Wrote `data/logs/verify-15-tls-e2e.py`: a real `host_agent.py --cert-bundle` + a real dashboard process over a scratch DB (`HOST_TARGETS=loop=https://…`) — **27/27 PASS**, transcript `verify-15-tls-e2e.out`, notes `data/logs/NOTES-tls-rollout.md`.
- Read-only checks against the running dev store: 5 `certs.*` rows byte-identical across a restart; served bundle == the `meta` row (1430 B); 0 events containing `-----BEGIN`; unsigned `/certs`→`303 ?next=`, unsigned `/api/certs`→`401`, signed-in `200`; screenshots `data/logs/15-final-{certs,system}.png`.
- Docs (step 6, full review): README — added *Protecting an agent, host by host* (the rollout task-07 left out), a `tests/test_certs.py`/TLS-matrix/sandbox-installer paragraph, and fixed two stale “the one write endpoint in the app” claims (`NUT_ALLOW_ACTIONS`, Battery-test buttons). AGENTS.md — test count 1424→1427, bundle-fetch command in *Commands*, a Testing-policy bullet on structurally pinning a key-holding module, fleet transport bullet (token mode is now the compatibility path), gap 11 final state.
**Tests / lint (full suite 100% green)**
- `uv run pytest` → **1427 passed, 15 skipped**, exit 0 · `bash .agents/validate.sh` → **exit 0** (coverage **98.94%** vs 94% floor, `certs.py` 100%; ruff “All checks passed”; pyright 0 errors; `node --check` clean; hygiene clean)
- `uv run pytest -m live --live` → **11 passed / 0 failed** · `./scripts/dev-server.sh status` → all 9 sources `ok`
- `uv pip list --outdated` → empty after bumping `fastapi 0.152.0 → 0.153.0` (`uv.lock` only); full suite re-run green.
**Completion criteria**
- Fresh-DB root CA, idempotent: ✅ (tests + live store hashes) · agent TLS matrix incl. wrong-CA/certless 403/`/health` CA-only/revoked 403 + live denylist re-read: ✅
- `https` `host:`/`disks:` `ok` + revoked says “agent certificate revoked” with no dial: ✅ on loopback (real agent + real dashboard) and spy-pinned hermetically; **not** on the fleet (below)
- Signed-in `/certs` + rotate/revoke/root-rotate + byte-identical bundle download: ✅ · installer bundle/unit/`--status` over TLS: ✅ (0640, see deviation)
- `pytest`/`validate.sh`/`--outdated`: ✅ · token-mode byte-identical, engine/UPS untouched, public surface unchanged (route walk): ✅
- **Live fleet TLS (deskwork+gateway flip): NOT done** — no live changes. It needs root on hosts without the installer grant (`deskwork`), has no both-ways-working window (`http://` dashboard vs https-only agent), and would deploy the agent from this not-yet-committed tree. Exact procedure recorded in README + `data/logs/NOTES-tls-rollout.md`.
**Deviations**: bundle mode `0640 root:dashboards-agent` instead of the task file’s `0600` (the agent re-reads its own bundle for the denylist; 0600 breaks the unit — already recorded in AGENTS.md); the loopback dashboard substitutes for the fleet flip. Incidental: a stray `pkill` of mine killed the dev server mid-pass; restarted and verified healthy.
**Next pending phase**: `.agents/phases/todo/16_agent_smart_pass_budget` (then `17_installer_device_filter`).
6.3 KiB
6.3 KiB
Task 01 — The dashboard owns a root CA and every cert it signs
Phase: 15_agent_mtls · Source: TODO.md:3 — "I want all exporters to be protected web servers. The collector should generate and manage a root certificate that needs to be installed with each exporter. Use the certs as auth to each exporter."
Objective
The certificate lifecycle becomes a pure, testable module: the dashboard generates a root CA once (idempotent across restarts), signs its own client cert and each agent's server cert, and supports reissue and revocation — all state in the meta table, exactly how auth.py keeps the verifier and the session secret.
Work
pyproject.toml— addcryptographytodependencies(dashboard-side only; the agent stays stdlib-only) and runuv lock. Invariant 8:uv pip list --outdatedempty afterwards.- ASSUMPTION:
cryptographyis the only new dependency and it stays on the dashboard side; the agent (task 02) gains no third-party code — it loads the PEMs with stdlibssl.
- ASSUMPTION:
src/dashboards/certs.py(new) — the whole lifecycle as pure functions over the three-methodMetaStoreprotocol (the same protocolauth.load_auth_stateuses, so this module imports nothing else from the package, and its tests use a dict):ensure_certs(store, *, now: float | None = None)→ a frozenCertsStatecarrying the PEM text (root_key,root_cert,client_key,client_cert) plus the root/clientserial+not_after. Reads themetarows first; only generates and persists when missing (first boot); an existing row wins and is never regenerated on restart — regenerating on every start would rotate the CA and invalidate every installed agent bundle.nowis injected (the module never reads a clock itself — theauth.pyrule).- meta keys (one concern per key, the
auth.*precedent):certs.root.key,certs.root.cert,certs.client.key,certs.client.cert,certs.agents(one JSON object mappinghost name→{serial, not_after, status, bundle_rev, bundle, san}— the bundle text is stored, so what the agent holds is byte-identical to what the API serves). - Shapes: root CA self-signed, CN
dashboards-root, 10 years; client cert signed by the root, CNdashboards, 1 year; agent server certs signed by the root, CN = the host name, 1 year,SAN= the address(es) the dashboard dials for that host.- ASSUMPTION: 10 y root / 1 y leaf lifetimes. No env seed for the root CA (unlike the admin password, the root key is a secret only the app needs to know, so it is auto-generated on first boot and merely persisted).
issue_agent_cert(store, state, name, san: list[str])/reissue_agent_cert(store, state, name, san)— a fresh serial,bundle_rev+1 (1 on first issue),status: "active", a freshbundle(below). Idempotent per call — never issued on a read.rotate_root(store, state)— a fresh root CA + fresh client cert + every existing agent entry reissued (eachbundle_rev+1): one action re-arms the whole fleet.revoke(store, state, name)—status: "revoked"(the serial stays on the denylist until a reissue gives the host a fresh identity; there is no "unrevoke" — reissue is the fix).- the bundle (the install artifact, the TODO's "root certificate installed with each exporter"): one PEM text = the agent's server cert + the agent's key + the root CA cert (never the root key) + a
# BUNDLE-REV <n>line + a# REVOKED <serial>line (one per revoked serial of the current root generation — a root rotation resets the list, since every cert is reissued anyway). - ASSUMPTION: one client cert for the whole dashboard (the agent verifies "signed by our root", not a per-process identity); one agent entry per
HOST_TARGETSname. - invariant 6, the
auth.pyrule: nologgingand no raise with anything interpolated — a malformed stored PEM is a fixed-textRuntimeErrortelling the operator to delete thecerts.*meta keys (the shape of the auth reset), and nothing else.
src/dashboards/app.py(+ theAppContextinsrc/dashboards/api.py) — the startup path buildsctx.certs(anensure_certsread, the one-time bootstrap included) beforebuild_hub, in both the owned-store (build_context) and the injected-store (lifespan) branches, through one helper so the two wiring sites cannot drift (thebuild_hubrule in AGENTS.md); the PEMs are written once to a private temp dir (mode0600, lives as long as the process) and the paths are whatAppContext.certscarries for task 03's dialing.- A
certswhose stored PEM is malformed (a truncatedmetarow) is a fail-closed startup — a dashboard that cannot present its own credential must not start the loops (thectx.authrule, the one deliberate raise).
- A
Testing & Quality
tests/test_certs.py(new) — a dictMetaStore(thetests/test_auth.pyidiom):- the first
ensure_certscall persists exactly the fivecerts.*keys; the second is byte-identical (no regeneration — pin thecerts.root.certvalue); - the parsed chain (
cryptography.x509loaders) verifies: client → root, agent → root; the SAN list appears in the agent cert; the expiries match the assumed lifetimes (injectednow); reissue_agent_certbumpsserialandbundle_revand setsstatusactive;revokeflips the status and puts the serial on the bundle's# REVOKEDline; a reissue after a revoke gives a fresh serial that is not on the list;rotate_rootreissues every agent entry (eachbundle_revbumped), the bundle carries the new root cert, and no stored value still contains the old root key;- the bundle contains the agent cert + key + root cert +
# BUNDLE-REV+ the# REVOKEDlines and never the root key (assert via the PEM header and a parsed-key mismatch); - a malformed stored PEM → the fixed-text
RuntimeErrornaming the meta keys, nothing interpolated.
- the first
- Coverage: >90% on this task's new code.
Completion Criteria
uv run pytest tests/test_certs.pygreen; the full suite greenuv pip list --outdatedempty;uv run ruff check src tests agent+uv run pyrightclean- a fresh store boots with the
certs.*rows present; a second boot changes no byte ofcerts.root.cert - no behaviour change in completed work:
auth.*keys, the public surface, and the poller wiring are untouched