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`).
This commit is contained in:
2026-10-20 10:02:16 -04:00
parent 569ca25fdb
commit cffbac5771
72 changed files with 7168 additions and 216 deletions
@@ -0,0 +1,22 @@
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`).
@@ -0,0 +1,63 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...........................s............................................ [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 59%]
........................................................................ [ 64%]
........................................sssssssssss..................... [ 69%]
........................................................................ [ 74%]
........................................................................ [ 79%]
........................................................................ [ 84%]
........................................................................ [ 89%]
........................................................................ [ 94%]
................sss..................................................... [ 99%]
.. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1427 passed, 15 skipped, 1 warning in 132.62s (0:02:12)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,20 @@
Everything is green. Final state:
**Implemented**
- `src/dashboards/certs.py` (new, 100% covered): root CA (`CN=dashboards-root`, 10 y) + dashboard client cert (`CN=dashboards`, 1 y) + per-agent server certs (`CN=<host>`, SAN = dialled addresses/IPs, 1 y) — pure lifecycle over the three-method `MetaStore` protocol, five `meta` rows (`certs.root.key/.cert`, `certs.client.key/.cert`, `certs.agents`), `ensure_certs` (stored row always wins; new root takes its client with it), `issue_agent_cert`/`reissue_agent_cert` (`bundle_rev` +1, fresh serial/key, `active`), `revoke` (marks host **and re-seals every bundle** — the `# REVOKED` denylist lives in each), `rotate_root` (new root+client, every agent reissued, denylist reset), `read_agents`/`agent_status`, the install **bundle** (agent cert + agent key + root *cert* + `# BUNDLE-REV`/`# REVOKED`; never the root key), `write_material`/`remove_material` (0700 dir, 0600 files, root key never on disk). No logging, one fixed-text `RuntimeError` naming the `DELETE FROM meta WHERE key LIKE 'certs.%'` reset; no clock except injected `now`.
- `pyproject.toml`/`uv.lock`: `cryptography>=50.0.2` (dashboard-side only; agent stays stdlib `ssl`) + invariant-8 refresh of the four stale transitive pins (certifi/fastapi/starlette/websockets) — suite green after.
- `app.py`/`api.py`: one `build_certs(store)` helper called **before** `build_hub` in both the owned-store and injected-store branches; `AppContext.certs` carries the `ca`/`client_cert`/`client_key` paths for task 03; lifespan removes the private dir on shutdown; corrupt stored PEM = fail-closed startup.
- `tests/test_certs.py` (53 tests): dict `MetaStore`, byte-identity across restarts, chain/EKU/SAN/lifetime shape, rotate/revoke/root-rotate incl. fleet-wide denylist, bundle-never-contains-root-key, 12 corrupt-row fail-closed cases, structural pins (no package import, no logging, only fixed-text raises), real `SampleStore` reopen, a **real `ssl` mTLS handshake** (client cert seen; certless probe still handshakes = task 02's `/health` path), and both app-wiring cases.
**Verification** (all from repo root)
- `uv run pytest` → **1320 passed, 15 skipped**; `uv run pytest tests/test_certs.py` → 53 passed
- `uv run pytest --cov --cov-fail-under=94` → **98.86%** (certs.py 100%, api.py 100%, app.py 100%)
- `uv run ruff check src tests agent` → clean; `uv run pyright` → 0 errors; `bash .agents/validate.sh` → **exit 0**
- `uv pip list --outdated` → empty; `uv run pytest -m live --live` → **11 passed**
- `./scripts/dev-server.sh restart` → all nine sources `ok`; dev DB gained the five `certs.*` rows and they were **byte-identical across two restarts**; the private temp dir belongs to the live process only
**Docs**: README (architecture diagram + `certs.py` bullet + `store.py` meta note), AGENTS.md (invariant 6 extended to private keys, `certs.py` module bullet, `app.py` `build_certs` note, new LOCKED decision for `cryptography`, new gap 11 "CA exists, no exporter uses it yet", test-count/coverage figures), and a one-line correction to `auth.py`'s "no dependency is coming" docstring.
**Decisions/deviations**: first boot also writes `certs.agents` = `{}` (task requires exactly five keys); a newly generated root regenerates the client too (a leaf under a dead root is unusable); every write function returns the new `CertsState` (uniform, store stays the truth); the denylist is fleet-wide by re-sealing all bundles; root rows' chain/key mismatch fails closed rather than self-healing; dependency refresh beyond `cryptography` was needed to satisfy invariant 8.
**Next pending task**: `.agents/phases/todo/15_agent_mtls/02_agent_tls.md`.
@@ -0,0 +1,61 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 5%]
........................................................................ [ 10%]
........................................................................ [ 16%]
........................................................................ [ 21%]
.............................................s.......................... [ 26%]
........................................................................ [ 32%]
........................................................................ [ 37%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 59%]
........................................................................ [ 64%]
.....sssssssssss........................................................ [ 70%]
........................................................................ [ 75%]
........................................................................ [ 80%]
........................................................................ [ 86%]
........................................................................ [ 91%]
.....................................................sss................ [ 97%]
....................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 358 0 100%
src/dashboards/app.py 184 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 276 0 100%
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 130 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3331 38 99%
Required test coverage of 94% reached. Total coverage: 98.86%
1320 passed, 15 skipped, 1 warning in 112.03s (0:01:52)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,15 @@
**Implemented** (`agent/host_agent.py`, stdlib only; `deploy/` untouched)
- `--cert-bundle`/`AGENT_CERT_BUNDLE` + `resolve_bundle(path) -> (context, bundle_rev, revoked, error)`: greps the three PEM blocks out of one installed bundle by text, writes them `0600` into `/run/dashboards-agent` when usable else `mkdtemp`, builds `PROTOCOL_TLS_SERVER` (`TLSv1_2`, `load_cert_chain`, `load_verify_locations(ca)`, `CERT_OPTIONAL`), then deletes the PEMs — the key lives only in the process.
- `TlsBundle` re-reads `# REVOKED` on `(mtime_ns,size)` change (denylist bites without a restart); handler enforces: 403 `client certificate required` on every path but `/health`, 403 `client certificate revoked` on **every** path; serial matched in both hex (`getpeercert`) and decimal (`certs.py`) spellings.
- Token is not read on the TLS path; `/snapshot` gains additive `tls: true` + `bundle_rev` only in TLS mode; `serve()` logs `… with tls (client cert required, rev <n>)`; off-loopback refusal now names the bundle too; unreadable bundle = exit 2; `AGENT_VERSION` 1.1.0 + SCHEMA/docstring lines.
**Tests / lint / coverage**
- `uv run pytest` → **1350 passed, 15 skipped** (baseline 1320+15; +30 new in `tests/test_agent.py`, certs minted by `dashboards.certs` over a dict store, real handshakes: matching cert / CA-only probe / foreign CA / plaintext probe / revoke→403→rewrite→200 / token+bundle / token mode unchanged / `serve()` start line / 4 `resolve_bundle` error paths).
- `uv run pytest --cov --cov-fail-under=94` → **98.86%** (gate met, no drop); `uv run ruff check src tests agent` clean; `uv run pyright` 0 errors; `bash .agents/validate.sh` → **exit 0**; `uv pip list --outdated` empty; `uv run pytest -m live --live` → **11 passed**; `./scripts/dev-server.sh status` → all 9 sources `ok`.
- Hand proof with **curl**: `/health` CA-only 200, `/snapshot` with client pair `tls=true bundle_rev=1`, certless 403, `http://` reset, `# REVOKED` appended while running → 403 revoked on `/snapshot` and `/health`.
**Docs**: README (agent section: `--cert-bundle` usage + TLS-mode paragraph + "token *or* bundle"), AGENTS.md (agent bullet TLS block, gap 11 updated to "agent has TLS mode, nothing dials it yet", test count 1350). Report at `.agents/reports/15_agent_mtls/15_agent_mtls__02_agent_tls.a1.md`.
**Decisions**: (1) a revoked cert is refused on `/health` too — stricter than the task's literal wording, cannot break the installer/systemd probe (it holds the CA, no client cert); (2) denylist matches both serial spellings (a false match can only deny, never allow); (3) unpacked PEMs deleted after load; (4) `main()` tests now `delenv AGENT_*` so a dev env can't turn one into a hang.
**Next**: `.agents/phases/todo/15_agent_mtls/03_collector_tls.md`.
@@ -0,0 +1,61 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 5%]
........................................................................ [ 10%]
........................................................................ [ 15%]
........................................................................ [ 21%]
........................................................................ [ 26%]
...s.................................................................... [ 31%]
........................................................................ [ 36%]
........................................................................ [ 42%]
........................................................................ [ 47%]
........................................................................ [ 52%]
........................................................................ [ 58%]
........................................................................ [ 63%]
...................................sssssssssss.......................... [ 68%]
........................................................................ [ 73%]
........................................................................ [ 79%]
........................................................................ [ 84%]
........................................................................ [ 89%]
........................................................................ [ 94%]
...........sss....................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 358 0 100%
src/dashboards/app.py 184 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 276 0 100%
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 130 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3331 38 99%
Required test coverage of 94% reached. Total coverage: 98.86%
1350 passed, 15 skipped, 1 warning in 115.46s (0:01:55)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,15 @@
**Implemented (task 03 — collector TLS)**
- `collectors.AgentTls` (`verify` CA path, `cert` pair, live `status` callable, test-only `transport`) + `agent_tls_map(material, store, targets)`; `fetch_snapshot(..., tls=...)` now dials an `https://` `HOST_TARGETS` entry with this app's client certificate, verified against this app's own root CA.
- Revocation happens **before any socket**: a host whose `certs.agents` status is `revoked` raises `UpstreamError("<host>: agent certificate revoked")`, so both `host:<name>` and `disks:<name>` go red on the next poll with no restart and no dial; `http://` token hosts still use the shared client byte-for-byte.
- 403 from a protected agent reports `agent refused the client certificate (<agent's own detail>) at <redacted url>`; token-mode refusals keep the 401/HTTP wording.
- Plumbing: `build_certs` → `agent_tls_map` → `build_hub(..., tls=...)` in both assembly branches; `AppContext.certs`; `agent_tls_fields()` carries `tls`/`bundle_rev` into the `system` and `disks` payloads (additive, no `severity`).
- Defect found and fixed in task 01's `certs.py`: no SKI/AuthorityKeyIdentifier, so a **strict** verifier — `ssl.create_default_context()`, which this dial builds — refused the chain with `CERTIFICATE_VERIFY_FAILED: Missing Authority Key Identifier`; the agent's non-strict server check hid it. Pinned in `tests/test_certs.py`.
**Checks:** `uv run pytest` 1371 passed / 15 skipped (21 new: `test_collectors.py` 31→56 incl. a real `host_agent.py` served over TLS, `test_api.py` revoked-through-real-`build_hub`, `test_certs.py` identifiers) · coverage 98.91 % (floor 94; new code 100 %, collectors' 5 misses are pre-existing `/slots`+`NutState` lines) · ruff + pyright clean · `bash .agents/validate.sh` exit 0 · live suite with `.env.dev` 11 passed · dev server restarted, all 9 sources ok.
**Docs:** README (agent TLS section + collector's half, `HOST_TARGETS` row, `certs.py` bullet), AGENTS.md (agent/collectors/certs/app/host bullets, gap 11, tallies), `.env.example` + `.env.dev.example`; evidence in `data/logs/NOTES-collector-tls.md`.
**Deviation:** httpx 0.28 has no per-request `verify`/`cert` (both deprecated), so the material rides on a **per-dial client built from an `ssl.SSLContext`** — same isolation the task wanted (shared engine/plain client untouched), stricter verification. No live infrastructure changed: no agent runs a bundle yet.
**Next:** `.agents/phases/todo/15_agent_mtls/04_cert_api.md`.
@@ -0,0 +1,64 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 5%]
........................................................................ [ 10%]
........................................................................ [ 15%]
........................................................................ [ 20%]
........................................................................ [ 25%]
....s................................................................... [ 31%]
........................................................................ [ 36%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 51%]
........................................................................ [ 57%]
........................................................................ [ 62%]
........................................................sssssssssss..... [ 67%]
........................................................................ [ 72%]
........................................................................ [ 77%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
................................sss..................................... [ 98%]
.................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 358 0 100%
src/dashboards/app.py 184 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 276 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3395 37 99%
Required test coverage of 94% reached. Total coverage: 98.91%
1371 passed, 15 skipped, 1 warning in 122.72s (0:02:02)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.150.0 0.151.0 wheel
@@ -0,0 +1,14 @@
All green. Final state:
**New / changed**
- `src/dashboards/certs.py` — `read_state()`: the request-time read (validates, never mints/writes); `ensure_certs()` stays the startup-only bootstrap.
- `src/dashboards/api.py` — `certs_router()`: `GET /api/certs` (root/client + one row per `HOST_TARGETS` ∪ `certs.agents` name, `status:"unissued"` for configured-never-issued, `deployed_bundle_rev` from the `system` payload's `agent` block, serials as decimal strings, no severity); `GET /api/certs/<name>/bundle` (stored bytes verbatim, `application/x-pem-file`, `attachment; filename="agent-<name>.bundle"`, `no-store`); `POST …/rotate` (SAN from live config, first-issue = rev 1), `POST …/revoke`, `POST /api/certs/root/rotate` (`restart_required:true`). Unknown name → `404 {"configured": false}` before any signing/dial/store write; writes log one fixed-text `certs`-source event. `app.py` includes the router.
- No new dial: the only upstream write stays `POST /api/ups/command`; `/api/certs/*` writes are a third, `meta`-only class in the invariant-1 pin.
**Tests** — `tests/test_api.py`: 22 cert tests (list shapes 0/1/3 hosts, ghost entry read-only, no-dial/no-write spies, rev+1 + old serial absent from the new denylist, revoke → next `fetch_snapshot` refuses without a socket, root rotate re-issues all + `restart_required`, bundle byte-identity + reissue changes bytes, no PEM on `/api/certs`/`/api/health`/`/api/events`/`/api/summary`/`/api/config`, GET cannot issue). `tests/test_auth_api.py`: unsigned JSON `401` on list/bundle/writes, missing-CSRF and cross-origin refusals with the table unmoved. `tests/test_certs.py`: `read_state` reports the stored authority after a rotate and refuses corrupt/mismatched rows with the fixed text, never writing.
**Verification** — `bash .agents/validate.sh` exit 0: 1400 passed + 15 skipped, coverage 98.94% (`api.py` 100%, `certs.py` 100%), ruff clean, pyright 0 errors. Dev server restarted: `/api/health` all nine sources ok, `GET /api/certs` unsigned → `401`, signed-in reads the real CA (root/client serials, three hosts `unissued`). No fleet or agent config touched.
**Docs** — README "Managing the certificates" section + `api.py` bullet + scripting note; AGENTS.md `api.py`/`certs.py` bullets, invariant-1 write lists, gap 11, test/coverage counts.
**Needs attention** — pre-existing advisory only: `fastapi 0.150.0 → 0.151.0` (no dependency moved in this task; left for the phase's dependency pass).
@@ -0,0 +1,64 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 5%]
........................................................................ [ 10%]
........................................................................ [ 15%]
........................................................................ [ 20%]
........................................................................ [ 25%]
.................s...................................................... [ 30%]
........................................................................ [ 35%]
........................................................................ [ 40%]
........................................................................ [ 45%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 61%]
........................................................................ [ 66%]
.............sssssssssss................................................ [ 71%]
........................................................................ [ 76%]
........................................................................ [ 81%]
........................................................................ [ 86%]
........................................................................ [ 91%]
.............................................................sss........ [ 96%]
............................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1400 passed, 15 skipped, 1 warning in 124.11s (0:02:04)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.150.0 0.151.0 wheel
@@ -0,0 +1,10 @@
**Task 15.05 complete — gate green.**
- **New:** `web/pages/certs.html` (Root CA block: CN/serial/expiry + `in 10.0y` + `Rotate root CA`; display-only Dashboard client line; `#agents-table`; empty state; two `role="status"` outcome lines; 5 tabs, no `aria-current`, no `#nav-status`) and `web/static/page-certs.js` (reads `GET /api/certs`; a `COLUMNS` table names every key it indexes; `status` word → chip class with `unissued` mapping to *no* class; `n/a` for a null `deployed_bundle_rev`; neutral `stale` chip when issued > deployed; `confirm()`ed writes printing the route's own `hint`).
- **`app.py`** — `/certs` in `PAGES`, so the gate guards it with no new code. **`dash.css`** — one rule (`table.data td.san`, the only cell that must wrap); the account outcome line is documented as shared, no new tokens.
- **Masthead** — `⚷ Certs` added to the five dashboards *and* `/settings`; `/certs` carries `⚙ Account`; neither admin page self-links (the `/settings` precedent).
- **Tests** — `/certs` joins `MARKUP_PAGES`/`PAGE_SCRIPTS`/`ASSETS` (CDN, `.sev-`, flavour-text, orphan-`id` audits); `test_every_dashboard_page_links_to_settings` extended into `test_every_page_links_to_both_admin_pages`; 6 new pins, incl. `COLUMNS`↔`CERT_AGENT_KEYS` resolved against a live `/api/certs` row in both directions, the CNs pinned to `certs.ROOT_CN`/`CLIENT_CN`, no `severity` anywhere on the page, no control the API would refuse, and the restart sentence being the server's `hint`; `test_auth_api` gained `/certs` in `PROTECTED_PAGES` and both served-route tables.
- **Fixed in passing** — one `api.py` comment claimed task 05's page reads `source: "certs"` events; it does not, so the comment now names `/api/events?source=certs` as the audit trail.
- **Gate** — `bash .agents/validate.sh` exit 0: **1408 passed, 15 skipped**, coverage **98.94 %**, ruff + pyright clean, `node --check` clean; dev server restarted (all nine sources `ok`), unsigned `GET /certs` → `303 /login?next=/certs`.
- **Browser** (`data/logs/`) — `15-certs-page.png` (live: three `unissued` rows), `-masthead.png`, `-masthead-settings.png`, `15-certs-rotated.png` (real Rotate → `active`, serial, `#1 → n/a`, `install the new bundle on deskwork — /api/certs/deskwork/bundle`), `15-certs-shapes.png` + `-shapes-dark.png` (stale chip, revoked, `expired`, unissued, `not in HOST_TARGETS`) — the last two used an intercepted `page.route` payload, since the live fleet has no bundles until task 07. That pass issued one real certificate for `deskwork` (`bundle_rev 1`) in the dev store.
- **Docs** — README page list, a `/certs` block in *Managing the certificates*, the signing-in scope line; AGENTS intro, the `web/` bullet (both rules the page keeps), and gap 11 now tasks 01–05 landed with 06/07 left.
@@ -0,0 +1,64 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 5%]
........................................................................ [ 10%]
........................................................................ [ 15%]
........................................................................ [ 20%]
........................................................................ [ 25%]
........................s............................................... [ 30%]
........................................................................ [ 35%]
........................................................................ [ 40%]
........................................................................ [ 45%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 60%]
........................................................................ [ 65%]
.....................sssssssssss........................................ [ 70%]
........................................................................ [ 75%]
........................................................................ [ 80%]
........................................................................ [ 86%]
........................................................................ [ 91%]
.....................................................................sss [ 96%]
....................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1408 passed, 15 skipped, 1 warning in 125.83s (0:02:05)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.150.0 0.151.0 wheel
@@ -0,0 +1,8 @@
**Report — task `15_agent_mtls/06_deploy_cert_bundle`**
- `deploy/install-agent.sh`: new `--cert-bundle <file>` flag; `validate_bundle()` (the `resolve_bundle` text-shape idiom: 2 certs + exactly 1 key + `# BUNDLE-REV`) runs **before `need_root`**, exit 2 with the reason, touches nothing, never dials; bundle installed to `/etc/dashboards-agent.certs` byte-identically, re-runs without the flag keep it (token-reuse idiom); the unit's TLS branch (one `if [ -f "$CERT_FILE" ]` block) adds `--cert-bundle` + `RuntimeDirectory=dashboards-agent` (`0700`) while keeping `--token-file`; `--status` probes `https` with the bundle's CA, `CERT_REQUIRED` + no client cert (`python3` `ssl` one-liner, no openssl), unreadable bundle → "run --status as root"; token mode keeps the plain curl probe; `--uninstall` sweeps the bundle; TLS install prints the `https://` `HOST_TARGETS` hint. `deploy/agent-sudoers` unchanged.
- **Deviation (deliberate):** the task's literal `0600` bundle mode cannot work — the agent opens the bundle as its own user and re-reads the denylist live (the exact incident the token file's comment records); installed at `0640 root:dashboards-agent`, which is the task's stated intent ("only root and the agent's own service user may read it") and the token-file idiom. Pinned in the script comment, the tests, and AGENTS.md gap 11.
- Tests: 16 new pins in `tests/test_deploy_scripts.py` (validation refusal matrix incl. `/nonexistent` before the sudoers hint; well-formed bundle reaches the root check; help/path/mode/chown pins; one-branch TLS unit lines; probe text pins incl. "no `load_cert_chain`, never openssl"; sandbox install → file 0640 + byte-identical + unit flags + `RuntimeDirectory`; bundle reuse; uninstall; plus end-to-end `--status` against a live in-process TLS agent — CA-only `/health` pass, foreign-root refusal, unreadable-bundle sentence, token-mode plain probe).
- `bash .agents/validate.sh` → exit 0: **1424 passed, 15 skipped**, coverage 98.94% (floor 94), ruff clean, pyright 0 errors, `node --check` clean, `uv pip list --outdated` empty (bumped fastapi 0.150→0.152 per invariant 8; suite re-run green).
- Docs: README (installer TLS install + `--status` note + installer paragraph in the TLS-mode section); AGENTS.md (deployment paragraph flags/bundle/RuntimeDirectory/probe, gap 11 rewritten to "what is left is task 07", test count 1424).
- Next pending task: `15_agent_mtls/07_live_rollout` (live infra: flips deskwork+gateway to TLS — user/operator gated, untouched here).
@@ -0,0 +1,62 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 5%]
........................................................................ [ 10%]
........................................................................ [ 15%]
........................................................................ [ 20%]
........................................................................ [ 25%]
........................s............................................... [ 30%]
........................................................................ [ 35%]
........................................................................ [ 40%]
........................................................................ [ 45%]
........................................................................ [ 50%]
........................................................................ [ 55%]
........................................................................ [ 60%]
........................................................................ [ 65%]
.....................................sssssssssss........................ [ 70%]
........................................................................ [ 75%]
........................................................................ [ 80%]
........................................................................ [ 85%]
........................................................................ [ 90%]
........................................................................ [ 95%]
.............sss....................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1424 passed, 15 skipped, 1 warning in 125.49s (0:02:05)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,16 @@
**Target task** (`.agents/phases/todo/16_agent_smart_pass_budget/01_pin_the_scaled_budget.md` — the harness's stated name `01_live_remeasure_and_pin.md` does not exist; the phase dir has exactly one task 01):
- `tests/test_agent.py`: added `FakeSmartPassClock` + `_TimeProxy` (drives the agent's `time.monotonic` and `subprocess.run` through proxies on `host_agent`'s own globals, recording the `min(deadline-now, 5 s)` each device was given — a deadline is invisible to a fake binary), `SHELF_DISKS`/`write_disk_shelf` (driveripper's 8 NVMe + 8 SATA shape), and 3 tests: `test_the_pass_budget_scales_with_the_disk_count` (16 disks, 24 s pass → all 16 asked, no budget line), `test_the_pass_budget_floor_is_twenty_seconds` (2nd device's timeout = 2 s → pins the 20 s floor), `test_the_pass_budget_error_line_names_the_scaled_figure` (6 s/device → 14 runs, 2 skipped, line is exactly `smart: pass budget of 80s exhausted, 2 disk(s) skipped`).
- Non-tautology proven by mutation: reverting `_smart_pass` to the flat `budget = self.smart_budget_seconds` makes the two scaling tests fail (`4 != 14` calls); `agent/host_agent.py` restored, md5 verified `4061ad59…` — **no code change**.
- Override test `test_the_pass_budget_skips_the_rest_with_one_error` untouched and green.
**Tests / lint (exact, all from repo root):**
`uv run pytest` → **1427 passed, 15 skipped** (baseline 1424+15); `uv run pytest --cov --cov-fail-under=94` → **98.94%**; `uv run ruff check src tests agent` → All checks passed; `uv run pyright` → 0 errors; `bash .agents/validate.sh` → **exit 0**; `uv run pytest tests/test_agent.py -q --cov=agent` → `agent/host_agent.py` 94 %, budget lines 584–602 fully covered; `uv run pytest -m live --live` → **11 passed**; `./scripts/dev-server.sh status` → all 9 sources ok.
**Docs:** no doc impact — tests-only task. Deliberately left for task `02_docs_and_gate.md`: `README.md:94` still reads "20 s per pass" (`grep -rn "20 s per pass" AGENTS.md README.md agent/ deploy/` → currently only that one hit).
**Decisions / findings:**
- No live/infra changes made for this task. Read-only md5 probe: repo agent ≠ deployed agent on all three hosts (working tree carries phase 15's un-deployed TLS mode), so the phase's md5-identity criterion can't be met yet; the meaningful check passes — driveripper's deployed agent **does** carry the scaled-budget line (deskwork/gateway don't, and can't be affected: 1 disk / no SMART, floor == scaled there).
- **Interrupted work (harness redirect):** phase 15 task `07_live_rollout` was mid-flight when the target changed. I made **no fleet mutations** and **no tracked-file changes** for it, and cleaned up all scratch processes (`:8091` scratch dashboard, `:9129` TLS agent removed; dev server untouched and healthy). Evidence captured in `data/logs/`: `probe-tls-matrix.py` (6/6 PASS: CA-only `/health` 200, certless `/snapshot` 403, dashboard client 200 with `tls/bundle_rev`, foreign-root client `TLSV1_ALERT_UNKNOWN_CA`, agent's own server cert refused as a client cert, untrusted-root `CERTIFICATE_VERIFY_FAILED`), byte-identical bundle download, revoke → `agent certificate revoked` with zero new agent-log lines, rotate → `#2 → #2`, live `# REVOKED` denylist re-read without restart, screenshots `15-tls-local-{certs,system}.png`, `15-tls-fleet-certs.png`. Still outstanding there: the README "Protecting the agent" how-to, the AGENTS.md transport/commands/gap-11 update, and the `NOTES-agent-install.md` operator append.
**Next pending task:** `.agents/phases/todo/16_agent_smart_pass_budget/02_docs_and_gate.md`.
@@ -0,0 +1,63 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...........................s............................................ [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 59%]
........................................................................ [ 64%]
........................................sssssssssss..................... [ 69%]
........................................................................ [ 74%]
........................................................................ [ 79%]
........................................................................ [ 84%]
........................................................................ [ 89%]
........................................................................ [ 94%]
................sss..................................................... [ 99%]
.. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1427 passed, 15 skipped, 1 warning in 133.41s (0:02:13)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
+6
View File
@@ -16,6 +16,12 @@
# into an Authorization header, so Target.url, /api/config and every error message stay
# credential-free.
#
# A host whose agent runs in TLS mode (--cert-bundle) is an https:// url with NO userinfo:
# the handshake is the auth there, the agent never reads a token on that path, and the
# dashboard verifies the agent against the root CA it minted itself (certs.py). Mixing the
# two (`https://TOKEN@host`) works — the token is simply unused — and is how a rollout leaves
# one host on tokens while another is on certificates.
#
# The host names below are placeholders (RFC 2606) — use your own.
export HOST_TARGETS="driveripper=http://TOKEN@driveripper.example.net:9120 deskwork=http://TOKEN@deskwork.example.net:9120 gateway=http://TOKEN@gateway.example.net:9120"
+4 -1
View File
@@ -42,7 +42,10 @@ DASH_PBKDF2_ITERATIONS=600000
# --------------------------------------------------------------------------- #
# System dashboard: hosts running agent/host_agent.py
# Space or comma separated name=url. A bearer token goes in the URL userinfo
# (http://TOKEN@host:9120) and is never sent to the browser.
# (http://TOKEN@host:9120) and is never sent to the browser. An https:// url instead
# dials a certificate-authenticated agent: the fetch verifies it against this app's own root CA
# and presents this app's own client certificate (certs.py), and a host revoked there is refused
# before any connection. Per host, so one host can be on https while its neighbours are on http.
# --------------------------------------------------------------------------- #
# HOST_TARGETS=driveripper=http://SECRET@driveripper.example.net:9120 deskwork=http://SECRET@deskwork.example.net:9120
HOST_TARGETS=
+282 -20
View File
@@ -34,9 +34,11 @@ History is a first-class feature, not a sparkline afterthought: every number wor
is stored, rolled up (raw → 1m → 1h), and served from `/api/*/history`.
None of it is public. One admin signs in at `/login` (the only page an anonymous visitor
reaches), and `/settings` — `⚙ Account` in the masthead — is where that session is read, ended,
and re-credentialled. Every page above, `/settings`, and every `/api` route but the four named
in invariant 12 require the session.
reaches), and the two admin pages live behind it: `/settings` — `⚙ Account` in the masthead —
where that session is read, ended and re-credentialled, and `/certs` — `⚷ Certs`, next to it —
where the fleet's certificate authority is listed, rotated, revoked and downloaded. Every page
above, `/settings`, `/certs`, and every `/api` route but the four named in invariant 12 require
the session.
## Non-negotiable rules
@@ -94,6 +96,12 @@ page still renders.
startup and the empty-new-password refusal) carry **fixed** operator text with nothing
interpolated, not even a prefix of a hash; `tests/test_auth.py` pins that structurally, so
the day someone adds `logger.warning(f"bad verifier {v}")` a test fails instead of a log line.
**A private key is the same case** (phase 15): the root CA key and the dashboard's client key
live only in `meta`, `certs.py` has no `logging` import and one fixed-text raise naming the rows
to delete (pinned structurally by `tests/test_certs.py`, which also asserts the refusal quotes
no line of the stored key), the root key is never written to the temp dir the client material
lives in, and a bundle carries the root *certificate* — never the key — because an agent only
has to verify its clients and a stolen agent install must not be a stolen CA.
7. **Verify against the deployed upstream; never guess a metric name.** Field maps in
`sources/llm.py` and `sources/nut.py` were captured from the running engines and NUT
2.8.5, and are pinned by fixtures in `tests/fixtures/`. Re-capture with
@@ -173,9 +181,31 @@ Responsibilities, one line each:
- `agent/host_agent.py` — stdlib-only (3.10+), reads `/proc`, `/sys/block`, `/sys/class/drm`,
`statvfs`; serves `GET /snapshot` + `GET /info` + `GET /schema` + `GET /health`, plus an
HTML status page at `GET /`. Refuses to bind off-loopback without a token (exit 2).
Counters stay counters: the dashboard computes
HTML status page at `GET /`. Refuses to bind off-loopback without a token **or** a certificate
bundle (exit 2). Counters stay counters: the dashboard computes
rates so the agent never remembers who polled it last.
**TLS mode (phase 15 task 02, `AGENT_VERSION` 1.1.0):** `--cert-bundle`/`AGENT_CERT_BUNDLE`
loads the one PEM the dashboard signs (`resolve_bundle`, the `resolve_token` idiom: server cert
+ server key + root CA + `# BUNDLE-REV <n>` + `# REVOKED <serial>` lines, grepped by text
because the agent has no `cryptography`), unpacks it into the unit's `RuntimeDirectory` when
there is one else a temp dir, builds a `PROTOCOL_TLS_SERVER` context with
`verify_mode = CERT_OPTIONAL`, and **deletes the PEMs once loaded** (the key then lives only in
this process, so a `kill -9` has no cleanup to forget). `CERT_OPTIONAL` is the assumption that
keeps `/health` probeable: the *handler* enforces, refusing a peer with no verified certificate
403 `client certificate required` on every path but `/health`, and refusing a serial on the
bundle's denylist 403 `client certificate revoked` on **every** path including `/health` (the
exemption is for a probe holding the CA and no certificate, not for an identity the operator
withdrew). The denylist is re-read when the bundle's `(mtime_ns, size)` stamp changes — that is
the whole reason the `# REVOKED` lines live in the bundle — while a new certificate needs a
restart, because `ssl` cannot swap a chain under a live socket. Serial matching accepts the hex
`getpeercert()` renders *and* the decimal `certs.py` writes, because comparing one spelling to
the other accepts a revoked client forever. On the TLS path the bearer token is **not** read (the
handshake is the auth, and a second credential that outlives a revocation defeats the list),
`/snapshot` gains additive `tls: true` + `bundle_rev` only in TLS mode, and token mode is
byte-identical to before. Task 03 has since flipped the collector (see the `collectors.py`
bullet), so those two fields are how the dashboard answers "is this host running the bundle
`/certs` issued?" — `sources/host.py` and `sources/disks.py` carry them into both payloads as
`agent.tls` / `agent.bundle_rev`, present only when the agent published them.
SMART (phase 09): `smart()` runs `smartctl -i -A -H` per whole `/sys/block` disk
(`parse_smart_output` covers ATA + NVMe; NVMe media errors map to
`offline_uncorrectable`, hex-packed vendor raws are never decoded, and an attribute row is
@@ -217,7 +247,31 @@ Responsibilities, one line each:
Events are stored under the **dashboard** name (`system`/`disks`/`llm`/`ups`), not the sample
source (`host`) — a page filtering `/api/events` by `host` gets an empty feed forever
(found on `/system` during phase 03 browser verification).
- `collectors.py` — the fetch half. `NutMonitor` owns per-UPS transition bookkeeping (its
- `collectors.py` — the fetch half. `AgentTls` (phase 15 task 03) is what makes an `https://`
`HOST_TARGETS` entry a **certificate-authenticated** dial: `verify` and `cert` are the paths
`certs.write_material` wrote at startup, and the dial is verified against this app's own root
and presents this app's own client certificate. Two things about it are load-bearing. (a) The
credential travels in a **client built per dial**, because `httpx` resolves TLS material when a
client is *constructed* (there is no per-request `verify`/`cert`, and `verify=<str>`/`cert=` are
deprecated anyway — the supported form is an `ssl.SSLContext`, which is also the honest shape:
trust in one named CA, hostname checks on) while one shared client dials the plain agents and
both engines — a `verify` on *that* would make every engine read depend on a CA nobody issued
their cert with. (b) `status` is a **callable** doing a fresh `certs.agents` read, so a
revocation clicked in the browser bites on the next poll with no restart, and a revoked host is
refused **before any socket opens** — invariant 3's shape: a source the app knows it must not
talk to says so without a network round trip, and the poller's stale/last-good semantics do the
rest. `refusal_detail()` quotes the agent's own 403 sentence (`client certificate required` /
`client certificate revoked`) because those are two different operator actions, and the
certificate wording is used only for a dial where a certificate was presented — a 401/403 from a
token-mode host keeps the token/HTTP wording, or the operator is sent to `/certs` for a token
problem. `agent_tls_map()` builds one entry per configured host (the CA and client pair are
shared — the dashboard has one identity — only the revocation answer is per name, and an absent
entry is *active*, never-issued being a different fact from revoked); `build_hub` takes that map
and hands the **same** entry to the `host:` and `disks:` jobs of one host, so a revocation
fails both sources with one sentence and neither of them dials. `fetch_snapshot()` keeps
`http://` targets on the shared client with their bearer token, unchanged from before the phase,
so a rollout is per host.
`NutMonitor` owns per-UPS transition bookkeeping (its
self-test event takes its severity from `nut.self_test_outcome()` and not its own word list,
so a passed test is never an amber line in the feed while the card beside it reads green).
Its per-UPS tolerance for a failed `LIST CMD`/`LIST RW` is for a *complete* error reply only:
@@ -237,7 +291,10 @@ Responsibilities, one line each:
smartmontools or device read access is unconfigured, not broken (invariant 3); they stay
in `agent.errors` for the `/disks` page to explain.
- `sources/host.py` — snapshot → payload + samples; reboot detection; GPU series per card
(`gpu.busy_pct@<index>`).
(`gpu.busy_pct@<index>`). `agent_tls_fields()` is the phase-15 passthrough: a TLS agent's
`tls`/`bundle_rev` ride into the payload's existing `agent` block, and a token-mode snapshot
grows nothing (no `tls: false`, no `bundle_rev: null` — rule 2), so the fields are how the API
distinguishes a stale bundle from an agent that never had one.
- `sources/disks.py` — the same snapshot projected onto **physical** drives for `/disks`
(phase 09): one row per whole-`/sys/block` disk (never a partition, never a mount), the
agent's `smart` block carried through verbatim, `health` the drive's own word normalised to
@@ -351,6 +408,50 @@ Responsibilities, one line each:
`DASH_SESSION_HOURS`. Its two raises, like the loader's, carry
fixed text — `tests/test_auth.py` pins the file's raise sites and its one logging call
structurally, so no future edit can interpolate a credential into either.
- `certs.py` — the fleet's certificate authority as the same shape of module (phase 15): a pure
lifecycle over the three-method `MetaStore` protocol, no clock it cannot be handed, no `logging`,
and one fixed-text `RuntimeError` (`tests/test_certs.py` pins all three structurally, the way
`tests/test_auth.py` pins `auth.py`). Five `meta` rows and nothing else:
`certs.root.key`/`certs.root.cert` (self-signed `CN=dashboards-root`, 10 years),
`certs.client.key`/`certs.client.cert` (`CN=dashboards`, 1 year — the identity every agent will
check), and `certs.agents`, one JSON object mapping a `HOST_TARGETS` name to
`{serial, not_after, status, bundle_rev, bundle, san}`. **A stored row always wins:** the root is
generated on the first boot of a database and never on a later one, because a CA that rotates
with the process untrusts every bundle already installed on a host — and a newly generated root
takes its client certificate with it, since a leaf signed by the dead root is a credential
nothing can verify. `issue_agent_cert`/`reissue_agent_cert` (the API's "rotate") sign a fresh
`CN=<host>` leaf whose SAN is the addresses that host is dialled by — the name is always in the
list, and an IP goes in as an `iPAddress` or hostname verification of an IP dial fails — bump
`bundle_rev`, and reset `status` to `active`. `revoke` marks the host and **re-seals every
bundle**, because the `# REVOKED` denylist lives inside each of them and an agent that cannot see
a serial keeps accepting that client; there is no "unrevoke", and revoking a never-issued host
still records the revocation (what a revoke enforces is the dashboard refusing to dial). The
**bundle** is the install artifact — agent cert, agent key, the root *cert* (never the root key,
asserted by header count and by parsing the one key against both keypairs), `# BUNDLE-REV <n>`,
then the denylist — stored verbatim so what an operator downloads is byte-identical to what the
row holds. `rotate_root` mints a new root plus a new client and reissues every agent
(`bundle_rev` +1 each, denylist reset with the generation); the running process keeps the
material it loaded at startup, which is the honest `restart_required` task 04 answers with.
Every certificate carries `SubjectKeyIdentifier` and every leaf an `AuthorityKeyIdentifier`
naming it, non-critical: OpenSSL's **strict** mode refuses a leaf with no AKI
(`CERTIFICATE_VERIFY_FAILED: Missing Authority Key Identifier`) and Python's
`ssl.create_default_context()` — the context the dashboard's own https dial builds — turns that
mode on. The agent's server-side check is not strict, so nothing on the agent side notices;
`tests/test_certs.py` pins the identifiers instead, and a bundle recorded without them is one a
strict client rejects. `write_material` puts the CA and the client pair in a `0700` temp dir with
`0600` files for the dialing — the root key is never written to disk, so its only copy stays the
`meta` row — and the lifespan removes the directory on shutdown. Unreadable PEM, a key that does
not match its certificate, or a leaf the stored root did not sign is a **fail-closed startup** (the
`ctx.auth` rule), and the message is the `sqlite3 "<db>" "DELETE FROM meta WHERE key LIKE
'certs.%'"` reset. `cryptography` is this module's dependency and the app's only new one;
`agent/host_agent.py` stays stdlib and loads the PEMs with `ssl`. There are **two reads** and the
difference is a side effect: `ensure_certs` is the startup one (it mints what the table is missing,
which is why it is what `app.build_certs` calls and nothing else may), and `read_state` is the
request-time one task 04's `GET /api/certs` uses — same validation, same fixed-text refusal, never
a write, so a page refresh cannot bootstrap an authority the operator deleted. The material is
read with (task 03): `collectors.agent_tls_map` turns `AppContext.certs` into the per-host dial
pair, and because the revocation answer is `agent_status()` called per poll, a revoke in this
module is what stops the dial — no restart, no cache, no second list to keep in sync.
- `severity.py` — the only place thresholds live: `THRESHOLDS`/`INVERTED` per metric,
`classify()` → `ok`/`warn`/`fail`/`None`, plus `attach()`/`with_severity()` to put the verdict
beside a value. No measurement, no verdict: `None` in → `None` out, never a green pass for
@@ -452,9 +553,33 @@ Responsibilities, one line each:
- `api.py` — GETs everywhere plus the one POST that dials an upstream, and the `auth_router()`
that creates, describes, **re-credentials**, and ends a session (`POST /api/auth/login` public;
`GET /api/auth/status`, `POST /api/auth/logout` and `POST /api/auth/password` protected). Those
three are the app's only other writes and they touch this process and its `meta` table and dial
nothing — invariant 1 is about the fleet, and `tests/test_api.py` pins the two lists separately
three, and the `certs_router()` of phase 15 (`GET /api/certs`, `GET /api/certs/<name>/bundle`,
`POST /api/certs/<name>/rotate`, `POST /api/certs/<name>/revoke`,
`POST /api/certs/root/rotate`), touch this process and its `meta` table and dial
nothing — invariant 1 is about the fleet, and `tests/test_api.py` pins the three lists separately
rather than widening the one that names `/api/ups/command`.
The **`/api/certs` surface** (phase 15 task 04) is the CA's own admin half. The list reads the
**stored** state through `certs.read_state` — the request-time twin of `ensure_certs`, which
mints and must only ever be called at startup, because a `GET` that bootstrapped a root CA would
replace the authority nobody asked it to replace — and its rows are the union of `HOST_TARGETS`
and `certs.agents`, so a configured-but-uninstalled host appears as `status: "unissued"` with
nulls (invariant 3, rule 2) and an entry whose host left the config is listed but not writable.
`deployed_bundle_rev` comes from that host's `system` payload `agent` block (task 03's
passthrough), never from what the CA hopes. `bundle_rev` is what was issued; the two numbers side
by side are the "install the new bundle" sentence, and neither carries a `severity` — an expiry
and an identity counter are not graded numbers. A `<name>` outside `HOST_TARGETS` is refused in
the route's first line with `404 {"configured": false}` **before** any signing, dial, or store
write (spy-pinned both ways, and the same gate makes `GET …/bundle` answer
`{"configured": true, "issued": false}` for a host that is configured and has nothing issued —
two different facts, two different bodies). The bundle download is the stored row **verbatim** as
`application/x-pem-file` with an `attachment` filename, because it is `install-agent.sh`'s input
and it carries that host's private key (session-gated, `no-store`). `POST /api/certs/root/rotate`
answers `restart_required: true` rather than pretending a hot-swap: the CA and the client pair are
startup material, so the table is new at once while the process keeps dialling with the old one.
Every write logs one fixed-text `certs`-source event naming host and action, and serials travel as
**decimal strings** — a 64-bit certificate serial through a JS number is a serial a page cannot
match against a bundle's `# REVOKED` line (invariant 6: no PEM reaches an event, a health record,
or any payload but the bundle route's).
The login route is where a *sign-in* password is compared: it
throttles before it hashes, refuses an absent/empty/non-object body with `422` so `""` never
reaches `verify_password`, answers every failure with the one sentence `wrong password`, and
@@ -490,8 +615,9 @@ Responsibilities, one line each:
no `disks` job in the hub at all (invariant 3), and `tests/test_api.py` proves it with
spies on `httpx.AsyncClient.get` and `collectors.fetch_snapshot`. The new surface grows no
write of its own — a fourth dashboard must not quietly become a second one, so the app's
only POST stays `/api/ups/command` (invariant 1, pinned by enumerating every served route's
verbs).
only POST that **dials an upstream** stays `/api/ups/command` (invariant 1, pinned by enumerating
every served route's verbs and sorting the writes into fleet / session / certificate-authority
lists — the third is phase 15's, and it writes `meta` rows and nothing else).
All four `history` routes reach the store through one door, `api._history_batch()` →
`store.history_many()` (phase 14 task 02). That is a cost change and nothing else: the series
list is host-major and metric-minor in request order, each entry the same
@@ -531,7 +657,16 @@ Responsibilities, one line each:
a `RuntimeError` from it leaves the process non-zero with the operator's fix in the output.
That failure is never caught here: a dashboard that cannot say who its admin is does not start
the loops either (`__main__.py` passes `SETTINGS` to `create_app()` explicitly so the served
app is configured by the same object the bind address came from).
app is configured by the same object the bind address came from). `build_certs(store)` is the
same rule for phase 15: one helper, called **before** `build_hub` in the owned-store
(`build_context`) and injected-store (lifespan) branches, whose `CertMaterial` (the CA + client
paths in a private temp dir) hangs off `AppContext.certs` and whose
directory the lifespan's `finally` removes. What hands that material to the loops is
`agent_tls_map(material, store, targets)` → `build_hub(..., tls=...)`, called in **both**
branches the same way: no material (or no targets) means no TLS map, which is the plain
`http://`/token path rather than an error, and one map covers both of a host's jobs. A corrupt stored certificate raises out of the same
place — a dashboard that cannot present its own client certificate would otherwise spend the
fleet's TLS history reporting an upstream problem it caused itself.
- `web/` — five dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`) plus the login page
(`/login`, `pages/login.html` + `static/page-login.js`), one stylesheet, `charts.js`. No
framework. `/login` renders the masthead's **brand link and theme toggle** and nothing else
@@ -555,7 +690,7 @@ Responsibilities, one line each:
sixth dashboard**: same `<head>` and masthead, the five tabs with **no** `aria-current` (it is
not one of them), no status dots, and it is reached from the `⚙ Account` link in
`.masthead-right` of every dashboard — outside `.tabs`, so the pinned tab structure is untouched
(`tests/test_api.py::test_every_dashboard_page_links_to_settings`). It prints
(`tests/test_api.py::test_every_page_links_to_both_admin_pages`). It prints
`/api/auth/status` (name, expiry as a local timestamp *and* as time left), signs out through
`POST /api/auth/logout`, and changes the password through `POST /api/auth/password` after
checking locally what it can judge itself (non-empty current, ≥ 8 new characters —
@@ -567,7 +702,30 @@ Responsibilities, one line each:
happened" call. So `app.js` exports **`resetAuth()`**: it clears the memoised `authStatus` and its
in-flight promise, and must be called after a password change (the secret rotated, so the cached
`csrf` is now invalid — every later write, the UPS buttons included, 403s until a reload) and on
any `401` bounce. `app.js`'s masthead status dots are one per dashboard job —
any `401` bounce.
`/certs` (`pages/certs.html` + `static/page-certs.js`) is the second of those admin pages, and it
is the same shape for the same reasons: five tabs with **no** `aria-current`, no `#nav-status`,
and the `⚷ Certs` masthead link beside `⚙ Account` (neither admin page links to itself, which is
why the pinned two-link audit loops over two different tuples). Everything on it is
`GET /api/certs`: the Root CA block, the display-only Dashboard client line (that certificate is
minted with the root and has no route of its own), and one row per host with its status chip,
serial, expiry (local timestamp *and* `Dash.duration` left), issued-vs-deployed `bundle_rev`,
`san` list, and the three controls. Two rules it exists to keep. (1) **No colour the API did not
send**: `/api/certs` publishes no `severity` — an expiry is a date and a revision count is not a
graded number — so the page has no `severity` token at all, the one chip class map is
`status` word → `ok|fail` with `unissued` mapping to *nothing* (a certificate that does not exist
never borrows green), and a behind bundle is a neutral `stale` chip. (2) **No control the API
would refuse**: a row with `configured: false` prints why (`not in HOST_TARGETS`) instead of
three buttons that all answer `404`, a never-issued host has no **Revoke** and no **bundle** link
(that route answers `issued: false`), and the download is a plain `GET` link because a session
cookie is all a signed `GET` needs. Writes are `confirm()`d, not typed-`DANGER`d — that gate is
for cutting power and this page cannot — and the outcome line prints the route's own `hint`, so
`restart_required`'s restart sentence lives in `api.py` and nowhere else. Its `COLUMNS` table
names every payload key it indexes, and `tests/test_api.py` resolves that table against a live
`GET /api/certs` row in both directions (the `SLOT_FIELDS` device), so a rename on either side
fails a test instead of printing `n/a` beside a healthy fleet. The two CNs are markup, and pinned
to `certs.ROOT_CN`/`certs.CLIENT_CN` the way `/settings` pins its password floor.
`app.js`'s masthead status dots are one per dashboard job —
including `disks`, because the same agent can answer the 10-second utilisation poll and
have a wedged SMART pass, and one dot for both would be right about neither. `app.js` owns the
colour vocabulary: `Dash.severityFrom(cellOrSeverity)` turns a payload verdict into an
@@ -634,6 +792,11 @@ That scheme is the history API's key — treat it as a public interface.
`/api/health` today reads **all nine sources `ok`**: `host:driveripper`, `host:deskwork`,
`host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `nut`, `llm:gufo`,
`llm:vllm`.
**Since phase 15 that bearer-token transport is the compatibility path, not the designed one:**
the agent can serve `https://` from an installed bundle and the dashboard dials it with its own
client certificate, but no bundle is installed on any host here, so `HOST_TARGETS` stays
`http://` + userinfo until somebody runs the rollout in README's *Protecting an agent, host by
host* (gap 11 says what that needs and why flipping the URL first turns a host red).
Phase 10's proof that the old drop was not this repo's firewall stands as history in
`data/logs/NOTES-verify.md` — do not re-add a tunnel on the strength of it, and re-run the
probe rather than trusting either note.
@@ -784,9 +947,9 @@ set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
./scripts/dev-server.sh logs
uv run pytest # hermetic: 1267 passed + 15 skipped (11 live,
uv run pytest # hermetic: 1427 passed + 15 skipped (11 live,
# 4 store_perf), no network
uv run pytest --cov # coverage gate (98.75%; floor 94%)
uv run pytest --cov # coverage gate (98.94%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
uv run pytest -m store_perf --store-perf # opt-in: the store hot-path timings over the seeded
# store, priced against `store_perf.BUDGETS` (the cached
@@ -804,6 +967,10 @@ uv run python scripts/store_perf_probe.py # ... as a report (data/logs/store-per
# store read-only, DASH_PERF_STORE_DIR moves the cache, and
# every pass that *writes* runs on a scratch copy so the
# seeded baseline never moves under the next measurement
# the bundle for one host: the stored bytes (that host's private key included) and
# `install-agent.sh --cert-bundle`'s only input — signed-in GET, see README's rollout
# curl -s -H "Authorization: Bearer $SESSION" -o /tmp/agent-<host>.bundle \
# http://127.0.0.1:8090/api/certs/<host>/bundle
uv run ruff check src tests agent && uv run pyright
./scripts/capture-fixtures.sh # re-record ground truth (agents + UPS + gufo /slots)
SMART_HOSTS="deskwork-ops driveripper-ops" ./scripts/capture-fixtures.sh
@@ -820,11 +987,23 @@ bash .agents/validate.sh # the phase gate: the four checks abov
Deployment: `Containerfile` (two-stage, `uv sync --frozen`, runs as uid 10001 on `0.0.0.0:8090`,
SQLite under `/var/lib/dashboards` — put a volume there) and `deploy/install-agent.sh`
per monitored host (systemd unit, own user, `--bind`/`--port`/`--token`/`--rotate-token`/
`--status`/`--uninstall`; it stores the token `0640 root:dashboards-agent` — the unit hands the
agent the *path* and the agent opens it as its own user, so that user must be able to read it and
no other account may — never puts a token on argv, and runs under `ProtectSystem=strict` with
`--cert-bundle`/`--status`/`--uninstall`; it stores the token `0640 root:dashboards-agent` — the
unit hands the agent the *path* and the agent opens it as its own user, so that user must be able
to read it and no other account may, and a `--cert-bundle` file is installed to
`/etc/dashboards-agent.certs` at that same mode after a shape check that runs before anything is
touched and never dials the dashboard (the agent re-reads the bundle live for the denylist, so
0600 would fail the unit exactly like it fails the token file; the task file's `0600` is the
intent — *root and the agent's own user only* — and 0640 is the mode that achieves it) — never
puts a token on argv, and runs under `ProtectSystem=strict` with
`DevicePolicy=strict` reopening only the disk nodes plus the `/dev/null` family, because strict
without `/dev/null` kills the unit at spawn (`status=208/STDIN`). No root shell? The one sudoers
without `/dev/null` kills the unit at spawn (`status=208/STDIN`); a bundle-installed unit also
gets `--cert-bundle` plus `RuntimeDirectory=dashboards-agent` (mode 0700 — the bundle unpacks its
PEM blocks there and the agent deletes the key once the SSL context holds it), keeps the
`--token-file` line for a host-by-host rollout, and `--status` then probes `https` on the
bundle's CA with **no** client certificate via a `python3` `ssl` one-liner — task 02's
`CERT_OPTIONAL` + `/health` exemption is what makes that probeable, and there is no external TLS
binary. A re-run without the flag leaves an installed bundle in place, and `--uninstall` removes
it with the token. No root shell? The one sudoers
line that lets `ops` run the installer is `deploy/agent-sudoers`; run the script without root and
it prints that line with its own resolved path.)
The quadlet unit + env template + nginx/SNI live in the deployment repo, like every other
@@ -927,6 +1106,21 @@ branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only
verbatim, the four config rows, the `DASH_COOKIE_SECURE=1` deployment note, the three auth
gaps). No server starts and no request is made: a doc that stops naming the reset command is a
locked-out admin, and that is worth a test even though it asserts nothing about Python.
- **A module that holds a private key is pinned structurally, not only behaviourally**
(phase 15). `tests/test_certs.py` reads `src/dashboards/certs.py` as text and fails if it grows a
`logging` import, a raise carrying an interpolated value, or an import from the rest of the
package — the same three pins `tests/test_auth.py` holds over `auth.py` — because this app turns
log lines and error strings into `event` rows and `/api/health` fields that a browser reads, and a
stored PEM is a key. Its behavioural half runs the whole lifecycle over a dict `MetaStore` (five
rows on first boot, no byte changed on the second, rotate/revoke/root-rotate and their denylists,
a bundle that carries the agent key and never the root key), and one test completes a real
client-certificate handshake through the material `write_material` lays down. The other two halves
run real code rather than a model: `tests/test_agent.py` drives the real `host_agent` handler over
a TLS-wrapped loopback port (the handshake matrix, the live denylist re-read, token mode unchanged)
and `tests/test_deploy_scripts.py` runs `install-agent.sh` in a sandbox whose `install`/`chown` are
faked, which is how a `0640` mode, a byte-identical bundle and the https `--status` probe are proven
without a root shell or a host. **A `0600` in a task file is not a fact about this repo**: the agent
opens its own bundle and re-reads it for the denylist, so the group bit is load-bearing.
- **A cost claim is pinned as a shape and a control, never as a wall clock** (phase 14). The default
run keeps the seeded-store tests hermetic and asserts **shape and determinism** — counts,
retention windows, the statements a plan is taken from, idempotence — while the timings live behind
@@ -1545,6 +1739,70 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
`/proc/devices` lists NVMe as `blkext`) — the template change is its own phase.
`tests/fixtures/smartctl_driveripper_*.txt` (captured via `sudo`) remain the ground truth
for the drives' shape.
11. **The CA is durable, the collector dials with it, and no bundle is installed with one**
(phase 15 tasks 01-05, landed 2026-10-20). `certs.py` mints the CA and the dashboard's client
certificate into `meta` on the first boot of a database — verified against the running dev
store: the five `certs.*` rows exist
and did not change across two restarts — signs per-host server certificates into
`certs.agents`, and does reissue, revocation, and root rotation. Task 02 gave
`agent/host_agent.py` its TLS mode (see the agent bullet above: one installed bundle, the
per-path client-cert check, the live `# REVOKED` denylist, `bundle_rev` in the snapshot) and
task 03 gave the collector the matching half: an `https://` `HOST_TARGETS` entry is fetched
with this app's client certificate and verified against its own root, and a host marked
`revoked` fails its `host:` and `disks:` jobs with `agent certificate revoked` **before any
socket opens** — spy-pinned in `tests/test_collectors.py` and again through the real
`build_hub` wiring in `tests/test_api.py`, where the revocation is written into the same `meta`
table the running app reads. So both halves are proven against each other with no network and
no hardware. What is still missing is an **installed** bundle: `HOST_TARGETS` is `http://` for
every host here and no agent runs `--cert-bundle`, so deleting `certs.*` costs nothing today
and everything once bundles are placed (which is what the reset message warns about). Proving
the dial also found one defect in task 01's output: the leaves carried no
`AuthorityKeyIdentifier`, and OpenSSL's strict mode — which is what Python's
`ssl.create_default_context()` turns on, and that is the context this dial builds — refuses
such a chain with `CERTIFICATE_VERIFY_FAILED: Missing Authority Key Identifier`. Fixed in
`certs.py` (SKI on the root and every leaf, AKI on every leaf, non-critical), pinned in
`tests/test_certs.py`, and the reason a bundle recorded before it must not be installed. Task
04 put the same lifecycle behind the gate: `/api/certs` lists the **stored** authority (through
`certs.read_state`, so a page refresh cannot mint anything) with one row per configured host and
per existing entry, `GET /api/certs/<name>/bundle` serves the stored bytes as an `attachment`,
and the three writes rotate, revoke, and rotate the root with `restart_required: true` said out
loud — each leaving one fixed-text `certs`-source event and no PEM outside `meta` (pinned by a
grep of the stored rows and of every payload a browser reads). Task 05 put the page on top of it:
`/certs` (`⚷ Certs` in every masthead, including `/settings`) renders that document — the root
CN/serial/expiry, the display-only client line, one row per host with its status chip and
issued-vs-deployed bundle rev and its **Rotate** / **Revoke** / **bundle** controls — and keeps
two rules the API already keeps: no colour the payload did not send (no `severity` token on the
page at all; `unissued` is a grey chip, a behind bundle a neutral `stale` one) and no control
the API would refuse (no buttons on a `configured: false` row, no revoke or download on a
never-issued host, the download a plain session `GET`). Writes are `confirm()`d rather than
typed-`DANGER`d, and the restart sentence is the route's own `hint`. Task 06 has since landed
the installer half: `install-agent.sh --cert-bundle <file>` validates the bundle by the
agent's own text shape *before* `need_root` (a bad file says "wrong file", not "add a sudoers
line"; exit 2), installs it `0640 root:dashboards-agent` byte-identically — the task file's
literal `0600` cannot work, because the agent opens the bundle as itself and re-reads the
denylist live (the exact incident the token file's comment records), and the task's own
intent is "only root and the agent's own service user may read it", which is what 0640
achieves — re-runs without the flag leave the installed bundle in place, the unit gains
`--cert-bundle` + `RuntimeDirectory=dashboards-agent` (`0700`) from one conditional branch
while keeping the token line, `--status` probes `https` with the bundle's CA and no client
certificate (`python3` `ssl`, never an external TLS binary; an unreadable bundle says *run
--status as root* instead of pretending), `--uninstall` sweeps the bundle with the token, and
`deploy/agent-sudoers` gained no line (the bundle travels through the same root invocation).
`tests/test_deploy_scripts.py` pins all of it, including the probe run end-to-end against a
real in-process TLS agent: CA-only reads `/health`, a foreign root is refused, token mode
keeps the plain probe. The final pass proved the composition rather than the halves: a real
`host_agent.py --cert-bundle` on loopback and a real dashboard process over a scratch database
(`HOST_TARGETS=loop=https://127.0.0.1:<port>`) answer 27 checks — `host:loop`/`disks:loop` `ok`
through the certificate dial, `agent.tls`/`agent.bundle_rev` in both snapshots, the bundle
download byte-identical to the `meta` row, an unsigned `/certs` a `303` with `?next=`, a revoke
followed by `agent certificate revoked` on both jobs with no dial, the dashboard's own client
identity untouched by a *host* revoke (the list is serials, not hosts), and a revoked **client**
serial refused 403 by the running agent on `/health` too the moment the re-sealed bundle is in
place (`data/logs/verify-15-tls-e2e.py`, transcript `verify-15-tls-e2e.out`). What is left of
the phase is the live rollout (07): the fleet's agents still run token mode, so `HOST_TARGETS`
stays `http://` here until somebody installs a bundle on a host — which needs root on that host
(`ops` has no installer grant on `deskwork`, and `driveripper`'s unit must not be rewritten, gap
10), and flipping the URL before the bundle is installed turns that host red by construction.
## Locked decisions (and why)
@@ -1560,6 +1818,10 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
explicit gating.
- **In-house Prometheus parser.** Only two upstreams use the format; keeps `prometheus_client`
out of the dependency set.
- **`cryptography` for X.509, dashboard-side only** (phase 15). Minting a CA and signing leaves is
not something to hand-roll, and there is exactly one place in this app that does it
(`certs.py`). The agent gets no new dependency: it loads the PEMs the dashboard signed with stdlib
`ssl`, which is why the bundle is plain text and why the agent still runs on a host's Python 3.10.
- **Port 8090**, inherited from the `llm_monitor` prototype this started from: 8000 is vLLM
and 8731 is gufo in this fleet.
- **`.env` is a seed, never an override** — container/systemd env must win.
+170 -20
View File
@@ -13,9 +13,11 @@ Four dashboards, one domain, one process, one SQLite file:
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
runtime; every asset is served by this app.
Two more pages are not dashboards: `/login` (the one thing an anonymous visitor sees) and
Three more pages are not dashboards: `/login` (the one thing an anonymous visitor sees),
`/settings` — `⚙ Account` in the masthead of every dashboard — which shows this browser's
session, signs it out, and changes the admin password.
session, signs it out, and changes the admin password, and `/certs` — `⚷ Certs`, beside it —
which is the certificate authority the agents are dialled with: the root CA, this dashboard's
own client certificate, and one row per host with its rotate, revoke and bundle download.
Working on this repo? **[`AGENTS.md`](AGENTS.md)** holds the invariants (read-only by
construction, never invent a number, unconfigured ≠ broken), the locked decisions, the real
@@ -70,14 +72,16 @@ and running the installer without root prints that exact line. upsd is dialled a
`/sys/block`, `/sys/class/drm` and `statvfs` and answers `GET /snapshot` (everything, one
JSON document; `?top=N` trims the process lists), `GET /info` (slow-changing identity: OS,
kernel, CPU model, GPU list), `GET /schema` (the schema version plus this document) and
`GET /health` (the one route that never needs the token) as JSON, with a plain HTML status
`GET /health` (the one route that needs no credential) as JSON, with a plain HTML status
page at `GET /` so a browser pointed at an agent learns what it is looking at.
```bash
python3 host_agent.py --once # print one snapshot, exit
python3 host_agent.py --host 0.0.0.0 --port 9120 --token-file /etc/dashboards-agent.token
python3 host_agent.py --host 0.0.0.0 --port 9120 --cert-bundle /etc/dashboards-agent.certs
sudo deploy/install-agent.sh # systemd unit + user + token
deploy/install-agent.sh --status # unit state and /health
sudo deploy/install-agent.sh --cert-bundle agent.bundle # ...or the TLS install (bundle from /certs)
deploy/install-agent.sh --status # unit state and /health (https when TLS)
sudo deploy/install-agent.sh --uninstall
```
@@ -106,8 +110,41 @@ one installed before phase 09 has no SMART pass to run. Opt out silently with `-
The drive truth this is all developed against lives in `tests/fixtures/smartctl_driveripper_*.txt`:
16 real drives, recorded with serials scrubbed, each one parsed by a test.
Binding off-loopback without a token is refused (exit 2) rather than warned about. Prefer
`--token-file` over `--token`: argv is world-readable through `ps`, and this token guards
**TLS mode is `--cert-bundle` (env `AGENT_CERT_BUNDLE`)**: one installed PEM the dashboard
signed — this host's server certificate and key, the dashboard's root CA, a `# BUNDLE-REV <n>`
line and the `# REVOKED <serial>` denylist. The agent then serves `https://` and answers every
path but `/health` only to a **client certificate signed by that root and not on the list**: 403
`client certificate required` for a peer that proved nothing, 403 `client certificate revoked`
for one the operator withdrew (a revoked identity is refused on `/health` too — that path is open
to the installer's probe, which holds the CA and no certificate, not to a certificate that was
taken away). The denylist is re-read when the bundle file's stamp changes, so a re-deployed
bundle takes effect on the next request rather than the next restart; the certificate itself needs
a restart, because `ssl` cannot swap a chain under a live socket. The unpacked PEMs exist only
long enough to load into the context and are then deleted, and the private key is written `0600`
in the unit's `RuntimeDirectory` when it has one. In this mode `/snapshot` gains two additive
fields — `tls: true` and the `bundle_rev` the bundle carried, which is how the dashboard tells a
stale install from a current one (they reach `/api/system/snapshot` and `/api/disks/snapshot` as
`agent.tls` / `agent.bundle_rev`) — and **the bearer token is not read**: the handshake is the
auth, and a token that kept working after a revocation would defeat the list. The dashboard's half
is `collectors.AgentTls`: point that host's `HOST_TARGETS` entry at `https://` and the fetch
presents the dashboard's client certificate and verifies the agent against the same root, and a
host revoked in `certs.agents` is refused before a socket is opened. A bundle that will
not load is exit 2 naming the file; there is no "fall back to plain HTTP" path.
`deploy/install-agent.sh --cert-bundle <file>` is how that file gets there. It validates the
bundle first by the same text shape (two certificates and exactly one key, plus the
`# BUNDLE-REV` line) — before touching anything, before the root check, and without ever
dialling the dashboard — then installs it to `/etc/dashboards-agent.certs` as
`0640 root:dashboards-agent`, the token-file idiom: the agent opens the bundle *itself* at
startup and re-reads it when its stamp changes, so the service user must be able to read it
and no other account may. The unit gains `--cert-bundle` and a private `RuntimeDirectory` for
the PEM blocks, `--status` probes `https://…/health` with the bundle's CA and no client
certificate (python3 `ssl`, never an external TLS binary), `--uninstall` removes the bundle
with the token, and a re-run without the flag leaves an installed bundle in place — the
token-reuse idiom.
Binding off-loopback without a token *or* a bundle is refused (exit 2) rather than warned about.
Prefer `--token-file` over `--token`: argv is world-readable through `ps`, and this token guards
hostname, mounts, disk and GPU models, and process names. `deploy/install-agent.sh` creates
a system user, generates a token once (re-runs reuse it; `--rotate-token` for a new one),
stores it `0640 root:dashboards-agent` — the unit hands the agent the *path*, and the agent
@@ -116,13 +153,77 @@ and runs the agent under `ProtectSystem=strict`, `DevicePolicy=strict` with only
nodes and the `/dev/null` family reopened (SMART needs the former; without `/dev/null` in
the allow list systemd cannot start the service at all), with no capabilities.
### Managing the certificates
The dashboard is the authority, and `/certs` (`⚷ Certs` in the masthead) is its page: the **Root
CA** block (CN, serial, expiry as a local timestamp *and* as time left, and **Rotate root CA**),
the **Dashboard client** line (the one certificate every agent verifies the dial against —
display-only, because it is minted with the root and has no route of its own), and one row per
host with its status chip, serial, expiry, issued-vs-deployed bundle revision, the addresses its
certificate is valid for, and **Rotate** / **Revoke** / **bundle**.
Nothing there is coloured: `severity.py` rates no certificate, so an expiry is a date and a
bundle revision is a count. The one verdict on the page is the API's `status` word (`active` green,
`revoked` red, `unissued` grey — a certificate that does not exist never borrows a green pass).
Every write asks a `confirm()` first and prints the server's own sentence afterwards; a root
rotation answers `restart_required`, because the CA and the client pair are startup material — the
table is new at once, the running process keeps dialling with what it loaded, and every host then
needs its new bundle installed. A row whose host has left `HOST_TARGETS` is listed with no buttons
at all, because every per-host route refuses it.
Under the page is the same API a script would call. Every route below is signed-in (and every write
is CSRF-gated like any other), and **none of them dials a host** — the effect of a revocation is
that the *next poll* refuses to dial (see `collectors.AgentTls` above):
| Route | What it does |
| ----- | ------------ |
| `GET /api/certs` | `root` and `client` with serial and expiry, plus one row per host the operator could act on: every `HOST_TARGETS` name and every certificate still in the table. A configured host nobody has installed yet reads `status: "unissued"` with `null` serial rather than going missing; `bundle_rev` is what this dashboard issued and `deployed_bundle_rev` is what that host's agent reported back (so "the box is one bundle behind" is visible, `n/a` when nothing answered). Reads are pure — no page refresh ever mints a certificate |
| `GET /api/certs/<name>/bundle` | the exact bytes of the stored bundle as `application/x-pem-file` with `Content-Disposition: attachment; filename="agent-<name>.bundle"`. That byte-equality is the point: `install-agent.sh` is handed the file and compares nothing else, so a route that re-signed on each read would make "is this the bundle I installed?" unanswerable. It carries that host's **private key**, which is why it is a download behind the session and `no-store` |
| `POST /api/certs/<name>/rotate` | fresh key, fresh serial, `bundle_rev` +1, `status: active` — and it is also how a host gets its **first** bundle (`bundle_rev` 1). The SAN comes from the current `HOST_TARGETS` URLs plus the name, not from the old certificate, because the address a host is dialled by is a deployment fact |
| `POST /api/certs/<name>/revoke` | marks the identity revoked, and **re-seals every other bundle** because the `# REVOKED` denylist lives inside each of them — so the fleet has new bundles to collect, while their `bundle_rev` does not move (their identity did not change). There is no unrevoke: the fix is a rotate |
| `POST /api/certs/root/rotate` | a new root, a new client certificate, every agent re-issued, and `{"ok": true, "restart_required": true}` — the CA and the client pair are startup material, so the table is new at once while the running process keeps dialling with what it loaded. Saying so beats pretending a hot-swap that does not exist |
### Protecting an agent, host by host
Three steps, and they are order-dependent because the dashboard picks its dial from the URL:
```bash
# 1. one bundle per host, from the dashboard that owns the authority (a signed-in GET; it is
# byte-identical to the stored row and it carries that host's private key, so `curl -o` it
# somewhere private rather than `cat`ting it into a shell history)
curl -s -H "Authorization: Bearer $SESSION" -o agent-deskwork.bundle \
http://127.0.0.1:8090/api/certs/deskwork/bundle
# 2. install it beside the token — as root on the host, 0640 root:dashboards-agent. The unit
# gains `--cert-bundle` and its RuntimeDirectory, and restarts into TLS mode.
scp agent-deskwork.bundle <host>-ops:/tmp/
ssh <host>-ops sudo /tmp/install-agent.sh --cert-bundle /tmp/agent-deskwork.bundle
ssh <host>-ops sudo /tmp/install-agent.sh --status # https, with the CA and no client cert
# 3. point that host's HOST_TARGETS entry at https with NO userinfo token — the certificate is
# the auth now — and restart the dashboard. `/certs` should then read issued == deployed rev.
export HOST_TARGETS="deskwork=https://<addr>:9120 …"
```
`install-agent.sh --bind … --port 9120` prints the `https://<addr>:9120` line for a bundle install,
so step 3 is a copy rather than a guess. A host left on `http://` keeps working over its bearer
token — the rollout is per host, and one revoked or expired certificate never blinks the others.
A name that is not in `HOST_TARGETS` answers `404 {"configured": false}` **before** any signing, any
dial, and any `meta` write: a typo that minted a key would leave a certificate nobody can install
in the authority forever. Each accepted write leaves one fixed-text event —
`cert-rotated`, `cert-revoked`, `cert-root-rotated` under `source: "certs"`, readable at
`/api/events?source=certs` — and a certificate serial never leaves a `meta` row. Serials are served
as decimal **strings** for that reason: a 64-bit serial through a JS number is a serial the page
cannot match against a denylist.
## Signing in
One admin, one password, and the server **refuses to start** when it has neither a stored
verifier nor `DASH_ADMIN_PASSWORD` — the domain is public and the fleet's hostnames and UPS
controls sit behind this login, so there is no way to run it open by accident. There is no
second account and no per-user view: the five pages (`/`, `/system`, `/disks`, `/llm`, `/ups`),
the `/settings` page and every data route belong to that one operator.
the two admin pages (`/settings`, `/certs`) and every data route belong to that one operator.
A session is an **HMAC-signed cookie** (`dash_session`), not a row in a table: it carries the
signed-in name and its own expiry, is valid for `DASH_SESSION_HOURS` (default `720` = 30 days),
@@ -301,10 +402,10 @@ curl -s -H "Authorization: Bearer $SESSION" -X POST -H 'content-type: applicatio
-d '{"ups":"apc","command":"test.panel.start"}' http://127.0.0.1:8090/api/ups/command
```
That is the app's one write endpoint **that dials anything** (the `/api/auth/*` writes change only
this process and its SQLite `meta` table — invariant 1 is about the fleet), and the bearer header
changes nothing about its gates: a command that can cut output still needs `"confirm":"DANGER"`,
and is still refused while the UPS is on battery.
That is the app's one write endpoint **that dials anything** (the `/api/auth/*` and `/api/certs/*`
writes change only this process and its SQLite `meta` table — invariant 1 is about the fleet), and
the bearer header changes nothing about its gates: a command that can cut output still needs
`"confirm":"DANGER"`, and is still refused while the UPS is on battery.
### Lockout
@@ -328,7 +429,7 @@ documentation and is never loaded, and `scripts/dev-server.sh` exports `.env.dev
| Variable | Meaning |
| ------------------------------------- | -------------------------------------------------------------- |
| `HOST_TARGETS` | `name=url` per host running the agent; a bearer token may ride in the URL userinfo (`http://TOKEN@host:9120`) |
| `HOST_TARGETS` | `name=url` per host running the agent; a bearer token may ride in the URL userinfo (`http://TOKEN@host:9120`). An `https://` URL is dialled as a *protected* agent instead: verified against the dashboard's own root CA and presented with its client certificate, with a revoked host refused before the dial (`certs.py`, `collectors.AgentTls`) |
| `GUFO_BASE_URL` / `VLLM_BASE_URL` | LLM engines; unset means *not deployed here*, and its half of the page disappears |
| `RATE_WINDOW_SECONDS` | the Δ window behind every measured rate — tokens/sec, bytes/sec, jiffies→CPU % (default 10); each value carries the window it actually got, and `/system`'s footnote prints that, not this default. A window the poll loop cannot honour — the ~0.2 s catch-up after a fetch slower than the interval, or a base older than 2× the window — answers one `n/a` instead of a spike, and a rate 8× above the last one is held until a window measured from a **different** sample confirms it (phase 13) |
| `DASH_HOST` / `DASH_PORT` | bind address for the dashboard itself (default `127.0.0.1:8090`; the container sets `0.0.0.0`) |
@@ -339,7 +440,7 @@ documentation and is never loaded, and `scripts/dev-server.sh` exports `.env.dev
| `GUFO_API_KEY` / `VLLM_API_KEY` | only if an engine is behind a bearer token; they never leave the server |
| `NUT_HOST` / `NUT_PORT` | upsd address — **whatever port it really listens on**, not always 3493. Reads are unauthenticated in stock NUT |
| `NUT_USER` / `NUT_PASSWORD` | only needed for instant commands (battery tests) |
| `NUT_ALLOW_ACTIONS` | `1` enables the one write endpoint in the app |
| `NUT_ALLOW_ACTIONS` | `1` enables the one write endpoint that dials **anything** — the `/api/auth/*` and `/api/certs/*` writes touch only this process and its `meta` table |
| `NUT_SIMULATE` | `1` replaces upsd with a **labelled** simulator (development only) |
| `DASH_READ_ONLY` | `1` disables every mutating endpoint regardless of other flags |
| `DASH_DB` | SQLite path (default `data/dashboards.sqlite3`) |
@@ -372,6 +473,7 @@ sources/host.py · disks.py · llm.py · nut.py poller.py (one tas
store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event + meta ── api.py → web/
▲
auth.py ── verifier + session key, loaded once at startup (no row and no seed → no server)
certs.py ─ root CA + client cert, minted once at startup (unreadable row → no server)
```
* `poller.py` — every source gets its own asyncio task, its own health record, and its own
@@ -404,13 +506,40 @@ auth.py ── verifier + session key, loaded once at startup (no row and no see
once-a-minute write transaction from 5.4 s to a few milliseconds, which is what stopped a poller's
insert losing its 5 s `busy_timeout` (known gap 4).
Its `meta` table is the one place state that must outlive a restart goes (`get_meta`/
`set_meta`/`delete_meta`), and its first user is the admin credential below.
`set_meta`/`delete_meta`). Its users are the admin credential below and, since phase 15, the
certificate authority in `certs.py` — five `certs.*` rows and nothing else.
* `auth.py` — the admin login's decisions, in stdlib and without HTTP: a self-describing
PBKDF2-HMAC-SHA256 verifier, an HMAC-signed stateless session cookie (no session table, and
rotating the stored key is what evicts every other browser), a session-bound CSRF token, and
an in-process login backoff. `load_auth_state()` is the startup half: the environment seeds a
password **once**, the stored verifier wins from then on, and a server with neither refuses to
start (see *Signing in*).
* `certs.py` — the fleet's certificate authority, in the same shape as `auth.py`: a pure lifecycle
over the `meta` table, no clock it cannot be handed, no logging, and one fixed-text raise. The
first boot of a database mints a **root CA** (`certs.root.key`/`.cert`, self-signed
`CN=dashboards-root`, 10 years) and the dashboard's own **client** certificate
(`certs.client.key`/`.cert`, `CN=dashboards`, 1 year); every later boot reads those rows and
regenerates nothing, because a root that changes on restart untrusts every certificate already
installed on a host. `issue_agent_cert`/`reissue_agent_cert` sign one server certificate per
`HOST_TARGETS` name (`CN` = the host, SAN = the addresses it is dialled by) into
`certs.agents`, and each entry's **bundle** — the agent's cert, the agent's key, the root *cert*
(never the root key), a `# BUNDLE-REV <n>` line and the current `# REVOKED <serial>` lines — is
stored verbatim so the bytes an installer places are the bytes the row holds. `revoke` marks the
host and re-seals every bundle, because the denylist lives inside each of them; `rotate_root`
replaces the root, the client, and every agent at once. A stored PEM that will not parse (or a
client leaf the stored root did not sign) is a **fail-closed startup**, the admin-password rule:
the message is `sqlite3 "<db>" "DELETE FROM meta WHERE key LIKE 'certs.%'"`, after which every
agent needs a fresh bundle. Every certificate carries a `SubjectKeyIdentifier` and every leaf an
`AuthorityKeyIdentifier` naming that root, because OpenSSL's strict mode — the one Python's
`ssl.create_default_context()` turns on, which is what the dial below builds — refuses a chain
without them. The **dial** is `collectors.AgentTls` (task 03): with an `https://` `HOST_TARGETS`
entry, the fetch verifies the agent against this root and presents this dashboard's own client
certificate, so only this dashboard can read the host; a host marked `revoked` fails its `host:`
and `disks:` jobs with `agent certificate revoked` *before any socket opens*, and because the
status is a fresh `certs.agents` read on every poll, revoking takes effect on the next poll with
no restart. An `http://` entry is untouched — same shared client, same bearer token — so the
rollout is per host. The private keys are credentials like any password: never served, never
logged, and the root key never leaves the row that holds it.
* `severity.py` — the single green/amber/red table (`THRESHOLDS`, plus `INVERTED` for the
metrics where lower is worse). Rated values in the API payloads travel with a sibling
`severity` (`cpu.severity.usage_pct`, `memory.severity`, `disks.severity`, a UPS card's
@@ -436,9 +565,16 @@ auth.py ── verifier + session key, loaded once at startup (no row and no see
one per stale metric; the list dedupes per (source, metric) and is capped at 12 with a
marker that says how many it hid.
* `api.py` — the JSON every page reads: `/api/config`, `/api/health`, `/api/events`,
`/api/history`, `/api/metrics`, `/api/summary`, and one `snapshot` + `history` pair per
dashboard (`/api/system/*`, `/api/disks/*`, `/api/llm/*`, `/api/ups/*`). GETs everywhere
except the single `POST /api/ups/command`. Two rules make the pages legible: a source with
`/api/history`, `/api/metrics`, `/api/summary`, one `snapshot` + `history` pair per
dashboard (`/api/system/*`, `/api/disks/*`, `/api/llm/*`, `/api/ups/*`), and the certificate
authority's own surface (`/api/certs`, `/api/certs/<name>/bundle`, `POST …/rotate`, `POST
…/revoke`, `POST /api/certs/root/rotate`). GETs everywhere except `POST /api/ups/command`,
which stays the **only** route that dials anything: the `/api/certs/*` writes are `meta` row
writes through `certs.py`, the same class of write `POST /api/auth/password` is, and a
revocation's whole effect is that the next poll refuses to dial (see `certs.py` below).
Reads there are pure — `certs.read_state` is the request-time read and `certs.ensure_certs` the
startup bootstrap — so refreshing the list cannot mint a certificate, and it reports the
**stored** authority even after a root rotation the running process has not picked up yet. Two rules make the pages legible: a source with
no env config answers `404 {"configured": false}` **without dialling** (so a page says
"not configured" instead of going red), and a history query for a series that does not
exist answers an empty series instead of an error (so a card asks for every plot it could
@@ -458,7 +594,8 @@ auth.py ── verifier + session key, loaded once at startup (no row and no see
per host — counts, `smartctl_available`, and `failed_devices`, so a notifier learns *which*
drive the hardware called `FAILED` without a second call); it is additive, exactly as `alerts`
was, and the key paths of both are pinned with and without targets configured.
* `web/` — five static pages (`/`, `/system`, `/disks`, `/llm`, `/ups`), one stylesheet, hand-rolled
* `web/` — five static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`) plus the three
admin pages (`/login`, `/settings`, `/certs`), one stylesheet, hand-rolled
SVG charts (`charts.js`), and a masthead status dot per dashboard job (`disks` has its own:
the same agent can answer the 10-second utilisation poll and have a wedged SMART pass). No
framework, no CDN, no bundler, and no narration: a page never explains its own pipeline, so
@@ -640,6 +777,18 @@ local-testing word stays legal in prose, which is the only place it can be usefu
the four public paths, the reset command, the four variables and the `DASH_COOKIE_SECURE=1`
deployment note. Nothing starts a server there; a sentence that quietly disappears is the bug.
`tests/test_certs.py` is the same kind of file for the certificate authority, and two of its tests
are structural rather than behavioural on purpose: they read `certs.py` itself and fail if it gains
a `logging` import or a raise that interpolates anything, because a stored PEM is a private key and
this app turns log lines and error strings into rows a browser reads. The rest runs the whole
lifecycle over a dict store — first boot persists exactly five rows, a second boot changes no byte,
rotate/revoke/root-rotate and their denylists, the bundle never carrying the root key — and one test
completes a real client-certificate handshake with the material a startup would write.
`tests/test_agent.py` runs the matching TLS matrix against the real agent handler, and
`tests/test_deploy_scripts.py` runs the installer in a sandbox where `install`/`chown` are faked, so
the `0640` mode, the byte-identical bundle and the `--status` probe (against a live TLS agent) are
proven without a root shell.
`tests/store_perf.py` is the second opt-in marker's machinery (`live` is the other). It seeds a
**production-shaped
SQLite file** — `SampleStore`'s own retention (`raw 48 h / 1m 30 d / 1h 400 d`), the fleet's real
@@ -699,8 +848,9 @@ the page never reads it.
## Battery-test buttons
Instant commands are the only mutating endpoint in the application (`POST /api/ups/command`)
and they are gated four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users`
Instant commands are the only endpoint in the application that changes anything **outside this
process** (`POST /api/ups/command`; the session and certificate-authority writes change only this
app's own rows), and they are gated four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users`
account that actually has the command, and a confirmation in the UI — destructive ones
(deep test, calibration, `load.off`) require typing `DANGER`. The server re-checks every
gate; hiding a button is not a safety control. Commands that would cut power are refused
+3
View File
@@ -1 +1,4 @@
# TODO
- [ ] The nav bar needs to collapse into a hamburger like brain of reese does
- [ ] The UPS tab should have an expandable, clear list of last incidents with dates/times
+348 -9
View File
@@ -42,6 +42,8 @@ SCHEMA (GET /snapshot)
ts float unix seconds (agent clock)
duration_ms float collection cost
agent_version str
tls true only when serving with --cert-bundle
bundle_rev int the `# BUNDLE-REV` of the bundle it loaded
host {hostname, os_pretty, kernel, arch, python, boot_ts, uptime_seconds,
cpu_model, cpu_cores_logical, cpu_cores_physical, container}
cpu {modes: {user,nice,system,idle,iowait,irq,softirq,steal,guest,
@@ -81,17 +83,26 @@ SCHEMA (GET /snapshot)
errors [str] reads that failed; non-empty means the snapshot is partial
Deployment: see deploy/install-agent.sh (systemd unit, binds 127.0.0.1 unless you pass
--host, requires a bearer token via --token or AGENT_TOKEN when bound off-loopback).
--host, requires a bearer token via --token or AGENT_TOKEN when bound off-loopback). The
other protection is `--cert-bundle`/AGENT_CERT_BUNDLE: one installed PEM (server cert, server
key, the dashboard's root CA, `# BUNDLE-REV <n>` and `# REVOKED <serial>` lines) turns the
server into HTTPS that answers every path but `/health` only to a client certificate signed
by that root, and refuses one on the denylist on every path — the certificate is the auth, so
no token is read on that path.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import os
import re
import shutil
import socket
import ssl
import subprocess
import tempfile
import threading
import time
from collections.abc import Callable
@@ -99,7 +110,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
AGENT_VERSION = "1.0.0"
AGENT_VERSION = "1.1.0"
SCHEMA_VERSION = 1
PAGE_SIZE = 0 # resolved from sysconf at runtime
@@ -1136,6 +1147,267 @@ def _split_stat(stat: str) -> list[str] | None:
return [match.group("comm"), *match.group("rest").split()]
# --------------------------------------------------------------------------- #
# TLS bundle: what the dashboard signs, what the installer places
# --------------------------------------------------------------------------- #
#: The two metadata lines inside a bundle, spelled exactly as `src/dashboards/certs.py` writes
#: them. The agent stays stdlib (no `cryptography` on a monitored host), so the format is agreed
#: here by text rather than imported, and `tests/test_agent.py` parses a bundle that `certs.py`
#: actually produced — the only way the two halves can stay honest.
BUNDLE_REV_LINE = "# BUNDLE-REV "
REVOKED_LINE = "# REVOKED "
#: Where the bundle is unpacked when systemd gave the unit a `RuntimeDirectory` (task 06): tmpfs,
#: private to the service user, gone when the unit stops. Otherwise a `mkdtemp` directory that is
#: removed as soon as the SSL context holds the material.
BUNDLE_RUNTIME_DIR = Path("/run/dashboards-agent")
_PEM_BLOCK_RE = re.compile(r"-----BEGIN [A-Z0-9 ]+-----.*?-----END [A-Z0-9 ]+-----\n", re.DOTALL)
_CERT_HEADER = "-----BEGIN CERTIFICATE-----"
#: The unpacked file names, in the order `_bundle_blocks` hands the blocks back.
_BUNDLE_FILES = ("agent-cert.pem", "agent-key.pem", "root-ca.pem")
def _line_value(line: str, prefix: str) -> str:
"""The first token after a bundle line's prefix, or "" when the line carries nothing."""
rest = line[len(prefix):].strip()
fields = rest.split()
return fields[0] if fields else ""
def bundle_revision(text: str) -> int | None:
"""The `# BUNDLE-REV <n>` number, or None when the line is absent or is not a number.
Absent is an error rather than a default of 0: the revision is how the dashboard tells a stale
bundle from the one it just issued, so an agent serving "rev 0" would be reporting a number it
never had.
"""
for line in text.splitlines():
stripped = line.strip()
if not stripped.startswith(BUNDLE_REV_LINE):
continue
token = _line_value(stripped, BUNDLE_REV_LINE)
try:
return int(token)
except ValueError:
return None
return None
def bundle_revoked(text: str) -> frozenset[str]:
"""Every `# REVOKED <serial>` value in a bundle, kept exactly as written."""
serials: set[str] = set()
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith(REVOKED_LINE):
serial = _line_value(stripped, REVOKED_LINE)
if serial:
serials.add(serial)
return frozenset(serials)
def _bundle_blocks(text: str) -> tuple[str, str, str]:
"""(agent certificate, agent key, root certificate) — positionally, because that is the format.
Two certificates and exactly one private key is the shape of a bundle. A bundle with the root
key in it is the failure this catches: an exporter needs to *verify* its clients, which is a
public-key operation, so a stolen agent install must never be a stolen CA.
"""
blocks = _PEM_BLOCK_RE.findall(text)
certs = [block for block in blocks if block.startswith(_CERT_HEADER)]
keys = [block for block in blocks if not block.startswith(_CERT_HEADER)]
if len(certs) != 2 or len(keys) != 1:
raise ValueError("expected the agent certificate, the agent key and the root certificate")
return certs[0], keys[0], certs[1]
def _file_stamp(path: str) -> tuple[int, int] | None:
"""(mtime_ns, size) for the denylist re-read, or None when the file cannot be stated.
Nanoseconds rather than the float `st_mtime`: two writes in the same millisecond are a real
redeploy, and a change this agent misses is a revoked certificate that keeps working.
"""
try:
info = os.stat(path)
except OSError:
return None
return (info.st_mtime_ns, info.st_size)
def _material_dir() -> Path:
"""The private directory to unpack into: the unit's RuntimeDirectory when usable, else tmp."""
if BUNDLE_RUNTIME_DIR.is_dir() and os.access(BUNDLE_RUNTIME_DIR, os.W_OK | os.X_OK):
return BUNDLE_RUNTIME_DIR
directory = Path(tempfile.mkdtemp(prefix="dashboards-agent-"))
os.chmod(directory, 0o700) # mkdtemp is 0700 already; the claim is this agent's, not the umask's
return directory
def _unlink(path: Path) -> None:
with contextlib.suppress(FileNotFoundError):
path.unlink()
def _write_private(path: Path, text: str) -> None:
"""Create `path` at 0600 in one `open()`, so a private key never exists at the umask default."""
_unlink(path) # O_EXCL would refuse a leftover from a killed run, and a symlink would be worse
handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(handle, "w", encoding="ascii") as stream:
stream.write(text)
def resolve_bundle(path: str) -> tuple[ssl.SSLContext | None, int, frozenset[str], str | None]:
"""(context, bundle_rev, revoked serials, error) for one installed bundle.
The `resolve_token` idiom: no globals, no server state, every failure an error string the
caller prints and exits on. A bundle this agent cannot load must stop the startup rather than
answer plain HTTP behind a token nobody configured — "degraded to no TLS" is the one answer
that is worse than a crash.
The three PEM blocks are grepped out by text and written into a private directory, because
`ssl` loads a chain from paths; they are unlinked as soon as the context has them, so the key
then lives in this process's memory and nowhere else (and there is no shutdown path that could
forget to remove it). The root CA goes in as the *verify* list, which is the whole of what an
exporter needs: it authenticates its clients, it is never authenticated by them.
"""
try:
text = Path(path).read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return None, 0, frozenset(), f"cannot read --cert-bundle {path}: {exc}"
if not text.strip():
return None, 0, frozenset(), f"--cert-bundle {path} is empty"
revision = bundle_revision(text)
if revision is None:
return None, 0, frozenset(), (
f"--cert-bundle {path} has no readable `# BUNDLE-REV <n>` line — the revision is how "
"the dashboard tells this bundle from a stale one, so a bundle without it is not one "
"this app signed")
try:
server_cert, server_key, root_cert = _bundle_blocks(text)
except ValueError as exc:
return None, 0, frozenset(), f"--cert-bundle {path} is not a bundle: {exc}"
directory: Path | None = None
ephemeral = False
try:
directory = _material_dir()
ephemeral = directory != BUNDLE_RUNTIME_DIR
written = [directory / name for name in _BUNDLE_FILES]
for target, body in zip(written, (server_cert, server_key, root_cert), strict=True):
_write_private(target, body)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain(str(written[0]), str(written[1]))
context.load_verify_locations(str(written[2]))
# Optional at the handshake, enforced by the handler: `CERT_REQUIRED` would make the
# installer's `--status` and systemd's probe fail, because neither holds a client
# certificate the installer was never given, and a service that cannot health-check is a
# dead install. See `Handler._certificate_allows` for who may read what.
context.verify_mode = ssl.CERT_OPTIONAL
except (OSError, ValueError, ssl.SSLError) as exc:
return None, 0, frozenset(), f"cannot load --cert-bundle {path}: {exc}"
finally:
if directory is not None:
for name in _BUNDLE_FILES:
_unlink(directory / name)
if ephemeral:
shutil.rmtree(directory, ignore_errors=True)
return context, revision, bundle_revoked(text), None
class TlsBundle:
"""One loaded bundle: the SSL context, the revision it carries, and the live denylist.
The context and the revision are fixed for the process's life — `ssl` cannot swap a chain
under a live socket, so a new certificate needs a restart (and says so on `/certs`). The
`# REVOKED` list is not fixed: re-reading one small text file when its stamp changes costs
nothing per poll and is the difference between "revoked" and "revoked on the next deploy",
which is the only reason the lines live in the bundle instead of in the dashboard alone.
"""
def __init__(self, path: str, context: ssl.SSLContext, bundle_rev: int,
revoked: frozenset[str]) -> None:
self.path = path
self.context = context
self.bundle_rev = bundle_rev
self._lock = threading.Lock() # ThreadingHTTPServer may re-read on several requests
self._revoked = revoked
self._stamp = _file_stamp(path)
@classmethod
def load(cls, path: str) -> tuple[TlsBundle | None, str | None]:
"""`resolve_bundle` plus the file stamp the denylist re-read compares against."""
context, revision, revoked, error = resolve_bundle(path)
if error is not None or context is None:
return None, error
return cls(path, context, revision, revoked), None
def revoked_serials(self) -> frozenset[str]:
"""The denylist as the file holds it right now, re-read only when the file changed."""
stamp = _file_stamp(self.path)
with self._lock:
if stamp is None or stamp == self._stamp:
return self._revoked
try:
text = Path(self.path).read_text(encoding="utf-8", errors="replace")
except OSError:
# Unreadable mid-redeploy: keep the list this agent already had. Forgetting a
# revocation because a file vanished for one second is the wrong direction.
return self._revoked
self._revoked = bundle_revoked(text)
self._stamp = stamp
return self._revoked
def _peer_certificate(connection: Any) -> dict[str, Any]:
"""The verified client certificate of this connection, or {} when there is none.
`getpeercert()` is only there on a TLS socket and only answers after a handshake, so a
plain-HTTP connection to a bundle-configured agent (a scanner, a port probe) reads as "no
certificate" and gets the same 403 as a certless TLS client rather than a traceback.
"""
getter = getattr(connection, "getpeercert", None)
if getter is None:
return {}
try:
peer = getter()
except (OSError, ValueError): # ValueError is `ssl` for "the handshake has not run"
return {}
return peer or {}
def _serial_forms(raw: str) -> frozenset[str]:
"""Every spelling one certificate serial can be written in.
`getpeercert()` renders a serial as **hexadecimal** text while `certs.py` writes the **decimal**
number into the bundle's `# REVOKED` line, and an agent that compared one spelling to the other
would accept a revoked client forever — a revocation that silently does nothing is worse than
no revocation list, because the operator believes it happened. Accepting both spellings on both
sides can only ever deny a certificate that is not revoked (two different serials whose hex and
decimal renderings coincide), never allow one that is.
"""
token = (raw or "").strip()
if not token:
return frozenset()
forms = {token}
for base in (10, 16):
try:
value = int(token, base)
except ValueError:
continue
forms.add(str(value))
forms.add(format(value, "X"))
return frozenset(forms)
def serial_is_revoked(cert: dict[str, Any], revoked: frozenset[str]) -> bool:
"""Is this peer certificate on the bundle's denylist?"""
peer = _serial_forms(str(cert.get("serialNumber") or ""))
if not peer:
return False
return any(_serial_forms(serial) & peer for serial in revoked)
# --------------------------------------------------------------------------- #
# HTTP server
# --------------------------------------------------------------------------- #
@@ -1165,10 +1437,18 @@ class Handler(BaseHTTPRequestHandler):
server_version = f"host_agent/{AGENT_VERSION}"
collector: Collector
token: str | None = None
bundle: TlsBundle | None = None
def do_GET(self) -> None: # the BaseHTTPRequestHandler API name — keep it
path = self.path.split("?", 1)[0].rstrip("/") or "/"
if self.token and path != "/health":
bundle = self.bundle
if bundle is not None:
# The handshake is the auth on this path, so the bearer check below never runs: a
# bundle *and* a token would let a stolen token read a host whose certificate the
# operator just revoked, which is the one thing the denylist exists to prevent.
if not self._certificate_allows(bundle, path):
return
elif self.token and path != "/health":
given = self.headers.get("Authorization", "")
if given != f"Bearer {self.token}":
self._send(401, {"ok": False, "detail": "missing or bad bearer token"})
@@ -1192,10 +1472,42 @@ class Handler(BaseHTTPRequestHandler):
top = max(0, int(part[4:]))
except ValueError:
top = None
self._send(200, {"ok": True, **self.collector.snapshot(top=top)})
body: dict[str, Any] = {"ok": True, **self.collector.snapshot(top=top)}
if bundle is not None:
# Additive, and only in TLS mode: the dashboard (task 03) reads the revision to
# tell a stale installed bundle from the one `/certs` just issued, and an absent
# field means "this agent predates certificates" rather than "revision 0".
body["tls"] = True
body["bundle_rev"] = bundle.bundle_rev
self._send(200, body)
else:
self._send(404, {"ok": False, "detail": f"no such path: {path}"})
def _certificate_allows(self, bundle: TlsBundle, path: str) -> bool:
"""Does this connection's client certificate authorise `path`? Sends the refusal if not.
Two answers, because they are two different operator actions: 403 "required" is a client
that never proved who it is (nothing installed, or the wrong CA), 403 "revoked" is a client
that proved it and was withdrawn.
The order is the design. A revoked identity is refused on **every** path, `/health` first:
the exemption is for the probe that holds the root CA and no client certificate at all, not
for a certificate the operator has just taken away. Then `/health` passes a certless probe,
because `install-agent.sh --status` and systemd's probe were never handed a client
certificate and a service that cannot health-check is a dead install.
"""
peer = _peer_certificate(self.connection)
present = bool(peer and peer.get("subject"))
if present and serial_is_revoked(peer, bundle.revoked_serials()):
self._send(403, {"ok": False, "detail": "client certificate revoked"})
return False
if path == "/health":
return True
if not present:
self._send(403, {"ok": False, "detail": "client certificate required"})
return False
return True
def _status_page(self) -> None:
info = self.collector.host_info()
uptime = info.get("uptime_seconds")
@@ -1232,10 +1544,24 @@ class Handler(BaseHTTPRequestHandler):
print(f"[agent] {self.address_string()} {fmt}", flush=True)
def serve(host: str, port: int, collector: Collector, token: str | None) -> None:
def serve(host: str, port: int, collector: Collector, token: str | None,
bundle: TlsBundle | None = None) -> None:
"""Serve forever on host:port — plain HTTP with an optional token, or HTTPS with a bundle.
All three class attributes are assigned on every call: `Handler` is process-wide state, so a
server started without a bundle must not inherit one from a server that had it (that would
answer a plain-HTTP socket with 403 on every path, or worse, accept a client certificate the
operator revoked).
"""
Handler.collector = collector
Handler.token = token
Handler.bundle = bundle
httpd = ThreadingHTTPServer((host, port), Handler)
if bundle is not None:
httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True)
print(f"[agent] listening on https://{host}:{port} with tls "
f"(client cert required, rev {bundle.bundle_rev}); pid {os.getpid()}", flush=True)
else:
print(f"[agent] listening on http://{host}:{port} "
f"({'token required' if token else 'no token'}); pid {os.getpid()}", flush=True)
try:
@@ -1271,6 +1597,12 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--token-file", default=os.environ.get("AGENT_TOKEN_FILE", ""),
help="read the bearer token from a file (keeps it out of ps); wins "
"over --token")
parser.add_argument("--cert-bundle", default=os.environ.get("AGENT_CERT_BUNDLE", ""),
help="one installed PEM from the dashboard: this agent's server "
"certificate and key, the dashboard's root CA, a `# BUNDLE-REV` "
"line and the `# REVOKED` denylist. Serves https and answers "
"every path but /health only to a client certificate signed by "
"that root and not on the list; on that path the token is not read")
parser.add_argument("--root", default="/", help="filesystem root to read (for tests)")
parser.add_argument("--top", type=int, default=5, help="processes per top-N list")
parser.add_argument("--net-all", action="store_true", help="include veth/bridge interfaces")
@@ -1292,11 +1624,18 @@ def main(argv: list[str] | None = None) -> int:
if error:
print(f"[agent] {error}", flush=True)
return 2
if args.host not in ("127.0.0.1", "localhost", "::1") and not token:
print("[agent] refusing to bind off-loopback without --token (host metrics are "
"sensitive); pass --token, --token-file or set AGENT_TOKEN", flush=True)
bundle: TlsBundle | None = None
if args.cert_bundle:
bundle, bundle_error = TlsBundle.load(args.cert_bundle)
if bundle_error is not None:
print(f"[agent] {bundle_error}", flush=True)
return 2
serve(args.host, args.port, collector, token)
if args.host not in ("127.0.0.1", "localhost", "::1") and not token and bundle is None:
print("[agent] refusing to bind off-loopback without --token or --cert-bundle (host "
"metrics are sensitive); pass --token, --token-file, --cert-bundle or set "
"AGENT_TOKEN / AGENT_CERT_BUNDLE", flush=True)
return 2
serve(args.host, args.port, collector, token, bundle)
return 0
+170 -4
View File
@@ -3,6 +3,7 @@
#
# sudo ./deploy/install-agent.sh # loopback only (read it locally)
# sudo ./deploy/install-agent.sh --bind 10.0.0.5 # serve the LAN, token required
# sudo ./deploy/install-agent.sh --cert-bundle agent.bundle # serve TLS (phase 15)
# ./deploy/install-agent.sh --status # unit state + /health, no root
# sudo ./deploy/install-agent.sh --uninstall
#
@@ -16,6 +17,21 @@
# names. Re-running the installer reuses the existing token — use --rotate-token when the old
# one leaked.
#
# The certificate bundle (--cert-bundle <file>, fetched by the operator from the dashboard's
# GET /api/certs/<name>/bundle or the /certs page) is the TLS install: one PEM holding this
# host's server certificate and key, the dashboard's root CA, a `# BUNDLE-REV <n>` line and
# the revocation denylist. This script never dials the dashboard for it. The bundle is
# validated before anything is touched — a bundle the agent could not load must stop the
# install with its reason, not leave a unit that exits at startup — and then installed to
# /etc/dashboards-agent.certs, the token-file idiom: the agent opens the bundle *itself* at
# startup and re-reads it when its stamp changes (that is how a re-deployed revocation list
# lands without a restart), so the mode must let the agent's own service user read it and no
# other account. The unit then runs https with --cert-bundle and a RuntimeDirectory for the
# PEM blocks the agent unpacks (it deletes the key once the SSL context holds it), and
# --status probes /health over TLS with the bundle's CA and no client certificate — the path
# the agent exempts for probes. Re-running without the flag leaves an installed bundle in
# place, the token-reuse idiom.
#
# The agent is read-only by construction (it answers GET and writes nothing), runs as its
# own system user, and gets the usual systemd lockdown. It still needs to read /proc, /sys
# and statvfs(), which the hardening below leaves alone.
@@ -34,6 +50,7 @@ AGENT_USER="dashboards-agent"
AGENT_DIR="/usr/local/lib/dashboards-agent"
AGENT_TARGET="$AGENT_DIR/host_agent.py"
TOKEN_FILE="/etc/dashboards-agent.token"
CERT_FILE="/etc/dashboards-agent.certs"
UNIT="/etc/systemd/system/$SERVICE.service"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
AGENT_SOURCE="$SCRIPT_DIR/../agent/host_agent.py"
@@ -51,6 +68,7 @@ BIND="127.0.0.1"
PORT="9120"
TOKEN="${AGENT_TOKEN:-}"
ROTATE=0
BUNDLE_SRC=""
MODE="install"
usage() {
@@ -65,6 +83,7 @@ while [ $# -gt 0 ]; do
--port) PORT="$2"; shift 2 ;;
--token) TOKEN="$2"; shift 2 ;;
--rotate-token) ROTATE=1; shift ;;
--cert-bundle) BUNDLE_SRC="$2"; shift 2 ;;
--status) MODE="status"; shift ;;
--uninstall) MODE="uninstall"; shift ;;
-h|--help) usage 0 ;;
@@ -81,7 +100,7 @@ need_root() {
cat >&2 <<HINT
$SCRIPT_PATH has to run as root: it writes $UNIT,
creates the $AGENT_USER user and its disk-group membership, and stores
the token in $TOKEN_FILE.
the token in $TOKEN_FILE (and, with --cert-bundle, the certificate bundle in $CERT_FILE).
Add this single sudoers line on this host (it is the whole grant; see $SUDOERS_SOURCE):
@@ -105,7 +124,47 @@ HINT
status() {
systemctl status "$SERVICE" --no-pager 2>&1 || true
echo
if command -v curl >/dev/null 2>&1; then
if [ -r "$UNIT" ] && grep -q -- '--cert-bundle' "$UNIT"; then
# TLS install (phase 15): probe over https with the bundle's root CA and *no* client
# certificate. The agent's CERT_OPTIONAL handshake plus the /health exemption is what
# makes this possible — the installer holds no client identity, and a service that
# cannot health-check is a dead install. The CA is the bundle's last certificate block
# (the format is positional: agent cert, agent key, root CA). Chain-to-our-root is the
# claim; the leaf names the dial addresses rather than loopback, so the name check is
# off — forging the chain would still need the root key. python3 is the unit's own
# interpreter: there is no external TLS binary to install on a monitored host.
printf 'health: '
python3 - "$BIND" "$PORT" "$CERT_FILE" <<'PY' || echo "(no answer)"
import re
import ssl
import sys
import urllib.request
host, port, bundle = sys.argv[1], sys.argv[2], sys.argv[3]
try:
with open(bundle, encoding="utf-8", errors="replace") as handle:
text = handle.read()
except OSError as exc:
print(f"(cannot read {bundle}: {exc.strerror or exc} — run --status as root)")
raise SystemExit(0)
blocks = re.findall(r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", text, re.DOTALL)
if len(blocks) < 2:
print(f"(no root CA block in {bundle})")
raise SystemExit(0)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_REQUIRED
context.load_verify_locations(cadata=blocks[-1])
try:
with urllib.request.urlopen(
f"https://{host}:{port}/health", timeout=4, context=context
) as response:
print(response.read().decode("utf-8", "replace").strip())
except Exception as exc: # the probe reports, it never propagates
print(f"(no answer: {exc})")
PY
elif command -v curl >/dev/null 2>&1; then
# token mode: the plain probe, unchanged
printf 'health: '
curl -s --max-time 4 "http://$BIND:$PORT/health" || echo "(no answer)"
echo
@@ -115,7 +174,7 @@ status() {
uninstall() {
need_root --uninstall
systemctl disable --now "$SERVICE" 2>/dev/null || true
rm -f "$UNIT" "$TOKEN_FILE" "$AGENT_TARGET"
rm -f "$UNIT" "$TOKEN_FILE" "$CERT_FILE" "$AGENT_TARGET"
rmdir "$AGENT_DIR" 2>/dev/null || true
systemctl daemon-reload
echo "removed $SERVICE (the $AGENT_USER user was left in place; remove it with userdel)"
@@ -127,11 +186,72 @@ new_token() {
python3 -c 'import secrets; print(secrets.token_urlsafe(32).rstrip("="))'
}
validate_bundle() {
# The `resolve_bundle` idiom from agent/host_agent.py, run before anything is touched:
# a bundle the agent could not load must stop the install with its reason, not leave a
# unit that exits at startup. Shape only, by text — the agent parses the same way (it is
# stdlib; a monitored host has no cryptography) and the dashboard is the only signer, so
# a second copy of this shape check would only ever disagree about nothing. The three PEM
# blocks are positional: agent certificate, agent key, root CA certificate.
if ! command -v python3 >/dev/null 2>&1; then
echo "--cert-bundle needs python3 (3.10 or newer) to be validated" >&2
exit 2
fi
if python3 - "$1" <<'PY' 1>&2
import re
import sys
path = sys.argv[1]
try:
with open(path, encoding="utf-8", errors="replace") as handle:
text = handle.read()
except OSError as exc:
print(f"--cert-bundle {path} cannot be read: {exc.strerror or exc}")
raise SystemExit(1)
if not text.strip():
print(f"--cert-bundle {path} is empty")
raise SystemExit(1)
revision = None
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("# BUNDLE-REV "):
fields = stripped[len("# BUNDLE-REV "):].split()
if fields:
try:
revision = int(fields[0])
except ValueError:
revision = None
break
if revision is None:
print(f"--cert-bundle {path} has no `# BUNDLE-REV <n>` line — that line is how the"
" dashboard tells the bundle it issued from a stale one, so a bundle without it"
" is not one this app signed")
raise SystemExit(1)
blocks = re.findall(r"-----BEGIN [A-Z0-9 ]+-----.*?-----END [A-Z0-9 ]+-----", text, re.DOTALL)
certificates = [b for b in blocks if b.startswith("-----BEGIN CERTIFICATE-----")]
keys = [b for b in blocks if not b.startswith("-----BEGIN CERTIFICATE-----")]
if len(certificates) != 2 or len(keys) != 1:
print(f"--cert-bundle {path} must hold exactly the agent certificate, the agent key and"
f" the root CA certificate — found {len(certificates)} certificates and"
f" {len(keys)} keys. Re-download it from GET /api/certs/<name>/bundle")
raise SystemExit(1)
PY
then
return 0
fi
exit 2
}
# NOT named `install`: bash resolves a function before an external command of the same name,
# so an `install()` here turns the `install -d` / `install -m` calls below into infinite
# recursion instead of creating $AGENT_DIR. Phase 10 task 02 found exactly that on a live
# host: the run looped on the SMART block forever and installed nothing.
do_install() {
# Validate before need_root: a bad bundle is refused with its own reason whether or not
# this shell is root, so a wrong file says "wrong file" and not "add a sudoers line".
if [ -n "$BUNDLE_SRC" ]; then
validate_bundle "$BUNDLE_SRC"
fi
need_root --bind "$BIND" --port "$PORT"
[ -f "$AGENT_SOURCE" ] || { echo "no agent next to this script: $AGENT_SOURCE" >&2; exit 1; }
if ! command -v python3 >/dev/null 2>&1; then
@@ -177,6 +297,36 @@ do_install() {
chmod 0640 "$TOKEN_FILE"
chown root:"$AGENT_USER" "$TOKEN_FILE"
# The certificate bundle is the token's twin and the mode is the whole design. It holds
# the agent's *private key*, so every other account — ops included — must not read it;
# but the agent process (running as $AGENT_USER) opens the bundle itself at startup and
# re-reads it when its stamp changes (that live re-read is how a re-deployed denylist
# lands without a restart), so a 0600 root-owned file would fail the unit exactly the way
# it would fail the token file. 0640 root:$AGENT_USER is the smallest grant that works.
# An existing bundle survives a re-run that was not given the flag — the token-reuse
# idiom — and this script never dials the dashboard to fetch one.
if [ -n "$BUNDLE_SRC" ]; then
install -m 0640 "$BUNDLE_SRC" "$CERT_FILE"
chown root:"$AGENT_USER" "$CERT_FILE"
echo " installed the certificate bundle in $CERT_FILE"
elif [ -f "$CERT_FILE" ]; then
echo "reusing the installed certificate bundle in $CERT_FILE (--cert-bundle <file> for a new one)"
fi
# TLS mode: the unit runs https with the installed bundle. The --token-file line stays —
# the agent skips the token check on the TLS path — so a rollout happens host by host
# instead of on a flag day.
TLS_EXEC_ARGS=""
TLS_UNIT_LINES=""
if [ -f "$CERT_FILE" ]; then
TLS_EXEC_ARGS=" --cert-bundle $CERT_FILE"
TLS_UNIT_LINES="# The TLS bundle unpacks its PEM blocks here: tmpfs, private to the unit,
# gone when it stops. ProtectSystem=strict leaves the agent nothing else writable, and the
# agent deletes the key the moment the SSL context holds it.
RuntimeDirectory=dashboards-agent
RuntimeDirectoryMode=0700"
fi
cat > "$UNIT" <<UNIT_FILE
[Unit]
Description=Homelab host agent (read-only system metrics)
@@ -186,11 +336,12 @@ Wants=network-online.target
[Service]
Type=simple
User=$AGENT_USER
ExecStart=/usr/bin/env python3 $AGENT_TARGET --host $BIND --port $PORT --token-file $TOKEN_FILE
ExecStart=/usr/bin/env python3 $AGENT_TARGET --host $BIND --port $PORT --token-file ${TOKEN_FILE}${TLS_EXEC_ARGS}
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
SyslogIdentifier=$SERVICE
$TLS_UNIT_LINES
# The agent reads /proc, /sys and statvfs() and writes nothing, so everything writable can
# be taken away. smartctl is the one thing that needs a real /dev node, so instead of
# PrivateDevices the unit closes all devices and reopens only the disk nodes, read/write
@@ -242,6 +393,20 @@ UNIT_FILE
journalctl -u "$SERVICE" -n 20 --no-pager >&2
exit 1
fi
if [ -n "$TLS_EXEC_ARGS" ]; then
cat <<NEXT
The unit now serves https with the certificate bundle in $CERT_FILE. Point the dashboard at
it over TLS — the bearer token plays no part on the certificate path, so the URL carries
none:
HOST_TARGETS="hostname=https://$BIND:$PORT"
After a rotate or a root rotation on the dashboard, re-download the bundle and re-run this
script with --cert-bundle; the agent picks a changed denylist up without a restart, but a
new certificate needs one.
NEXT
else
cat <<NEXT
Point the dashboard at it (the token is the URL userinfo, so it never needs a variable of
@@ -254,6 +419,7 @@ Paste the token now if this is the first install — it is not printed again wit
$TOKEN
NEXT
fi
}
case "$MODE" in
+4
View File
@@ -16,6 +16,10 @@ dependencies = [
"uvicorn[standard]>=0.54.0",
"pydantic>=2.14.1",
"python-dotenv>=1.2.4",
# Phase 15: the dashboard mints its own root CA and signs the agent certs (`certs.py`).
# Dashboard-side ONLY — `agent/host_agent.py` stays stdlib and loads the PEMs with `ssl`.
# Wheels exist for every platform we build on, and it is the only sane way to issue X.509.
"cryptography>=50.0.2",
]
[dependency-groups]
+299 -2
View File
@@ -15,17 +15,20 @@ from __future__ import annotations
import logging
import math
import re
import time
from collections.abc import Sequence
from dataclasses import dataclass, field, replace
from typing import Any
from urllib.parse import urlsplit
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse
from . import alerts as alert_rules
from . import auth
from . import auth, certs
from .auth import ADMIN_USER, LOGIN_PATH, AuthState, LoginThrottle, Session
from .certs import CertMaterial
from .collectors import NutMonitor
from .config import Settings, strip_userinfo
from .poller import Hub, SourceState
@@ -75,6 +78,14 @@ class AppContext:
the session gate (task 03) may treat it as always present and still has to answer a
rejection rather than a 500 if it somehow is not.
`certs` is the same story one level down (phase 15): the dashboard's own CA and client
certificate are loaded once at startup and written to a private temp directory, because
`httpx` takes paths and not PEM text. The `host:`/`disks:` jobs dial an `https://`
`HOST_TARGETS` entry with those paths (`collectors.AgentTls`, task 03), and an `http://`
entry still goes out on the shared client with its bearer token. It is `None` only on a
context assembled outside the lifespan, which is what makes `agent_tls_map` answer an empty
map instead of pointing every TLS host at files that were never written.
`login_throttle` is a field default rather than a lifespan assignment for one reason: the
context is constructed exactly once per app in both assembly branches, so the default
*is* "one throttle per process" — and `None` would be a live branch in the login route
@@ -88,6 +99,7 @@ class AppContext:
nut_monitor: NutMonitor | None = None
started_at: float = 0.0
auth: AuthState | None = None
certs: CertMaterial | None = None
login_throttle: LoginThrottle = field(default_factory=LoginThrottle)
@@ -1001,3 +1013,288 @@ def _action_event(ups_name: str, command: str, advice: dict[str, str], detail: s
severity = "warn" if advice["risk"] != nut.RISK_SAFE else "info"
return Event(time.time(), "ups", ups_name, "action", severity,
f"{advice['label']} sent to {ups_name}: {detail}", {"command": command})
# --------------------------------------------------------------------------- #
# Certificate authority (phase 15)
# --------------------------------------------------------------------------- #
#: The event source for every certificate action, the `auth` precedent: `/api/events?source=certs`
#: is the audit trail, and `alerts.py` promotes only `kind: "unreachable"`, so a rotation can never
#: blink the overview. No dashboard feed reads this source: `/certs` prints the outcome of the
#: write the operator just made (the route's own `hint`), and the audit trail of every past one is
#: this source at `/api/events?source=certs`.
CERT_SOURCE = "certs"
#: The row status of a host in `HOST_TARGETS` the CA has never signed. Not a `certs.py` status
#: (nothing is stored for such a host): it is the API saying "issue it" in the same field the
#: issued rows use, instead of dropping the row and letting the page infer a machine from a gap
#: (invariant 3 — a configured thing that has not happened is not the same as an absent thing).
UNISSUED = "unissued"
#: A bundle is a private key, so it is a file and never a rendered page. `application/x-pem-file`
#: plus `attachment` is what makes the browser save it instead of opening a key in a tab.
PEM_MEDIA_TYPE = "application/x-pem-file"
def _cert_host_names(ctx: AppContext) -> list[str]:
"""Every `HOST_TARGETS` name, de-duplicated, in config order."""
return list(dict.fromkeys(target.name for target in ctx.settings.host_targets))
def _require_cert_host(ctx: AppContext, name: str) -> None:
"""A name this dashboard does not dial is not a certificate it manages (invariant 3).
The **first** line of every `/<name>/…` route, before a store read and before any signing: a
typo that minted a key would put a certificate nobody can ever install into the CA's table and
bump nothing, and a typo on the revoke half would mark revoked a host the dashboard never
dials. The answer is `404 {"configured": false}` with no side effect at all, which is the
`/api/disks` gate's shape and is spy-pinned from both directions in `tests/test_api.py`.
"""
if name not in _cert_host_names(ctx):
raise HTTPException(404, detail={"configured": False,
"detail": f"no host {name!r} in HOST_TARGETS"})
def _cert_san(ctx: AppContext, name: str) -> list[str]:
"""The addresses this dashboard dials for one host — the SAN its certificate must carry.
A TLS peer is verified by *name*, so the SAN has to be whatever goes on the wire: the URL host
of every `HOST_TARGETS` entry naming this host (an IP is entered as an IP address, which is
`certs.py`'s decision, not this one's). `display_url` rather than `url` keeps userinfo out of
the name list even for a hand-built `Target`, and the host's own name is added by the signing
call — this function returns what to add, not the whole list.
"""
hosts: list[str] = []
for target in ctx.settings.host_targets:
if target.name != name:
continue
host = urlsplit(target.display_url).hostname
if host and host not in hosts:
hosts.append(host)
return hosts
def _deployed_bundle_rev(ctx: AppContext, name: str) -> int | None:
"""The `bundle_rev` this host's agent reported about itself, or `None`.
Read out of the same in-memory `system` payload every panel reads (task 03 carries the agent's
own `tls`/`bundle_rev` into its `agent` block), so "is this box running the bundle I just
issued?" is answered with what the host said rather than what the CA hopes. `None` for a host
that has never answered and for a token-mode agent, which publishes nothing — the page prints
`n/a`, and `n/a` and "version 1" are different sentences (rule 2).
"""
for job in ctx.hub.jobs.values():
if job.dashboard != "system" or job.label != name:
continue
agent = (job.state.payload or {}).get("agent") or {}
revision = agent.get("bundle_rev")
if isinstance(revision, int) and not isinstance(revision, bool):
return revision
return None
return None
def _cert_rows(ctx: AppContext, stored: Sequence[certs.AgentCert]) -> list[dict[str, Any]]:
"""One row per host the operator could act on: every configured host, plus every entry.
The union rather than the stored table alone, because each side has a fact the other cannot
show: a configured host with no certificate is the *issue it* case, and an entry whose host has
left `HOST_TARGETS` is a private key still sitting in the table. `configured` names which half
of the union a row came from, and the per-host routes keep the configured gate either way — a
machine this dashboard no longer dials is not one to install a bundle on, so its row is listed
and not writable (`tests/test_api.py` pins that asymmetry). Nulls mean "no certificate on
record", never `0`, and a row with `status: unissued` carries `san: []` because there are no
names on record rather than a certificate with no names in it (rule 2).
"""
entries = {entry.name: entry for entry in stored}
names = sorted(set(entries) | set(_cert_host_names(ctx)))
live = {name: _deployed_bundle_rev(ctx, name) for name in names}
rows: list[dict[str, Any]] = []
for name in names:
entry = entries.get(name)
rows.append({
"name": name,
"configured": name in set(_cert_host_names(ctx)),
# A certificate serial is up to 64 bits and JS numbers are 53, so it travels as the
# decimal text the bundle's `# REVOKED` lines use: a serial the page rounds is a serial
# the page cannot match against a denylist.
"serial": str(entry.serial) if entry else None,
"not_after": entry.not_after if entry else None,
"status": entry.status if entry else UNISSUED,
"bundle_rev": entry.bundle_rev if entry else None,
"deployed_bundle_rev": live[name],
"san": list(entry.san) if entry else [],
})
return rows
def _cert_event(kind: str, host: str, severity: str, message: str,
data: dict[str, Any] | None = None) -> Event:
"""One certificate audit line.
`message` is fixed text naming the host and the action and nothing else: no PEM, no serial of
ours, no reason. An `event` row is served by `/api/events` to a browser and kept forever, so
the only facts in it are the ones an operator can act on (AGENTS.md invariant 6). The `data`
payload carries the bundle revision — a counter, not a credential — because the follow-up question
after "who rotated this?" is "which bundle do I have to install now?".
"""
return Event(time.time(), CERT_SOURCE, host, kind, severity, message, data)
def _bundle_filename(name: str) -> str:
"""The `Content-Disposition` name for one host's bundle.
The name is already constrained — `_require_cert_host` only lets through a `HOST_TARGETS` name,
and `parse_targets` validates those against `^[a-z0-9][a-z0-9_.-]*$` — so there is no quote or
newline to escape here. The filter is still applied because this string goes into a **header**
and the guarantee comes from a config value: a header built out of configuration is a header
injection waiting for somebody to loosen the config parser.
"""
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name) or "agent"
return f'attachment; filename="agent-{safe}.bundle"'
def certs_router() -> APIRouter:
"""`/api/certs` — the CA's own surface: list it, download a bundle, rotate, revoke.
**Nothing here dials an upstream** (invariant 1). The writes are `meta` row writes through
`certs.py` in this process, which is the same class of write `POST /api/auth/password` is: the
fleet is not touched, and the enforcement of a revocation happens later, in
`collectors.fetch_snapshot`, which refuses the host before it opens a socket (task 03). The
event row this route writes is therefore the audit line and not the control — worth saying
because it is the opposite of `/api/ups/command`, where the write *is* the effect.
All four writes are gated by the session middleware like every other non-public route
(invariant 12): a session, a same-origin `Origin`/`Sec-Fetch-Site`, and — on the cookie — this
session's `X-CSRF-Token`. A revoked-token write changes no row, which is what
`tests/test_auth_api.py` measures.
"""
router = APIRouter(prefix="/api/certs", tags=["certs"])
@router.get("")
def listing(ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
"""Every certificate this CA holds, with its expiry and status — a read, never an issue.
`root` and `client` are read from the **stored** rows (`certs.read_state`), not from the
material this process loaded at startup: after `POST /api/certs/root/rotate` the table holds
the new CA while the socket still uses the old one, and the page has to be able to show what
the authority actually contains. Reading the state is also what keeps a page refresh free of
side effects — a GET that minted a certificate would make "did anything change?" unanswerable.
No `severity` anywhere: an expiry is a date the page prints, and `severity.py` rates no
certificate. A cert about to expire is worth reading, not colouring by a threshold nobody
measured — and the renewal is one button press away on the page this feeds.
"""
state = certs.read_state(ctx.store)
return {
"root": {"serial": str(state.root_serial), "not_after": state.root_not_after},
"client": {"serial": str(state.client_serial), "not_after": state.client_not_after},
"agents": _cert_rows(ctx, certs.read_agents(ctx.store)),
}
# Declared before `/{name}/rotate` on purpose: Starlette matches in registration order, so the
# CA-wide route wins for the literal name `root`. A host called `root` is therefore refused
# below rather than silently rotated past — the wrong-direction failure there is a fleet-wide CA
# rotation nobody meant to do, not a missing button.
@router.post("/root/rotate")
def rotate_root(ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
"""A new root CA, a new client certificate, every agent re-issued under it.
`restart_required: true` is the honest constraint rather than a hedge: the CA and the
client pair are startup material (`app.build_certs` writes them once, and `ssl` cannot swap
a chain under a live socket), so the stored authority is new immediately while this process
keeps dialling with the material it loaded. Saying so is the only way the operator learns to
restart before wondering why the fleet still chains to the old root — and every host needs
its new bundle installed, which is why the answer counts them.
"""
if "root" in _cert_host_names(ctx):
raise HTTPException(409, "a host in HOST_TARGETS is named `root`, which this route "
"would shadow — rename that host before rotating the CA")
state = certs.rotate_root(ctx.store, certs.ensure_certs(ctx.store))
ctx.store.log_event(_cert_event(
"cert-root-rotated", "root", "warn",
"root certificate rotated — every agent certificate reissued, restart required",
{"agents": len(state.agents)}))
return {"ok": True, "restart_required": True,
"root": {"serial": str(state.root_serial), "not_after": state.root_not_after},
"agents": len(state.agents),
"hint": "restart the dashboard, then install every host's new bundle"}
@router.get("/{name}/bundle")
def bundle(name: str, ctx: AppContext = Depends(get_context)) -> Response:
"""The exact bytes the agent should hold, as a file.
Served from the stored `certs.agents` row verbatim, which is the whole point of storing the
bundle instead of re-signing it on demand: "is the box running the bundle I downloaded?"
has to be answerable with a checksum, and a route that re-signed would make it answerable
only by luck. The bundle contains that host's **private key** by design — it is the install
artifact — which is why the route is behind the session, `no-store`, and an `attachment`.
"""
_require_cert_host(ctx, name)
entry = next((item for item in certs.read_agents(ctx.store) if item.name == name), None)
if entry is None:
# A configured host with nothing issued is a different fact from an unconfigured name,
# and the body says which: `configured` stays true so no caller reads this as "this
# machine is not deployed" (invariant 3).
raise HTTPException(404, detail={
"configured": True, "issued": False,
"detail": f"no certificate issued for {name} yet — "
f"POST /api/certs/{name}/rotate mints one"})
return Response(content=entry.bundle, media_type=PEM_MEDIA_TYPE,
headers={"content-disposition": _bundle_filename(name), **NO_STORE})
@router.post("/{name}/rotate")
def rotate_agent(name: str, ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
"""Re-issue one host's identity: fresh key, fresh serial, `bundle_rev` +1.
The SANs come from the config rather than from the previous certificate, because the address
a host is dialled by is a deployment fact that may well have changed — and a rotated
certificate that no longer matches the URL is a red source with a handshake error instead of
an answer. This is also how a host gets its **first** bundle (`bundle_rev` 1), which is why
there is no separate "issue" route.
"""
_require_cert_host(ctx, name)
state = certs.reissue_agent_cert(ctx.store, certs.ensure_certs(ctx.store), name,
_cert_san(ctx, name))
entry = state.agent(name)
if entry is None: # pragma: no cover - reissue always returns the entry it just wrote
raise HTTPException(500, detail={"ok": False, "detail": "reissue wrote nothing"})
ctx.store.log_event(_cert_event("cert-rotated", name, "warn",
f"agent certificate rotated for {name}",
{"bundle_rev": entry.bundle_rev}))
return {"ok": True, "name": name, "serial": str(entry.serial),
"not_after": entry.not_after, "status": entry.status,
"bundle_rev": entry.bundle_rev, "san": list(entry.san),
"deployed_bundle_rev": _deployed_bundle_rev(ctx, name),
"hint": f"install the new bundle on {name} — /api/certs/{name}/bundle"}
@router.post("/{name}/revoke")
def revoke_agent(name: str, ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
"""Strike one host's identity out — and re-seal every other bundle with the new denylist.
The effect is enforced on the next poll, by this dashboard refusing to dial the host at all
(task 03), and by any agent holding this serial on its `# REVOKED` list refusing that
client. Every other host's bundle is rewritten here because the denylist lives *inside* the
bundle: an agent that cannot see the serial keeps accepting the certificate it was given,
which is why the hint says the fleet has new bundles to collect and why their
`bundle_rev` did **not** move (their identity did not change).
There is no unrevoke. The fix for a struck-out identity is a new one, which is `rotate`.
"""
_require_cert_host(ctx, name)
state = certs.revoke(ctx.store, certs.ensure_certs(ctx.store), name)
entry = state.agent(name)
if entry is None: # pragma: no cover - revoke always returns the entry it just wrote
raise HTTPException(500, detail={"ok": False, "detail": "revoke wrote nothing"})
ctx.store.log_event(_cert_event("cert-revoked", name, "warn",
f"agent certificate revoked for {name}",
{"bundle_rev": entry.bundle_rev}))
return {"ok": True, "name": name, "serial": str(entry.serial),
"not_after": entry.not_after, "status": entry.status,
"bundle_rev": entry.bundle_rev,
"deployed_bundle_rev": _deployed_bundle_rev(ctx, name),
"hint": f"{name} is refused on the next poll without dialling; every other "
f"bundle now lists this serial and needs re-installing"}
return router
+57 -16
View File
@@ -3,18 +3,22 @@
`create_app()` takes injectable settings and store so tests can build an app with an
in-memory DB and no targets — the same code path production uses, without the network.
Startup is fail-closed: the lifespan builds the admin :class:`~dashboards.auth.AuthState`
before the first poll, and a server with neither a stored verifier nor `DASH_ADMIN_PASSWORD`
raises out of the lifespan and exits non-zero instead of serving the fleet to whoever finds the
domain. The `RuntimeError` is deliberately not caught here — uvicorn prints it and leaves, and
the message is the operator's fix.
Startup is fail-closed twice over. The lifespan builds the admin
:class:`~dashboards.auth.AuthState` before the first poll, and a server with neither a stored
verifier nor `DASH_ADMIN_PASSWORD` raises out of the lifespan and exits non-zero instead of
serving the fleet to whoever finds the domain. It also builds the
:class:`~dashboards.certs.CertMaterial` before the poll loops start (phase 15): a stored root CA
or client certificate that will not parse raises the same way, because a dashboard that cannot
present its own client certificate would spend the whole fleet's TLS history reporting an upstream
problem it caused itself. Neither `RuntimeError` is caught here — uvicorn prints it and leaves,
and the message is the operator's fix.
"""
from __future__ import annotations
import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
@@ -27,11 +31,12 @@ from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.routing import Match
from . import auth
from . import auth, certs
from .api import (
NO_STORE,
AppContext,
auth_router,
certs_router,
common_router,
disks_router,
llm_router,
@@ -40,9 +45,12 @@ from .api import (
ups_router,
)
from .auth import load_auth_state
from .certs import CertMaterial
from .collectors import (
AgentTls,
NutMonitor,
UpstreamError,
agent_tls_map,
disks_jobs,
host_jobs,
llm_jobs,
@@ -62,9 +70,11 @@ WEB = Path(__file__).resolve().parent / "web"
# `PUBLIC_PATHS` (so the gate guards it with no extra code — the route-enumeration test in
# `tests/test_auth_api.py` proves that, and `tests/test_api.py` proves the anonymous refusal for
# this page specifically). It carries no `aria-current` tab because it is not one of the four.
# `/certs` (phase 15 task 05) is the second of those admin pages, for the same reason: the
# certificate authority is not a fifth dashboard, it is the thing you administer past them.
PAGES = {"/": "index.html", "/system": "system.html", "/disks": "disks.html",
"/llm": "llm.html", "/ups": "ups.html", "/login": "login.html",
"/settings": "settings.html"}
"/settings": "settings.html", "/certs": "certs.html"}
VERSION = "0.1.0"
# Everything else requires a signed-in admin. This list is the whole attack surface, so
@@ -89,8 +99,8 @@ def is_public(path: str) -> bool:
return any(rooted.startswith(prefix) for prefix in PUBLIC_PREFIXES)
def build_hub(settings: Settings, store: SampleStore,
client: httpx.AsyncClient) -> tuple[Hub, NutMonitor | None]:
def build_hub(settings: Settings, store: SampleStore, client: httpx.AsyncClient,
tls: Mapping[str, AgentTls] | None = None) -> tuple[Hub, NutMonitor | None]:
"""Every poll loop the settings describe, on one hub.
One function because the assembly is wired twice: `build_context()` for production, and
@@ -98,6 +108,12 @@ def build_hub(settings: Settings, store: SampleStore,
one that drifts is the one production does not run — which is how a source can end up
polling in tests and not in the container.
`tls` is the per-host certificate material (`agent_tls_map`, built from `build_certs`), and
it is a parameter rather than something this function derives because the two `host:`/`disks:`
jobs for one host must hold **the same** entry: the same identity on the wire and the same
revocation answer, so a revoked agent fails both of its sources with one sentence and neither
of them opens a socket.
Order is host → disks → llm → ups so far only for readability: two jobs carry the same
host label (`host:gw`, `disks:gw`), so anything that looks a job up by label alone gets
the system record — `alerts.py` filters by dashboard instead and does not depend on it.
@@ -105,10 +121,10 @@ def build_hub(settings: Settings, store: SampleStore,
rates = RateEstimator(span=settings.rate_window)
hub = Hub(store)
for job in host_jobs(list(settings.host_targets), client, HostCollector(rates),
settings.host_interval):
settings.host_interval, tls=tls):
hub.add(job)
for job in disks_jobs(list(settings.host_targets), client, DisksCollector(),
settings.disks_interval, timeout=settings.disks_timeout):
settings.disks_interval, timeout=settings.disks_timeout, tls=tls):
hub.add(job)
for job in llm_jobs(settings, client, rates):
hub.add(job)
@@ -120,6 +136,21 @@ def build_hub(settings: Settings, store: SampleStore,
return hub, nut_monitor
def build_certs(store: SampleStore) -> CertMaterial:
"""This process's certificate material: read (or mint, once) the CA, then write it out.
One helper because the wiring exists twice — here for production, and in the lifespan's
injected-store branch for tests — and two copies of a startup sequence drift into the state
where one branch has certificates and the other does not (the `build_hub` rule in AGENTS.md).
It runs **before** `build_hub`, and a failure is fatal on purpose: `certs.ensure_certs` raises
when a stored PEM will not parse, and a dashboard that cannot present its own client
certificate must not start the loops and report every TLS host as an upstream problem (the
`ctx.auth` rule, AGENTS.md invariant 11).
"""
return certs.write_material(certs.ensure_certs(store))
def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
AppContext, SampleStore, Callable[[], None]]:
"""Create the store, the collectors, and the hub's jobs. Returns (ctx, store, start)."""
@@ -129,9 +160,11 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
minute_keep_days=settings.minute_keep_days,
hour_keep_days=settings.hour_keep_days,
)
hub, nut_monitor = build_hub(settings, store, client)
material = build_certs(store)
hub, nut_monitor = build_hub(settings, store, client,
tls=agent_tls_map(material, store, settings.host_targets))
ctx = AppContext(settings=settings, store=store, hub=hub, nut_monitor=nut_monitor,
started_at=time.time())
started_at=time.time(), certs=material)
return ctx, store, hub.start
@@ -268,9 +301,14 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
try:
if store is not None:
# injected: rebuild everything except the DB, with the production wiring
hub, nut_monitor = build_hub(settings, store, client)
# (certificates first, then the per-host TLS material the agent jobs dial with)
material = build_certs(store)
hub, nut_monitor = build_hub(settings, store, client,
tls=agent_tls_map(material, store,
settings.host_targets))
ctx = AppContext(settings=settings, store=store, hub=hub,
nut_monitor=nut_monitor, started_at=time.time())
nut_monitor=nut_monitor, started_at=time.time(),
certs=material)
start = hub.start
else:
ctx, owned_store, start = build_context(settings, client)
@@ -290,6 +328,8 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
if ctx is not None:
await ctx.hub.stop()
await client.aclose()
# The private directory holding the client key goes with the process that wrote it.
certs.remove_material(ctx.certs if ctx is not None else None)
if owned_store is not None and store is None:
owned_store.close()
@@ -301,6 +341,7 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
app.add_api_route(path, _page_handler(filename), methods=["GET"], name=f"page:{path}")
app.include_router(auth_router())
app.include_router(certs_router())
app.include_router(common_router())
app.include_router(system_router())
app.include_router(disks_router())
+2 -1
View File
@@ -15,7 +15,8 @@ the host agent: the alternative was `itsdangerous` + Starlette `SessionMiddlewar
`bcrypt`, which buys a session store to operate and a native wheel to build in the
Containerfile, and costs the one property that matters here — every rule readable in one file
with no magic inside the cookie. `hmac`/`hashlib`/`secrets` are the primitives those
libraries wrap. No dependency was added to write this and none is coming.
libraries wrap. No dependency was added to write this and none is coming **to this file** — the
app's one later addition, `cryptography`, belongs to `certs.py` (phase 15) and is not imported here.
**Credentials never leave the process.** A password, a verifier, a salt, or a session token
must never appear in a log line, an exception message, or a value a caller could render. The
+955
View File
@@ -0,0 +1,955 @@
"""The dashboard's own certificate authority: the root, its client cert, and one per agent.
Phase 15 makes every exporter this repo installs a *protected web server* whose protection is a
certificate instead of a bearer token: the dashboard holds a root CA, presents its own client
certificate when it dials an agent, and signs each agent's server certificate. "Protected" here
means *only this dashboard can read an agent* — the agent requires a client cert chained to this
root on every path but `/health` — so the root key and the client key are as sensitive as the
admin password and in one way more so: whoever holds them *is* the dashboard as far as the fleet
is concerned.
Everything lives in the `meta` table, exactly how `auth.py` keeps the verifier and the session
signing key, for the same three reasons: a restart must not rotate it (a new root CA would
invalidate every bundle the agents hold, and a fleet that has to be re-installed on every deploy
is a fleet that will be run without TLS), there must be no second place durable state can hide in
(no config file, no keyring, no directory outside the DB), and the operator's reset path is one
`DELETE FROM meta` they can type.
**The shapes.** The root CA is self-signed (`CN=dashboards-root`, 10 years) and can only sign. The
dashboard's client cert (`CN=dashboards`, 1 year) is the identity every agent checks, so there is
exactly one per dashboard — an agent trusts "signed by our root", not a per-process name. Each
agent gets a server cert (`CN=<host name>`, 1 year) whose SAN is the address(es) the dashboard
dials for it, because a TLS peer is verified by *name* and a certificate without the name in its
SAN is a handshake failure, not a warning.
**The bundle** is the install artifact — the TODO's "root certificate installed with each
exporter", plus what that exporter needs to serve: the agent's server cert, the agent's key, the
root CA *cert* (never the root key), a `# BUNDLE-REV <n>` line, and the current `# REVOKED
<serial>` lines. It is stored in `meta` as text rather than re-derived on demand so the bytes an
operator downloads from `/api/certs/<name>/bundle` are byte-identical to the bytes the agent holds
— a page that re-signed on every read would make "is this host running the bundle I just issued?"
unanswerable.
**Nothing here reads a clock** (`now` is an argument, the `auth.py` rule), nothing logs, and the
only raise is the fixed-text one for a `meta` row that will not parse: a dashboard whose own
credential is corrupt must stop rather than serve a fleet it cannot authenticate to, and the
message may not interpolate a value because a traceback goes to a log and, through the lifespan,
to whatever prints the startup failure (`AGENTS.md` invariant 6).
This module is the *lifecycle* only. The agent's TLS mode, the dial with a client certificate, the
`/api/certs` surface and the `/certs` page are tasks 02-05.
"""
from __future__ import annotations
import ipaddress
import json
import os
import re
import shutil
import tempfile
import time
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Protocol
from cryptography import x509
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
)
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
# --- the vocabulary ---------------------------------------------------------------------- #
#: Meta keys this module owns, named here because the operator has to be able to type them (they
#: are the reset command) and because task 04's API and `tests/test_certs.py` all read the same
#: five rows. One concern per key, the `auth.*` precedent — except the agents, which share the
#: single `certs.agents` row because a revocation changes the denylist *every* bundle carries:
#: updating one host's row and not the others would leave most of the fleet trusting a
#: certificate the operator just revoked.
META_ROOT_KEY = "certs.root.key"
META_ROOT_CERT = "certs.root.cert"
META_CLIENT_KEY = "certs.client.key"
META_CLIENT_CERT = "certs.client.cert"
META_AGENTS = "certs.agents"
#: Every key this module writes, in the order the reset message names them.
META_KEYS = (META_ROOT_KEY, META_ROOT_CERT, META_CLIENT_KEY, META_CLIENT_CERT, META_AGENTS)
#: The root CA's own CN and the client cert's. Nothing in this app compares them to anything;
#: they are what `openssl x509 -noout -subject` answers on a host, which is why they are words an
#: operator would want to read there.
ROOT_CN = "dashboards-root"
CLIENT_CN = "dashboards"
#: Ten years for the root, one for everything it signs. The root's lifetime is the *install
#: lifetime of the fleet*: a bundle installed once has to still chain to the root years later, and
#: re-trusting a new root on N hosts is the expensive operation this number buys time against. A
#: leaf's lifetime is the opposite: a quietly expired agent certificate is a red source with a
#: confusing error, so it is short enough that rotation is routine and long enough that it is
#: never urgent. Neither is a security claim — the private keys never leave the `meta` table or an
#: agent's `0600` file.
ROOT_LIFETIME = timedelta(days=3650)
LEAF_LIFETIME = timedelta(days=365)
#: The two values of an agent entry's `status`. `revoked` is enforced by the *dashboard* before it
#: dials (task 03) and by any agent holding that serial on its denylist. There is no "unrevoke":
#: the fix for a compromised identity is a new one (`reissue_agent_cert`).
ACTIVE = "active"
REVOKED = "revoked"
#: The two metadata lines inside a bundle. Text lines rather than JSON because the bundle is a PEM
#: file an operator will `cat` and an agent parses by hand with stdlib only (task 02), and
#: mandatory because a bundle with no revision is not one this app signed — `# BUNDLE-REV 0` is
#: fine, its absence is not.
BUNDLE_REV_LINE = "# BUNDLE-REV "
REVOKED_LINE = "# REVOKED "
#: The one message this module raises, and the only text it raises with. Fixed on purpose
#: (invariant 6): the row that failed to parse may be a private key, and an exception message goes
#: to a log and out to whoever prints the startup failure. It names the keys and the command
#: rather than the reason, because "which row?" is answerable by the operator in one `sqlite3`
#: call and the answer is always the same.
CORRUPT_STORE_MESSAGE = (
"the stored certificates in this database cannot be read — refusing to start a dashboard\n"
" that cannot present its own credential.\n"
"\n"
" Delete this module's rows and start again; a fresh root CA and client certificate are\n"
" generated on the first boot of an empty table:\n"
"\n"
" sqlite3 \"<db>\" \"DELETE FROM meta WHERE key LIKE 'certs.%'\"\n"
"\n"
" (certs.root.key, certs.root.cert, certs.client.key, certs.client.cert, certs.agents —\n"
" nothing outside this module's own rows.)\n"
"\n"
" Every installed agent bundle stops chaining to the new root, so each agent needs a fresh\n"
" bundle from /certs after the restart."
)
#: What "I cannot read this PEM" looks like. `InvalidSignature` belongs here because a stored
#: client certificate the stored root did not sign is the same corruption by another route (a
#: root rotation that died halfway), and `UnsupportedAlgorithm` because somebody swapped in a key
#: type this build cannot use — also a row to delete, not a stack to debug.
_PEM_ERRORS = (ValueError, TypeError, OSError, InvalidSignature, UnsupportedAlgorithm)
#: One PEM block, grepped out of a bundle by text. The agent does the same thing with stdlib
#: (task 02), so this module and the agent agree on the format by construction rather than by a
#: shared import they cannot have.
_BLOCK_RE = re.compile(r"-----BEGIN [A-Z0-9 ]+-----.*?-----END [A-Z0-9 ]+-----\n", re.DOTALL)
_CERT_HEADER = "-----BEGIN CERTIFICATE-----"
class MetaStore(Protocol):
"""The slice of :class:`dashboards.store.SampleStore` the cert lifecycle needs.
Declared rather than imported, exactly as `auth.MetaStore` is: this module then depends on
nothing else in the package, cannot reach a database connection from a unit test, and is
exercised with a dict. `delete_meta` is part of the shape because the reset path has to be
provable — "did the row you named actually go away" is a question with a rowcount answer.
"""
def get_meta(self, key: str) -> str | None: ...
def set_meta(self, key: str, value: str) -> None: ...
def delete_meta(self, key: str) -> int: ...
# --- the state ------------------------------------------------------------------------- #
@dataclass(frozen=True)
class AgentCert:
"""One host's issued identity, as stored in `certs.agents`.
`bundle` is the whole install artifact (server cert + server key + root cert + the metadata
lines), so "what does this host hold?" is one string, and `bundle_rev` is the number that
question is actually asked with: task 05's page compares the issued rev against the rev the
agent reported in its snapshot and says *stale* without colouring it.
`serial` is an int and the denylist lines in a bundle are decimal text — the same number
`getpeercert()` reports, so an agent's check is an integer membership test rather than a
comparison of formatted strings.
"""
name: str
serial: int
not_after: float
status: str
bundle_rev: int
bundle: str
san: tuple[str, ...]
def to_json(self) -> dict[str, object]:
"""The stored form. `san` becomes a list because JSON has no tuple and this row is the record."""
return {
"serial": self.serial,
"not_after": self.not_after,
"status": self.status,
"bundle_rev": self.bundle_rev,
"bundle": self.bundle,
"san": list(self.san),
}
@dataclass(frozen=True)
class CertsState:
"""Everything one process knows about its own certificates, read once at startup.
This is a bundle of secrets in the same sense `auth.AuthState` is: `root_key` and `client_key`
are unencrypted PEM text, so it is never rendered, never serialised into a response, and never
logged. Task 04's routes serve serials and expiries derived from it, never a field of it, and
the on-disk material (:func:`write_material`) deliberately leaves the root key out — signing
happens in this process and nowhere else.
`agents` is the state of the fleet's identities **as of the read that produced this object**.
The write functions below return a new state instead of mutating one, and the store stays the
truth: :func:`read_agents` is how a caller that did not just write gets a current answer.
"""
root_key: str
root_cert: str
root_serial: int
root_not_after: float
client_key: str
client_cert: str
client_serial: int
client_not_after: float
agents: tuple[AgentCert, ...] = ()
def agent(self, name: str) -> AgentCert | None:
"""The entry for one host, or `None` when this state has never issued one for it."""
for entry in self.agents:
if entry.name == name:
return entry
return None
def with_agents(self, entries: Iterable[AgentCert]) -> CertsState:
"""This state with a new agent list — sorted by name, so the stored row has one order."""
return replace(self, agents=tuple(sorted(entries, key=lambda item: item.name)))
# --- PEM helpers ------------------------------------------------------------------------- #
def _moment(base: float) -> datetime:
"""The injected epoch seconds as the timezone-aware UTC datetime X.509 wants."""
return datetime.fromtimestamp(base, UTC)
def _epoch(moment: datetime) -> float:
"""The inverse, for the numbers the API and the tests compare against."""
return moment.timestamp()
def _text(store: MetaStore, key: str) -> str:
"""A stored row exactly as it was written.
Not stripped: a PEM document's trailing newline is part of the document, and a state whose
`root_cert` differs by one byte from the row it was read from would make every bundle built
from it differ from the bundle already installed on an agent. "Is this row present?" is
therefore a separate question — :func:`_present` — because a row of whitespace is not a
certificate, and that is the only thing stripping buys.
"""
return store.get_meta(key) or ""
def _present(text: str) -> bool:
"""Is there anything in this row at all? A blank row is an absent row."""
return bool(text.strip())
def _new_key() -> EllipticCurvePrivateKey:
"""A fresh P-256 key.
EC rather than RSA for two reasons that both matter: it is generated in microseconds instead of
a half-second of prime hunting (this runs on every test app's startup, and a root rotation
mints a dozen keys at once), and every `ssl` build in this fleet does ECDSA in TLS 1.3, so
there is no old peer this excludes.
"""
return ec.generate_private_key(ec.SECP256R1())
def _pem_key(key: EllipticCurvePrivateKey) -> str:
"""PKCS#8 PEM, unencrypted.
Unencrypted because nobody could type the passphrase: the dashboard signs unattended at
startup and the agent loads its key from a `0600` file in a systemd unit with no terminal. The
protection is the file mode and the `meta` table's permissions — the same bargain as the agent
bearer token this replaces, and the reason the mode is task 06's job rather than a comment.
"""
return key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode("ascii")
def _pem_cert(cert: x509.Certificate) -> str:
return cert.public_bytes(Encoding.PEM).decode("ascii")
def _load_key(text: str) -> EllipticCurvePrivateKey:
try:
key = load_pem_private_key(text.encode("utf-8", "surrogatepass"), password=None)
except _PEM_ERRORS as exc:
raise RuntimeError(CORRUPT_STORE_MESSAGE) from exc
if not isinstance(key, EllipticCurvePrivateKey):
raise RuntimeError(CORRUPT_STORE_MESSAGE)
return key
def _load_cert(text: str) -> x509.Certificate:
try:
return x509.load_pem_x509_certificate(text.encode("utf-8", "surrogatepass"))
except _PEM_ERRORS as exc:
raise RuntimeError(CORRUPT_STORE_MESSAGE) from exc
def _same_public_key(key: EllipticCurvePrivateKey, cert: x509.Certificate) -> bool:
"""Does this certificate belong to that key? A pair that disagrees is a corrupt row.
The comparison is on the SPKI encoding — the only cheap identity a public key has — and it
returns a bool rather than asserting so the caller's answer stays the one fixed-text
`RuntimeError` whatever the mismatch turns out to be.
"""
left = key.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
right = cert.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
return left == right
def _chain_ok(cert: x509.Certificate, root: x509.Certificate) -> bool:
"""Was `cert` signed by `root`? `False` rather than raise, so the caller picks the words."""
try:
cert.verify_directly_issued_by(root)
except _PEM_ERRORS:
return False
return True
def _san_names(name: str, san: Sequence[str] | None) -> tuple[str, ...]:
"""The SAN entries for a host: the addresses given, then the host name, deduplicated.
The name is always in the list, so the result is never empty. That is not cosmetic: OpenSSL
refuses to build a certificate without a SAN, and a certificate whose SAN omits the address it
is dialled by fails hostname verification — the `n/a` of TLS, with an error message about the
handshake rather than about the name.
"""
values: list[str] = []
for raw in [*(san or ()), name]:
value = (raw or "").strip()
if value and value not in values:
values.append(value)
return tuple(values)
def _san_extension(names: Sequence[str]) -> x509.SubjectAlternativeName:
"""DNS name or IP address, decided by the stdlib parser rather than a hunch about the string."""
items: list[x509.GeneralName] = []
for value in names:
try:
items.append(x509.IPAddress(ipaddress.ip_address(value)))
except ValueError:
items.append(x509.DNSName(value))
return x509.SubjectAlternativeName(items)
def _self_signed_root(key: EllipticCurvePrivateKey, moment: datetime) -> x509.Certificate:
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, ROOT_CN)])
return (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(moment)
.not_valid_after(moment + ROOT_LIFETIME)
# The SKI is what a verifier matches an issued certificate's AuthorityKeyIdentifier
# against (RFC 5280 §4.2.1.2), and OpenSSL refuses a chain built from a CA that has none
# when it verifies in strict mode — which is what Python's `create_default_context` turns
# on. A CA whose leaves are rejected by a default client is not a CA this fleet can use,
# so the identifier is part of the shape rather than an optimisation.
.add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()),
critical=False)
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=False,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=True,
crl_sign=True,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.sign(key, SHA256())
)
def _sign_leaf(*, key: EllipticCurvePrivateKey, cn: str, san: Sequence[str],
root_cert: x509.Certificate, root_key: EllipticCurvePrivateKey,
moment: datetime, client_auth: bool) -> x509.Certificate:
"""Sign `key` into a leaf: named `cn`, valid for `san`, client or server purpose.
The EKU matters more than it looks. An agent verifying a *client* certificate checks the
`clientAuth` purpose and the dashboard verifying a *server* checks `serverAuth`; a leaf with
the wrong one is a handshake failure no operator maps back to this function. Every leaf is
`CA=FALSE`, so nothing an agent is tricked into holding can sign a further certificate.
"""
builder = (
x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]))
.issuer_name(root_cert.subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(moment)
.not_valid_after(moment + LEAF_LIFETIME)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
# Both identifiers, on every leaf: AKI names the CA that signed this one (OpenSSL's
# strict mode rejects an issued certificate that omits it, and a Python client defaults
# to strict), SKI identifies this key for whatever chains to it. Non-critical both times,
# as RFC 5280 asks, so a verifier that ignores them still validates the signature.
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(root_key.public_key()),
critical=False,
)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
.add_extension(
x509.ExtendedKeyUsage(
[
ExtendedKeyUsageOID.CLIENT_AUTH
if client_auth
else ExtendedKeyUsageOID.SERVER_AUTH
]
),
critical=False,
)
)
if san:
builder = builder.add_extension(_san_extension(san), critical=False)
return builder.sign(root_key, SHA256())
# --- the bundle -------------------------------------------------------------------------- #
def _bundle_text(*, cert_pem: str, key_pem: str, root_pem: str, bundle_rev: int,
revoked: Sequence[int]) -> str:
"""The install artifact, as text.
Order: the metadata lines, the agent's own certificate, the agent's key, then the root CA
cert. That order *is* the format — :func:`_bundle_parts` reads it back positionally and task
02's agent parses the same way with stdlib only. The root **key** is never in a bundle: an
exporter needs to verify its clients, which is a public-key operation, so a stolen agent
install would otherwise be a stolen CA.
"""
lines = [f"{BUNDLE_REV_LINE}{bundle_rev}"]
lines.extend(f"{REVOKED_LINE}{serial}" for serial in sorted(set(revoked)))
return "\n".join(lines) + "\n" + cert_pem + key_pem + root_pem
def _bundle_parts(bundle: str) -> tuple[str, str, str]:
"""`(agent cert, agent key, root cert)` out of a stored bundle, or the fixed-text raise.
Two certificate blocks and exactly one key block is the shape; anything else means the row is
not a bundle this module wrote. Parsing by text instead of re-serialising is what lets a
revoked bundle be *re-sealed* (new denylist, same cert and key) without adding a seventh and
eighth meta row for material that is already in there.
"""
blocks = _BLOCK_RE.findall(bundle or "")
certs = [block for block in blocks if block.startswith(_CERT_HEADER)]
keys = [block for block in blocks if not block.startswith(_CERT_HEADER)]
if len(certs) != 2 or len(keys) != 1:
raise RuntimeError(CORRUPT_STORE_MESSAGE)
return certs[0], keys[0], certs[1]
def _revoked_serials(entries: Iterable[AgentCert]) -> tuple[int, ...]:
"""Every serial currently on the denylist — the serial of each entry marked revoked."""
return tuple(sorted({entry.serial for entry in entries if entry.status == REVOKED}))
def _seal(entries: Sequence[AgentCert], *, root_pem: str) -> tuple[AgentCert, ...]:
"""Give every entry the bundle it should hold right now, for this root and this denylist.
The revocation list is **fleet-wide**, which is why sealing every entry is part of revoking
one: the list lives inside the bundle, so an agent whose bundle omits a serial keeps accepting
that client certificate until somebody re-installs it. `bundle_rev` does not move when a
bundle is only re-sealed — the revision means "this host has a new identity", and a peer being
denied is not a new identity for anybody.
"""
revoked = _revoked_serials(entries)
sealed: list[AgentCert] = []
for entry in entries:
cert_pem, key_pem, _root = _bundle_parts(entry.bundle)
sealed.append(
replace(
entry,
bundle=_bundle_text(
cert_pem=cert_pem,
key_pem=key_pem,
root_pem=root_pem,
bundle_rev=entry.bundle_rev,
revoked=revoked,
),
)
)
return tuple(sealed)
# --- the stored agent table --------------------------------------------------------------- #
def _agent_from_json(name: str, raw: object) -> AgentCert:
"""One entry out of the JSON row. Anything that is not the shape is the fixed-text raise."""
if not isinstance(raw, dict):
raise RuntimeError(CORRUPT_STORE_MESSAGE)
raw_san = raw.get("san")
if not isinstance(raw_san, list):
raise RuntimeError(CORRUPT_STORE_MESSAGE)
try:
entry = AgentCert(
name=name,
serial=int(raw["serial"]),
not_after=float(raw["not_after"]),
status=str(raw["status"]),
bundle_rev=int(raw["bundle_rev"]),
bundle=str(raw["bundle"]),
san=tuple(str(item) for item in raw_san),
)
except (KeyError, TypeError, ValueError) as exc:
raise RuntimeError(CORRUPT_STORE_MESSAGE) from exc
if not entry.bundle:
# An entry with no bundle is a host nobody can install: the identity exists but the
# artifact does not, which is exactly as broken as a missing key and gets the same answer.
raise RuntimeError(CORRUPT_STORE_MESSAGE)
return entry
def _agents_from_text(raw: str) -> tuple[AgentCert, ...]:
"""The `certs.agents` row as entries, sorted by name (one order in memory and on disk)."""
if not raw.strip():
return ()
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError(CORRUPT_STORE_MESSAGE) from exc
if not isinstance(parsed, dict):
raise RuntimeError(CORRUPT_STORE_MESSAGE)
return tuple(_agent_from_json(name, value) for name, value in sorted(parsed.items()))
def _store_agents(store: MetaStore, state: CertsState) -> CertsState:
"""Persist the agent table and hand the state back — the only writer of `certs.agents`."""
payload = {entry.name: entry.to_json() for entry in sorted(state.agents, key=lambda i: i.name)}
store.set_meta(META_AGENTS, json.dumps(payload))
return state
# --- public lifecycle ---------------------------------------------------------------------- #
def ensure_certs(store: MetaStore, *, now: float | None = None) -> CertsState:
"""Read the certificate state, generating and persisting whatever is missing.
The rule that makes this safe to call on every start: **a stored row always wins.** A root CA
that changed between restarts would untrust every installed agent bundle and turn a redeploy
into a fleet-wide outage with no operator action available, so this function generates only
what is absent and never overwrites a value it read.
Half-populated rows generate the missing half, which is what a crash between two `set_meta`
calls leaves behind — and a *newly generated* root takes its client certificate with it, because
a leaf signed by the previous root is a credential no agent in the fleet can verify. An empty
agent table is still written as `{}`, so the first boot leaves exactly the five rows this
module owns.
What is *not* forgiven is a row that will not parse, a key that does not match its
certificate, or a client certificate the stored root did not sign: those raise
:class:`RuntimeError` with :data:`CORRUPT_STORE_MESSAGE` and nothing interpolated. A dashboard
that cannot present its own credential must not start the poll loops and imply it is watching
the fleet (the `ctx.auth` rule, `AGENTS.md` invariant 11); the fix is the `DELETE FROM meta`
the message names, after which every agent needs a new bundle.
`now` is injected for the same reason `auth.mint_session` takes it: the expiries in the state
become a number a test asserts on instead of a sleep.
"""
moment = _moment(time.time() if now is None else now)
root_key_text = _text(store, META_ROOT_KEY)
root_cert_text = _text(store, META_ROOT_CERT)
root_is_new = not (_present(root_key_text) and _present(root_cert_text))
if not root_is_new:
root_key = _load_key(root_key_text)
root_cert = _load_cert(root_cert_text)
if not _same_public_key(root_key, root_cert):
raise RuntimeError(CORRUPT_STORE_MESSAGE)
else:
root_key = _new_key()
root_cert = _self_signed_root(root_key, moment)
root_key_text, root_cert_text = _pem_key(root_key), _pem_cert(root_cert)
store.set_meta(META_ROOT_KEY, root_key_text)
store.set_meta(META_ROOT_CERT, root_cert_text)
client_key_text = _text(store, META_CLIENT_KEY)
client_cert_text = _text(store, META_CLIENT_CERT)
# A new root invalidates the client certificate that was signed under the old one, so the
# "stored wins" rule does not extend across a generation: keeping that leaf would hand the
# process a credential nothing in the fleet can verify.
if not root_is_new and _present(client_key_text) and _present(client_cert_text):
client_key = _load_key(client_key_text)
client_cert = _load_cert(client_cert_text)
if not _same_public_key(client_key, client_cert) or not _chain_ok(client_cert, root_cert):
raise RuntimeError(CORRUPT_STORE_MESSAGE)
else:
client_key = _new_key()
client_cert = _sign_leaf(
key=client_key,
cn=CLIENT_CN,
san=_san_names(CLIENT_CN, ()),
root_cert=root_cert,
root_key=root_key,
moment=moment,
client_auth=True,
)
client_key_text, client_cert_text = _pem_key(client_key), _pem_cert(client_cert)
store.set_meta(META_CLIENT_KEY, client_key_text)
store.set_meta(META_CLIENT_CERT, client_cert_text)
agents_text = _text(store, META_AGENTS)
if not _present(agents_text):
# The empty fleet is a row too. Writing it here means the first boot leaves exactly the
# five keys this module owns — an operator reading the table can tell "no agents issued
# yet" from "this module has never run", and task 04's API has a row to list.
store.set_meta(META_AGENTS, "{}")
agents_text = "{}"
return CertsState(
root_key=root_key_text,
root_cert=root_cert_text,
root_serial=root_cert.serial_number,
root_not_after=_epoch(root_cert.not_valid_after_utc),
client_key=client_key_text,
client_cert=client_cert_text,
client_serial=client_cert.serial_number,
client_not_after=_epoch(client_cert.not_valid_after_utc),
agents=_agents_from_text(agents_text),
)
def issue_agent_cert(store: MetaStore, state: CertsState, name: str,
san: Sequence[str] | None = (), *, now: float | None = None) -> CertsState:
"""Mint (or re-mint) one host's server certificate and persist its bundle.
A re-issue on a name that already has an entry bumps `bundle_rev` and replaces the serial: that
*is* rotation, and it is why there is no "unrevoke" — a revoked identity is replaced, never
forgiven. Nothing here issues on a read path: `GET /api/certs` lists what is stored, so
refreshing a page never mints a certificate nobody asked for.
Returns the new state; `state.agent(name)` on the return value is the entry just written.
"""
return _issue(store, state, name, san=san, status=ACTIVE, now=now)
def reissue_agent_cert(store: MetaStore, state: CertsState, name: str,
san: Sequence[str] | None = (), *, now: float | None = None) -> CertsState:
"""`issue_agent_cert`, named for `POST /api/certs/<name>/rotate`.
One implementation on purpose: rotating a certificate and issuing one for the first time are
the same operation with the same result (fresh key, fresh serial, `bundle_rev` +1, active), and
two code paths would eventually disagree about one of those. Rotating a host with no
certificate yet is legal and yields `bundle_rev` 1 — the operator asked for a working cert.
"""
return _issue(store, state, name, san=san, status=ACTIVE, now=now)
def revoke(store: MetaStore, state: CertsState, name: str, *,
now: float | None = None) -> CertsState:
"""Mark one host's identity revoked and re-seal every bundle so the fleet knows.
The serial stays listed until a reissue replaces it. Revoking a name with no entry yet mints
one and marks it revoked immediately: what a revocation *does* is make the dashboard refuse to
dial that host (task 03), so "never issued" must not be a state in which the operator's click
silently does nothing.
Every other bundle is rewritten because the denylist lives inside each of them — see
:func:`_seal`. No other host's `bundle_rev` moves: its identity did not change, only the list
of peers it must refuse.
"""
if state.agent(name) is None:
return _issue(store, state, name, san=None, status=REVOKED, now=now)
entries = [
replace(entry, status=REVOKED) if entry.name == name else entry
for entry in state.agents
]
return _store_agents(store, state.with_agents(_seal(entries, root_pem=state.root_cert)))
def rotate_root(store: MetaStore, state: CertsState, *, now: float | None = None) -> CertsState:
"""A new root CA, a new client certificate, and every agent re-issued under it.
One action re-arms the whole fleet, which is the point: a root whose key might have been
copied is worthless if replacing it means hand-signing N certificates somewhere else. Every
host's `bundle_rev` bumps and each operator installs the new bundle from `/certs`. The denylist
resets with the generation — every old serial is being replaced anyway, and a list of
certificates that no longer exist would only confuse the agent reading it.
The write order is root key, root cert, client key, client cert, then the agent table. It
cannot be atomic (the `MetaStore` protocol has one row per call, and inventing a transaction
here would put a SQLite detail into a module tested with a dict), so a crash halfway leaves a
new root beside an old client — which the next :func:`ensure_certs` *detects* through the chain
check and answers with the fixed-text refusal rather than a dashboard dialing every agent with
a certificate nothing trusts. That is the intended failure: stop, print the `DELETE FROM meta`
command, start again.
The running process keeps using the material it loaded at startup (its client certificate and
CA are already on disk from :func:`write_material`), which is why task 04's response answers
`restart_required: true` instead of pretending a hot-swap exists.
"""
moment = _moment(time.time() if now is None else now)
root_key = _new_key()
root_cert = _self_signed_root(root_key, moment)
root_key_text, root_cert_text = _pem_key(root_key), _pem_cert(root_cert)
store.set_meta(META_ROOT_KEY, root_key_text)
store.set_meta(META_ROOT_CERT, root_cert_text)
client_key = _new_key()
client_cert = _sign_leaf(
key=client_key,
cn=CLIENT_CN,
san=_san_names(CLIENT_CN, ()),
root_cert=root_cert,
root_key=root_key,
moment=moment,
client_auth=True,
)
client_key_text, client_cert_text = _pem_key(client_key), _pem_cert(client_cert)
store.set_meta(META_CLIENT_KEY, client_key_text)
store.set_meta(META_CLIENT_CERT, client_cert_text)
issued: list[AgentCert] = []
for entry in state.agents:
key = _new_key()
cert = _sign_leaf(
key=key,
cn=entry.name,
san=_san_names(entry.name, entry.san),
root_cert=root_cert,
root_key=root_key,
moment=moment,
client_auth=False,
)
issued.append(
replace(
entry,
serial=cert.serial_number,
not_after=_epoch(cert.not_valid_after_utc),
status=ACTIVE,
bundle_rev=entry.bundle_rev + 1,
bundle=_bundle_text(
cert_pem=_pem_cert(cert),
key_pem=_pem_key(key),
root_pem=root_cert_text,
bundle_rev=entry.bundle_rev + 1,
revoked=(),
),
)
)
fresh = CertsState(
root_key=root_key_text,
root_cert=root_cert_text,
root_serial=root_cert.serial_number,
root_not_after=_epoch(root_cert.not_valid_after_utc),
client_key=client_key_text,
client_cert=client_cert_text,
client_serial=client_cert.serial_number,
client_not_after=_epoch(client_cert.not_valid_after_utc),
)
return _store_agents(store, fresh.with_agents(_seal(issued, root_pem=root_cert_text)))
def read_agents(store: MetaStore) -> tuple[AgentCert, ...]:
"""The agent table exactly as stored — no generation, no writes, no PEM of ours.
Task 04's `GET /api/certs` and task 03's per-poll revocation check both go through here, which
is what makes a revocation clicked in the browser take effect on the next poll without a
restart.
"""
return _agents_from_text(_text(store, META_AGENTS))
def read_state(store: MetaStore) -> CertsState:
"""The stored certificate state with **nothing generated and nothing written**.
:func:`ensure_certs` is the bootstrapping read: it mints whatever a table is missing, which is
exactly right for a process start and wrong for `GET /api/certs` (task 04). A page refresh that
minted a root CA would be a side effect nobody asked for, and a root that appears the moment a
row is deleted is a root nobody can explain to the operator reading the page.
So this is the same read without the generation: the rows are the truth, they are validated the
same way (a PEM that will not parse, a key that does not match its certificate, and a client
leaf the stored root did not sign are all the one fixed-text refusal), and a missing row is
that refusal too rather than an invitation to create one. The rows cannot legitimately be
missing while a request is being served — :func:`app.build_certs` runs :func:`ensure_certs`
before the app can answer anything — so "the table has no CA in it" really is the corrupt state
the message describes.
Task 04's reads use this; its writes use :func:`ensure_certs`, because a write is allowed to
bootstrap the state it is about to change.
"""
root_key_text = _text(store, META_ROOT_KEY)
root_cert_text = _text(store, META_ROOT_CERT)
client_key_text = _text(store, META_CLIENT_KEY)
client_cert_text = _text(store, META_CLIENT_CERT)
if not all(_present(row) for row in
(root_key_text, root_cert_text, client_key_text, client_cert_text)):
raise RuntimeError(CORRUPT_STORE_MESSAGE)
root_key = _load_key(root_key_text)
root_cert = _load_cert(root_cert_text)
client_key = _load_key(client_key_text)
client_cert = _load_cert(client_cert_text)
if not _same_public_key(root_key, root_cert) or not _same_public_key(client_key, client_cert):
raise RuntimeError(CORRUPT_STORE_MESSAGE)
if not _chain_ok(client_cert, root_cert):
raise RuntimeError(CORRUPT_STORE_MESSAGE)
return CertsState(
root_key=root_key_text,
root_cert=root_cert_text,
root_serial=root_cert.serial_number,
root_not_after=_epoch(root_cert.not_valid_after_utc),
client_key=client_key_text,
client_cert=client_cert_text,
client_serial=client_cert.serial_number,
client_not_after=_epoch(client_cert.not_valid_after_utc),
agents=read_agents(store),
)
def agent_status(store: MetaStore, name: str) -> str:
"""`REVOKED` for a host the operator revoked, `ACTIVE` for every other host.
Absent means active rather than unknown: a host that was never issued has no certificate to
revoke, and answering `REVOKED` because a row is missing from a half-written table would take
a working host's source red with no operator action behind it (invariant 3's shape). The
caller that cares about issued-versus-not — the page — reads the entry itself.
"""
for entry in read_agents(store):
if entry.name == name:
return REVOKED if entry.status == REVOKED else ACTIVE
return ACTIVE
def _issue(store: MetaStore, state: CertsState, name: str, *, san: Sequence[str] | None,
status: str, now: float | None) -> CertsState:
"""The one implementation behind issue / reissue / revoke-of-an-unknown-host."""
moment = _moment(time.time() if now is None else now)
root_key = _load_key(state.root_key)
root_cert = _load_cert(state.root_cert)
key = _new_key()
names = _san_names(name, san)
cert = _sign_leaf(
key=key,
cn=name,
san=names,
root_cert=root_cert,
root_key=root_key,
moment=moment,
client_auth=False,
)
previous = state.agent(name)
# 1 on a first issue, +1 on every rotation: this counter is the only answer to "is the host
# running the bundle I just issued?", so it moves exactly when the identity does.
bundle_rev = 1 if previous is None else previous.bundle_rev + 1
entry = AgentCert(
name=name,
serial=cert.serial_number,
not_after=_epoch(cert.not_valid_after_utc),
status=status,
bundle_rev=bundle_rev,
bundle=_bundle_text(
cert_pem=_pem_cert(cert),
key_pem=_pem_key(key),
root_pem=state.root_cert,
bundle_rev=bundle_rev,
revoked=(),
),
san=names,
)
entries = [entry if item.name == name else item for item in state.agents]
if previous is None:
entries.append(entry)
return _store_agents(store, state.with_agents(_seal(entries, root_pem=state.root_cert)))
# --- on-disk material ---------------------------------------------------------------------- #
@dataclass(frozen=True)
class CertMaterial:
"""Where this process's own credential lives on disk, so `httpx` can be pointed at it.
`httpx` (and `ssl` generally) wants *paths*, not PEM text, so the state has to be written
somewhere to be usable. That somewhere is a private temp directory from `mkdtemp` (0700 by
construction, and chmodded again so the claim is this module's and not inherited from the
platform) with every file `0600`, removed by :func:`remove_material` on shutdown. **The root
key is never written at all**: it is used to sign inside this process and nowhere else, so the
one copy of it stays the `meta` row.
"""
dir: Path
ca: Path
client_cert: Path
client_key: Path
state: CertsState
def _write_private(path: Path, text: str) -> Path:
"""Create `path` with mode 0600 in one `open()`, so there is no window at the umask default."""
handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(handle, "w", encoding="ascii") as stream:
stream.write(text)
return path
def write_material(state: CertsState,
tmp_base: str | os.PathLike[str] | None = None) -> CertMaterial:
"""Write the CA and the client pair into a fresh private directory.
Called once per process (`app.build_certs`) and never per request: those paths go into every
dial, and a directory changing under a live connection pool is a poll that fails for a reason
no upstream error can explain. `tmp_base` exists for tests and for a deployment that wants the
material somewhere specific; it defaults to the environment's `TMPDIR`.
"""
directory = Path(tempfile.mkdtemp(prefix="dashboards-certs-", dir=tmp_base))
os.chmod(directory, 0o700)
return CertMaterial(
dir=directory,
ca=_write_private(directory / "root-ca.pem", state.root_cert),
client_cert=_write_private(directory / "client-cert.pem", state.client_cert),
client_key=_write_private(directory / "client-key.pem", state.client_key),
state=state,
)
def remove_material(material: CertMaterial | None) -> None:
"""Best-effort removal of a process's material directory.
Best effort because failing here must not turn a clean shutdown into a crash: the files are
`0600` inside a `0700` directory owned by this user, so a leftover is no exposure beyond what
the running process already had, and there is nothing an operator could act on from a
shutdown path.
"""
if material is None:
return
shutil.rmtree(material.dir, ignore_errors=True)
+188 -11
View File
@@ -7,12 +7,16 @@ red dot plus an event. Nothing here writes to any upstream — with one delibera
from __future__ import annotations
import ssl
import time
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from functools import partial
from typing import Any
import httpx
from . import certs
from .config import Target, strip_userinfo
from .poller import Job, Result
from .rates import RateEstimator
@@ -35,8 +39,117 @@ class UpstreamError(Exception):
# --------------------------------------------------------------------------- #
# System hosts
# --------------------------------------------------------------------------- #
#: The scheme a protected exporter is dialled by. An `http://` target keeps the bearer-token
#: path byte-for-byte as it was before phase 15, so one deployment can hold token-mode and
#: certificate-mode agents at the same time — which is what a host-by-host rollout needs.
HTTPS_SCHEME = "https://"
@dataclass(frozen=True)
class AgentTls:
"""How to dial one agent as a *protected web server* — and whether it may be dialled.
`verify` and `cert` are **paths**, not PEM text, because that is what `ssl` consumes: they
are the files :func:`dashboards.certs.write_material` wrote once at startup, which is also
the only place this process's client private key exists outside the `meta` row. The root
**key** is not in the picture at all — signing happens in :mod:`dashboards.certs`, and a
dial needs only the CA to verify the agent and the leaf to identify itself.
`status` is a callable rather than a string, and the reason is revocation. The operator
clicks revoke on `/certs`; a poll that had cached `"active"` at startup would go on dialling
a host the operator just struck out. Reading `certs.agents` fresh (one small `meta` row, at
a 10-second cadence) is what makes the next poll the one that notices — no restart, no
second process talking to the first.
`transport` exists for the tests: handing a `httpx.MockTransport` here scripts an agent's
403 the same way the plain-host tests hand one to the shared client. It is `None` in every
dial this app makes for real.
"""
verify: str
cert: tuple[str, str]
status: Callable[[], str]
transport: httpx.AsyncBaseTransport | None = None
@property
def revoked(self) -> bool:
"""Has the operator struck this host's identity out?
Only the one word refuses. A `status` callable that raises (a store that will not read)
propagates to the job and is reported as that store failure — answering `False` here
would turn a broken read into "not revoked, carry on dialling", which is the wrong way
for a security check to fail.
"""
return self.status() == certs.REVOKED
def ssl_context(self) -> ssl.SSLContext:
"""A client context that trusts **our** root and presents **our** leaf.
Built here rather than passed to `httpx` as `verify=<path>`/`cert=<pair>` because 0.28
deprecates both of those spellings and the supported form is a context the caller made —
which is also the honest shape for this app: the trust decision is ours, made against one
named CA file, not a library default that would also accept whatever the host trusts.
`create_default_context` leaves `check_hostname` on and `verify_mode` at `CERT_REQUIRED`,
so an agent whose certificate does not chain to this root *or* is not valid for the name
it is dialled by fails the handshake instead of degrading to a warning.
Per dial, so the client key lives in a context that goes away with the poll rather than
in a connection pool that outlives it. A missing or unreadable PEM raises `OSError`,
which is what the caller's failure branch turns into a red source.
"""
context = ssl.create_default_context(cafile=self.verify)
context.load_cert_chain(self.cert[0], self.cert[1])
return context
def client(self, *, shared: httpx.AsyncClient) -> httpx.AsyncClient:
"""A client for **one** https dial, carrying this dashboard's own credential.
Per dial rather than per process, because `httpx` resolves the TLS material when a client
is *constructed* — there is no per-request override to hand `get()` — and the app reads
plain agents, gufo and vLLM through one shared client whose own defaults must stay
untouched: setting `verify` there would make every engine read depend on a CA nobody
issued their certificate with.
The cost is one TLS handshake per poll, which is what the plain path already pays: the
shared pool expires an idle keepalive connection after 5 s and an agent is polled every
10 s. The gain is that no live client key sits in an open connection pool between polls.
"""
verify: ssl.SSLContext | bool = True
if self.transport is None:
# Only the real transport needs the material: a scripted one opens no socket, and a
# test of a refusal sentence has no reason to hold a key pair. That is the one thing
# the seam changes, and why the seam is a transport rather than a patched `get`.
verify = self.ssl_context()
return httpx.AsyncClient(verify=verify, timeout=shared.timeout,
follow_redirects=shared.follow_redirects,
transport=self.transport)
def refusal_detail(response: httpx.Response) -> str:
"""Why an agent refused us, in its own words — the JSON `detail` the handler sends.
The two sentences a TLS agent writes (`client certificate required`, `client certificate
revoked`) are exactly what the operator needs in a health record, and paraphrasing them here
would be the dashboard claiming to know more than the peer said. A body that is not the
agent's JSON — a proxy's HTML refusal, an empty reply — gives a bounded slice of it instead,
the way the non-JSON failure above quotes the body it could not parse.
"""
try:
body = response.json()
except ValueError:
body = None
if isinstance(body, dict):
detail = body.get("detail")
if isinstance(detail, str) and detail.strip():
return detail.strip()
quoted = " ".join(response.text.split())[:80]
return quoted or "no reason given"
async def fetch_snapshot(client: httpx.AsyncClient, target: Target,
*, timeout: float | None = None) -> dict[str, Any]:
*, timeout: float | None = None,
tls: AgentTls | None = None) -> dict[str, Any]:
"""One agent `/snapshot`, fetched and checked. Shared by every dashboard that reads one.
`system` and `disks` dial the same URL on different cadences, so the failure sentences
@@ -45,24 +158,49 @@ async def fetch_snapshot(client: httpx.AsyncClient, target: Target,
health record and written to the event table, both of which the browser reads, and
`target.url` may carry the agent's token as userinfo (invariant 6).
With `tls` given, an `https://` target is dialled as a protected web server: the agent is
verified against this dashboard's own root CA and this dashboard presents its own client
certificate, so "who may read this host" is decided by the certificate rather than by a
token in a URL. A host the operator revoked is refused **before any socket opens** —
invariant 3's shape, a source the app knows it must not talk to says so without a network
round trip — and the poller's last-good/stale semantics handle the rest (invariant 4).
`timeout` overrides the client default for this one request (`None` = keep the
client's). The SMART pass lives inside the agent's snapshot handler, so the slow
`disks` poll needs a budget a 4-second system poll cannot afford — and must not
inherit one. `None` is translated to httpx's own sentinel because passing a literal
`None` to `get()` means *no timeout at all*, which would hang the loop forever.
"""
if tls is not None and tls.revoked:
raise UpstreamError(f"{target.name}: agent certificate revoked")
request_timeout: Any = httpx.USE_CLIENT_DEFAULT if timeout is None else timeout
url = f"{target.url}/snapshot"
shown = f"{target.display_url}/snapshot"
# `None` for an `http://` target even when material exists: a token-mode host is dialled by
# the shared client exactly as it was before phase 15, and a 403 from it is not about a
# certificate this app never presented.
secure = tls if (tls is not None and target.url.startswith(HTTPS_SCHEME)) else None
try:
if secure is not None:
async with secure.client(shared=client) as scoped:
response = await scoped.get(url, headers=target.headers,
timeout=request_timeout)
else:
response = await client.get(url, headers=target.headers, timeout=request_timeout)
except httpx.TimeoutException as exc:
raise UpstreamError(f"{target.name}: {shown} timed out", status=504) from exc
except httpx.HTTPError as exc:
except (httpx.HTTPError, OSError) as exc:
# `OSError` is not belt-and-braces: `httpx` loads the CA and the client chain when the
# client is built, so a material file that has gone missing surfaces here as a
# `FileNotFoundError`/`ssl.SSLError`, and a poll loop must report that as an upstream
# failure rather than lose the task to it (invariant 4).
raise UpstreamError(f"{target.name}: {shown} unreachable ({type(exc).__name__})") from exc
if response.status_code == 401:
raise UpstreamError(f"{target.name}: agent rejected the token (401) — check the "
f"userinfo token in HOST_TARGETS")
if secure is not None and response.status_code == 403:
raise UpstreamError(f"{target.name}: agent refused the client certificate "
f"({refusal_detail(response)}) at {shown}")
if response.status_code >= 400:
raise UpstreamError(f"{target.name}: {shown} -> HTTP {response.status_code}")
try:
@@ -76,8 +214,9 @@ async def fetch_snapshot(client: httpx.AsyncClient, target: Target,
async def fetch_host_snapshot(client: httpx.AsyncClient, target: Target,
host_collector: HostCollector) -> Result:
body = await fetch_snapshot(client, target)
host_collector: HostCollector,
*, tls: AgentTls | None = None) -> Result:
body = await fetch_snapshot(client, target, tls=tls)
result = parse_host_snapshot(target.name, body, host_collector)
if body.get("errors"):
# The agent degraded a section (a /proc read failed) but still answered. Say so
@@ -99,15 +238,19 @@ async def fetch_host_snapshot(client: httpx.AsyncClient, target: Target,
# --------------------------------------------------------------------------- #
async def fetch_disk_snapshot(client: httpx.AsyncClient, target: Target,
disks_collector: disks_source.DisksCollector,
*, timeout: float | None = None) -> Result:
*, timeout: float | None = None,
tls: AgentTls | None = None) -> Result:
"""The same `/snapshot`, projected onto physical drives and their SMART health.
Deliberately a second read of an endpoint `system` already polls: the two dashboards
want different cadences (SMART is a 5-minute signal, utilisation is a 10-second one),
and sharing one fetch would either chart stale SMART or run `smartctl` every 10
seconds. Separate jobs also means a dead disk poll ages `/disks` alone (invariant 4).
`tls` is the same per-host material `system` dials with: one revoked host fails both of
its jobs with the same sentence, and neither of them opens a socket.
"""
body = await fetch_snapshot(client, target, timeout=timeout)
body = await fetch_snapshot(client, target, timeout=timeout, tls=tls)
return disks_source.parse_disks_snapshot(target.name, body, disks_collector)
@@ -272,27 +415,61 @@ class NutMonitor:
# --------------------------------------------------------------------------- #
# Job factories (what app.py wires into the Hub)
# --------------------------------------------------------------------------- #
def agent_tls_map(material: certs.CertMaterial | None, store: certs.MetaStore,
targets: Iterable[Target]) -> dict[str, AgentTls]:
"""One :class:`AgentTls` per configured host, from this process's own certificate material.
The CA and the client certificate are the same for every host — the dashboard has **one**
identity, which is what an agent verifies — so only the revocation check is per name, and it
is a live read of the store the jobs already write to (`certs.agent_status`, one small
`meta` row). An absent `certs.agents` entry counts as active: a host that has never been
issued has no certificate to revoke, and refusing it because a row is missing would take a
working source red with no operator action behind it.
No material means an empty map, which is what a context assembled without certificates (or
a test hub) gets — every host is then dialled on the pre-phase-15 path.
"""
if material is None:
return {}
verify = str(material.ca)
cert = (str(material.client_cert), str(material.client_key))
return {
target.name: AgentTls(verify=verify, cert=cert,
status=partial(certs.agent_status, store, target.name))
for target in targets
}
def host_jobs(targets: list[Target], client: httpx.AsyncClient, host_collector: HostCollector,
interval: float) -> list[Job]:
interval: float, *,
tls: Mapping[str, AgentTls] | None = None) -> list[Job]:
"""One `system` job per agent. `tls` is keyed by host name; a host missing from it is
dialled with no client certificate and no revocation check, exactly as before phase 15."""
tls_map = tls or {}
return [
Job(f"host:{target.name}", "system", target.name, interval,
lambda t=target: fetch_host_snapshot(client, t, host_collector))
lambda t=target: fetch_host_snapshot(client, t, host_collector,
tls=tls_map.get(t.name)))
for target in targets
]
def disks_jobs(targets: list[Target], client: httpx.AsyncClient,
disks_collector: disks_source.DisksCollector, interval: float,
*, timeout: float | None = None) -> list[Job]:
*, timeout: float | None = None,
tls: Mapping[str, AgentTls] | None = None) -> list[Job]:
"""One job per host agent — the same target list `system` polls, at its own cadence.
No targets, no jobs: `disks` has no upstream of its own, so "unconfigured" means the
same thing it means for `system` (invariant 3), and `/api/disks/*` 404s without ever
dialling (task 03).
dialling (task 03). `tls` is the same per-host map `system` gets — the two jobs dial one
agent, so a revoked host is refused by both or by neither.
"""
tls_map = tls or {}
return [
Job(f"disks:{target.name}", disks_source.DASHBOARD, target.name, interval,
lambda t=target: fetch_disk_snapshot(client, t, disks_collector, timeout=timeout))
lambda t=target: fetch_disk_snapshot(client, t, disks_collector, timeout=timeout,
tls=tls_map.get(t.name)))
for target in targets
]
+12
View File
@@ -48,6 +48,7 @@ from typing import Any
from ..poller import Result
from ..severity import classify
from ..store import Event, Sample
from .host import agent_tls_fields
#: The sample family and the dashboard name. They are the same word here on purpose:
#: `/api/events?source=disks` and `hub.states("disks")` must agree, which is the phase-03
@@ -333,5 +334,16 @@ def parse_disks_snapshot(name: str, snapshot: Mapping[str, Any], collector: Disk
"disks": rows,
"summary": disk_summary(rows),
}
# The same `agent` block `/api/system/snapshot` carries, from the same snapshot, so the page
# that compares an issued bundle revision with the one this agent is running can read either
# dashboard. Added only when the agent published one — a token-mode host keeps the payload it
# had before phase 15 rather than gaining an empty block or an invented `false`.
tls_report = agent_tls_fields(snapshot)
if tls_report:
# `agent` because these are the agent's own words about the server it is running, which is
# the same key path `/api/system/snapshot` uses for them (there the block also carries
# `version` and `duration_ms`; this payload never did, so it grows only the two fields
# phase 15 added rather than a half-populated copy of the other page's block).
payload["agent"] = tls_report
return Result(payload=payload, samples=samples,
events=health_events(collector, name, rows, now))
+27 -1
View File
@@ -16,6 +16,7 @@ Metric names in the history DB use `@stream` for per-device series:
from __future__ import annotations
import time
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
@@ -47,6 +48,31 @@ class HostCollector:
return self.targets.setdefault(name, HostTargetState())
def agent_tls_fields(snapshot: Mapping[str, Any]) -> dict[str, Any]:
"""The agent's own TLS self-report, or `{}` when it published none.
`host_agent.py` adds `tls: true` and `bundle_rev` **only** in TLS mode, so a token-mode (or
pre-1.1.0) agent keeps the payload byte-identical to before phase 15 — the `degraded` idiom,
a key that appears when the fact exists rather than a `false` the agent never asserted.
This is not a verdict and carries no `severity`: `bundle_rev` is an identity counter, and the
question task 05 asks with it ("is the installed bundle the one `/certs` just issued?") is
answered beside the number, not by colouring it — the fleet-header rule that a count is not a
graded number.
Shared with `sources/disks.py` because both dashboards read the same snapshot and task 05 has
to be able to read the deployed revision out of either payload.
"""
fields: dict[str, Any] = {}
tls = snapshot.get("tls")
if isinstance(tls, bool):
fields["tls"] = tls
revision = snapshot.get("bundle_rev")
if isinstance(revision, int) and not isinstance(revision, bool):
fields["bundle_rev"] = revision
return fields
def _rate(collector: HostCollector, host: str, key: str, value: float | None,
now: float) -> tuple[float | None, float | None]:
return collector.rates.rate(f"{host}:{key}", value, now)
@@ -382,7 +408,7 @@ def parse_host_snapshot(name: str, snapshot: dict[str, Any], collector: HostColl
"top_rss": processes.get("top_rss") or [],
},
"agent": {"duration_ms": snapshot.get("duration_ms"), "errors": snapshot.get("errors") or [],
"version": snapshot.get("agent_version")},
"version": snapshot.get("agent_version"), **agent_tls_fields(snapshot)},
}
return Result(payload=payload, samples=samples, events=events)
+101
View File
@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab · certificates</title>
<link rel="stylesheet" href="/static/dash.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='4' fill='%235b8cff'/%3E%3C/svg%3E">
</head>
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<!-- The five dashboards, and no `aria-current`: `/certs` is not one of them, and a tab list
that marks a tab this page is not would be a lie about the navigation. The masthead
carries the two admin links — `⚙ Account` and `⚷ Certs` — outside `.tabs`, so the tab
structure stays five links deep on every page. -->
<nav class="tabs" aria-label="Dashboards">
<a href="/">Overview</a>
<a href="/system">System</a>
<a href="/disks">Disks</a>
<a href="/llm">LLM</a>
<a href="/ups">UPS</a>
</nav>
<div class="masthead-right">
<span class="clock" id="clock">--:--:--</span>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button">☾ Dark</button>
</div>
</header>
<main>
<div class="page-head">
<div>
<h1>Certificates</h1>
<div class="sub">the authority this dashboard signs its agents with</div>
</div>
<div class="spacer"></div>
<button class="ghost" id="reload" type="button">Reload</button>
</div>
<div class="grid auto items-start">
<section class="card" aria-labelledby="root-title">
<header><h2 id="root-title">Root CA</h2></header>
<div class="body">
<dl class="kv">
<!-- The CN is a constant of `certs.py` and the one thing about the root that never
changes, so it is markup rather than a payload field; the serial and the expiry
below are read from the stored certificate. -->
<dt>Common name</dt><dd>dashboards-root</dd>
<dt>Serial</dt><dd id="root-serial">…</dd>
<dt>Expires</dt><dd id="root-expires">…</dd>
<dt>Time left</dt><dd id="root-left">…</dd>
</dl>
<div class="gap" style="margin-top:.9rem">
<button class="action caution" id="rotate-root" type="button">Rotate root CA</button>
</div>
<p class="settings-result" id="root-result" role="status" aria-live="polite"></p>
</div>
</section>
<section class="card" aria-labelledby="client-title">
<!-- The one certificate every agent checks the dashboard's dial against. Display-only on
purpose: it is minted with the root and has no route of its own. -->
<header><h2 id="client-title">Dashboard client</h2></header>
<div class="body">
<dl class="kv">
<dt>Common name</dt><dd>dashboards</dd>
<dt>Serial</dt><dd id="client-serial">…</dd>
<dt>Expires</dt><dd id="client-expires">…</dd>
<dt>Time left</dt><dd id="client-left">…</dd>
</dl>
</div>
</section>
</div>
<section class="card" style="margin-top:.85rem" aria-labelledby="agents-title">
<header><h2 id="agents-title">Agents</h2></header>
<div class="body tight">
<table class="data" id="agents-table">
<thead>
<tr>
<th>Host</th><th>Status</th><th>Serial</th><th>Expires</th>
<th>Bundle</th><th>Dialled addresses</th><th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="empty-state" id="agents-empty" hidden>
<h3>No hosts configured</h3>
<p>List the machines in <code>HOST_TARGETS</code>; each one gets its own certificate, and
<b>Rotate</b> is what signs the first bundle.</p>
</div>
<p class="settings-result" id="agents-result" role="status" aria-live="polite"></p>
</div>
</section>
</main>
<script src="/static/app.js" defer></script>
<script src="/static/page-certs.js" defer></script>
</body>
</html>
+1
View File
@@ -21,6 +21,7 @@
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
+1
View File
@@ -20,6 +20,7 @@
<div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
+1
View File
@@ -21,6 +21,7 @@
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
+1
View File
@@ -24,6 +24,7 @@
<!-- No `#nav-status`: a session page has no fleet reachability to report, and
`Dash.initChrome()` builds the dots only when the container exists. -->
<span class="clock" id="clock">--:--:--</span>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
</header>
+1
View File
@@ -21,6 +21,7 @@
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
+1
View File
@@ -21,6 +21,7 @@
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
+11 -1
View File
@@ -871,12 +871,22 @@ input[type="text"].confirm {
.settings-form button { justify-self: start; margin-top: .35rem; }
/* The outcome line: no space until it has something to say, and its colour is the shared word
* for what happened (`ok` landed, `warn` was refused before the wire, `fail` was refused by the
* credential), not a third palette. */
* credential), not a third palette. `/certs` borrows the rule for the outcome of a rotate or a
* revoke — same sentence shape, and a second near-copy is how one of them stops matching the
* theme. */
.settings-result { margin: .8rem 0 0; font-size: 12.5px; }
.settings-result.ok { color: var(--ok); }
.settings-result.warn { color: var(--warn); }
.settings-result.fail { color: var(--fail); }
/* ---- certificates -------------------------------------------------------
* `/certs` (phase 15 task 05) is built out of parts the sheet already owns — `.card` for the
* panels, `.kv` for the two authority rows, `table.data` for the agents, `.chip` for a status
* word, `button.ghost` / `button.action.caution` for the writes, `.stale` for a bundle that is
* behind, and the account page's `.settings-result` for the outcome line. One shape is missing,
* and it is the one cell a table cannot nowrap: an address list. */
table.data td.san { white-space: normal; }
/* ---- dialog ------------------------------------------------------------- */
dialog {
border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--bg-raised);
+288
View File
@@ -0,0 +1,288 @@
/* page-certs.js — the certificate authority's own page: read the table, rotate, revoke,
* download a bundle.
*
* Every value on it comes from `GET /api/certs` and every action is one of the routes task 04
* serves, so the page holds no opinion about certificates of its own — it has buttons. There is
* no colour on it that the API did not send, either: the only verdict here is the payload's own
* `status` word put through a string-to-class map, and an expiry is a date the page prints
* (`severity.py` rates no certificate), so no row can be coloured green or red by a threshold
* nobody measured.
*/
'use strict';
(function () {
/**
* The payload's `status` word to the chip's class.
*
* `unissued` maps to no class at all, which is the neutral chip: a certificate that does not
* exist must not borrow the `ok` green (`Dash.severityFrom`'s rule, and the reason this is a
* word map rather than a comparison).
*/
const STATUS_CHIP = { active: 'ok', revoked: 'fail', unissued: '' };
/**
* One entry per column of `#agents-table`, in the order `<thead>` prints them, naming every
* `agents[]` key it reads.
*
* This table is the page's whole idea of a row, which is what makes it worth extracting:
* `tests/test_api.py` reads the served `fields` lists and resolves them against a live
* `GET /api/certs` row in both directions, so a rename on either side fails a test instead of
* printing a dash beside a real certificate (the `SLOT_FIELDS` device on `/llm`).
*/
const COLUMNS = [
{ fields: ['name'], build: (entry) => textCell('name', entry.name) },
{ fields: ['status'], build: statusCell },
{ fields: ['serial'], build: (entry) => textCell('num', decimal(entry.serial)) },
{ fields: ['not_after'], build: expiryCell },
{ fields: ['bundle_rev', 'deployed_bundle_rev'], build: bundleCell },
{ fields: ['san'], build: (entry) => textCell('num san', (entry.san || []).join(' ') || null) },
// The actions column is the only one that reads more than its own fact: `configured` is the
// API's gate on all three controls, and there is nothing to download or revoke without a
// serial.
{ fields: ['configured', 'status', 'serial'], build: actionCell },
];
const els = {
rootSerial: document.getElementById('root-serial'),
rootExpires: document.getElementById('root-expires'),
rootLeft: document.getElementById('root-left'),
rootResult: document.getElementById('root-result'),
rotateRoot: document.getElementById('rotate-root'),
clientSerial: document.getElementById('client-serial'),
clientExpires: document.getElementById('client-expires'),
clientLeft: document.getElementById('client-left'),
reload: document.getElementById('reload'),
table: document.getElementById('agents-table'),
body: document.querySelector('#agents-table tbody'),
empty: document.getElementById('agents-empty'),
result: document.getElementById('agents-result'),
};
/**
* The question asked before each write: what the press does to the fleet, in one sentence.
*
* `confirm()` and not the typed `DANGER` word: that gate is for commands that cut power, and
* the worst thing a certificate action does is stop one host being dialled until its new
* bundle is installed. Fixed sentences from the page — the API's answer arrives afterwards.
*/
const CONFIRM = {
rotate: (name) => 'Issue a new certificate for ' + name + '? The dashboard dials with it '
+ 'from the next poll, so that host needs its new bundle installed before it answers '
+ 'again.',
revoke: (name) => 'Revoke ' + name + '? It stops the dashboard trusting that host until a '
+ 'reissue and a re-delivered bundle, and every other bundle is re-sealed to list this '
+ 'one.',
root: 'Rotate the root CA? Every agent certificate and this dashboard\'s own client '
+ 'certificate are replaced. The dashboard has to restart, and every host needs its '
+ 'bundle re-installed.',
};
function missing(value) {
return value === null || value === undefined || value === '';
}
function localTime(epoch) {
if (typeof epoch !== 'number' || !isFinite(epoch)) return null;
return new Date(epoch * 1000).toLocaleString(undefined,
{ dateStyle: 'medium', timeStyle: 'short' });
}
/** `in 364d`, or the truth about a certificate whose clock has already run out. */
function timeLeft(epoch) {
if (typeof epoch !== 'number' || !isFinite(epoch)) return null;
const seconds = epoch - Date.now() / 1000;
return seconds <= 0 ? 'expired' : 'in ' + Dash.duration(seconds);
}
/**
* A serial as text. A certificate serial is up to 64 bits and JS numbers are 53, so the API
* sends the decimal string and the page never lets it near an arithmetic operator.
*/
function decimal(serial) {
return missing(serial) ? null : String(serial);
}
function textCell(className, value) {
const td = document.createElement('td');
if (className) td.className = className;
Dash.setTxt(td, value);
return td;
}
function statusCell(entry) {
const td = document.createElement('td');
const word = missing(entry.status) ? 'unknown' : String(entry.status);
td.appendChild(Dash.chip(word, STATUS_CHIP[word] || ''));
return td;
}
/** The expiry twice: the moment it happens, and how long is left from now. */
function expiryCell(entry) {
const td = document.createElement('td');
td.textContent = localTime(entry.not_after) || Dash.naText;
const left = timeLeft(entry.not_after);
if (left) {
const faint = document.createElement('span');
faint.className = 'faint';
faint.textContent = ' · ' + left;
td.appendChild(faint);
}
return td;
}
/**
* The bundle pair: what the CA issued, and what that agent says it is running.
*
* A `null` `deployed_bundle_rev` means the agent has never published one — a token-mode host
* sends no `agent.tls` block at all — which is `n/a` and never revision 0. A pair that differs
* gets a neutral chip with no verdict class: a revision count is not a graded number, exactly
* the `/llm` fleet-header rule, so "install the new bundle" is a fact in grey and not an alarm.
*/
function bundleCell(entry) {
const issued = entry.bundle_rev;
const deployed = entry.deployed_bundle_rev;
const td = document.createElement('td');
td.className = 'num';
if (missing(issued)) {
Dash.setTxt(td, null);
return td;
}
td.textContent = '#' + issued + ' → ' + (missing(deployed) ? Dash.naText : '#' + deployed);
if (!missing(deployed) && deployed < issued) {
td.appendChild(document.createTextNode(' '));
td.appendChild(Dash.chip('stale', ''));
}
return td;
}
/**
* The three controls, minus any the API would refuse.
*
* A row whose host has left `HOST_TARGETS` is listed and not writable — every per-host route
* answers `404 {"configured": false}` before it signs or serves anything — so it gets the
* reason instead of three buttons whose only outcome is an error. A never-issued host has no
* bundle to download and nothing to strike out, so it gets **Rotate**, which is the action that
* mints revision 1.
*/
function actionCell(entry) {
const td = document.createElement('td');
if (!entry.configured) {
const note = document.createElement('span');
note.className = 'faint';
note.textContent = 'not in HOST_TARGETS';
td.appendChild(note);
return td;
}
const controls = document.createElement('div');
controls.className = 'gap';
td.appendChild(controls);
controls.appendChild(button('Rotate', () => write(
'/api/certs/' + encodeURIComponent(entry.name) + '/rotate',
CONFIRM.rotate(entry.name), entry.name)));
if (entry.status !== 'unissued') {
controls.appendChild(button('Revoke', () => write(
'/api/certs/' + encodeURIComponent(entry.name) + '/revoke',
CONFIRM.revoke(entry.name), entry.name)));
}
if (!missing(entry.serial)) {
const bundle = document.createElement('a');
bundle.className = 'ghost';
bundle.href = '/api/certs/' + encodeURIComponent(entry.name) + '/bundle';
bundle.textContent = 'bundle';
bundle.title = 'the stored bytes this agent must hold, private key included';
controls.appendChild(bundle);
}
return td;
}
function button(label, onClick) {
const el = document.createElement('button');
el.className = 'ghost';
el.type = 'button';
el.textContent = label;
el.addEventListener('click', onClick);
return el;
}
function row(entry) {
const tr = document.createElement('tr');
for (const column of COLUMNS) tr.appendChild(column.build(entry));
return tr;
}
/** Outcome of a write: the server's own `hint`, then a re-read so the table cannot lie.
*
* Both write routes answer with the sentence about what happens next (`install the new bundle
* on deskwork — /api/certs/deskwork/bundle`), which is the only reason the line exists: a
* generic "done" would be the page narrating its own buttons. Every word goes out through
* `textContent`.
*/
async function write(url, question, subject) {
if (!window.confirm(question)) return;
say(els.result, subject + '…', 'warn');
try {
const body = await Dash.fetchJSON(url, { method: 'POST' });
say(els.result, body.hint || subject + ': ok', 'ok');
await read();
} catch (err) {
say(els.result, err.message || 'the write was refused', 'fail');
}
}
async function rotateRoot() {
if (!window.confirm(CONFIRM.root)) return;
say(els.rootResult, 'rotating the root CA…', 'warn');
try {
const body = await Dash.fetchJSON('/api/certs/root/rotate', { method: 'POST' });
// `restart_required` is why the answer is amber and not green: the table holds the new CA
// while this process goes on dialling with the material it loaded at startup. The sentence
// printed is the route's own `hint`, so the page cannot drift from it.
say(els.rootResult, body.hint || 'root certificate rotated',
body.restart_required ? 'warn' : 'ok');
await read();
} catch (err) {
say(els.rootResult, err.message || 'the write was refused', 'fail');
}
}
function say(el, text, kind) {
if (!el) return;
el.textContent = text || '';
el.classList.remove('ok', 'warn', 'fail');
if (kind) el.classList.add(kind);
}
/** The two certificates that are not per-host, printed from the stored rows. */
function paintAuthority(block, serialEl, expiresEl, leftEl) {
Dash.setTxt(serialEl, decimal(block.serial));
Dash.setTxt(expiresEl, localTime(block.not_after));
Dash.setTxt(leftEl, timeLeft(block.not_after));
}
async function read() {
let body;
try {
body = await Dash.fetchJSON('/api/certs');
} catch (err) {
if (err.status === 401) return; // `fetchJSON` has already navigated
paintAuthority({}, els.rootSerial, els.rootExpires, els.rootLeft);
paintAuthority({}, els.clientSerial, els.clientExpires, els.clientLeft);
say(els.result, err.message || 'the authority could not be read', 'fail');
return;
}
paintAuthority(body.root, els.rootSerial, els.rootExpires, els.rootLeft);
paintAuthority(body.client, els.clientSerial, els.clientExpires, els.clientLeft);
const agents = Array.isArray(body.agents) ? body.agents : [];
els.body.replaceChildren(...agents.map(row));
els.table.hidden = agents.length === 0;
els.empty.hidden = agents.length !== 0;
}
if (els.reload) els.reload.addEventListener('click', read);
if (els.rotateRoot) els.rotateRoot.addEventListener('click', rotateRoot);
read();
}());
+12
View File
@@ -156,6 +156,18 @@ SMART_MISSING_BINARY = ("smart: /usr/sbin/smartctl not found — install smartmo
"give this user device read access (disk group) for SMART data")
@pytest.fixture(scope="session")
def host_agent_module() -> Any:
"""The stdlib agent as a module, for the tests that run it outside `tests/test_agent.py`.
Loaded by path because it is one file that ships to every host and must not become a package
(see `_load_host_agent`). The phase-15 collector tests need one module object for both the
bundle they load and the handler they serve, so the TLS dial runs the artifact the fleet
really runs rather than a look-alike.
"""
return _load_host_agent()
@pytest.fixture
def smart_shape() -> Any:
"""`smart_shape('nvme')` / `smart_shape('ata_hdd')` -> the capture, parsed by the agent.
+642 -7
View File
@@ -1,26 +1,49 @@
"""The host agent: stdlib-only, read-only by construction, and wrong on no box.
Three things are asserted here:
Four things are asserted here:
* parsing of /proc and /sys files exactly as Linux writes them (fake roots, so the
weird cases are testable on any machine);
* the collector produces a complete, non-crashing snapshot against the *real* /proc of
whatever runs the tests, because that is the code that ships;
* the HTTP surface answers GETs, needs a token when it is exposed, and has no write path.
* the HTTP surface answers GETs, needs a token when it is exposed, and has no write path;
* the TLS mode (phase 15) answers a real handshake only to a client certificate the bundle's
root signed and the bundle's denylist does not name, with certificates minted by the
dashboard's own `certs.py` — there is no second generator to disagree with.
"""
from __future__ import annotations
import http.client
import importlib.util
import json
import re
import socket
import ssl
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from datetime import UTC, datetime, timedelta
from http.server import ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
import pytest
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
load_pem_private_key,
)
from cryptography.x509.oid import NameOID
from dashboards import certs
ROOT = Path(__file__).resolve().parents[1]
AGENT_PATH = ROOT / "agent" / "host_agent.py"
@@ -414,6 +437,141 @@ def test_the_pass_budget_skips_the_rest_with_one_error(tmp_path, monkeypatch) ->
assert any("budget" in e and "3 disk(s) skipped" in e for e in snap["errors"])
class _TimeProxy:
"""The agent's `time` module with one instrumented function: `monotonic`.
Everything else (`time()`, `sleep()`) stays the stdlib one, so patching the clock cannot
leak into the rest of the snapshot or into pytest.
"""
def __init__(self, clock: FakeSmartPassClock) -> None:
self._clock = clock
def monotonic(self) -> float:
return self._clock.now
def __getattr__(self, name: str) -> Any:
"""Everything the agent's `time` module offers besides `monotonic`, unchanged."""
return getattr(time, name)
class FakeSmartPassClock:
"""A clock plus a `smartctl` spawn the test drives, so a **deadline** is assertable.
The budget arithmetic in `_smart_pass` is invisible to a fake binary: `subprocess.run`'s
`timeout` never reaches the child, and burning 80 real seconds to watch a 16-disk pass
truncate is not a measurement anybody keeps. So this drives the two things the pass
actually reads — `time.monotonic()` and `subprocess.run()` — and records the timeout each
device was given, which is `min(deadline - now, 5 s)`: the pass's own view of how much
budget was left when it asked. Both patches go on `host_agent`'s own module globals
through proxies, never onto the stdlib modules themselves.
"""
def __init__(self, *, seconds_per_device: float, start: float = 10_000.0) -> None:
self.seconds_per_device = seconds_per_device
self.now = start
self.calls: list[tuple[str, float]] = []
self.output = read_fixture(NVME_CAPTURE)
def run(self, argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
device = str(argv[-1]).rsplit("/", 1)[-1]
timeout = float(kwargs["timeout"])
self.calls.append((device, timeout))
self.now += self.seconds_per_device
return subprocess.CompletedProcess(argv, 0, self.output, "")
def install(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(host_agent, "time", _TimeProxy(self))
monkeypatch.setattr(host_agent, "subprocess", SimpleNamespace(
run=self.run,
TimeoutExpired=subprocess.TimeoutExpired,
CompletedProcess=subprocess.CompletedProcess))
# driveripper's real inventory: 8 NVMe + 8 spinning, which is the shape the budget scales for
SHELF_DISKS = [f"nvme{i}n1" for i in range(8)] + [f"sd{c}" for c in "abcdefgh"]
def write_disk_shelf(root: Path, names: list[str]) -> None:
for name in names:
write_fake_disk(root, name, model=f"SYSFS {name}")
def test_the_pass_budget_scales_with_the_disk_count(tmp_path, monkeypatch) -> None:
"""A 16-disk pass gets 5 s x 16, not the 20 s floor — because a truncated pass nulls the
tail drives on every single poll (the bug that put this on the operator's screen on
2026-10-19). The clock here burns 1.5 s per device, so the pass costs 24 s of budget:
over the floor, far under the scale. With the flat budget this test truncates.
"""
root = tmp_path / "root"
write_disk_shelf(root, SHELF_DISKS)
clock = FakeSmartPassClock(seconds_per_device=1.5)
clock.install(monkeypatch)
collector = host_agent.Collector(str(root))
snap = collector.snapshot()
asked = [name for name, _ in clock.calls]
assert asked == snap["disk_io"]["device_names"], "every disk got its smartctl run"
assert len(asked) == 16
assert clock.seconds_per_device * len(asked) > host_agent.SMARTCTL_BUDGET_SECONDS, (
"this pass costs more than the 20 s floor — that is what makes it a scaling test")
assert [e for e in snap["errors"] if "budget" in e] == [], snap["errors"]
assert all(device["smart"] is not None for device in snap["disk_io"]["devices"])
# the *late* devices still hold the whole per-device bound, which is the point of scaling
assert clock.calls[-1][1] == host_agent.SMARTCTL_TIMEOUT_SECONDS
assert clock.calls[0][1] == host_agent.SMARTCTL_TIMEOUT_SECONDS
def test_the_pass_budget_floor_is_twenty_seconds(tmp_path, monkeypatch) -> None:
"""Scaling is `max(20 s, 5 s x disks)`, so one or two disks still get the 20 s floor and
not a 5 s or 10 s window. The second device's timeout is the visible half: with a 20 s
floor it is `min(20 - 18, 5)` = 2 s, where a budget of 5 s x 2 would have skipped it and a
30 s floor would still have handed out the full 5 s.
"""
root = tmp_path / "root"
write_disk_shelf(root, ["nvme0n1", "sda"])
clock = FakeSmartPassClock(seconds_per_device=18.0)
clock.install(monkeypatch)
collector = host_agent.Collector(str(root))
snap = collector.snapshot()
assert [name for name, _ in clock.calls] == ["nvme0n1", "sda"]
assert clock.calls[0][1] == host_agent.SMARTCTL_TIMEOUT_SECONDS
assert clock.calls[1][1] == pytest.approx(
host_agent.SMARTCTL_BUDGET_SECONDS - clock.seconds_per_device)
assert [e for e in snap["errors"] if "budget" in e] == []
assert all(device["smart"] is not None for device in snap["disk_io"]["devices"])
def test_the_pass_budget_error_line_names_the_scaled_figure(tmp_path, monkeypatch) -> None:
"""When a scaled pass does run out, the operator's one line says *which* budget ran out:
80 s on a 16-disk shelf, never the 20 s floor. A test that asserted only "a budget error
appears" would pass on the pre-scaling code; this one cannot.
"""
root = tmp_path / "root"
write_disk_shelf(root, SHELF_DISKS)
clock = FakeSmartPassClock(seconds_per_device=6.0)
clock.install(monkeypatch)
collector = host_agent.Collector(str(root))
snap = collector.snapshot()
scaled = max(host_agent.SMARTCTL_BUDGET_SECONDS,
host_agent.SMARTCTL_TIMEOUT_SECONDS * len(SHELF_DISKS))
assert scaled == 80.0
# 6 s per device: devices 1-14 fit inside 80 s (78 s spent), the 15th finds the deadline
# gone, and its sibling is skipped with it.
assert len(clock.calls) == 14, [name for name, _ in clock.calls]
assert clock.calls[-1][1] == pytest.approx(scaled - 6.0 * 13)
budget_lines = [e for e in snap["errors"] if "budget" in e]
assert len(budget_lines) == 1, snap["errors"]
assert budget_lines[0] == (f"smart: pass budget of {scaled:.0f}s exhausted, "
f"2 disk(s) skipped")
assert f"{host_agent.SMARTCTL_BUDGET_SECONDS:.0f}s" not in budget_lines[0]
by_name = {device["name"]: device for device in snap["disk_io"]["devices"]}
assert [by_name[name]["smart"] is None for name in SHELF_DISKS[:14]] == [False] * 14
assert [by_name[name]["smart"] is None for name in SHELF_DISKS[14:]] == [True, True]
def test_unexecutable_smartctl_degrades_like_a_missing_one(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
@@ -606,6 +764,9 @@ def agent_server():
def start(token: str | None = None) -> tuple[str, int]:
host_agent.Handler.collector = host_agent.Collector("/")
host_agent.Handler.token = token
# Cleared on purpose: `Handler` is process-wide state, so a token-mode server started
# after a TLS test in this file must not inherit that test's bundle.
host_agent.Handler.bundle = None
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
servers.append(httpd)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
@@ -687,8 +848,10 @@ def test_agent_has_no_write_path(agent_server) -> None:
assert excinfo.value.code in (405, 501)
def test_refuses_to_bind_off_loopback_without_a_token() -> None:
def test_refuses_to_bind_off_loopback_without_a_token(monkeypatch) -> None:
"""0.0.0.0 with no token would hand host inventory to the whole LAN."""
monkeypatch.delenv("AGENT_TOKEN", raising=False)
monkeypatch.delenv("AGENT_CERT_BUNDLE", raising=False)
assert host_agent.main(["--host", "0.0.0.0", "--port", "0"]) == 2
@@ -712,9 +875,10 @@ def test_agent_is_dependency_free() -> None:
top = {name.split(".")[0] for name in imports}
third_party = top - set(sys.stdlib_module_names) - {"host_agent"}
assert not third_party, f"agent grew a non-stdlib import: {third_party}"
assert top <= {"__future__", "argparse", "collections", "dataclasses", "http", "json",
"os", "pathlib", "re", "socket", "subprocess", "threading", "time",
"typing"}, f"agent import footprint grew: {sorted(top)}"
assert top <= {"__future__", "argparse", "collections", "contextlib", "dataclasses", "http",
"json", "os", "pathlib", "re", "shutil", "socket", "ssl", "subprocess",
"tempfile", "threading", "time", "typing"}, (
f"agent import footprint grew: {sorted(top)}")
# ---- how the token is supplied ------------------------------------------- #
@@ -746,6 +910,477 @@ def test_no_token_at_all_is_allowed_on_loopback(tmp_path) -> None:
assert (token, error) == (None, None)
def test_main_refuses_to_bind_off_loopback_without_a_token(tmp_path) -> None:
def test_main_refuses_to_bind_off_loopback_without_a_token(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("AGENT_TOKEN", raising=False)
monkeypatch.delenv("AGENT_CERT_BUNDLE", raising=False)
code = host_agent.main(["--host", "0.0.0.0", "--token-file", str(tmp_path / "missing")])
assert code == 2, "the agent must not start exposed and unauthenticated"
# ---- TLS mode: one installed bundle, and its client certificate is the auth --- #
#
# Everything below runs a real `ssl` handshake against the real handler, because the claims
# here are handshake claims: "optional at the handshake, enforced by the handler" is exactly
# the kind of thing that reads fine and then makes `/health` unprobeable. The certificates come
# from `dashboards.certs` over a dict store, so the agent is tested against the artifact the
# dashboard actually signs rather than against a second generator that could drift from it.
#: The agent module is loaded by `importlib` (it is one file with no importable package), so
#: pyright sees `host_agent` as an untyped module and rejects `host_agent.TlsBundle` in a type
#: position. This alias is the honest spelling: the object is that class, and the tests below
#: assert `isinstance` where the claim actually matters.
Bundle = Any
class DictMeta:
"""The three-method `certs.MetaStore` slice, backed by a dict (the `test_certs.py` idiom)."""
def __init__(self) -> None:
self.rows: dict[str, str] = {}
def get_meta(self, key: str) -> str | None:
return self.rows.get(key)
def set_meta(self, key: str, value: str) -> None:
self.rows[key] = value
def delete_meta(self, key: str) -> int:
return 1 if self.rows.pop(key, None) is not None else 0
def sign_client_leaf(state: certs.CertsState, cn: str, where: Path) -> SimpleNamespace:
"""A second client identity under the dashboard's own root, so it verifies and can be revoked.
`certs.py` mints exactly one client certificate on purpose (the dashboard has one identity),
so the revoked-peer and wrong-authority cases need a leaf signed by hand. The serial comes
back because the denylist names a serial, not a name.
"""
root_key = cast(EllipticCurvePrivateKey,
load_pem_private_key(state.root_key.encode("utf-8"), password=None))
root_cert = x509.load_pem_x509_certificate(state.root_cert.encode("utf-8"))
key = ec.generate_private_key(ec.SECP256R1())
moment = datetime.now(UTC)
cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]))
.issuer_name(root_cert.subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(moment - timedelta(days=1))
.not_valid_after(moment + timedelta(days=365))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(root_key, SHA256())
)
cert_path = where / f"{cn}-cert.pem"
key_path = where / f"{cn}-key.pem"
cert_path.write_bytes(cert.public_bytes(Encoding.PEM))
key_path.write_bytes(key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()))
return SimpleNamespace(cert=cert_path, key=key_path, serial=cert.serial_number)
@pytest.fixture(scope="module")
def tls_fleet(tmp_path_factory: pytest.TempPathFactory) -> SimpleNamespace:
"""A dashboard CA, its client pair, one issued agent bundle, and one foreign authority."""
base = tmp_path_factory.mktemp("agent-tls")
store = DictMeta()
state = certs.ensure_certs(store)
state = certs.issue_agent_cert(store, state, "localhost", ["127.0.0.1", "localhost"])
entry = state.agent("localhost")
assert entry is not None
for name in ("dashboard", "foreign"):
(base / name).mkdir()
dashboard = certs.write_material(state, base / "dashboard")
foreign = certs.write_material(certs.ensure_certs(DictMeta()), base / "foreign")
return SimpleNamespace(
root_cert=state.root_cert,
bundle=entry.bundle,
bundle_rev=entry.bundle_rev,
client_cert=str(dashboard.client_cert),
client_key=str(dashboard.client_key),
foreign_cert=str(foreign.client_cert),
foreign_key=str(foreign.client_key),
stolen=sign_client_leaf(state, "stolen", base),
)
def write_bundle(tmp_path: Path, text: str) -> Path:
"""An installed bundle: one PEM file, exactly as `install-agent.sh` will place it."""
path = tmp_path / "dashboards-agent.certs"
path.write_text(text, encoding="ascii")
return path
def load_bundle(tmp_path: Path, text: str) -> Bundle:
path = write_bundle(tmp_path, text)
bundle, error = host_agent.TlsBundle.load(str(path))
assert error is None and bundle is not None
return bundle
def client_context(ca: str, cert: str | None = None, key: str | None = None) -> ssl.SSLContext:
"""A client that trusts `ca`, and presents a client certificate only when given one."""
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.load_verify_locations(cadata=ca)
if cert and key:
context.load_cert_chain(cert, key)
return context
@pytest.fixture
def tls_agent_server():
"""The real handler on a TLS-wrapped loopback port; yields the base URL."""
servers: list[ThreadingHTTPServer] = []
def start(bundle: Bundle, token: str | None = None) -> str:
host_agent.Handler.collector = host_agent.Collector("/", smart=False)
host_agent.Handler.token = token
host_agent.Handler.bundle = bundle
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True)
servers.append(httpd)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return f"https://127.0.0.1:{httpd.server_address[1]}"
yield start
for httpd in servers:
httpd.shutdown()
httpd.server_close()
host_agent.Handler.bundle = None
def _json_or_raw(body: bytes) -> dict:
"""JSON when the handler answered with JSON; the raw text for the status page."""
try:
return json.loads(body)
except json.JSONDecodeError:
return {"raw": body.decode(errors="replace")}
def _https_get(url: str, context: ssl.SSLContext) -> tuple[int, dict]:
request = urllib.request.Request(url)
try:
with urllib.request.urlopen(request, timeout=15, context=context) as response:
return response.status, _json_or_raw(response.read())
except urllib.error.HTTPError as error:
return error.code, _json_or_raw(error.read())
def test_the_agent_and_the_dashboard_agree_on_the_bundle_lines() -> None:
"""The agent parses a PEM by text with stdlib only, so the two literals are the contract."""
assert host_agent.BUNDLE_REV_LINE == certs.BUNDLE_REV_LINE
assert host_agent.REVOKED_LINE == certs.REVOKED_LINE
def test_the_bundle_lines_are_read_as_written() -> None:
text = "# BUNDLE-REV 12\n# REVOKED 5\n# REVOKED 77\n"
assert host_agent.bundle_revision(text) == 12
assert host_agent.bundle_revoked(text) == frozenset({"5", "77"})
assert host_agent.bundle_revision("nothing at all\n") is None
assert host_agent.bundle_revision("# BUNDLE-REV not-a-number\n") is None
assert host_agent.bundle_revoked("# REVOKED\n") == frozenset()
@pytest.mark.parametrize("revoked, peer_hex, expected", [
# the shape a real `x509.random_serial_number()` serial arrives in, both spellings
("57989923439447977599989997426575336710275559875",
"A285BAC84EB9C5FC282EF7CDB8D962F38BF71C3", True),
("12648430", "C0FFEE", True),
("14", "0E", True),
("0E", "0E", True),
("15", "0E", False),
("0", "0E", False),
])
def test_the_denylist_matches_a_serial_whichever_way_it_is_written(revoked: str,
peer_hex: str,
expected: bool) -> None:
"""`getpeercert()` says hex, `certs.py` writes decimal, and the agent must meet in the middle.
An agent comparing one spelling to the other accepts a revoked client forever: the operator
clicked revoke, `/certs` says revoked, and the fleet keeps trusting the stolen identity.
"""
cert = {"serialNumber": peer_hex} if peer_hex else {}
assert host_agent.serial_is_revoked(cert, frozenset({revoked})) is expected
def test_a_certificate_with_no_serial_is_never_treated_as_revoked() -> None:
assert host_agent.serial_is_revoked({}, frozenset({"1", "0"})) is False
def test_resolve_bundle_reports_the_revision_and_the_denylist_of_a_real_bundle(
tls_fleet: SimpleNamespace, tmp_path: Path) -> None:
text = tls_fleet.bundle + f"{host_agent.REVOKED_LINE}{tls_fleet.stolen.serial}\n"
context, revision, revoked, error = host_agent.resolve_bundle(str(write_bundle(tmp_path, text)))
assert error is None
assert context is not None and isinstance(context, ssl.SSLContext)
assert revision == tls_fleet.bundle_rev
assert revoked == frozenset({str(tls_fleet.stolen.serial)})
@pytest.mark.parametrize("shape", ["missing", "empty", "no-key", "no-revision"])
def test_a_bundle_that_cannot_be_loaded_is_an_error_naming_the_file(
tls_fleet: SimpleNamespace, tmp_path: Path, shape: str) -> None:
"""Every failure is a message the operator can act on, and none of them is "no TLS today"."""
if shape == "missing":
path = tmp_path / "absent.certs"
elif shape == "empty":
path = write_bundle(tmp_path, " \n")
elif shape == "no-key":
body = [block for block in re.findall(
r"-----BEGIN [A-Z0-9 ]+-----.*?-----END [A-Z0-9 ]+-----\n", tls_fleet.bundle, re.S)
if block.startswith("-----BEGIN CERTIFICATE")]
path = write_bundle(tmp_path, "# BUNDLE-REV 1\n" + "".join(body))
else:
path = write_bundle(tmp_path, tls_fleet.bundle.replace("# BUNDLE-REV 1\n", ""))
context, revision, revoked, error = host_agent.resolve_bundle(str(path))
assert context is None and revision == 0 and revoked == frozenset()
assert error is not None and ("absent" in error if shape == "missing" else str(path) in error)
if shape == "no-revision":
assert "BUNDLE-REV" in error
def test_the_unpacked_pems_are_gone_once_the_context_has_them(
tls_fleet: SimpleNamespace, tmp_path: Path, monkeypatch) -> None:
"""The unit's RuntimeDirectory is used when it exists, and holds nothing afterwards.
`ssl` loads a chain from paths, so the key is written; leaving it there means the private key
outlives the process on a disk an operator did not choose, and `kill -9` has no cleanup path.
"""
runtime = tmp_path / "run"
runtime.mkdir()
monkeypatch.setattr(host_agent, "BUNDLE_RUNTIME_DIR", runtime)
context, _revision, _revoked, error = host_agent.resolve_bundle(str(write_bundle(tmp_path,
tls_fleet.bundle)))
assert error is None and context is not None
assert list(runtime.iterdir()) == [], "the private key must not stay on disk once loaded"
def test_a_missing_runtime_directory_falls_back_to_a_temporary_one(
tls_fleet: SimpleNamespace, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(host_agent, "BUNDLE_RUNTIME_DIR", tmp_path / "not-mounted")
context, revision, _revoked, error = host_agent.resolve_bundle(str(write_bundle(tmp_path,
tls_fleet.bundle)))
assert error is None and context is not None
assert revision == tls_fleet.bundle_rev
def test_a_client_certificate_signed_by_the_root_reads_the_snapshot(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The whole point of the phase, plus the two fields task 03 reads.
`bundle_rev` is how `/certs` tells "this host runs an old bundle" from "this host is current",
so it is the number the bundle carried, not a value the agent made up.
"""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
status, body = _https_get(base + "/snapshot",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key))
assert status == 200
assert body["ok"] is True and body["host"]["hostname"]
assert body["tls"] is True
assert body["bundle_rev"] == tls_fleet.bundle_rev
for path in ("/info", "/schema", "/"):
status, _body = _https_get(base + path, client_context(tls_fleet.root_cert,
tls_fleet.client_cert,
tls_fleet.client_key))
assert status == 200, path
def test_health_is_probeable_with_the_root_ca_alone_while_every_other_path_is_not(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The `CERT_OPTIONAL` assumption: a probe with no client certificate must still work.
`install-agent.sh --status` and the systemd probe hold the CA and were never handed a client
certificate; `CERT_REQUIRED` would make every TLS install look dead. Everything that carries
metrics is 403 — including the status page, which repeats hostname, kernel and CPU model.
"""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
probe = client_context(tls_fleet.root_cert)
status, body = _https_get(base + "/health", probe)
assert status == 200 and body["ok"] is True
for path in ("/snapshot", "/info", "/schema", "/", "/nope"):
refused, detail = _https_get(base + path, probe)
assert refused == 403, path
assert detail["detail"] == "client certificate required", path
def test_a_client_signed_by_another_authority_is_refused(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""A certificate that does not chain to the bundle's root never reaches a handler.
The server verifies at the handshake, so the answer is an aborted connection rather than an
HTTP code — the assertion is only ever `not 200` because which alert arrives is the peer
OpenSSL's choice, not ours.
"""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
stranger = client_context(tls_fleet.root_cert, tls_fleet.foreign_cert, tls_fleet.foreign_key)
with pytest.raises((OSError, http.client.HTTPException)) as caught:
_https_get(base + "/snapshot", stranger)
assert not isinstance(caught.value, urllib.error.HTTPError)
def test_a_plaintext_request_to_a_tls_agent_gets_nothing(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""A port probe cannot read the answer, and does not get a helpful error either."""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
port = int(base.rsplit(":", 1)[-1])
with socket.create_connection(("127.0.0.1", port), timeout=15) as raw:
raw.sendall(b"GET /snapshot HTTP/1.1\r\nHost: x\r\n\r\n")
raw.settimeout(5)
try:
payload = raw.recv(200)
except OSError:
payload = b""
assert b"hostname" not in payload
def test_a_revoked_client_certificate_is_refused_and_the_bundle_is_re_read(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The two halves: the denylist bites, and re-deploying the bundle takes effect no later
than the next request — no restart, which is the only reason the lines live in the file."""
path = write_bundle(tmp_path, tls_fleet.bundle
+ f"{host_agent.REVOKED_LINE}{tls_fleet.stolen.serial}\n")
bundle, error = host_agent.TlsBundle.load(str(path))
assert error is None and bundle is not None
base = tls_agent_server(bundle)
stolen = client_context(tls_fleet.root_cert, str(tls_fleet.stolen.cert), str(tls_fleet.stolen.key))
status, body = _https_get(base + "/snapshot", stolen)
assert status == 403
assert body["detail"] == "client certificate revoked"
# A withdrawn identity is refused even on the open path: the exemption is for a probe that
# holds the CA and no certificate, not for a certificate the operator has just taken away.
assert _https_get(base + "/health", stolen)[0] == 403
# The dashboard's own identity is untouched — the list names a serial, not a host.
assert _https_get(base + "/snapshot", client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key))[0] == 200
path.write_text(tls_fleet.bundle, encoding="ascii") # a redeploy: the line is gone
status, body = _https_get(base + "/snapshot", stolen)
assert status == 200, "the denylist is re-read when the file changes, without a restart"
def test_a_renamed_denylist_entry_is_re_read_on_the_next_request(
tls_fleet: SimpleNamespace, tmp_path: Path) -> None:
"""An unreadable bundle keeps the list this agent already had rather than forget it."""
path = write_bundle(tmp_path, tls_fleet.bundle
+ f"{host_agent.REVOKED_LINE}{tls_fleet.stolen.serial}\n")
bundle, error = host_agent.TlsBundle.load(str(path))
assert error is None and bundle is not None
assert bundle.revoked_serials() == frozenset({str(tls_fleet.stolen.serial)})
path.unlink()
assert bundle.revoked_serials() == frozenset({str(tls_fleet.stolen.serial)})
def test_the_token_is_not_read_on_the_tls_path(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""Bundle plus token: the handshake is the auth, so a stolen token cannot outlive a revoke.
Checking the bearer on the TLS path too would not be defence in depth — it would be a second
credential that keeps working after the operator revoked the certificate, which is the one
thing the denylist exists to prevent.
"""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle), token="s3cret")
status, _body = _https_get(base + "/snapshot",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key))
assert status == 200
def test_token_mode_reports_no_tls_fields(agent_server) -> None:
"""Token mode is byte-identical: no `tls`, no `bundle_rev`, no new field that an agent
installed before phase 15 does not have."""
assert host_agent.Handler.bundle is None, "the token-mode fixture must not inherit a bundle"
base, _port = agent_server(token="s3cret")
_status, body = _get(base + "/snapshot", {"Authorization": "Bearer s3cret"})
assert body["ok"] is True
assert "tls" not in body and "bundle_rev" not in body
def test_serve_wraps_the_socket_and_says_tls_and_the_revision(
tls_fleet: SimpleNamespace, tmp_path: Path, capsys, monkeypatch) -> None:
"""`serve()` is the function the unit runs, so its start line is what an operator reads.
The server is recorded rather than guessed at a port: the test needs the ephemeral port the
OS chose *and* a way to stop `serve_forever`, which is the same object.
"""
created: list[ThreadingHTTPServer] = []
class Recording(ThreadingHTTPServer):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs) # type: ignore[arg-type]
created.append(self)
monkeypatch.setattr(host_agent, "ThreadingHTTPServer", Recording)
bundle = load_bundle(tmp_path, tls_fleet.bundle)
thread = threading.Thread(target=host_agent.serve,
args=("127.0.0.1", 0, host_agent.Collector("/", smart=False),
None, bundle),
daemon=True)
thread.start()
deadline = time.monotonic() + 15
while not created and time.monotonic() < deadline:
time.sleep(0.01)
assert created, "serve() never built its server"
httpd = created[0]
port = httpd.server_address[1]
status, _body = _https_get(f"https://127.0.0.1:{port}/health",
client_context(tls_fleet.root_cert))
assert status == 200
httpd.shutdown()
thread.join(timeout=15)
printed = capsys.readouterr().out
assert "tls (client cert required, rev " in printed
# the scheme is the whole claim here (the port in the line is the requested one, which is 0
# when the OS picks, exactly as in token mode)
assert "[agent] listening on https://127.0.0.1" in printed
assert "token" not in printed, "a TLS agent does not read a token"
def test_a_bundle_allows_the_off_loopback_bind_without_a_token(
tls_fleet: SimpleNamespace, tmp_path: Path, monkeypatch) -> None:
"""The client certificate *is* the protection, so the exit-2 refusal does not apply."""
served: list[tuple] = []
monkeypatch.setattr(host_agent, "serve",
lambda *args: served.append(args))
path = write_bundle(tmp_path, tls_fleet.bundle)
assert host_agent.main(["--host", "0.0.0.0", "--port", "0", "--cert-bundle", str(path)]) == 0
assert served, "a bundle-configured agent must start"
host_arg, _port, _collector, token_arg, bundle_arg = served[0]
assert (host_arg, token_arg) == ("0.0.0.0", None)
assert isinstance(bundle_arg, host_agent.TlsBundle)
@pytest.mark.parametrize("extra", [[], ["--cert-bundle", ""]])
def test_off_loopback_without_a_token_or_a_bundle_is_refused_and_names_both(
tmp_path: Path, capsys, monkeypatch, extra: list[str]) -> None:
monkeypatch.delenv("AGENT_TOKEN", raising=False)
monkeypatch.delenv("AGENT_CERT_BUNDLE", raising=False)
args = ["--host", "0.0.0.0", "--port", "0", *extra]
assert host_agent.main(args) == 2
printed = capsys.readouterr().out
assert "--token" in printed and "--cert-bundle" in printed
def test_an_unreadable_bundle_is_fatal_at_startup(tmp_path: Path, capsys) -> None:
"""Exit 2 naming the file: a degraded-to-plain-HTTP agent is the answer that is worse."""
missing = tmp_path / "absent.certs"
assert host_agent.main(["--host", "127.0.0.1", "--port", "0",
"--cert-bundle", str(missing)]) == 2
assert str(missing) in capsys.readouterr().out
def schema_documents(field: str) -> bool:
"""Is this field in the SCHEMA block? `GET /schema` answers with that text, self-documenting
an install for whoever has to debug it, so a field the payload carries belongs there."""
doc = host_agent.__doc__ or ""
schema = doc.split("SCHEMA (GET /snapshot)", 1)[-1].split("Deployment:", 1)[0]
return re.search(rf"^\s+{field}\s", schema, re.M) is not None
def test_the_agent_version_and_schema_document_the_new_fields() -> None:
"""1.1.0 because the payload grew, and the docstring is what `GET /schema` answers."""
assert host_agent.AGENT_VERSION == "1.1.0"
assert schema_documents("tls") and schema_documents("bundle_rev")
assert "--cert-bundle" in (host_agent.__doc__ or "")
+732 -21
View File
@@ -7,6 +7,8 @@ asset the network can fail to deliver.
"""
from __future__ import annotations
import ipaddress
import json
import re
import time
from collections.abc import Iterator
@@ -16,13 +18,15 @@ from typing import Any
import httpx
import pytest
from cryptography import x509
from cryptography.x509.oid import NameOID
from fastapi import HTTPException
from fastapi.testclient import TestClient
import store_perf
from auth_client import sign_in
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
from dashboards import alerts, auth
from dashboards import alerts, auth, certs
from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, parse_range
from dashboards.app import create_app
from dashboards.collectors import UpstreamError
@@ -61,7 +65,11 @@ PAGES = ("/", "/system", "/disks", "/llm", "/ups")
#: tab, a fixed tab order — which this page deliberately does not have. `MARKUP_PAGES` is the
#: list for the checks that are about the bytes rather than the nav.
SETTINGS_PAGE = "/settings"
MARKUP_PAGES = (*PAGES, SETTINGS_PAGE)
#: `/certs` is the second admin page (phase 15 task 05), kept out of `PAGES` for exactly the same
#: reason: it is not a tab. It is in `MARKUP_PAGES` and `PAGE_SCRIPTS`, so the CDN, `.sev-`,
#: flavour-text and orphan-`id` audits cover it like the five dashboards.
CERTS_PAGE = "/certs"
MARKUP_PAGES = (*PAGES, SETTINGS_PAGE, CERTS_PAGE)
@contextmanager
@@ -105,6 +113,25 @@ def ups_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Test
yield c
def wait_for_source_error(client: TestClient, key: str, fragment: str,
*, timeout: float = 15.0) -> str:
"""Wait for one poll loop to record an error saying `fragment`, and hand back the sentence.
The poll loops run in the app's event loop, so an assertion about what a source said has to
wait for a poll rather than read the state at import time. The failure names the last error
seen, because "it never said that" and "it said something else entirely" are different bugs.
"""
deadline = time.time() + timeout
error = ""
while time.time() < deadline:
rows = {row["key"]: row for row in client.get("/api/health").json()["sources"]}
error = str((rows.get(key) or {}).get("error") or "")
if fragment in error:
return error
time.sleep(0.05)
pytest.fail(f"{key} never reported {fragment!r} (last error: {error!r})")
def wait_for_payload(client: TestClient, path: str, *, timeout: float = 5.0) -> dict[str, Any]:
deadline = time.time() + timeout
while time.time() < deadline:
@@ -721,6 +748,49 @@ def test_a_dead_host_never_writes_its_token_into_the_feed(tmp_path: Path) -> Non
store.close()
def test_a_revoked_agent_fails_both_of_its_sources_without_a_dial(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""The chain end to end, wiring included: revoke it, and the next poll stops talking to it.
`collectors.agent_tls_map` is what `build_hub` hands the jobs, so this is the proof that a
revocation reaches a *running* app through its own `meta` table — no restart, and no second
process telling the first. The spy replaces `httpx.AsyncClient.get` on the class, so it
catches the shared client and the per-dial TLS client alike: what must not happen is any
request at all, because a dial that then gets refused still put a socket on a host the
operator struck out.
"""
db = str(tmp_path / "revoked-agent.sqlite3")
store = SampleStore(db)
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2,
host_interval=0.05, disks_interval=0.05,
host_targets=(Target(name="deskwork", url="https://127.0.0.1:1"),))
with TestClient(create_app(settings, store)) as client:
sign_in(client)
# Before the revoke the same dead host fails as an unreachable upstream. That sentence is
# the control: it is what a dial produces, so the one after it has to mean something else.
wait_for_source_error(client, "host:deskwork", "unreachable")
state = certs.issue_agent_cert(store, certs.ensure_certs(store), "deskwork",
["127.0.0.1"])
certs.revoke(store, state, "deskwork")
calls: list[str] = []
async def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
calls.append(str(url))
raise AssertionError(f"a revoked agent was dialled at {url}")
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
for key in ("host:deskwork", "disks:deskwork"):
error = wait_for_source_error(client, key, "agent certificate revoked")
assert "unreachable" not in error, "the refusal replaced the dial's sentence"
assert calls == [], "the gate is in front of the network, not behind it"
# and the overview says the operator revoked it rather than that the box went down
messages = [row["message"] for row in client.get("/api/summary").json()["alerts"]]
assert any("certificate revoked" in text for text in messages), messages
store.close()
def test_engine_urls_are_redacted_the_same_way_hosts_are(tmp_path: Path) -> None:
"""`GUFO_BASE_URL`/`VLLM_BASE_URL` go to the browser verbatim in `/api/config`, and unlike
`HOST_TARGETS` nothing parses a userinfo token out of them into a header — so a URL that
@@ -926,25 +996,176 @@ def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> N
store.close()
def test_every_dashboard_page_links_to_settings(client) -> None:
"""The `⚙ Account` link is the only way to the session page, so it is on every dashboard.
def test_every_page_links_to_both_admin_pages(client) -> None:
"""`⚙ Account` and `⚷ Certs` are the only way to the two admin pages, so they are everywhere.
It sits in `.masthead-right` before the theme toggle and **outside** `.tabs`, because the tab
They sit in `.masthead-right` before the theme toggle and **outside** `.tabs`, because the tab
strip's structure — five dashboards, one of them current — is pinned by
`test_the_disks_tab_is_in_the_masthead_of_every_page` and a sixth entry with no
`aria-current` would break what that marker means.
Neither page links to itself, which is the `/settings` precedent from phase 11 and the reason
the two loops below are over different tuples: an `⚷ Certs` link on `/certs` is a no-op the
operator learns to ignore, and ignored chrome is worse than none.
"""
link = '<a class="ghost account" href="/settings"'
for path in PAGES:
account = '<a class="ghost account" href="/settings"'
certs_link = '<a class="ghost account" href="/certs"'
for path in (*PAGES, SETTINGS_PAGE):
html = client.get(path).text
assert link in html, f"{path} has no route to the account page"
assert certs_link in html, f"{path} has no route to the certificate authority"
assert certs_link in html[:html.index('id="theme-toggle"')], \
f"{path} put the Certs link after the theme toggle"
tabs = html[html.index('<nav class="tabs"'):html.index("</nav>")]
assert "/settings" not in tabs, f"{path} made Account a dashboard tab"
assert html.index(link) < html.index('id="theme-toggle"'), \
f"{path} put the account link after the theme toggle"
# the stylesheet styles the link with the button's own rule, not a copy of it
css = client.get("/static/dash.css").text
assert "a.ghost" in css
assert "/certs" not in tabs, f"{path} made Certs a dashboard tab"
for path in (*PAGES, CERTS_PAGE):
html = client.get(path).text
assert account in html, f"{path} has no route to the account page"
assert account in html[:html.index('id="theme-toggle"')], \
f"{path} put the Account link after the theme toggle"
assert account not in client.get(SETTINGS_PAGE).text, "/settings links to itself"
assert certs_link not in client.get(CERTS_PAGE).text, "/certs links to itself"
def test_the_certs_page_is_a_page_and_not_a_sixth_dashboard(client) -> None:
"""`/certs` borrows the dashboard chrome and none of the dashboard meaning (task 15.05).
Five tabs with nothing current, no `#nav-status` (a CA page has no fleet reachability to
report), and the same head and stylesheet as every other page — this is the
`test_the_settings_page_is_not_one_of_the_dashboards` assertion for the second admin page,
and it is what a sixth tab in the strip would fail on.
"""
html = client.get(CERTS_PAGE).text
assert html.count('aria-current="page"') == 0
tabs = re.findall(r'<a href="([^"]+)"[^>]*aria-current="page"', html)
assert tabs == [], f"/certs marked a tab current: {tabs}"
assert 'id="nav-status"' not in html, "/certs grew fleet dots it has nothing to fill"
for path in PAGES:
assert f'href="{path}"' in html, f"/certs cannot navigate to {path}"
assert '/static/dash.css' in html
def test_the_certs_page_carries_the_authority_and_the_agents_table(client) -> None:
"""The three blocks the page is: the root, the client line, and one row per host.
Pinned on the served bytes rather than on a browser: the root and client panels and the table
are markup, and the two rows of the table body are built from `GET /api/certs` by
`page-certs.js` — so the per-row action labels are asserted on the served **script**, the way
`/ups` pins its command buttons. `tests/test_api.py` has no JS runner (a locked anchor), and a
label that shipped in neither file would be an invisible button either way.
"""
html = client.get(CERTS_PAGE).text
js = client.get("/static/page-certs.js").text
assert "Root CA" in html and 'id="root-serial"' in html
assert 'id="root-expires"' in html and 'id="root-left"' in html
assert "Rotate root CA" in html, "the CA-wide action disappeared"
assert "Dashboard client" in html and 'id="client-serial"' in html
assert 'id="client-expires"' in html, "/certs lost the client expiry"
assert 'id="agents-table"' in html and "<tbody></tbody>" in html
assert 'id="agents-empty"' in html and "hidden" in html
for label in ("Rotate", "Revoke", "bundle"):
assert f"'{label}'" in js, f"the {label} action lost its label"
for route in ("/rotate", "/revoke", "/bundle"):
assert route in js, f"the page no longer reaches {route}"
assert "/api/certs/root/rotate" in js
def test_the_certs_page_prints_the_names_the_authority_mints(client) -> None:
"""The CN of each certificate is markup, so the literal is pinned to `certs.py`.
`MIN_PASSWORD_LENGTH`'s idiom: there is no build step to share a Python constant with a page,
so a page that restates one says it in markup and a test compares the two. Printing a CN the
module did not sign with would be the page inventing a fact about a stored certificate.
"""
html = client.get(CERTS_PAGE).text
assert f">{certs.ROOT_CN}<" in html, "the root panel's CN is not what certs.py signs"
assert f">{certs.CLIENT_CN}<" in html, "the client line's CN is not what certs.py signs"
def test_the_certs_page_reads_only_fields_the_cert_api_publishes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""End to end, in both directions: the page's column table and `GET /api/certs` are one shape.
The served script's `COLUMNS` entries name every payload key the page indexes, so this is the
`SLOT_FIELDS` device on the surface where a rename is worst: a `deployed_bundle_rev` that
quietly became `agent_bundle_rev` would print `n/a` beside a healthy fleet forever.
"""
script = "/static/page-certs.js"
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(3),
host_interval=3600.0) as client:
js = client.get(script).text
names: set[str] = set()
for listed in re.findall(r"fields: \[([^\]]*)\]", js):
names.update(re.findall(r"'([^']+)'", listed))
assert names == CERT_AGENT_KEYS, sorted(names ^ CERT_AGENT_KEYS)
client.post("/api/certs/deskwork/rotate")
for row in cert_agent_rows(client):
assert len(row) == 8
for name in names:
assert name in row, f"the page indexes `{name}` and the API does not publish it"
# …and the two authority blocks: the page prints a serial and an expiry for each, which is
# exactly the key set the API answers there.
body = cert_document(client)
for block in ("root", "client"):
assert set(body[block]) == CERT_TIMESERIAL_KEYS
for name in CERT_TIMESERIAL_KEYS:
assert f".{name}" in js, f"the page stopped reading `{name}`"
def test_the_certs_page_never_colours_a_certificate(client) -> None:
"""No severity vocabulary reaches this page at all — an expiry is a date, not a verdict.
`/api/certs` sends no `severity` anywhere (task 04, and `severity.py` rates no certificate),
so a page that coloured an expiring cert would be inventing a threshold in JavaScript. The one
colour on the page is the payload's `status` word through a word→class map, and `unissued`
maps to no class at all — a certificate that does not exist must not borrow green.
"""
js = _code(client.get("/static/page-certs.js").text)
assert "severity" not in js, "/certs grew a verdict the API does not send"
assert "severityFrom" not in js and "severityFor" not in js
table = js[js.index("const STATUS_CHIP = {"):js.index("};")]
assert "active: 'ok'" in table and "revoked: 'fail'" in table
assert "unissued: ''" in table, "an unissued host would be coloured"
assert "not_after" not in table and "serial" not in table, "an expiry became a verdict"
def test_the_certs_page_asks_before_it_writes_and_offers_nothing_the_api_refuses(
client) -> None:
"""Every write is confirmed, and no row offers a control its own payload rules out.
`confirm()` and not the typed `DANGER` word: that gate belongs to commands that cut power,
and this page has none — the worst outcome here is a host that is not dialled until somebody
installs a bundle. The guards are the API's, restated: a row outside `HOST_TARGETS` gets no
buttons at all (`404 {"configured": false}` is all three routes' first line), a never-issued
host has nothing to revoke and no bundle to download, and the bundle download is a plain
`GET` link because a session cookie is all a signed `GET` needs.
"""
js = _code(client.get("/static/page-certs.js").text)
assert "window.confirm(" in js
assert "DANGER" not in js, "the power-cut word migrated to a page with no power cut"
assert "entry.configured" in js and "not in HOST_TARGETS" in js
assert "entry.status !== 'unissued'" in js, "a row could revoke nothing"
assert "entry.serial" in js
assert "bundle.href = '/api/certs/'" in js
assert "method: 'POST'" in js
# …and the refusal is text, never markup: these are server sentences
assert "innerHTML" not in js
def test_the_certs_page_prints_the_restart_sentence_the_api_spoke(client) -> None:
"""`restart_required` prints the route's `hint`, not a sentence the page wrote for itself.
The restart fact lives in `api.py` (the CA and the client pair are startup material, so the
table is new before the socket is). A page copy of it is the version that quietly stops
saying "and every bundle needs re-installing".
"""
js = client.get("/static/page-certs.js").text
assert "restart_required" in js
assert "body.hint" in js
assert "restart the dashboard" not in js.lower(), "the page invented the restart sentence"
def test_the_password_floor_the_form_checks_is_the_one_the_api_enforces(client) -> None:
@@ -1000,7 +1221,7 @@ def test_a_rotated_secret_clears_the_cached_session_every_page_uses(client) -> N
ASSETS = ("/static/dash.css", "/static/app.js", "/static/charts.js",
"/static/page-index.js", "/static/page-disks.js", "/static/page-system.js",
"/static/page-llm.js", "/static/page-ups.js", "/static/page-login.js",
"/static/page-settings.js")
"/static/page-settings.js", "/static/page-certs.js")
def _css(client: TestClient) -> str:
@@ -2894,6 +3115,9 @@ PAGE_SCRIPTS = {
# `/settings` is audited like a dashboard even though it is not one: an id nothing reads is
# dead markup whatever page it sits on, and this page has five new ones.
SETTINGS_PAGE: "page-settings.js",
# `/certs` too (phase 15 task 05): an authority panel and an agents table are markup, and
# every id in them is written by this page's own script.
CERTS_PAGE: "page-certs.js",
}
# `/login` is deliberately **not** in this table even though it is audited (phase 11): a
# signed-in client asking for it gets a 303 to the dashboard, so this helper's own
@@ -3459,14 +3683,19 @@ def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one(
assert response.status_code == 405, f"{verb} /api/disks/snapshot was accepted"
writes = sorted(str(route.path) for route in routes
if "POST" in (getattr(route, "methods", None) or set()))
# Invariant 1 is about the fleet, and phase 11 did not touch the fleet: the three `/auth/`
# writes create, re-credential, and clear a session in this process and dial nothing. They
# are named here instead of filtered out of sight, because the failure this assertion catches
# is a POST arriving quietly — a list the reader recognises is worth more than a short one.
upstream = [path for path in writes if not path.startswith("/api/auth/")]
# Invariant 1 is about the fleet. The `/auth/` writes create, re-credential, and clear a
# session in this process; the `/certs/` writes (phase 15) mint, reissue, and revoke a
# certificate in this process's `meta` table. Neither dials anything — and a revocation's whole
# effect is that the *next poll* refuses to dial — so they are named here rather than filtered
# out of sight, because the failure this assertion catches is a POST arriving quietly.
authority = [path for path in writes if path.startswith("/api/certs/")]
session = [path for path in writes if path.startswith("/api/auth/")]
upstream = [path for path in writes
if not path.startswith(("/api/auth/", "/api/certs/"))]
assert upstream == ["/api/ups/command"], "one write endpoint, four gates, unchanged"
assert session == ["/api/auth/login", "/api/auth/logout", "/api/auth/password"]
assert authority == ["/api/certs/root/rotate", "/api/certs/{name}/revoke",
"/api/certs/{name}/rotate"], "the CA surface grew a write nobody named"
# …and nothing anywhere grew the verbs this app has no use for, session included.
assert not sorted(path for route in routes
for path in [str(route.path)]
@@ -3478,7 +3707,8 @@ def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one(
and not str(route.path).startswith("/api/"))
# `/settings` (phase 11) is a fifth dashboard-shaped **page** and it grew no verb: a page
# route is a GET, so the write surface this test guards stays the one UPS command.
assert pages == ["/", "/disks", "/llm", "/login", "/settings", "/system", "/ups"]
assert pages == ["/", "/certs", "/disks", "/llm", "/login", "/settings", "/system",
"/ups"]
def test_config_prints_the_disks_cadence_the_page_explains_itself_with(
@@ -4160,6 +4390,487 @@ def test_samples_written_by_the_poller_are_queryable(tmp_path: Path,
store.close()
# --------------------------------------------------------------------------- #
# The certificate authority surface (phase 15 task 04)
#
# These routes mint, re-sign, and revoke the identities every agent in the fleet is dialled with,
# so the assertions are about **stored rows and wire effects** rather than about JSON being shaped
# nicely: a read that issued a certificate, a typo that wrote a row, a revocation that did not
# reach the collector, and a key that left `meta` are the four ways this surface can be wrong while
# every status code still reads 200.
#
# The fleet here is loopback ports nobody listens on. That is deliberate: nothing on this surface
# is allowed to need a live agent, so a refused connection is both the whole environment and the
# control that says "a dial was attempted, and it was not this".
# --------------------------------------------------------------------------- #
#: Three configured hosts, none of them reachable, on the scheme a protected exporter is dialled
#: by. The URL host is also what a rotated certificate's SAN must carry, so these are the SANs the
#: assertions below read.
DEAD_TARGETS = (Target(name="deskwork", url="https://127.0.0.1:1"),
Target(name="driveripper", url="https://127.0.0.1:2"),
Target(name="gateway", url="https://127.0.0.1:3"))
#: The whole `GET /api/certs` document and one agent row, pinned as a table so a panel that
#: retires can never shrink the payload (`/api/disks`' idiom) and task 05's page has a contract.
CERT_DOCUMENT_KEYS = {"root", "client", "agents"}
CERT_TIMESERIAL_KEYS = {"serial", "not_after"}
CERT_AGENT_KEYS = {"name", "configured", "serial", "not_after", "status", "bundle_rev",
"deployed_bundle_rev", "san"}
def cert_document(client: TestClient) -> dict[str, Any]:
"""`GET /api/certs`, with the status asserted: a refusal here would pass a `.get()` chain."""
response = client.get("/api/certs")
assert response.status_code == 200, response.text
body = response.json()
assert set(body) == CERT_DOCUMENT_KEYS, "the CA document grew or lost a top-level key"
for block in ("root", "client"):
assert set(body[block]) == CERT_TIMESERIAL_KEYS, block
assert int(body[block]["serial"]) > 0, "a serial is the decimal text of a positive int"
assert body[block]["not_after"] > time.time(), "a stored CA may not already be expired"
return body
def cert_agent_rows(client: TestClient, name: str | None = None) -> list[dict[str, Any]]:
"""The agent rows, every one of them shaped, optionally just one host's."""
rows = cert_document(client)["agents"]
for row in rows:
assert set(row) == CERT_AGENT_KEYS, sorted(row)
if name is None:
return rows
return [row for row in rows if row["name"] == name]
def cert_context(client: TestClient) -> Any:
"""The app's `AppContext`.
`TestClient.app` is typed as the ASGI callable it wraps, so the one cast lives here instead of
one `# type: ignore` at every assertion that needs the store or a job.
"""
return client.app.state.ctx # type: ignore[attr-defined]
def cert_meta(store: SampleStore) -> dict[str, str]:
"""Every `certs.*` row, for the before/after comparisons that prove a route wrote nothing.
Coalesced to `""` because the comparison is "did any byte of these five rows move", and an
absent row is the empty answer to that question rather than a second kind of nothing.
"""
return {key: store.get_meta(key) or "" for key in certs.META_KEYS}
def revoked_serials(bundle: str) -> list[str]:
"""The denylist of a bundle, as the agent reads it: the `# REVOKED <serial>` lines."""
return [line.removeprefix(certs.REVOKED_LINE).strip()
for line in bundle.splitlines() if line.startswith(certs.REVOKED_LINE)]
#: One PEM block out of a bundle, by text — the same read task 02's agent does with stdlib.
_PEM_BLOCK = re.compile(r"-----BEGIN [A-Z0-9 ]+-----.*?-----END [A-Z0-9 ]+-----\n", re.DOTALL)
def bundle_certs(bundle: str) -> list[x509.Certificate]:
"""The two certificates of a bundle, in the order the format writes them.
`[0]` is the agent's own server certificate and `[1]` the root it chains to (`certs.py`'s
`_bundle_text` order: metadata lines, agent cert, agent key, root cert). Reading them out of the
served bytes rather than trusting the response's echo is what makes "did the rotation reach the
artifact?" a checked claim.
"""
blocks = [block for block in _PEM_BLOCK.findall(bundle) if "CERTIFICATE" in block]
assert len(blocks) == 2, f"a bundle is two certificates and a key, got {len(blocks)}"
return [x509.load_pem_x509_certificate(block.encode()) for block in blocks]
def bundle_of(client: TestClient, name: str) -> str:
"""The served bundle, asserted to still be a bundle (2 certs, 1 key) before it is compared."""
response = client.get(f"/api/certs/{name}/bundle")
assert response.status_code == 200, response.text
assert response.headers["content-type"].startswith("application/x-pem-file")
assert "no-store" in response.headers["cache-control"], "a private key may not be cached"
blocks = re.findall(r"-----BEGIN [A-Z0-9 ]+-----", response.text)
assert blocks.count("-----BEGIN CERTIFICATE-----") == 2, blocks
assert blocks.count("-----BEGIN PRIVATE KEY-----") == 1, blocks
return response.text
def stored_bundle(store: SampleStore, name: str) -> str:
"""The bundle exactly as the `certs.agents` row holds it."""
row: dict[str, Any] = json.loads(store.get_meta(certs.META_AGENTS) or "{}")
return str(row[name]["bundle"])
def cert_events(client: TestClient) -> list[dict[str, Any]]:
return client.get("/api/events", params={"source": "certs"}).json()["events"]
def cert_hosts(count: int) -> tuple[Target, ...]:
return DEAD_TARGETS[:count]
@pytest.mark.parametrize("hosts", [0, 1, 3], ids=["none", "one", "three"])
def test_the_cert_list_is_the_stored_table_for_zero_one_and_three_hosts(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, hosts: int) -> None:
"""The list works with no fleet at all, and says *unissued* rather than omitting a host.
The CA exists whether or not `HOST_TARGETS` does — a fresh database boots with a root and a
client certificate — so this is not the `/api/disks` "not configured" surface and does not 404.
A configured host with nothing issued still gets a row (invariant 3: a machine that is deployed
and not yet installed is not an absent machine), with `None` and not `0` where there is no
certificate (rule 2).
"""
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(hosts),
host_interval=3600.0) as client:
rows = cert_agent_rows(client)
assert len(rows) == hosts, "one row per configured host, and no more"
assert [row["name"] for row in rows] == sorted(row["name"] for row in rows), (
"one order in the payload and one in the table")
for row in rows:
assert row["configured"] is True
assert row["status"] == "unissued", "nothing has been signed for it yet"
assert row["serial"] is None and row["bundle_rev"] is None, "no invented zeros"
assert row["deployed_bundle_rev"] is None, "nobody has answered, so nobody reported"
assert row["san"] == [], "no names on record, not a certificate with no names"
def test_the_cert_list_carries_the_rev_the_agent_reported(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""`deployed_bundle_rev` is what the host said about itself, not what the CA hopes.
Task 05's "stale bundle" sentence is a comparison of two numbers that come from two different
places: the row this dashboard issued and the `agent` block of the last `system` payload. Both
halves are pinned here, including the direction that matters — issued 2 against deployed 1 is
the operator's "install the new bundle" case, and it has to be visible rather than smoothed
over.
"""
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
host_interval=3600.0) as client:
assert client.post("/api/certs/deskwork/rotate").status_code == 200
job = cert_context(client).hub.jobs["host:deskwork"]
assert cert_agent_rows(client, "deskwork")[0]["deployed_bundle_rev"] is None, (
"a host that has never answered reports nothing")
job.state.payload = {"name": "deskwork", "agent": {"tls": True, "bundle_rev": 1}}
assert cert_agent_rows(client, "deskwork")[0]["deployed_bundle_rev"] == 1
assert client.post("/api/certs/deskwork/rotate").json()["bundle_rev"] == 2
row = cert_agent_rows(client, "deskwork")[0]
assert (row["bundle_rev"], row["deployed_bundle_rev"]) == (2, 1), "the stale pair, both"
# A token-mode agent publishes no `agent.tls` block at all, and that is `n/a`, not rev 0.
job.state.payload = {"name": "deskwork", "agent": {"version": "1.0.0"}}
assert cert_agent_rows(client, "deskwork")[0]["deployed_bundle_rev"] is None
def test_an_entry_for_a_host_that_left_the_config_is_listed_and_not_writable(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A row issued for a machine that has gone is a key still sitting in the table.
Listing it is the honest answer (`/certs` is where you learn you still hold one); refusing to
rotate or revoke it is the same invariant from the other side — `HOST_TARGETS` is what this
dashboard dials, and a name it no longer dials is not a name to install a bundle on. The row is
written straight to the store because that is the only way this state arises: a host removed
from the config, never a route that created it.
"""
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
host_interval=3600.0) as client:
store: SampleStore = cert_context(client).store
certs.issue_agent_cert(store, certs.read_state(store), "old-host", ["10.9.8.7"])
rows = cert_agent_rows(client)
assert [row["name"] for row in rows] == ["deskwork", "old-host"]
ghost = rows[1]
assert ghost["configured"] is False, "listed, but this dashboard does not dial it"
assert ghost["status"] == certs.ACTIVE and ghost["bundle_rev"] == 1
assert ghost["deployed_bundle_rev"] is None, "no job means no report, not rev 0"
assert client.post("/api/certs/old-host/rotate").status_code == 404
assert client.post("/api/certs/old-host/revoke").status_code == 404
assert cert_events(client) == [], "a refusal is not an audit line"
def test_a_cert_route_for_an_unknown_name_dials_nothing_and_writes_nothing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""The invariant-3 gate on the per-host routes, taken before anything can have a side effect.
`not-installed` is refused in the first line of each route, so no client is built, no store row
is written, and no key is minted. All three are measured: the two network spies raise, `set_meta`
is watched for any `certs.*` write, and the five rows are compared byte for byte. A typo that
minted a certificate would be invisible on every status code and permanent in the table.
"""
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
host_interval=3600.0) as client:
store: SampleStore = cert_context(client).store
calls: list[str] = []
written: list[str] = []
async def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
calls.append(f"GET {url}")
raise AssertionError(f"an unconfigured cert name dialled {url}")
def spy_fetch(*args: Any, **kwargs: Any) -> Any:
calls.append("fetch_snapshot")
raise AssertionError("an unconfigured cert name fetched an agent snapshot")
real_set_meta = SampleStore.set_meta
def spy_set_meta(self: SampleStore, key: str, value: str) -> None:
written.append(key)
real_set_meta(self, key, value)
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
monkeypatch.setattr("dashboards.collectors.fetch_snapshot", spy_fetch)
monkeypatch.setattr(SampleStore, "set_meta", spy_set_meta)
before = cert_meta(store)
for response in (client.get("/api/certs/not-configured/bundle"),
client.post("/api/certs/not-configured/rotate"),
client.post("/api/certs/not-configured/revoke")):
assert response.status_code == 404, response.text
body = response.json()
assert body["configured"] is False
assert "HOST_TARGETS" in body["detail"], "the sentence names the fix"
assert calls == [], "the gate is in front of the network, not behind it"
assert [key for key in written if key.startswith("certs.")] == [], "a write happened"
assert cert_meta(store) == before, "the stored CA and agent table did not move"
assert cert_events(client) == [], "and no audit line was written for a name that is not here"
# …and the same route still works for the host that *is* configured, so the refusal above
# is the gate and not a broken router.
assert client.post("/api/certs/deskwork/rotate").status_code == 200
def test_rotating_a_host_mints_the_bundle_it_asks_for(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Rotate is also *issue*: first call is `bundle_rev` 1, and the SAN is the config's address.
The SAN assertion is the one a rollout fails on silently — a certificate that does not name the
address the dashboard dials is a handshake error and a red source, not a warning — so it is read
out of the served bundle's own certificate rather than out of the response's echo.
"""
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
host_interval=3600.0) as client:
store: SampleStore = cert_context(client).store
body = client.post("/api/certs/deskwork/rotate").json()
assert body["ok"] is True and body["status"] == certs.ACTIVE
assert body["bundle_rev"] == 1 and body["serial"] and body["not_after"] > time.time()
assert body["san"] == ["127.0.0.1", "deskwork"], "the dialled address and the name"
cert = bundle_certs(bundle_of(client, "deskwork"))[0]
names = cert.extensions.get_extension_for_class(
x509.SubjectAlternativeName).value.get_values_for_type(x509.IPAddress)
assert names == [ipaddress.ip_address("127.0.0.1")], "an IP SAN, or an IP dial fails"
assert cert.subject == x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "deskwork")])
events = cert_events(client)
assert len(events) == 1 and events[0]["kind"] == "cert-rotated"
assert events[0]["host"] == "deskwork" and events[0]["message"] == (
"agent certificate rotated for deskwork")
assert events[0]["data"] == {"bundle_rev": 1}
second = client.post("/api/certs/deskwork/rotate").json()
assert second["bundle_rev"] == 2 and second["serial"] != body["serial"]
# The new identity is not on the denylist: rotation replaces an identity, it does not strike
# one out, and a bundle that listed its own serial would refuse the client it ships.
bundle = bundle_of(client, "deskwork")
assert revoked_serials(bundle) == [], revoked_serials(bundle)
assert second["serial"] not in bundle.splitlines(), "no denylist line for the live serial"
assert stored_bundle(store, "deskwork") == bundle, "served bytes are the stored bytes"
def test_revoking_a_host_marks_the_table_and_the_next_poll_refuses_without_a_dial(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""The API half of the revocation chain: this route is what task 03's refusal reads.
The control is the sentence from *before* the click (`unreachable`, which is what a dial to a
dead port says), and after it the poll has to say `agent certificate revoked` with a spy on
`httpx.AsyncClient.get` proving no socket was opened. The stored denylist is the other half: an
agent holding a bundle that omits the serial keeps accepting the client it was given.
"""
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
host_interval=0.05, disks_interval=0.05) as client:
store: SampleStore = cert_context(client).store
wait_for_source_error(client, "host:deskwork", "unreachable")
serial = client.post("/api/certs/deskwork/rotate").json()["serial"]
body = client.post("/api/certs/deskwork/revoke").json()
assert body["ok"] is True and body["status"] == certs.REVOKED
assert body["serial"] == serial, "revocation marks the identity it has, it does not replace"
assert body["hint"] and "next poll" in body["hint"]
calls: list[str] = []
async def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
calls.append(str(url))
raise AssertionError(f"a revoked agent was dialled at {url}")
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
for key in ("host:deskwork", "disks:deskwork"):
error = wait_for_source_error(client, key, "agent certificate revoked")
assert "unreachable" not in error
assert calls == []
assert revoked_serials(bundle_of(client, "deskwork")) == [serial]
events = cert_events(client)
assert [row["kind"] for row in events] == ["cert-revoked", "cert-rotated"]
assert events[0]["message"] == "agent certificate revoked for deskwork"
assert certs.agent_status(store, "deskwork") == certs.REVOKED, (
"the collector's own read agrees with the row the route wrote")
def test_root_rotation_reissues_every_host_and_answers_restart_required(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""One action re-arms the fleet — and says the part it cannot do from inside the process.
`restart_required` is not decoration: the CA and the client pair are startup material, so the
table is new immediately while the sockets keep the old identity. The page (task 05) prints
that sentence because the API answered it, and the alternative is an operator who rotated a
root and watches a fleet that still chains to the previous one.
"""
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(3),
host_interval=3600.0) as client:
store: SampleStore = cert_context(client).store
for target in DEAD_TARGETS:
assert client.post(f"/api/certs/{target.name}/rotate").status_code == 200
before = cert_document(client)
rows_before = {row["name"]: row for row in before["agents"]}
root_pem_before = store.get_meta(certs.META_ROOT_CERT) or ""
key_before = store.get_meta(certs.META_ROOT_KEY) or ""
assert root_pem_before and key_before, "both halves of the CA exist before the rotation"
body = client.post("/api/certs/root/rotate").json()
assert body["ok"] is True and body["restart_required"] is True
assert body["agents"] == 3
assert body["root"]["serial"] != before["root"]["serial"], "a fresh root"
after = cert_document(client)
assert after["root"] == body["root"], "the list reports what the rotation wrote"
assert after["client"]["serial"] != before["client"]["serial"], "and a fresh client leaf"
for row in after["agents"]:
was = rows_before[row["name"]]
assert row["bundle_rev"] == was["bundle_rev"] + 1, "every host reissued, rev bumped"
assert row["serial"] != was["serial"] and row["status"] == certs.ACTIVE
bundle = bundle_of(client, row["name"])
leaf, root = bundle_certs(bundle)
assert leaf.serial_number == int(row["serial"]), "the served bundle is the listed one"
assert root.serial_number == int(after["root"]["serial"]), (
"and it chains to the authority the list now reports")
assert revoked_serials(bundle) == [], "the denylist resets with the generation"
assert store.get_meta(certs.META_ROOT_KEY) != key_before
assert store.get_meta(certs.META_ROOT_CERT) != root_pem_before
assert root_pem_before not in "".join(cert_meta(store).values()), (
"the old root is gone")
events = cert_events(client)
assert events[0]["kind"] == "cert-root-rotated"
assert events[0]["message"] == (
"root certificate rotated — every agent certificate reissued, restart required")
assert events[0]["data"] == {"agents": 3}
def test_the_bundle_route_names_a_file_and_changes_when_the_identity_does(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Byte-identical to the stored row, an `attachment`, and different bytes after a reissue.
This is the installer's input (task 06), so the claim is about bytes and not about a bundle
that merely re-verifies: `install-agent.sh` is handed the file and compares nothing else.
"""
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
host_interval=3600.0) as client:
store: SampleStore = cert_context(client).store
missing = client.get("/api/certs/deskwork/bundle")
assert missing.status_code == 404, "nothing issued yet, so nothing to download"
body = missing.json()
assert body["configured"] is True and body["issued"] is False, "not the same 404"
assert "rotate" in body["detail"], "and the answer names the way to get one"
first = client.post("/api/certs/deskwork/rotate").json()["serial"]
response = client.get("/api/certs/deskwork/bundle")
assert response.headers["content-disposition"] == (
'attachment; filename="agent-deskwork.bundle"')
assert response.text == stored_bundle(store, "deskwork")
client.post("/api/certs/deskwork/rotate")
second = client.post("/api/certs/deskwork/rotate").json()["serial"]
assert second != first
updated = client.get("/api/certs/deskwork/bundle")
assert updated.text != response.text, "a reissue changes the served bytes"
assert updated.text == stored_bundle(store, "deskwork")
def test_no_certificate_material_leaves_meta_through_the_cert_surface(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""invariant 6 on the API that owns the keys: serials and dates go out, PEMs do not.
Every write here is exercised first, so the grep is over a table that has been rotated and
revoked rather than a pristine one, and it covers both the payloads a browser reads and the
stored event rows those payloads are built from — an event row is kept forever, which is what
makes a PEM in a message permanent rather than transient.
"""
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(2),
host_interval=3600.0) as client:
store: SampleStore = cert_context(client).store
client.post("/api/certs/deskwork/rotate")
client.post("/api/certs/driveripper/rotate")
client.post("/api/certs/driveripper/revoke")
client.post("/api/certs/root/rotate")
secrets = [value for value in cert_meta(store).values() if value]
assert any("PRIVATE KEY" in value for value in secrets), "the table really does hold keys"
for path in ("/api/certs", "/api/health", "/api/events", "/api/summary", "/api/config"):
text = client.get(path).text
for marker in ("BEGIN CERTIFICATE", "PRIVATE KEY", "BUNDLE-REV"):
assert marker not in text, f"{path} served {marker}"
assert client.get("/api/health").json()["db"], "health still answers, keys or no keys"
for event in store.recent_events(50, source="certs"):
assert "BEGIN" not in event.message and "PRIVATE KEY" not in event.message
assert not any(secret in json.dumps(event.as_json()) for secret in secrets)
def test_a_host_named_root_cannot_be_rotated_past_the_certificate_authority(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""`/api/certs/root/rotate` and `/api/certs/{name}/rotate` are one URL for a host called root.
The CA route is registered first, so it wins — which means the wrong button would rotate the
whole authority instead of one host's leaf. Refusing with the reason is the only honest answer:
the alternative is a fleet-wide outage behind a click that looked like routine maintenance.
"""
with build_client(tmp_path, monkeypatch,
host_targets=(Target(name="root", url="https://127.0.0.1:1"),),
host_interval=3600.0) as client:
store: SampleStore = cert_context(client).store
before = cert_document(client)["root"]["serial"]
refused = client.post("/api/certs/root/rotate")
assert refused.status_code == 409, refused.text
assert "rename" in refused.json()["detail"], "the sentence says what to do"
assert cert_document(client)["root"]["serial"] == before, "and the CA did not move"
assert cert_events(client) == []
assert cert_meta(store)[certs.META_AGENTS] == "{}", "nothing was issued for the name either"
def test_a_read_of_the_cert_surface_issues_nothing_and_writes_nothing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A GET that mints a certificate is the surprise this route must never be.
`certs.ensure_certs` is the bootstrapping read and `certs.read_state` is the request-time one;
making `ensure_certs` explode is what proves the difference rather than asserting it in prose,
and the meta rows are compared byte for byte across three reads.
"""
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
host_interval=3600.0) as client:
store: SampleStore = cert_context(client).store
before = cert_meta(store)
def explode(*args: Any, **kwargs: Any) -> Any:
raise AssertionError("a GET tried to bootstrap the certificate authority")
monkeypatch.setattr(certs, "ensure_certs", explode)
assert cert_agent_rows(client)
assert cert_document(client)["root"]["serial"] == str(
x509.load_pem_x509_certificate(
str(before[certs.META_ROOT_CERT]).encode()).serial_number)
# A bundle that has not been issued is still a 404 rather than a mint, on a read too.
assert client.get("/api/certs/deskwork/bundle").status_code == 404
assert cert_meta(store) == before, "three reads, no write"
assert cert_events(client) == []
# --------------------------------------------------------------------------- #
# phase 14 task 04: the endpoint budgets
#
+112 -6
View File
@@ -42,7 +42,7 @@ from auth_contract import FAST_PBKDF2
from dashboards import auth
from dashboards.api import MIN_PASSWORD_LENGTH, AppContext
from dashboards.app import PUBLIC_PATHS, PUBLIC_PREFIXES, create_app, is_public
from dashboards.config import Settings
from dashboards.config import Settings, Target
from dashboards.poller import Hub
from dashboards.sources import nut
from dashboards.store import SampleStore
@@ -58,11 +58,17 @@ PROTECTED_API = (
"/api/system/snapshot", "/api/system/history", "/api/disks/snapshot",
"/api/disks/history", "/api/llm/snapshot", "/api/llm/history", "/api/ups/snapshot",
"/api/ups/history", "/api/ups/commands", "/api/auth/status",
# Phase 15: the certificate authority's own surface. `/api/certs/<name>/bundle` is every
# agent's private key, so "it needs a session" is the whole point of listing it here.
"/api/certs", "/api/certs/{name}/bundle", "/api/certs/{name}/rotate",
"/api/certs/{name}/revoke", "/api/certs/root/rotate",
)
#: Every HTML page behind the gate. `/settings` is a **page** and not one of the four
#: dashboards (it carries no `aria-current` tab), which is exactly why it is in this list: the
#: one thing that must never regress is a new route answering anonymously.
PROTECTED_PAGES = ("/", "/system", "/disks", "/llm", "/ups", "/settings")
#: Every HTML page behind the gate. `/settings` and `/certs` are **pages** and not dashboards
#: (neither carries an `aria-current` tab), which is exactly why they are in this list: the one
#: thing that must never regress is a new route answering anonymously. `/certs` is the sharper
#: case of the two — an anonymous caller reaching it would see the CA's serials and every host
#: the dashboard dials.
PROTECTED_PAGES = ("/", "/system", "/disks", "/llm", "/ups", "/settings", "/certs")
#: The one write this app has, with the fields the route needs (the UPS is unconfigured, so a
@@ -1256,6 +1262,105 @@ def test_the_danger_word_is_still_what_cuts_power_after_the_gate(nut_app: TestCl
assert WireRecorder.sent == [("apc", "driver.killpower")]
# --------------------------------------------------------------------------- #
# The certificate authority's writes (phase 15 task 04)
# --------------------------------------------------------------------------- #
@pytest.fixture
def cert_app(tmp_path: Path) -> Iterator[TestClient]:
"""A signed-in client on an app with **one configured agent** and no live host.
What these routes *do* is `tests/test_api.py`'s business; this file asks only whether the gate
stops a write before it reaches `certs.py`. The target is a dead loopback port because a
certificate route is never allowed to dial anything (invariant 1) — the stored agent table is
the wire here, which is what the assertions below read.
"""
db = tmp_path / "certs-gate.sqlite3"
store = SampleStore(str(db))
try:
app = create_app(Settings(db_path=str(db), admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2,
host_targets=(Target(name="deskwork",
url="https://127.0.0.1:1"),),
host_interval=3600.0, disks_interval=3600.0), store)
with TestClient(app, follow_redirects=False) as client:
sign_in(client)
yield client
finally:
store.close()
def test_anonymous_calls_see_no_certificate_list_and_no_bundle(anon: TestClient) -> None:
"""The named, explicit version of the route-walk proof.
`test_every_route_that_is_not_public_requires_a_session` walks the served table and cannot miss
a route; this test is here so the *bundle* route — every agent's private key, behind a login —
has one assertion that says what it is about, and so a redirect that would leak the existence of
a host name into a `Location` header is caught as deliberately as the 401 is.
"""
listing = anon.get("/api/certs", headers=JSON_ACCEPT)
assert listing.status_code == 401
assert listing.json() == {"ok": False, "detail": "sign in required", "login": "/login"}
assert "no-store" in listing.headers["cache-control"]
bundle = anon.get("/api/certs/deskwork/bundle", headers=JSON_ACCEPT)
assert bundle.status_code == 401, "a bundle is a private key with a filename on it"
assert "location" not in bundle.headers
for write in (anon.post("/api/certs/deskwork/rotate"),
anon.post("/api/certs/deskwork/revoke"),
anon.post("/api/certs/root/rotate")):
assert write.status_code == 401, write.url
assert write.json() == {"ok": False, "detail": "sign in required", "login": "/login"}
assert "no-store" in write.headers["cache-control"]
def test_a_cert_write_without_the_csrf_token_changes_no_stored_row(
cert_app: TestClient) -> None:
"""The UPS wire test's shape on the CA's own writes, with the row as the wire.
Proven in both directions on purpose: a gate that refused every write would pass a test that
only asserted the refusal, and a route that wrote before checking would pass one that only
asserted the success. The stored agent table is what a rotation changes, so it is what is read.
"""
def issued() -> dict[str, Any]:
rows: list[dict[str, Any]] = cert_app.get("/api/certs").json()["agents"]
return rows[0]
assert issued()["status"] == "unissued" and issued()["bundle_rev"] is None
csrf = str(cert_app.headers.get(auth.CSRF_HEADER))
del cert_app.headers[auth.CSRF_HEADER]
refused = cert_app.post("/api/certs/deskwork/rotate")
assert refused.status_code == 403
assert "csrf" in refused.json()["detail"].lower()
assert issued()["status"] == "unissued", "a key was minted by a write the gate refused"
cert_app.headers[auth.CSRF_HEADER] = csrf
accepted = cert_app.post("/api/certs/deskwork/rotate")
assert accepted.status_code == 200, accepted.text
assert accepted.json()["bundle_rev"] == 1
assert issued()["bundle_rev"] == 1
def test_a_cross_origin_cert_write_is_refused_before_the_authority_is_touched(
cert_app: TestClient) -> None:
"""The origin half of the gate on a route whose effect is durable and fleet-wide."""
cross_site = cert_app.post("/api/certs/deskwork/revoke",
headers={"origin": "https://elsewhere.example"})
assert cross_site.status_code == 403
assert "cross-origin" in cross_site.json()["detail"]
stalled = cert_app.post("/api/certs/root/rotate", headers={"sec-fetch-site": "cross-site"})
assert stalled.status_code == 403
assert cert_app.get("/api/certs").json()["agents"][0]["status"] == "unissued", (
"nothing moved: not the host's identity and not the root")
# …and the same request with nothing forged is an ordinary gated write.
assert cert_app.post("/api/certs/deskwork/revoke").status_code == 200
def test_a_cross_origin_write_is_refused_even_with_a_session_and_a_csrf_token(
authed: TestClient) -> None:
response = authed.post("/api/ups/command", json=WRITE,
@@ -1493,7 +1598,8 @@ def test_the_served_route_table_is_the_surface_the_gate_describes(anon: TestClie
pages = sorted(str(route.path) for route in served_routes(anon)
if isinstance(getattr(route, "path", None), str)
and not str(route.path).startswith(("/api", "/static")))
assert pages == ["/", "/disks", "/llm", "/login", "/settings", "/system", "/ups"]
assert pages == ["/", "/certs", "/disks", "/llm", "/login", "/settings", "/system",
"/ups"]
for path in pages:
response = anon.get(path, headers=BROWSER_HEADERS)
if path == "/login":
+1094
View File
File diff suppressed because it is too large Load Diff
+406
View File
@@ -10,21 +10,30 @@ transitions); the socket protocol itself is in test_nut.py.
from __future__ import annotations
import json
import threading
from http.server import ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from typing import Any, ClassVar
import httpx
import pytest
from dashboards import certs
from dashboards.collectors import (
AgentTls,
NutMonitor,
UpstreamError,
agent_tls_map,
disks_jobs,
fetch_disk_snapshot,
fetch_engine,
fetch_host_snapshot,
fetch_snapshot,
host_jobs,
)
from dashboards.config import Target
from dashboards.poller import Result
from dashboards.rates import RateEstimator
from dashboards.sources import nut
from dashboards.sources.disks import DisksCollector
@@ -351,6 +360,403 @@ async def test_smart_addon_errors_reach_the_disk_payload_verbatim(
assert "degraded" not in system.payload, "and /system stays unbothered by it"
# --------------------------------------------------------------------------- #
# TLS: the dashboard dials a protected exporter with its own client certificate
# --------------------------------------------------------------------------- #
#
# The certificates come from `dashboards.certs` over a dict store and the server is the real
# `agent/host_agent.py` in the TLS mode task 02 added, so this proves the dial itself: the
# client certificate is presented, the agent is verified against the root this dashboard minted,
# and a host the operator revoked is refused before a socket opens. `httpx.MockTransport` is
# kept for the sentences (a 403 body is the agent's words, not a handshake).
class DictMeta:
"""The three-method `certs.MetaStore` slice, backed by a dict (the `test_certs.py` idiom)."""
def __init__(self) -> None:
self.rows: dict[str, str] = {}
def get_meta(self, key: str) -> str | None:
return self.rows.get(key)
def set_meta(self, key: str, value: str) -> None:
self.rows[key] = value
def delete_meta(self, key: str) -> int:
return 1 if self.rows.pop(key, None) is not None else 0
@pytest.fixture
def tls_fleet(tmp_path: Path, host_agent_module: Any) -> Any:
"""One dashboard CA + client pair, one issued agent bundle, and the agent serving it.
Everything production has: the material written to a private directory (`httpx` takes
paths), the bundle installed where `install-agent.sh` will put it, and the `AgentTls` the
jobs get — built by `agent_tls_map`, the same factory `app.build_hub` is handed, so the
tests dial with the production wiring and not a hand-built guess of it.
"""
store = DictMeta()
state = certs.ensure_certs(store)
state = certs.issue_agent_cert(store, state, "localhost", ["127.0.0.1", "localhost"])
entry = state.agent("localhost")
assert entry is not None
material = certs.write_material(state, tmp_path)
bundle_path = tmp_path / "dashboards-agent.certs"
bundle_path.write_text(entry.bundle, encoding="ascii")
bundle, error = host_agent_module.TlsBundle.load(str(bundle_path))
assert error is None and bundle is not None
host_agent_module.Handler.collector = host_agent_module.Collector("/", smart=False)
host_agent_module.Handler.token = None
host_agent_module.Handler.bundle = bundle
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent_module.Handler)
httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
base = f"https://127.0.0.1:{httpd.server_address[1]}"
target = Target("localhost", base)
try:
yield SimpleNamespace(
store=store,
state=state,
material=material,
bundle=bundle,
bundle_rev=entry.bundle_rev,
base=base,
target=target,
tls=agent_tls_map(material, store, [target])["localhost"],
)
finally:
httpd.shutdown()
httpd.server_close()
# `Handler` is process-wide state: a later test must not inherit this bundle.
host_agent_module.Handler.bundle = None
host_agent_module.Handler.token = None
certs.remove_material(material)
async def test_an_https_agent_answers_the_dashboard_that_presents_its_certificate(
tls_fleet: SimpleNamespace) -> None:
"""The whole point of the phase, collector side: our cert opens the agent.
The two fields the agent adds in TLS mode come back too, because task 05's page compares the
revision `/certs` issued with the revision the host is actually running.
"""
async with httpx.AsyncClient(timeout=15.0) as client:
body = await fetch_snapshot(client, tls_fleet.target, tls=tls_fleet.tls)
assert body["ok"] is True
assert body["tls"] is True
assert body["bundle_rev"] == tls_fleet.bundle_rev
result = await fetch_host_snapshot(client, tls_fleet.target,
HostCollector(RateEstimator()), tls=tls_fleet.tls)
assert result.payload["agent"]["tls"] is True
assert result.payload["agent"]["bundle_rev"] == tls_fleet.bundle_rev
assert result.payload["memory"]["total_bytes"] > 0, "the snapshot is a real one"
async def test_an_agent_signed_by_another_authority_is_not_trusted(
tls_fleet: SimpleNamespace, tmp_path: Path) -> None:
"""A root that is not ours verifies nothing here, so the dial fails instead of accepting.
Trusting any certificate would make the whole phase a no-op with extra steps, and the
failure has to reach the poller as an `UpstreamError` naming the host rather than as a
traceback out of `ssl`.
"""
stranger_base = tmp_path / "stranger"
stranger_base.mkdir()
stranger = certs.write_material(certs.ensure_certs(DictMeta()), stranger_base)
foreign = agent_tls_map(stranger, tls_fleet.store, [tls_fleet.target])["localhost"]
async with httpx.AsyncClient(timeout=15.0) as client:
with pytest.raises(UpstreamError) as caught:
await fetch_snapshot(client, tls_fleet.target, tls=foreign)
assert "unreachable" in caught.value.message
assert tls_fleet.base in caught.value.message
certs.remove_material(stranger)
async def test_dashboard_material_that_cannot_be_read_fails_the_poll(
tls_fleet: SimpleNamespace, tmp_path: Path) -> None:
"""A missing client-cert file is a red source with a sentence, not a dead poll loop.
`httpx` loads the CA and the client chain when the client is *built*, so this arrives as a
`FileNotFoundError` from inside the fetch. A TLS dial that can only fail with a non-HTTP
exception is the invariant-4 failure mode: the task would die and the source would look
healthy while its charts stopped.
"""
broken = AgentTls(verify=str(tls_fleet.material.ca),
cert=(str(tmp_path / "gone-cert.pem"), str(tmp_path / "gone-key.pem")),
status=lambda: certs.ACTIVE)
async with httpx.AsyncClient(timeout=15.0) as client:
with pytest.raises(UpstreamError, match="unreachable") as caught:
await fetch_snapshot(client, tls_fleet.target, tls=broken)
assert "FileNotFoundError" in caught.value.message
assert tls_fleet.base in caught.value.message, "the failure still names the host's URL"
async def test_an_https_agent_is_not_readable_without_the_material(tls_fleet: SimpleNamespace) -> None:
"""A shared client with no client certificate gets nowhere — "only this dashboard can read
an agent" has to hold for the collector's own client too, not just for a stranger's.
"""
async with httpx.AsyncClient(timeout=15.0) as client:
with pytest.raises(UpstreamError) as caught:
await fetch_snapshot(client, tls_fleet.target)
assert "unreachable" in caught.value.message
async def test_a_revoked_agent_is_refused_before_anything_is_dialled(
tls_fleet: SimpleNamespace, monkeypatch: pytest.MonkeyPatch) -> None:
"""The revocation gate is in front of the network, not behind it.
The spy replaces `httpx.AsyncClient.get` on the class, so it catches the shared client and
the per-dial TLS client alike: what is being proved is that no request is built at all, not
that one particular client was unused. A dial-then-refuse would still put a socket on a host
the operator just struck out.
"""
calls: list[str] = []
async def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
calls.append(str(url))
raise AssertionError(f"a revoked agent was dialled at {url}")
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
certs.revoke(tls_fleet.store, tls_fleet.state, "localhost")
async with httpx.AsyncClient(timeout=15.0) as client:
with pytest.raises(UpstreamError, match="agent certificate revoked") as caught:
await fetch_snapshot(client, tls_fleet.target, tls=tls_fleet.tls)
with pytest.raises(UpstreamError, match="agent certificate revoked"):
await fetch_disk_snapshot(client, tls_fleet.target, DisksCollector(),
tls=tls_fleet.tls)
assert calls == [], "a revoked host must not be dialled on either cadence"
assert "revoked" in caught.value.message
async def test_the_revocation_answer_is_a_live_read_of_the_store(tls_fleet: SimpleNamespace) -> None:
"""`status` is a callable because the operator clicks revoke while this process is running.
A status string read at startup would keep dialling a struck-out host until the next
restart, and a dashboard that needs a restart to enforce a security decision does not
enforce it.
"""
tls = tls_fleet.tls
assert tls.status() == certs.ACTIVE
assert tls.revoked is False
certs.revoke(tls_fleet.store, tls_fleet.state, "localhost")
assert tls.status() == certs.REVOKED
assert tls.revoked is True, "the same object, with no rebuild and no restart"
def test_the_revocation_is_per_host_and_the_material_is_shared(tls_fleet: SimpleNamespace) -> None:
"""One identity for every dial, one revocation answer per name.
`certs.py` gives the dashboard a single client certificate, so `verify`/`cert` are the same
paths for every host; what differs is whose identity the operator struck out. A name with no
entry is active rather than refused — never-issued is not the same fact as revoked, and
answering revoked for it would take a working host red with no operator action behind it
(invariant 3).
"""
targets = [tls_fleet.target, Target("deskwork", "https://10.1.0.51:9120")]
mapping = agent_tls_map(tls_fleet.material, tls_fleet.store, targets)
assert mapping["localhost"].verify == str(tls_fleet.material.ca)
assert mapping["localhost"].cert == mapping["deskwork"].cert == (
str(tls_fleet.material.client_cert), str(tls_fleet.material.client_key))
certs.revoke(tls_fleet.store, tls_fleet.state, "localhost")
assert mapping["localhost"].status() == certs.REVOKED
assert mapping["deskwork"].status() == certs.ACTIVE
assert agent_tls_map(None, tls_fleet.store, targets) == {}, (
"no material means no TLS dial, exactly the pre-phase-15 path")
def scripted_refusal(body: Any, *, status: int = 403) -> AgentTls:
"""An agent that answers every `/snapshot` with a refusal, in whatever shape it sent the body."""
def handler(request: httpx.Request) -> httpx.Response:
if isinstance(body, dict):
return httpx.Response(status, json=body)
return httpx.Response(status, text=body)
return AgentTls(verify="/unused/root.pem", cert=("/unused/cert.pem", "/unused/key.pem"),
status=lambda: certs.ACTIVE, transport=httpx.MockTransport(handler))
@pytest.mark.parametrize("body,expected", [
({"ok": False, "detail": "client certificate required"}, "client certificate required"),
({"ok": False, "detail": "client certificate revoked"}, "client certificate revoked"),
("<html><body>403 Forbidden</body></html>", "403 Forbidden"),
("", "no reason given"),
])
async def test_a_refusal_from_a_protected_agent_quotes_its_own_words(
body: Any, expected: str) -> None:
"""The 403 sentence names the refusal the *agent* made, redacted and never blank.
An `UpstreamError` string goes into the health record and the event table, so it has to say
which of the agent's two refusals this was (a required certificate and a revoked one are two
different operator actions) while quoting nothing but the URL it was dialling
(AGENTS.md invariant 6). The `verify`/`cert` paths here do not exist: a scripted refusal
proves the wording, and the tests above prove the handshake.
"""
tls = scripted_refusal(body)
target = Target("gw", "https://SEKRIT-AGENT-TOKEN@agent.test")
async with httpx.AsyncClient(timeout=15.0) as client:
with pytest.raises(UpstreamError) as caught:
await fetch_snapshot(client, target, tls=tls)
message = caught.value.message
assert "agent refused the client certificate" in message
assert expected in message
assert "SEKRIT-AGENT-TOKEN" not in message, "the token leaked into a browser-readable string"
assert "https://agent.test/snapshot" in message, "and it still says where"
@pytest.mark.parametrize("status", [401, 403, 500])
async def test_a_refusal_from_an_unprotected_agent_keeps_the_status_wording(status: int) -> None:
"""The certificate sentence is only for a dial where a certificate was presented.
A token-mode host answers 401 for a bad token and would answer 403 for something that has
nothing to do with certificates; calling that "refused the client certificate" would send
the operator to `/certs` for a token problem.
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status, text="nope")
target = Target("gw", "http://agent.test")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
with pytest.raises(UpstreamError) as caught:
await fetch_snapshot(client, target,
tls=AgentTls(verify="/unused/root.pem",
cert=("/unused/c.pem", "/unused/k.pem"),
status=lambda: certs.ACTIVE))
if status == 401:
assert "token" in caught.value.message
else:
assert f"HTTP {status}" in caught.value.message
assert "client certificate" not in caught.value.message
async def test_a_token_mode_host_still_uses_the_shared_client(tls_fleet: SimpleNamespace) -> None:
"""An `http://` entry is dialled exactly as it was before phase 15.
The material exists in this deployment (one CA, one client pair, every host), so the scheme
is the only thing that may decide the path — a rollout is per host, and the plain hosts
cannot be allowed to pick up a client certificate they never asked for.
"""
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(str(request.url))
return httpx.Response(200, json=agent_body())
plain = Target("localhost", "http://agent.test")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
body = await fetch_snapshot(client, plain, tls=tls_fleet.tls)
assert body["ok"] is True
assert seen == ["http://agent.test/snapshot"], "the shared client, the shared transport"
async def test_a_revoked_agent_fails_both_jobs_with_one_sentence(store: Any,
tls_fleet: SimpleNamespace,
monkeypatch: pytest.MonkeyPatch) -> None:
"""The phase's criterion, at the level the health record is written: `host:` and `disks:`.
The two jobs dial one agent on two cadences, so a revocation has to fail both with the same
sentence (one dead source must not blink the other, and both must stop talking to it).
"""
calls: list[str] = []
async def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
calls.append(str(url))
raise AssertionError(f"a revoked agent was dialled at {url}")
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
certs.revoke(tls_fleet.store, tls_fleet.state, "localhost")
async with httpx.AsyncClient(timeout=15.0) as client:
jobs = host_jobs([tls_fleet.target], client, HostCollector(RateEstimator()), 10.0,
tls={"localhost": tls_fleet.tls})
jobs += disks_jobs([tls_fleet.target], client, DisksCollector(), 300.0,
tls={"localhost": tls_fleet.tls})
assert [job.key for job in jobs] == ["host:localhost", "disks:localhost"]
for job in jobs:
assert await job.run_once(store) is False
assert [job.state.error for job in jobs] == [
"UpstreamError: localhost: agent certificate revoked"] * 2
assert calls == []
async def test_host_and_disk_jobs_hand_each_host_its_own_material(
monkeypatch: pytest.MonkeyPatch) -> None:
"""The plumbing, pinned: a host in the map gets its entry, a host outside it gets `None`.
Two hosts and one wrong-key miss is the minimum that shows the lookup is by name rather than
"whatever was built first", which is how a revoked host would keep being dialled with
another host's revocation answer.
"""
seen: dict[str, Any] = {}
def record(name: str) -> Any:
async def scrape(client: Any, target: Target, *_args: Any, **kwargs: Any) -> Result:
seen[f"{name}:{target.name}"] = kwargs.get("tls")
return Result(payload={}, samples=[], events=[])
return scrape
fetch_host = record("system")
fetch_disk = record("disks")
monkeypatch.setattr("dashboards.collectors.fetch_host_snapshot", fetch_host)
monkeypatch.setattr("dashboards.collectors.fetch_disk_snapshot", fetch_disk)
entry = AgentTls(verify="/ca.pem", cert=("/c.pem", "/k.pem"),
status=lambda: certs.ACTIVE)
targets = [Target("gw", "https://gw.test"), Target("desk", "http://desk.test")]
async with httpx.AsyncClient() as client:
for job in (host_jobs(targets, client, HostCollector(RateEstimator()), 10.0,
tls={"gw": entry})
+ disks_jobs(targets, client, DisksCollector(), 300.0, tls={"gw": entry})):
await job.fn()
assert seen == {"system:gw": entry, "system:desk": None,
"disks:gw": entry, "disks:desk": None}
async def test_the_tls_self_report_reaches_both_payloads(disk_snapshot: Any) -> None:
"""`tls` + `bundle_rev` travel into `/api/system/snapshot` and `/api/disks/snapshot`.
Task 05's page has to answer "is the installed bundle the one I just issued?", and both
dashboards read the same snapshot, so both carry the deployed revision under the same
`agent` key. Nothing grades it: a revision is an identity counter, not a threshold.
"""
def stamped(snapshot: dict[str, Any]) -> dict[str, Any]:
"""What a TLS-mode agent's `/snapshot` looks like on the wire."""
snapshot["tls"] = True
snapshot["bundle_rev"] = 3
return snapshot
target = Target("driveripper", "http://agent.test")
async with client_for(lambda request: httpx.Response(200,
json=stamped(agent_body()))) as client:
system = (await fetch_host_snapshot(client, target,
HostCollector(RateEstimator()))).payload
assert system["agent"]["tls"] is True
assert system["agent"]["bundle_rev"] == 3
# the `/disks` payload is built from a capture that also carries SMART blocks, so it gets
# its own stamped snapshot rather than sharing the one above
with_smart = disk_snapshot(mutate=stamped)
async with client_for(lambda request: httpx.Response(200, json=with_smart)) as client:
disks = (await fetch_disk_snapshot(client, target, DisksCollector())).payload
assert disks["agent"] == {"tls": True, "bundle_rev": 3}
async def test_a_token_mode_snapshot_gains_no_tls_fields() -> None:
"""The captures are token-mode agents, and their payloads must stay byte-identical.
`false` and `0` would be numbers the agent never published (rule 2), and an empty block
would be a container that means nothing.
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=agent_body())
target = Target("driveripper", "http://agent.test")
async with client_for(handler) as client:
system = (await fetch_host_snapshot(client, target, HostCollector(RateEstimator()))).payload
assert "tls" not in system["agent"] and "bundle_rev" not in system["agent"]
assert system["agent"]["version"], "the block is still the block it was"
# --------------------------------------------------------------------------- #
# LLM engines
# --------------------------------------------------------------------------- #
+378
View File
@@ -12,15 +12,21 @@ apart from the path the script was invoked through.
from __future__ import annotations
import json
import os
import re
import shutil
import stat
import subprocess
import threading
from http.server import ThreadingHTTPServer
from pathlib import Path
from typing import Any
import pytest
from dashboards import certs
ROOT = Path(__file__).resolve().parent.parent
DEPLOY = ROOT / "deploy"
INSTALLER = DEPLOY / "install-agent.sh"
@@ -291,6 +297,7 @@ ROOT_BOUNDARY_STUBS = ("id", "getent", "useradd", "usermod", "chown", "systemctl
SANDBOX_REWRITES = {
'AGENT_DIR="/usr/local/lib/dashboards-agent"': 'AGENT_DIR="{root}/lib"',
'TOKEN_FILE="/etc/dashboards-agent.token"': 'TOKEN_FILE="{root}/token"',
'CERT_FILE="/etc/dashboards-agent.certs"': 'CERT_FILE="{root}/certs"',
'UNIT="/etc/systemd/system/$SERVICE.service"': 'UNIT="{root}/dashboards-agent.service"',
}
@@ -361,6 +368,10 @@ def test_install_path_installs_into_its_own_paths_and_prints_the_token_once(tmp_
assert "User=dashboards-agent" in unit
assert f"--token-file {token_path}" in unit, "the unit must use the file, never --token"
assert "--host 10.0.0.5 --port 9120" in unit
# A token-only install runs no TLS, so it gets neither the bundle flag nor the directory
# the bundle unpacks into (the agent falls back to a private mkdtemp without one).
assert "--cert-bundle" not in unit, "no bundle installed means no TLS flag on a token install"
assert "RuntimeDirectory=dashboards-agent" not in unit
# DevicePolicy=strict denies every node not listed, and systemd opens /dev/null for stdin
# before it execs python: without this line the unit dies at spawn (status 208/STDIN).
assert "DevicePolicy=strict" in unit
@@ -396,3 +407,370 @@ def test_token_file_is_readable_by_the_service_user_and_nobody_else(tmp_path: Pa
mode = stat.S_IMODE((tmp_path / "target" / "token").stat().st_mode)
assert mode == 0o640, f"token file mode is {oct(mode)}, expected 0o640 (group = the agent user)"
assert not mode & 0o007, "the token file must never be world-readable"
# --- the certificate bundle (phase 15 task 06) --------------------------------------------
#: Where the installed bundle lives — one constant in the script, pinned here so a rename
#: fails a test instead of stranding every already-written unit file.
CERT_FILE_PATH = "/etc/dashboards-agent.certs"
def _fake_bundle_text(revision: str = "3") -> str:
"""A syntactically real bundle: agent cert, agent key, root CA, and the rev line.
Shape only. The installer's validator greps PEM blocks by text exactly like
`resolve_bundle` does (a monitored host has no `cryptography`), so a well-formed *fake*
is all an unprivileged validation run needs; the functional probe tests below use a
real bundle minted by `dashboards.certs`.
"""
def block(kind: str, body: str) -> str:
return f"-----BEGIN {kind}-----\n{body}\n-----END {kind}-----\n"
return (
f"# BUNDLE-REV {revision}\n"
+ block("CERTIFICATE", "AGENTCERT")
+ block("PRIVATE KEY", "AGENTKEY")
+ block("CERTIFICATE", "ROOTCA")
)
def _installer_status(stdout: str) -> str:
"""The line `status()` prints as `health: …`, parsed as JSON when it is JSON."""
for line in stdout.splitlines():
if line.startswith("health: "):
body = line.removeprefix("health: ").strip()
try:
return str(json.loads(body)["ok"])
except (json.JSONDecodeError, KeyError):
return body
pytest.fail(f"the probe printed no health line:\n{stdout}")
@pytest.mark.parametrize(
("shape", "reason"),
[
("missing", "cannot be read"),
("empty", "is empty"),
("no-revision", "BUNDLE-REV"),
("two-keys", "the agent certificate, the agent key and the root CA"),
],
)
def test_a_bad_cert_bundle_is_refused_with_its_reason_before_anything_else(
tmp_path: Path, shape: str, reason: str
) -> None:
"""Completion criterion: `--cert-bundle /nonexistent` refuses with the reason, touches nothing.
The validation runs *before* `need_root`, so the failure names the file rather than
sending the operator to add a sudoers line for a typo in the flag's argument — and
nothing after the validation (user, files, unit) can have run.
"""
if shape == "missing":
path = tmp_path / "absent.bundle"
elif shape == "empty":
path = tmp_path / "empty.bundle"
path.write_text("\n", encoding="ascii")
elif shape == "no-revision":
path = tmp_path / "revless.bundle"
path.write_text(_fake_bundle_text().replace("# BUNDLE-REV 3\n", ""), encoding="ascii")
else: # a bundle with the root *key* in it is exactly what the shape check refuses
path = tmp_path / "leaky.bundle"
extra = "-----BEGIN PRIVATE KEY-----\nROOTKEY\n-----END PRIVATE KEY-----\n"
path.write_text(_fake_bundle_text() + extra, encoding="ascii")
result = _installer_without_root("--cert-bundle", str(path))
assert result.returncode == 2, "the bundle refusal keeps its own exit code (2)"
assert result.stdout == "", "a refusal goes to stderr, not stdout"
assert reason in result.stderr
assert str(path) in result.stderr, "the message names the file the operator passed"
assert GRANT_PREFIX not in result.stderr, "a bad bundle is not a sudoers problem"
def test_a_well_formed_bundle_passes_validation_and_reaches_the_root_check(
tmp_path: Path,
) -> None:
"""Validation is shape-only and never dials: the next refusal is the usual sudoers one."""
path = tmp_path / "agent.bundle"
path.write_text(_fake_bundle_text(), encoding="ascii")
result = _installer_without_root("--bind", "10.0.0.5", "--cert-bundle", str(path))
assert result.returncode == 1
assert _grant_lines(result.stderr), "the bundle validated; what remains is the root boundary"
def test_installer_help_documents_the_cert_bundle_and_its_path() -> None:
result = _run([BASH, str(INSTALLER), "--help"])
assert result.returncode == 0
assert "--cert-bundle" in result.stdout
assert CERT_FILE_PATH in result.stdout
def test_the_bundle_lands_at_the_token_file_idiom_in_the_script() -> None:
"""`0640 root:$AGENT_USER` + chown: the agent opens the bundle itself and re-reads it."""
text = INSTALLER.read_text()
assert f'CERT_FILE="{CERT_FILE_PATH}"' in text
assert 'install -m 0640 "$BUNDLE_SRC" "$CERT_FILE"' in text
assert 'chown root:"$AGENT_USER" "$CERT_FILE"' in text
# 0600 would fail the unit the way it would fail the token file: the service user opens
# the bundle at startup and re-reads the denylist live. Pin the reasoning, not just the mode.
assert "0600 root-owned file would fail the unit" in text
assert "re-reads it when its stamp changes" in text
def test_the_tls_unit_lines_come_from_one_bundle_branch() -> None:
"""Flag + RuntimeDirectory are emitted together, or the unit is a token-mode unit."""
text = INSTALLER.read_text()
match = re.search(r'\n if \[ -f "\$CERT_FILE" \]; then(.*?)\n fi\n', text, re.DOTALL)
assert match, "the TLS unit lines must come from one `if [ -f \"$CERT_FILE\" ]` branch"
block = match.group(1)
assert 'TLS_EXEC_ARGS=" --cert-bundle $CERT_FILE"' in block
assert "RuntimeDirectory=dashboards-agent" in block
assert "${TLS_EXEC_ARGS}" in text, "ExecStart must carry the flag from that branch"
def test_status_probes_tls_with_the_bundle_ca_and_no_client_cert() -> None:
"""The status branch: unit says --cert-bundle → python3 ssl probe, never openssl, no cert."""
status_body = INSTALLER.read_text().split("status() {", 1)[1].split("\n}", 1)[0]
assert "grep -q -- '--cert-bundle'" in status_body
assert "PROTOCOL_TLS_CLIENT" in status_body
assert "load_verify_locations" in status_body
assert "https://" in status_body
assert "load_cert_chain" not in status_body, "the installer holds no client certificate"
assert 'http://$BIND:$PORT/health' in status_body, "token mode keeps the plain probe"
assert "openssl" not in INSTALLER.read_text(), "no new binary dependency on a monitored host"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_a_bundle_install_lands_the_file_the_unit_flag_and_the_runtime_directory(
tmp_path: Path,
) -> None:
"""The install body with a bundle: byte-identical file at 0640, TLS unit, https hint."""
bundle = tmp_path / "agent.bundle"
bundle.write_text(_fake_bundle_text(), encoding="ascii")
script, call_log, env = _sandbox_installer(tmp_path)
result = subprocess.run(
[BASH, str(script), "--bind", "10.0.0.5", "--port", "9120", "--cert-bundle", str(bundle)],
capture_output=True,
text=True,
env=env,
timeout=60,
check=False,
)
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
cert_path = tmp_path / "target" / "certs"
assert cert_path.is_file(), "install -m never landed the bundle"
assert cert_path.read_text(encoding="ascii") == _fake_bundle_text(), (
"the installed bundle must be byte-identical to the downloaded one"
)
mode = stat.S_IMODE(cert_path.stat().st_mode)
assert mode == 0o640, f"bundle mode is {oct(mode)}, expected 0o640 (group = the agent user)"
assert not mode & 0o007, "the bundle holds the agent's key: never world-readable"
unit = (tmp_path / "target" / "dashboards-agent.service").read_text()
assert f"--cert-bundle {cert_path}" in unit
assert "RuntimeDirectory=dashboards-agent" in unit, (
"ProtectSystem=strict leaves the agent no writable file but this, and the bundle "
"unpacks its PEM blocks there"
)
assert "--token-file" in unit, "the dual-mode line stays — rollout is host by host"
assert 'HOST_TARGETS="hostname=https://10.0.0.5:9120"' in result.stdout, (
"a TLS install points the dashboard at https, with no token in the URL"
)
calls = call_log.read_text()
assert calls.count("chown root:dashboards-agent") == 2, "token and bundle both chowned"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_an_unrelated_rerun_reuses_the_installed_bundle(tmp_path: Path) -> None:
"""The token-reuse idiom: a re-run without the flag neither rewrites nor drops the bundle."""
script, _call_log, env = _sandbox_installer(tmp_path)
target = tmp_path / "target"
target.mkdir(parents=True)
cert_path = target / "certs"
cert_path.write_text(_fake_bundle_text(revision="7"), encoding="ascii")
result = subprocess.run(
[BASH, str(script), "--bind", "10.0.0.5"],
capture_output=True,
text=True,
env=env,
timeout=60,
check=False,
)
assert result.returncode == 0, f"re-run failed:\n{result.stdout}\n{result.stderr}"
assert cert_path.read_text(encoding="ascii") == _fake_bundle_text(revision="7")
assert "reusing the installed certificate bundle" in result.stdout
unit = (target / "dashboards-agent.service").read_text()
assert f"--cert-bundle {cert_path}" in unit, "an installed bundle keeps the unit in TLS mode"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_uninstall_removes_the_bundle_with_the_token(tmp_path: Path) -> None:
"""An uninstalled agent leaves no private key behind."""
script, call_log, env = _sandbox_installer(tmp_path)
target = tmp_path / "target"
(target / "lib").mkdir(parents=True)
leftovers = {
target / "token": "tok\n",
target / "certs": _fake_bundle_text(),
target / "lib" / "host_agent.py": "# stub\n",
target / "dashboards-agent.service": "[Unit]\n",
}
for path, body in leftovers.items():
path.write_text(body, encoding="ascii")
result = subprocess.run(
[BASH, str(script), "--uninstall"],
capture_output=True,
text=True,
env=env,
timeout=60,
check=False,
)
assert result.returncode == 0, f"uninstall failed:\n{result.stdout}\n{result.stderr}"
for path in leftovers:
assert not path.exists(), f"uninstall left {path} on disk"
assert "systemctl disable --now dashboards-agent" in call_log.read_text()
# --- the --status probe, end to end against the real TLS agent -----------------------------
class DictMeta:
"""The three-method `certs.MetaStore` slice, backed by a dict (the `test_auth.py` idiom)."""
def __init__(self) -> None:
self.rows: dict[str, str] = {}
def get_meta(self, key: str) -> str | None:
return self.rows.get(key)
def set_meta(self, key: str, value: str) -> None:
self.rows[key] = value
def delete_meta(self, key: str) -> int:
return 1 if self.rows.pop(key, None) is not None else 0
def _mint_bundle() -> str:
"""A real bundle from the dashboard's own CA — the artifact the operator downloads."""
store = DictMeta()
state = certs.ensure_certs(store)
state = certs.issue_agent_cert(store, state, "localhost", ["127.0.0.1", "localhost"])
entry = state.agent("localhost")
assert entry is not None
return entry.bundle
def _serve_tls_agent(host_agent: Any, bundle_text: str, where: Path) -> tuple[Any, int]:
"""The real agent handler on a TLS-wrapped loopback port, exactly as the unit runs it."""
path = where / "installed.certs"
path.write_text(bundle_text, encoding="ascii")
bundle, error = host_agent.TlsBundle.load(str(path))
assert error is None and bundle is not None
host_agent.Handler.collector = host_agent.Collector("/", smart=False)
host_agent.Handler.token = None
host_agent.Handler.bundle = bundle
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return httpd, httpd.server_address[1]
def _run_status(script: Path, port: int, env: dict[str, str]) -> str:
result = subprocess.run(
[BASH, str(script), "--status", "--bind", "127.0.0.1", "--port", str(port)],
capture_output=True,
text=True,
env=env,
timeout=60,
check=False,
)
assert result.returncode == 0, f"--status exited {result.returncode}:\n{result.stderr}"
return result.stdout
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_tls_status_probe_reads_health_with_the_ca_and_no_client_cert(
tmp_path: Path, host_agent_module: Any
) -> None:
"""The task-02 ASSUMPTION, proven through the installer: CERT_OPTIONAL + the /health
exemption let a probe that holds only the bundle's CA read /health on a TLS agent."""
bundle = _mint_bundle()
httpd, port = _serve_tls_agent(host_agent_module, bundle, tmp_path)
try:
script, _call_log, env = _sandbox_installer(tmp_path / "installer")
target = tmp_path / "installer" / "target"
target.mkdir(parents=True)
(target / "dashboards-agent.service").write_text(
f"ExecStart=python3 host_agent.py --cert-bundle {target / 'certs'}\n", encoding="ascii"
)
(target / "certs").write_text(bundle, encoding="ascii")
assert _installer_status(_run_status(script, port, env)) == "True"
finally:
httpd.shutdown()
httpd.server_close()
host_agent_module.Handler.bundle = None
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_tls_status_probe_verifies_the_chain_and_says_no_to_another_authority(
tmp_path: Path, host_agent_module: Any
) -> None:
"""The probe loads the CA for a reason: a server signed by a different root is not health."""
httpd, port = _serve_tls_agent(host_agent_module, _mint_bundle(), tmp_path)
try:
script, _call_log, env = _sandbox_installer(tmp_path / "installer")
target = tmp_path / "installer" / "target"
target.mkdir(parents=True)
(target / "dashboards-agent.service").write_text(
f"ExecStart=python3 host_agent.py --cert-bundle {target / 'certs'}\n", encoding="ascii"
)
(target / "certs").write_text(_mint_bundle(), encoding="ascii") # a *different* CA
stdout = _run_status(script, port, env)
finally:
httpd.shutdown()
httpd.server_close()
host_agent_module.Handler.bundle = None
line = _installer_status(stdout)
assert line != "True" and line.startswith("(no answer"), f"the probe must refuse: {line}"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_tls_status_probe_names_the_bundle_when_it_cannot_read_it(tmp_path: Path) -> None:
"""The bundle is 0640 root:agent — an unprivileged --status cannot read it, and says so."""
script, _call_log, env = _sandbox_installer(tmp_path)
target = tmp_path / "target"
target.mkdir(parents=True)
(target / "dashboards-agent.service").write_text(
f"ExecStart=python3 host_agent.py --cert-bundle {target / 'certs'}\n", encoding="ascii"
)
cert = target / "certs"
cert.write_text(_fake_bundle_text(), encoding="ascii")
cert.chmod(0o000)
line = _installer_status(_run_status(script, 1, env))
assert "cannot read" in line and "run --status as root" in line, line
@pytest.mark.skipif(shutil.which("curl") is None, reason="curl is not installed on this box")
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_a_token_mode_status_probe_stays_plain_http(tmp_path: Path) -> None:
"""The other branch: no bundle in the unit → the unchanged plain probe, not https."""
script, _call_log, env = _sandbox_installer(tmp_path)
target = tmp_path / "target"
target.mkdir(parents=True)
(target / "dashboards-agent.service").write_text(
"ExecStart=python3 host_agent.py --token-file /etc/dashboards-agent.token\n",
encoding="ascii",
)
result = subprocess.run(
[BASH, str(script), "--status", "--bind", "127.0.0.1", "--port", "1"],
capture_output=True,
text=True,
env=env,
timeout=60,
check=False,
)
assert result.returncode == 0
assert _installer_status(result.stdout).startswith("(no answer"), (
"token mode must probe plain http — nothing listens here, and the refusal is the point"
)
Generated
+225 -92
View File
@@ -35,11 +35,70 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.7.22"
version = "2026.10.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
@@ -129,11 +188,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" },
]
[[package]]
name = "cryptography"
version = "50.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
{ url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
{ url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
{ url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
{ url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
{ url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
{ url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
{ url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
{ url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
{ url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" },
{ url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" },
{ url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" },
{ url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" },
{ url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" },
{ url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" },
{ url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" },
{ url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" },
{ url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" },
{ url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" },
{ url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" },
{ url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" },
{ url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
{ url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
{ url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
{ url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
{ url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
{ url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
{ url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
{ url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
{ url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
{ url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
{ url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
{ url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
{ url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
{ url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
{ url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
{ url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
{ url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
{ url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
{ url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
]
[[package]]
name = "dashboards"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
@@ -153,6 +276,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=50.0.2" },
{ name = "fastapi", specifier = ">=0.143.3" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pydantic", specifier = ">=2.14.1" },
@@ -189,7 +313,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.143.5"
version = "0.153.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -199,9 +323,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/6a/ac6ec87026038fb7583018e0905f6c9d55bc8ee07d30d44c743f18b19435/fastapi-0.143.5.tar.gz", hash = "sha256:0b5c8e52d243cd2f015376b41e78db5bee585578782481abd145e1ab5a62a3ba", size = 470247, upload-time = "2026-10-18T09:12:55.556Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/69/8461d4c25490b5efe65b2178c857273785bd8b42a5555264b6b4f9bf2ed0/fastapi-0.153.0.tar.gz", hash = "sha256:5dbf5dcf6cf86388326c147566077c56a14c55b0f1bef370195da98e724a503f", size = 489663, upload-time = "2026-10-20T13:47:40.029Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/4e/5f7d1268900eb14a6697a42d94c8f7e3b8450d27301761f231672f928419/fastapi-0.143.5-py3-none-any.whl", hash = "sha256:09cc38ed8c6a95f407783ef4d98ceeb350e192fe8fbb8ed711070bcd5208fdfa", size = 144894, upload-time = "2026-10-18T09:12:54.125Z" },
{ url = "https://files.pythonhosted.org/packages/93/41/c2c2c10be9068304a522dd64893916b5b21549af084ea942c26388aaa5b0/fastapi-0.153.0-py3-none-any.whl", hash = "sha256:77fbc9f41d2e5cd81f0c0a20482e627f8ebbb9b9005ac7586e415ee69dfea4ec", size = 146107, upload-time = "2026-10-20T13:47:38.295Z" },
]
[[package]]
@@ -358,6 +482,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "pycparser"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" },
]
[[package]]
name = "pydantic"
version = "2.14.1"
@@ -571,14 +704,14 @@ wheels = [
[[package]]
name = "starlette"
version = "1.8.0"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/0c/6efb252d091ecccd7d62048ae11f0ea35cd75a4fbaeea5e30f9c3bf91d10/starlette-1.8.0.tar.gz", hash = "sha256:1565dc0b35d5737a271ed1e0e04e949f4e81198799f216d2667b0a0fb9cf9522", size = 2730457, upload-time = "2026-10-13T07:54:39.53Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ad/eb/4e197bf26b023abafc5e9043ec8710d7de0fcb032d0969045236d72aa0b1/starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318", size = 2732466, upload-time = "2026-10-18T15:50:33.189Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b0/5742e4ac7af5eb58ec3470a537a49d7aa507e5539413e504b3a65ef50ba8/starlette-1.8.0-py3-none-any.whl", hash = "sha256:dfdd6b29c26483288088d990eee59631dedadd66ce20d203402a7ca8e3c4656f", size = 79612, upload-time = "2026-10-13T07:54:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" },
]
[[package]]
@@ -698,89 +831,89 @@ wheels = [
[[package]]
name = "websockets"
version = "17.2"
version = "17.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" }
sdist = { url = "https://files.pythonhosted.org/packages/30/4b/4692be6d5bf739ed049b3813901c42091f8c054df89d5c089343823dea0d/websockets-17.3.tar.gz", hash = "sha256:1c7dd93a1c9f89297471493674b1fc1d9467cafd45374f2172213046bdb3a329", size = 188878, upload-time = "2026-10-19T16:58:05.813Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" },
{ url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" },
{ url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" },
{ url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" },
{ url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" },
{ url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" },
{ url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" },
{ url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" },
{ url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" },
{ url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" },
{ url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" },
{ url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" },
{ url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" },
{ url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" },
{ url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" },
{ url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" },
{ url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" },
{ url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" },
{ url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" },
{ url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" },
{ url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" },
{ url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" },
{ url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" },
{ url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" },
{ url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" },
{ url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" },
{ url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" },
{ url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" },
{ url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" },
{ url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" },
{ url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" },
{ url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" },
{ url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" },
{ url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" },
{ url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" },
{ url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" },
{ url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" },
{ url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" },
{ url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" },
{ url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" },
{ url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" },
{ url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" },
{ url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" },
{ url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" },
{ url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" },
{ url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" },
{ url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" },
{ url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" },
{ url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" },
{ url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" },
{ url = "https://files.pythonhosted.org/packages/27/ad/f65e365bbf38d61164b0c2f64776fc7714bfa59ac51e492b038a35f4ab1b/websockets-17.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:bc8fd230ef013951900c1fda100eebe4a4b1b407dde2a44186995042dda2c628", size = 218288, upload-time = "2026-10-19T16:55:53.387Z" },
{ url = "https://files.pythonhosted.org/packages/48/1c/33271eba53b4927a1b91d99d838a6d7b888e6f708425262c911450d73696/websockets-17.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cdb8e4b0f6a41b857c74c03116e8ffbef59ef8e533e934b532843178990d01d5", size = 215977, upload-time = "2026-10-19T16:55:55.063Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f3/6d982c60f6c7c95e5faed53d39263df273ecfda904c7d6920c37eee58dd1/websockets-17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:018fab26140ce221c3853454a608e4e7004f4f10641cb97819dcd5b917bb2e99", size = 216193, upload-time = "2026-10-19T16:55:56.424Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/4ff6b8d2a4b32719d4658bbd791ee9d19939a47a69b961c961fd9dc3a64c/websockets-17.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd0f3037d501178a761fd2814370aca547f79e99a200a61a1f6e7b79ec3d78b9", size = 225573, upload-time = "2026-10-19T16:55:58.252Z" },
{ url = "https://files.pythonhosted.org/packages/eb/8f/46e4030947f5590cb61832901f44a3edb07ef8d1e27f6c06b42c5f1c4d33/websockets-17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8dbb3862aa649de95660b6337d97101977f160b5b354d07b82fe309874d2c8bc", size = 225927, upload-time = "2026-10-19T16:55:59.561Z" },
{ url = "https://files.pythonhosted.org/packages/f0/1b/3db30fdf4fbefa4ac463060d3f87e2d444f960aa1234028468c09b88bffc/websockets-17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af37a1e6b6bd506b597d5fb4cf111fb527d1b2223608a5d441d4738827cfd166", size = 227169, upload-time = "2026-10-19T16:56:01.029Z" },
{ url = "https://files.pythonhosted.org/packages/02/ed/849799cfc0e3e359b75ca1d50605376da52dc9ef0b2a53931e3f5991f96e/websockets-17.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:740bba4223fa5216d4d6c5dcd29e697f37229b8a61b69e1a50527b0ea993ab2c", size = 228542, upload-time = "2026-10-19T16:56:02.377Z" },
{ url = "https://files.pythonhosted.org/packages/19/53/dcc397e20ce792fffd9a8f79b6333275748ee7a23f807e576166a0b16edd/websockets-17.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4170556762c6fd2913d99dfdd8dbb4c927584899df7facb1034627e355263c45", size = 227772, upload-time = "2026-10-19T16:56:03.768Z" },
{ url = "https://files.pythonhosted.org/packages/c9/36/e5935782f71e9e22247b2a2e562da05e18d8945c49f2692ca1bdaceec178/websockets-17.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d355b22270be8062360b9df2ee0b10277590789a1059257916fb9e256a0cff80", size = 226518, upload-time = "2026-10-19T16:56:05.332Z" },
{ url = "https://files.pythonhosted.org/packages/27/76/bd5978a1e403fc646230cf35cf7c06b40b3a232d440881b9e89ede229b18/websockets-17.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4aa85b34c21cf97b3470793d0bf0763d32bfd1e67b83067a0a3ba8dc3474dacf", size = 223766, upload-time = "2026-10-19T16:56:06.703Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/c66eb39bd851d5c14d9eb75aa64d03cfcf2ded77927ca067b5917291d19d/websockets-17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0e2e7c2c1a4f25db7b6596738d810c10732e77f852247300a1bc49e364d6dc9", size = 226644, upload-time = "2026-10-19T16:56:08.198Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ae/b9a31d5497fa0b73629a097b173a26123da2ed656b562114644bc623414b/websockets-17.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7fa69212ea60ea24e2d8aff23fe102b1f87f1b27a320f92d2e7b3468997a496a", size = 225107, upload-time = "2026-10-19T16:56:09.627Z" },
{ url = "https://files.pythonhosted.org/packages/61/0c/b773a102f84b60b2052b2cfde773d2f3cd3e8eaa93a145f54450460549cf/websockets-17.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:80a6e37e235780f189558ab804f3cbc01c18f5c578995739b49613d544d34ebf", size = 225891, upload-time = "2026-10-19T16:56:10.973Z" },
{ url = "https://files.pythonhosted.org/packages/b2/fa/e7e538575dd4912dea9ea4c9ae86519aa1420969d9cb5cf02fd10fd20ba7/websockets-17.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:628efe13630ac3397e7d1278b6be6f22f37a86d638a324334b4cd43c1daf9928", size = 227064, upload-time = "2026-10-19T16:56:12.295Z" },
{ url = "https://files.pythonhosted.org/packages/28/87/bf2b928c156deefd0c50285a48fc985e2ca85d284e7d9981cc5b79b0e696/websockets-17.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:443fa28652682d2a3ec3eca49af1b87cb2523561257683e62e03a98967b22138", size = 224618, upload-time = "2026-10-19T16:56:13.73Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/d59817ba3535f787c1cf8990a01b3d2896a7d9d8f3b5d55bed22fce595a6/websockets-17.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6705af9d1005127302c04d9f1533ba56e289336c465923ef878430e99295a0b9", size = 225571, upload-time = "2026-10-19T16:56:15.199Z" },
{ url = "https://files.pythonhosted.org/packages/a6/96/8210379dac889e16c75099aefffa200dc55dc8cb030010f309a7eb9c75c1/websockets-17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9bde5d82d7ac4859e0cb3a35e148f537a8d4e56aa43f42582b5f263982f095ae", size = 225869, upload-time = "2026-10-19T16:56:16.569Z" },
{ url = "https://files.pythonhosted.org/packages/97/0b/d4741c977fec9e6482beffece572d544d42d230f56fc3ef504f96521063a/websockets-17.3-cp314-cp314-win32.whl", hash = "sha256:be7be57097a65a8fab3d0f0a10d12931deb626a7d8082eb732b1ae41f3a202e3", size = 218232, upload-time = "2026-10-19T16:56:18.028Z" },
{ url = "https://files.pythonhosted.org/packages/33/69/2f9ce899776d0c522f7f44080cad4228a1c14ec13b0125a2efc12ebe41c9/websockets-17.3-cp314-cp314-win_amd64.whl", hash = "sha256:2b06213c8e1ce93b18d0476d7a5e647436c8f23d099888c9065e930e80f4bed5", size = 218582, upload-time = "2026-10-19T16:56:19.399Z" },
{ url = "https://files.pythonhosted.org/packages/4e/e9/11b885666d72add674440a3435c059bea325616959cec8be42605948714c/websockets-17.3-cp314-cp314-win_arm64.whl", hash = "sha256:6b7b540bb321f7f0a7b8f5d107f941bb361e3a154c0b594dae0394997b91b743", size = 218465, upload-time = "2026-10-19T16:56:21.005Z" },
{ url = "https://files.pythonhosted.org/packages/c4/0e/0c0f40ab8eefe9d5a6c78b4e267d8e37d70d3db8dd195574e0ab867e4f81/websockets-17.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c9d06edfd974e918b40e0662a7e76dac13125529ee5ec9fbe42dc0d6faa209c3", size = 218614, upload-time = "2026-10-19T16:56:22.457Z" },
{ url = "https://files.pythonhosted.org/packages/1f/93/d22b3d63aa439bc01a9477880070d05e0b257b0548294786a33be3e842ec/websockets-17.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a0bcf66289b9d017c175562c35c5304b5cc3ed56aded994ae30b0a9654ba405", size = 216151, upload-time = "2026-10-19T16:56:23.95Z" },
{ url = "https://files.pythonhosted.org/packages/b9/92/f60a9672cdeb8cb4f7156e6e83c76d59fdc586737a97ac77129e9fc188a2/websockets-17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f3ed747b7651668131029c720fbc32c7ee2d129bdad200e6c8f4037b20f7913a", size = 216354, upload-time = "2026-10-19T16:56:25.251Z" },
{ url = "https://files.pythonhosted.org/packages/9d/da/2be8c5f3c6612d4523f95b074e2352bc11bd090cf121d32bc11af1d52e70/websockets-17.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5868a02c4f6d698ddd19b4c9742748c03dec045289915806845189d96bc3d314", size = 225937, upload-time = "2026-10-19T16:56:26.726Z" },
{ url = "https://files.pythonhosted.org/packages/85/1c/cb84aec871840acd66dfa84520bf7b8e48ce70bdb3f4baa4efd097fbe6b1/websockets-17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa87ea2ad64e59fe5622c1dc5716106b6d6e7e4b26b848cb5f5710a75c292ef5", size = 226215, upload-time = "2026-10-19T16:56:28.1Z" },
{ url = "https://files.pythonhosted.org/packages/9b/7b/cfb65557accec2eda2c80288e13eca6f1c797c579375906eb73f4053cfd1/websockets-17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65e937576141ac50fac32fa9736dbb3e0e4a7769f91be004011a09216c6ab05f", size = 227389, upload-time = "2026-10-19T16:56:29.518Z" },
{ url = "https://files.pythonhosted.org/packages/70/a3/7e08ec4b23e376ed228831aa44b2f2a3f2e7025cc66dda4b2e53dcc2619e/websockets-17.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:35381b575f70a08d4241e73c0cde4672bb9b2a9aae8767ca6b7fb61305a052c8", size = 229534, upload-time = "2026-10-19T16:56:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/4b/7d/8a813e3e53956f876cfaad4f6358625bbd94168aa8046c44e2f3561f12af/websockets-17.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5142798871a248b422f353e0684d51dfa119de3403a6fa0904b7cb806d9790ce", size = 227959, upload-time = "2026-10-19T16:56:32.656Z" },
{ url = "https://files.pythonhosted.org/packages/41/23/5f3dab6ab44f2f30042db83a658a57129d5e3c0425c798b3c3e727bc060a/websockets-17.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e164c4a6868d11988c7f2386e3278ccff21c665cd7d08279b62709dd37a3167c", size = 226691, upload-time = "2026-10-19T16:56:34.101Z" },
{ url = "https://files.pythonhosted.org/packages/5b/49/99a3fb5a2e94d677fbd0d014a6f9a56a3d2b8e8007b7175727bc4fc4ae94/websockets-17.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10b24e836ee0233ca078e1244f6ba40343a22127a9a736f46a7e7b18de2cc711", size = 224225, upload-time = "2026-10-19T16:56:35.452Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e5/f503a4d757858d083c90ea85dd1fd636ae9eb63c9552c75a4c38514ee7ed/websockets-17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:56a76556ab04ab54a675acce2cac8b4e74ea2cec558a7c9ed37e0367b04b5d50", size = 226984, upload-time = "2026-10-19T16:56:36.887Z" },
{ url = "https://files.pythonhosted.org/packages/4a/a6/8586844ad4b21988837624e1f4b331bc71919c85a6eb1ee3c8bd29893f27/websockets-17.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:33221e49be79d7bfe6d12bc52ef9458b256ab74fa8ac5660acfa3065de1c7870", size = 225436, upload-time = "2026-10-19T16:56:38.213Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/f419506494d6ffd205a9c3258f56ff5aeae6976de26361057de74f4ab659/websockets-17.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2fa79b9a48ae59744316c71a34cb9f76f30c93e117338522c5135cb7eac10351", size = 226325, upload-time = "2026-10-19T16:56:39.606Z" },
{ url = "https://files.pythonhosted.org/packages/28/c5/55a58faa67149e84cb98b33f9d8fda95242677ea07b78f3ecaf2e77877d6/websockets-17.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f87f02afe05c1c246012e3de100a77cfbabd17db709fe93cf074271ab239b312", size = 227342, upload-time = "2026-10-19T16:56:41.041Z" },
{ url = "https://files.pythonhosted.org/packages/35/e0/fbe561dff24b6ef1b2034877f37b56eb9b81b3c103e2a8b03819bf2b3b6d/websockets-17.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8135a549e27e9941a70e2d2156676ef2ffeff4b06e9d564dbaaae963482afdef", size = 225036, upload-time = "2026-10-19T16:56:42.433Z" },
{ url = "https://files.pythonhosted.org/packages/0d/37/d80753064f8370a24d3bec74807c3928d7b0ebef5381956e2a0108b365c2/websockets-17.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ace8dd1efa5f44c482d091aa38322e20e3ffe5adcdba6b67ad9f4e7da58f3f68", size = 225807, upload-time = "2026-10-19T16:56:43.774Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9d/a11d3168522a2959c4a5661728744d62b9eee344abdba5f9fbc1d359742f/websockets-17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2129c9a5fc05a230ce088745a790af073e582b584badc7b05db067ee326f56d7", size = 226241, upload-time = "2026-10-19T16:56:45.088Z" },
{ url = "https://files.pythonhosted.org/packages/06/28/16107b3b83fd2b5024bfecf8d000140f555204a0191202f9c4c981137810/websockets-17.3-cp314-cp314t-win32.whl", hash = "sha256:af7c474f0d8f4d2a196370e85033873c7b063a0b5da9a4f143aa023e3bd25407", size = 218365, upload-time = "2026-10-19T16:56:46.41Z" },
{ url = "https://files.pythonhosted.org/packages/77/dd/8b00e1dd552788d8c7762d7ef61de8f2f238d7357b1a02d4c4650b112196/websockets-17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0c63ad657695e8f1a48624754cb6b464118ac50d4c1d477c26461c98390f0a62", size = 218706, upload-time = "2026-10-19T16:56:47.754Z" },
{ url = "https://files.pythonhosted.org/packages/39/eb/5d90d57a5cbe614b2f13433aaf1c0b068c804589379061ba06812a185a89/websockets-17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:3c4f631dc8d00e470cde3ec0a35d4b58a817bea592411956f1cf4099dbb306b3", size = 218622, upload-time = "2026-10-19T16:56:49.07Z" },
{ url = "https://files.pythonhosted.org/packages/6e/41/1efe5ecae1f32495dc75641704f959bd2d8e56cdc825f3acc5e010a51960/websockets-17.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f300f28dff74ff4148c21e6971e4de6ca0c34e0086a3d95c017c5174ebf85ca5", size = 218211, upload-time = "2026-10-19T16:56:50.423Z" },
{ url = "https://files.pythonhosted.org/packages/01/ec/4edb653533241203db3f11522e122338d4fda50a41ce2a542029086f8eda/websockets-17.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32029441af953e980b9940f042b337fe4ad9a97fa8b17a4ed878ec67b5b89ef4", size = 215944, upload-time = "2026-10-19T16:56:51.766Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b9/ec450b9650925dd5cf27c1f36d0464786fe3ac702ed656879c4c0dd5c4df/websockets-17.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09f007b2150eff015b2109f006e2ac2012d376f41486defa88ae78a776b00cff", size = 216164, upload-time = "2026-10-19T16:56:53.125Z" },
{ url = "https://files.pythonhosted.org/packages/15/1e/63f7fbff9f0540a0331f1a62424c9ff24faa6ba8aec264f55e9e31017eea/websockets-17.3-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bdc658c9474864893e0b6b94f1390bf6a912952df1870dd93bb7d1a902b5628", size = 225608, upload-time = "2026-10-19T16:56:54.442Z" },
{ url = "https://files.pythonhosted.org/packages/0c/1b/f6679ad61cb8c110033a1b30d561d5c1052983e202157251ff1778f27932/websockets-17.3-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15decc0fe293d9e3f924bd483f3d76f3d0ff26845112a470e3f8842fec2c82a1", size = 226042, upload-time = "2026-10-19T16:56:55.727Z" },
{ url = "https://files.pythonhosted.org/packages/28/c6/ed993aaef8046cbbb939ae367ee3dd5383dbd055f0500a5b8aed101168b6/websockets-17.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:997617dd7856c5dad742bf6893b872319156dd43f47587541a59e4a852d52bbc", size = 227203, upload-time = "2026-10-19T16:56:57.076Z" },
{ url = "https://files.pythonhosted.org/packages/aa/96/7057dc71affe6c8252df8648f5a225642228a6fdd8d046a6b9aa1fb0e9eb/websockets-17.3-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a004c1fe5a72c496f28c6c1d8c2dc5cfe3278d99478e18f8b838c70a912b366f", size = 228900, upload-time = "2026-10-19T16:56:58.633Z" },
{ url = "https://files.pythonhosted.org/packages/43/e3/8b3268bb7e72b465e55172b86d55f58aa277fb6d9bac92aee615345874df/websockets-17.3-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35fe35d20baa661cef4a12ee7bc42eaf2376d43ef9816ab29dd47ca957f7a73a", size = 227684, upload-time = "2026-10-19T16:56:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/59/15/fa31c0a12240b1225c3712ff080d2f7ede4f695ca90e59fdb58e8affabba/websockets-17.3-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8b3bede7d583bc9f47d8693a29984882e59077db1a5238ad977fc70ae8169741", size = 226580, upload-time = "2026-10-19T16:57:01.371Z" },
{ url = "https://files.pythonhosted.org/packages/22/b5/75d911c206a3035e2ecfad107878e62c06e574b2437fa2cfb4ecef436fa3/websockets-17.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dafbb58a149d1ebd5767b3f064f911c5ee0897608005e51c3904b0ada2a8d99c", size = 223967, upload-time = "2026-10-19T16:57:02.839Z" },
{ url = "https://files.pythonhosted.org/packages/0e/f8/f98aa36f611acd5770819f6690125c20d55f221fd6db8d00d284b5f78e89/websockets-17.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80326bc571164bbbb6bead2e35d0c943b23b24fde629032197c58151529d24d8", size = 226689, upload-time = "2026-10-19T16:57:04.458Z" },
{ url = "https://files.pythonhosted.org/packages/1a/36/d52f5dff7597de6fbfc26c3d126abd668137915719aebf97abcc3be77eca/websockets-17.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:7d53fc4b90ef6e11de639dc18744d9be8cbd70ccb5ce80027aaffe7b1ab0215a", size = 225353, upload-time = "2026-10-19T16:57:05.857Z" },
{ url = "https://files.pythonhosted.org/packages/d4/10/dcd70ad9f9cd20e8af43284ac250af610e8bf9022da25f5b409153d033b4/websockets-17.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:fdd00937f15f5f93c8e53d8060a8c4c010431ab595a9b5b50ddd569a04603944", size = 225879, upload-time = "2026-10-19T16:57:07.249Z" },
{ url = "https://files.pythonhosted.org/packages/70/ab/f26c6bfa99d38ca3d1bf23145306d97c5d0c75e133a8d837373e4b3d4629/websockets-17.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1dae5cd6c92d4440b15f20aa2987baa73c47ea14baa0fec8536a542e82e0ab33", size = 227031, upload-time = "2026-10-19T16:57:08.817Z" },
{ url = "https://files.pythonhosted.org/packages/d7/3f/c8fd74cdb526cc65295b7468f2f84ba16678646d8174288822f99f510274/websockets-17.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b225c656efa2dedecc81651d2290170af25887b7de4f38c5efc7c5ae2b9442ab", size = 224842, upload-time = "2026-10-19T16:57:10.298Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c5/b3a07ce1c54fc0f5441642f5352662b90d7e658c966730eaf6d78a5f20bc/websockets-17.3-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:bd645b5d2f810a181c44f4a71135e7c365b44f4be9ed3d1892635be13a857553", size = 225744, upload-time = "2026-10-19T16:57:11.615Z" },
{ url = "https://files.pythonhosted.org/packages/66/37/8321a8a6ab0215d4df0238e49ace5f539364cf337bb122b85c788e8c93ec/websockets-17.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d2605a3b2af8d66f8b75312d223642c065ec773446c04566d9030ad5e2db13c9", size = 226006, upload-time = "2026-10-19T16:57:13.049Z" },
{ url = "https://files.pythonhosted.org/packages/f8/04/8c457a7e6a260b673bf4e6f2f422eb4af5f11c48505748359706f24f1bef/websockets-17.3-cp315-cp315-win32.whl", hash = "sha256:82e60c00cfb8493a5ba27cdf9b3695fdd153f666f73e11f18968f78ead1d8705", size = 218196, upload-time = "2026-10-19T16:57:14.41Z" },
{ url = "https://files.pythonhosted.org/packages/a0/7c/c2df129d9e344b99bb388bcac54fbbdfeec29e6c716c12dc9a3398757940/websockets-17.3-cp315-cp315-win_amd64.whl", hash = "sha256:4d5f2492cb2192aab2d42e57f2caa2159b4fd20fde06f859ec13354f524b176c", size = 218490, upload-time = "2026-10-19T16:57:16.234Z" },
{ url = "https://files.pythonhosted.org/packages/e6/4f/721b778c85559b2949e46246838e31a346dd815b3cdd8460fe517235675a/websockets-17.3-cp315-cp315-win_arm64.whl", hash = "sha256:55494475206113dbaf731dc8c5ffd5e1094175b753b7bbaad09fe3d7df61165c", size = 218403, upload-time = "2026-10-19T16:57:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/b3/28/241d9ebb4dad79346b5a6abf9f14d7685b12ca358d250eea4920cdb1caf3/websockets-17.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:23cb6f6d64e3a389bb8ba150f947f9a6c8c0aca383e814ca3c5cffab738d5646", size = 218523, upload-time = "2026-10-19T16:57:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b0/bb/559fe539992102c66cbca38b488af6c7b184ab7736d819afc0f72268a2b4/websockets-17.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f27e7e8a85512b6a4b490d5756edde8e6772883a5800e1476172e44eda3f7d7c", size = 216103, upload-time = "2026-10-19T16:57:20.412Z" },
{ url = "https://files.pythonhosted.org/packages/34/ee/e8ade4f13a3d0da68636fe44bb8ac1a3e36fc57070512788320dead42067/websockets-17.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6342f79d6f50e680c8aa59e0615d745e867e51ffd59c77093be60b7c433067af", size = 216316, upload-time = "2026-10-19T16:57:21.761Z" },
{ url = "https://files.pythonhosted.org/packages/4d/06/4812b2a7a5e6571d9be425e76cf54ee69aaa15720caa02307785aa5b19a7/websockets-17.3-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fc23e72bd7f8a8e540fed2244524d0a9aeb6ae3325f3d56f04a7a3debe5b04c2", size = 226467, upload-time = "2026-10-19T16:57:23.077Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e8/adfc05d7f9b6b0cce6cc554426c50b7058609119b6b84a39171fe5ed4e50/websockets-17.3-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:722ab747ff5dc28070b1a8c020dba6466f13f2ab56bc251ad301fefa2454aa1e", size = 226736, upload-time = "2026-10-19T16:57:24.419Z" },
{ url = "https://files.pythonhosted.org/packages/96/b2/d5b93dd933552554507edae9e3a4890269a8ea8b46019450ea9c8a30e7cc/websockets-17.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d20d6ea5aabbeba2e054d33e405192ef75dc12947bbd00ec97d23b14969d325", size = 228065, upload-time = "2026-10-19T16:57:25.908Z" },
{ url = "https://files.pythonhosted.org/packages/10/37/642f0f1cf1c4814147a4f0586f796a0b38e42b69e5a296bfc270fb660051/websockets-17.3-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:90cc9973c51811918247a78d9ab2f54871296e4e37e69a98aba4d3c9acedbf62", size = 229692, upload-time = "2026-10-19T16:57:27.63Z" },
{ url = "https://files.pythonhosted.org/packages/5b/33/eac81cf62ac394b61db6a27ccbe8fb9ae382016ef73ede1edf9242a6a43e/websockets-17.3-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63b87c93e28957e6dc2bb92d31727a863c9bf4a4daa080f0995fdcb0242c9166", size = 228503, upload-time = "2026-10-19T16:57:29.031Z" },
{ url = "https://files.pythonhosted.org/packages/0d/84/e057788a9bd056f4f7d3a06b59efaaf724bbc6f2d1e1d316f8a93c951abc/websockets-17.3-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f7431e71072962d3ed1768038200aa4f1e76d9c2d7bedd554d57a55faaf6644", size = 227379, upload-time = "2026-10-19T16:57:30.571Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a7/cca1f9f45b893bb5b7545f2b234f910e07fcd2f8065724ac1a62a0961be2/websockets-17.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:534ed583fd4323de45d196535a444cebd1af2105e6e4e0cdcbf8cea266a8747a", size = 224719, upload-time = "2026-10-19T16:57:31.895Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a0/5d07451027150c802ce4c9999e86994a3f0de5f4dbe3d1cce46995999269/websockets-17.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8879b6a4040ee925c638548238ab7fe612cf5fa8752fe847346da0da8be81c3f", size = 227564, upload-time = "2026-10-19T16:57:33.236Z" },
{ url = "https://files.pythonhosted.org/packages/62/90/835f37e13fac0c615a700caae0d39deff1de2d2b9b341836a786f9b359e0/websockets-17.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58e5d1e935a90878a935c7d2737a25c71cf4f1c13b6e6bef454ab722c4f338bb", size = 226218, upload-time = "2026-10-19T16:57:34.876Z" },
{ url = "https://files.pythonhosted.org/packages/16/8a/718639939b947fe715501e8073350fd3f8a42f8b5eba3c0c2389c5836513/websockets-17.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:0a657b51e4a5f4ac577c11e706ff9a884ea5aaf3e2666bae283a251c63e66acb", size = 226712, upload-time = "2026-10-19T16:57:36.248Z" },
{ url = "https://files.pythonhosted.org/packages/e8/45/6bef8572b9b0dee51369b69776af1e4c7cc3a807f199dc3c165bbcf1199a/websockets-17.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:411aa0fb234f42a1444dea1e177890ddfc577b36ffdfafec25cb0d2c818e1af8", size = 227819, upload-time = "2026-10-19T16:57:37.586Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3f/3b726d0b46a132e641d2d8c0243ea8a7d39464dbaf28c7a206e51fbae681/websockets-17.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:623d4b44f64efdf235b1e8dce266057073d93cde6197e7d7684f20c297f67f30", size = 225573, upload-time = "2026-10-19T16:57:39.285Z" },
{ url = "https://files.pythonhosted.org/packages/90/96/cd972a12e1412d9b42c42ef42efbd81aa396ca9408d982d524012f011a8d/websockets-17.3-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5d0c871efc696ef5eb8514df64b86ab28736db753ebb42a2101f3b562f4849c0", size = 226430, upload-time = "2026-10-19T16:57:40.915Z" },
{ url = "https://files.pythonhosted.org/packages/7d/2d/e11524771588188a39f6da234779d4942496b96c5a10b92f3df30db48608/websockets-17.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1db2604e6aedb7a2cb2f6e0575d6c9a852ccac8239703a930ac83b63309177a5", size = 226728, upload-time = "2026-10-19T16:57:42.245Z" },
{ url = "https://files.pythonhosted.org/packages/17/9b/63722aed973f53ea579e1ce0c4563a2b8b536629e556feff26e4c6a2a60d/websockets-17.3-cp315-cp315t-win32.whl", hash = "sha256:07246d4f0293ef0b87eff3d7dd88c1f03dd9f678fa39f6de87b2af8504e7e0de", size = 218325, upload-time = "2026-10-19T16:57:43.585Z" },
{ url = "https://files.pythonhosted.org/packages/34/07/5e0c92e225546f1789c957c0383852797be7c899b07207e9b6eb4a93473f/websockets-17.3-cp315-cp315t-win_amd64.whl", hash = "sha256:856bbb24f2ee6436af7609267e73309e4bb0a0f9982f79f1c2e01019279c6671", size = 218641, upload-time = "2026-10-19T16:57:44.918Z" },
{ url = "https://files.pythonhosted.org/packages/d4/e4/339663de65c44ab3eb5209890e86eed26e1a131d6afab3df6f1c3e5b4d8c/websockets-17.3-cp315-cp315t-win_arm64.whl", hash = "sha256:a22c4956a4d858200dba83f0691bb692d77b0eeddfca80157820d20b85dea4f1", size = 218564, upload-time = "2026-10-19T16:57:46.332Z" },
{ url = "https://files.pythonhosted.org/packages/d7/b0/3f01fcb3406662f6deb1e3e72b34dd0979e993991c0b7435a9e12bc14f02/websockets-17.3-py3-none-any.whl", hash = "sha256:dc2b495a92b3971966bf102e73589f950db3ec1ca6713265355ce7771d97b199", size = 212375, upload-time = "2026-10-19T16:58:04.483Z" },
]