Files
dashboards/.agents/phases/todo/15_agent_mtls/01_cert_management.md
T
ducoterra 04d934dd94 chore(agent): phase 15_agent_mtls from TODO.md (protected exporter web servers)
The single TODO item (exporters as protected web servers, certs as auth,
root certificate generated and managed by the collector) is now phase 15:
cert management in meta, agent TLS mode, https collector dials, gated
/api/certs surface, /certs admin page, installer + live rollout.

LLM-Generated: true
2026-10-19 13:25:45 -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