Files
dashboards/.agents/phases/complete/15_agent_mtls/01_cert_management.md
T
ducoterra cffbac5771 phase: 15_agent_mtls
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`).
2026-10-20 10:02:16 -04:00

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

  1. pyproject.toml — add cryptography to dependencies (dashboard-side only; the agent stays stdlib-only) and run uv lock. Invariant 8: uv pip list --outdated empty afterwards.
    • ASSUMPTION: cryptography is 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 stdlib ssl.
  2. src/dashboards/certs.py (new) — the whole lifecycle as pure functions over the three-method MetaStore protocol (the same protocol auth.load_auth_state uses, so this module imports nothing else from the package, and its tests use a dict):
    • ensure_certs(store, *, now: float | None = None) → a frozen CertsState carrying the PEM text (root_key, root_cert, client_key, client_cert) plus the root/client serial + not_after. Reads the meta rows 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. now is injected (the module never reads a clock itself — the auth.py rule).
    • 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 mapping host 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, CN dashboards, 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 fresh bundle (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 (each bundle_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_TARGETS name.
    • invariant 6, the auth.py rule: no logging and no raise with anything interpolated — a malformed stored PEM is a fixed-text RuntimeError telling the operator to delete the certs.* meta keys (the shape of the auth reset), and nothing else.
  3. src/dashboards/app.py (+ the AppContext in src/dashboards/api.py) — the startup path builds ctx.certs (an ensure_certs read, the one-time bootstrap included) before build_hub, in both the owned-store (build_context) and the injected-store (lifespan) branches, through one helper so the two wiring sites cannot drift (the build_hub rule in AGENTS.md); the PEMs are written once to a private temp dir (mode 0600, lives as long as the process) and the paths are what AppContext.certs carries for task 03's dialing.
    • A certs whose stored PEM is malformed (a truncated meta row) is a fail-closed startup — a dashboard that cannot present its own credential must not start the loops (the ctx.auth rule, the one deliberate raise).

Testing & Quality

  • tests/test_certs.py (new) — a dict MetaStore (the tests/test_auth.py idiom):
    • the first ensure_certs call persists exactly the five certs.* keys; the second is byte-identical (no regeneration — pin the certs.root.cert value);
    • the parsed chain (cryptography.x509 loaders) verifies: client → root, agent → root; the SAN list appears in the agent cert; the expiries match the assumed lifetimes (injected now);
    • reissue_agent_cert bumps serial and bundle_rev and sets status active; revoke flips the status and puts the serial on the bundle's # REVOKED line; a reissue after a revoke gives a fresh serial that is not on the list;
    • rotate_root reissues every agent entry (each bundle_rev bumped), 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 # REVOKED lines and never the root key (assert via the PEM header and a parsed-key mismatch);
    • a malformed stored PEM → the fixed-text RuntimeError naming the meta keys, nothing interpolated.
  • Coverage: >90% on this task's new code.

Completion Criteria

  • uv run pytest tests/test_certs.py green; the full suite green
  • uv pip list --outdated empty; uv run ruff check src tests agent + uv run pyright clean
  • a fresh store boots with the certs.* rows present; a second boot changes no byte of certs.root.cert
  • no behaviour change in completed work: auth.* keys, the public surface, and the poller wiring are untouched