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
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