phase: 24_dev_mtls_rollout
**Phase 24 final verification — all green**
- **Verified (read-only re-run, 62/62 PASS)** via `data/logs/verify-24-mtls.py`: nine sources `ok`; `/api/certs` all three `active` with `deployed_bundle_rev == bundle_rev == 2`; `agent.tls: true` + `agent.version 1.1.0` on every host; driveripper 16/16 drives live; gateway absent from `/disks` but `disks:gateway ok`; all served target URLs `https://`, no userinfo; pre-flip backup present; no transport/cert alert.
- **Criterion 1 (export):** `data/certs/{driveripper,deskwork,gateway}.bundle` re-checked byte-identical (sha256) to `GET /api/certs/<name>/bundle`, mode `0600`, under gitignored `data/`; live arms re-probed (issued→file, unknown/`issued:false`→one-line note + no file, wrong password→exit 2, nothing written).
- **Criterion 2 (`--fetch-bundle`):** 54 sandbox pins in `tests/test_deploy_scripts.py` pass — byte-identical `0640` install + TLS unit lines, refusals (unissued / 401 / non-bundle body / dead dashboard) before `need_root` with no write, token absent from token file, unit, stdout and sudoers hint.
- **Criterion 3–4:** `./scripts/dev-server.sh status` nine `ok` + bundles block; screenshots `data/logs/24-task04-{certs,disks,system}.png` exist.
- **Criterion 7:** no behavior change — `agent/` untouched, `src/` diff is a `certs.ensure_certs` docstring only; token-mode, revocation-before-dial and bundle-route tests green.
- **Tests/lint:** `bash .agents/validate.sh` → **EXIT=0**; `uv run pytest --cov --cov-fail-under=94` → **1542 passed, 15 skipped, coverage 98.94%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` clean; `uv run pytest -m live --live` → **11 passed**.
- **Docs:** README.md (all 1144 lines) + AGENTS.md reviewed against the live project — every config.py env var is in README and both templates, route set / severity thresholds / test counts / coverage figure match, all repo paths, cited test names and `Dash.*`/`DashCharts.*` helpers resolve, phase-24 doc pins green. **Verified consistent — no changes needed.**
- **Notable:** left `fastapi 0.153.0 → 0.158.0` (validate reports it as advisory; no dependency was touched, rule 8 conditions the check on deps moving). Killed two abandoned `/tmp/fbtest` loopback stub processes left by task 02's manual probe; dev server unaffected.
- **Next pending phase:** `.agents/phases/todo/25_masthead_control_placement/`.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
**Phase 24 final verification — all green**
|
||||
|
||||
- **Verified (read-only re-run, 62/62 PASS)** via `data/logs/verify-24-mtls.py`: nine sources `ok`; `/api/certs` all three `active` with `deployed_bundle_rev == bundle_rev == 2`; `agent.tls: true` + `agent.version 1.1.0` on every host; driveripper 16/16 drives live; gateway absent from `/disks` but `disks:gateway ok`; all served target URLs `https://`, no userinfo; pre-flip backup present; no transport/cert alert.
|
||||
- **Criterion 1 (export):** `data/certs/{driveripper,deskwork,gateway}.bundle` re-checked byte-identical (sha256) to `GET /api/certs/<name>/bundle`, mode `0600`, under gitignored `data/`; live arms re-probed (issued→file, unknown/`issued:false`→one-line note + no file, wrong password→exit 2, nothing written).
|
||||
- **Criterion 2 (`--fetch-bundle`):** 54 sandbox pins in `tests/test_deploy_scripts.py` pass — byte-identical `0640` install + TLS unit lines, refusals (unissued / 401 / non-bundle body / dead dashboard) before `need_root` with no write, token absent from token file, unit, stdout and sudoers hint.
|
||||
- **Criterion 3–4:** `./scripts/dev-server.sh status` nine `ok` + bundles block; screenshots `data/logs/24-task04-{certs,disks,system}.png` exist.
|
||||
- **Criterion 7:** no behavior change — `agent/` untouched, `src/` diff is a `certs.ensure_certs` docstring only; token-mode, revocation-before-dial and bundle-route tests green.
|
||||
- **Tests/lint:** `bash .agents/validate.sh` → **EXIT=0**; `uv run pytest --cov --cov-fail-under=94` → **1542 passed, 15 skipped, coverage 98.94%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` clean; `uv run pytest -m live --live` → **11 passed**.
|
||||
- **Docs:** README.md (all 1144 lines) + AGENTS.md reviewed against the live project — every config.py env var is in README and both templates, route set / severity thresholds / test counts / coverage figure match, all repo paths, cited test names and `Dash.*`/`DashCharts.*` helpers resolve, phase-24 doc pins green. **Verified consistent — no changes needed.**
|
||||
- **Notable:** left `fastapi 0.153.0 → 0.158.0` (validate reports it as advisory; no dependency was touched, rule 8 conditions the check on deps moving). Killed two abandoned `/tmp/fbtest` loopback stub processes left by task 02's manual probe; dev server unaffected.
|
||||
- **Next pending phase:** `.agents/phases/todo/25_masthead_control_placement/`.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,66 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 23%]
|
||||
.........................................................s.............. [ 27%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 69%]
|
||||
sssssssssss............................................................. [ 73%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 92%]
|
||||
...........................................................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 440 0 100%
|
||||
src/dashboards/app.py 186 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 3495 37 99%
|
||||
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||
1542 passed, 15 skipped, 1 warning in 165.53s (0:02:45)
|
||||
== 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.153.0 0.158.1 wheel
|
||||
@@ -0,0 +1,15 @@
|
||||
**Implemented**
|
||||
- `scripts/dev-certs-export.py` (new, stdlib `urllib`): `--url/--password(+$DASH_ADMIN_PASSWORD)/--names/--out-dir`; signs in via public `POST /api/auth/login`, reads the token out of `Set-Cookie`, `GET /api/certs/<name>/bundle` per name; writes `0600` via `os.open` (birth mode + re-chmod) into gitignored `data/certs/`. Arms: issued→file, `issued:false`/`configured:false`→one-line note + no file, other statuses→`!` line; exit 0 all-resolved / 1 a refused host (others still attempted) / 2 no sign-in. Reads the route only — never `ensure_certs`, so it cannot mint.
|
||||
- `scripts/dev-server.sh`: `export_bundles` after the readiness probe (names split from `HOST_TARGETS`, failure = `|| echo` warning, never fails `start`); `bundles` block (names/sizes/modes) printed by `status` and as its own subcommand.
|
||||
- 19 new tests in `tests/test_deploy_scripts.py`: real `create_app` served by uvicorn on `127.0.0.1:0`, byte-identity vs the route, `0600` (+ re-chmod of a loosened file), unissued/unconfigured notes, wrong-password & no-server exit 2 writing nothing, idempotence, "never mints" (serials/revs/root unchanged), no PEM/token/password printed, name-escape refusal, and the dev-server composition pins (order, warning-only, `status` block, `CERTDIR` under `data/`).
|
||||
|
||||
**Verification** — `bash .agents/validate.sh`: `uv run pytest --cov --cov-fail-under=94` → **1513 passed, 15 skipped**, coverage **98.94%** (floor 94%); `ruff check src tests agent` clean (also clean on `scripts/`); `pyright` 0 errors; `node --check` ran. `uv run pytest -m live --live` → **11 passed**. Real boot: `./scripts/dev-server.sh restart` exported `data/certs/deskwork.bundle` (1430 B, `mode 0600`, byte-identical to the route; verified over HTTP) and printed the unissued note for `driveripper`/`gateway`; `status` still reads nine sources `ok`.
|
||||
|
||||
**Docs** — README (Quickstart `bundles` subcommand + export paragraph, new *Managing the certificates* dev paragraph, rollout step 1 note, Testing paragraph) and AGENTS.md (invariant 6 export rules, `certs.py` bullet, Commands block + count 1494→1513, new testing-policy bullet, gap 11 dev-side half closed).
|
||||
|
||||
**Decisions / deviations**
|
||||
- Kept the logic in the standalone script as the task specifies; `scripts/` is outside `[tool.coverage.run] source` (like the other dev scripts), so the gate number is unchanged — the new code is instead exercised end-to-end as a subprocess on every arm plus in-process helper tests.
|
||||
- Cookie name is imported from `dashboards.auth` (with an `ImportError` fallback) so it cannot drift; `--password` also falls back to the env var.
|
||||
- No live-infra changes: only the local dev-server restart and a read-only export over loopback. No fleet change, no bundle installed, no `HOST_TARGETS` edited — tasks 02-04 are untouched. Pre-existing `uv pip list --outdated` advisory (fastapi 0.153.0→0.156.8) left alone: dependencies were not touched here.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/24_dev_mtls_rollout/02_installer_fetch_bundle.md`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 23%]
|
||||
.........................................................s.............. [ 28%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 65%]
|
||||
...........................................sssssssssss.................. [ 70%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 94%]
|
||||
..............................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 440 0 100%
|
||||
src/dashboards/app.py 186 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 3495 37 99%
|
||||
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||
1513 passed, 15 skipped, 1 warning in 156.26s (0:02:36)
|
||||
== 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.153.0 0.156.8 wheel
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
All green. Final state:
|
||||
|
||||
**Implemented**
|
||||
- `deploy/install-agent.sh`: `--fetch-bundle <url> --cert-name <name> --session <token>` — `python3` `urllib` GETs `/api/certs/<name>/bundle`, writes it to a `mktemp` file that becomes `$BUNDLE_SRC`, so the existing shape check → `0640 root:agent` install → unit `--cert-bundle`+`RuntimeDirectory` → `--status` probe all run on fetched bytes unchanged.
|
||||
- Order: flag checks → fetch → `validate_bundle` → `need_root`; refusals (unissued `issued:false`, `401`/`403`/`5xx`/`404`, no dashboard, non-bundle 200 body) print one line and write nothing; `trap cleanup_fetch EXIT` sweeps the temp key on every path.
|
||||
- Refusals added before `need_root`: partial flag set (exit 1), `--fetch-bundle` + local `--cert-bundle` (two sources), fetch with `--status`/`--uninstall`.
|
||||
- Token hygiene: travels to python via env (not argv), only the URL is logged, `unset` before any write, never in the token file/unit/bundle/log nor in the sudoers hint's re-quoted command; `validate_bundle` gained a flag-name label so a fetched refusal says `--fetch-bundle` and the file path still says `--cert-bundle`.
|
||||
- `--help` documents the three flags + copy-pasteable recipe.
|
||||
|
||||
**Tests/lint/coverage** — `uv run pytest` → **1535 passed, 15 skipped**; `bash .agents/validate.sh` → **exit 0** (coverage 98.94%, floor 94%; ruff clean; pyright 0 errors; `node --check` ok; hygiene clean). 12 new pins in `tests/test_deploy_scripts.py` run the real `create_app` on a socket (byte-identical `0640` install + TLS unit lines, unissued/`configured:false`/401 refusals with no stub call logged, non-bundle 200 bodies, dead server, token grep of every file + both streams + sudoers hint, all-or-nothing/two-source/mode refusals, fetch-before-shape-before-root ordering). Four deliberate script mutations were used to confirm the pins bite.
|
||||
|
||||
**Docs** — README: rollout step 1 is now the `--fetch-bundle` registration run (manual signed-in `curl` demoted to the fallback), plus the flag list and the dev-export paragraph; AGENTS.md: invariant 6 (session token as fetch credential), installer deployment paragraph, gap 11 (task 02 landed), testing-policy pin, pytest count.
|
||||
|
||||
**Decisions / deviations**
|
||||
- Pins drive the real `served_dashboard` socket (not a look-alike `http.server`); a canned `http.server` covers only the shapes the app cannot answer (200-with-HTML, empty 200).
|
||||
- Added two refusals beyond the task list (fetch with `--status`/`--uninstall`; per-flag value check follows existing style) — silent ignoring of a credential flag was the alternative.
|
||||
- Live infra: **no live changes made**. Verified read-only against the running dev dashboard: fetched `deskwork` (then correctly stopped at the unprivileged root boundary, token absent from output, temp dir empty) and got the one-line unissued refusal for `gateway`. The per-host rollout is task 03.
|
||||
|
||||
Next pending task: `.agents/phases/todo/24_dev_mtls_rollout/03_fleet_rollout.md` (agent 1.1.0 + bundle on deskwork/driveripper/gateway, `HOST_TARGETS` flipped per host — live, operator-gated root on two hosts).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 23%]
|
||||
.........................................................s.............. [ 27%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 65%]
|
||||
.................................................................sssssss [ 69%]
|
||||
ssss.................................................................... [ 74%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 92%]
|
||||
....................................................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 440 0 100%
|
||||
src/dashboards/app.py 186 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 3495 37 99%
|
||||
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||
1535 passed, 15 skipped, 1 warning in 162.74s (0:02:42)
|
||||
== 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.153.0 0.156.8 wheel
|
||||
@@ -0,0 +1,18 @@
|
||||
Final state verified. Summary of this task's work:
|
||||
|
||||
**Fleet rollout (live, all three hosts now mTLS-only)**
|
||||
| host | how | proof |
|
||||
|---|---|---|
|
||||
| `gateway` | staged installer + 1.1.0, dry-run diff → `--force`, restart, CA-only `--status` probe, then URL flip | `answering over TLS (bundle rev 2)` |
|
||||
| `deskwork` | same; its `zz-devices.conf` drop-in (`DeviceAllow=/dev/nvme0n1`, SYS_ADMIN caps) survived the rewrite | same, SMART still reads |
|
||||
| `driveripper` | gap-10 line `--as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force`; diff moved comments + TLS lines only | same, **16/16 drives with temps before and after** |
|
||||
|
||||
`.env.dev` `HOST_TARGETS` is now `driveripper/deskwork/gateway=https://…:9120` with no `http://`, no userinfo (pre-flip file backed up in `data/env-backup/`); `/certs` reads `bundle_rev == deployed_bundle_rev == 2` on all three; `dev-server.sh status` = nine sources `ok`. Every installer ran twice, once without `--force` to read the guard's diff. A canary revoke on `gateway` proved the bite (`agent certificate revoked (serial …)` on `host:gateway` + `disks:gateway`, page-side too), then restored.
|
||||
|
||||
**Defects found and fixed**
|
||||
- `data/logs/NOTES-24-rollout.md` § *the defect that stopped the first flip*: the stored root predated the SKI fix → strict dials failed `Missing Subject Key Identifier`. Repaired with `POST /api/certs/root/rotate` (not the `DELETE FROM meta` reset), pinned by `tests/test_certs.py::test_a_stored_root_without_an_identifier_still_boots_and_root_rotate_repairs_it` and a new `certs.ensure_certs` docstring paragraph.
|
||||
- The live suite could not dial the fleet (3 failures, `CERTIFICATE_VERIFY_FAILED: self-signed certificate in chain`): `tests/test_live_fleet.py` gained an `agent_tls` fixture (read-only `sqlite3` authority whose `set_meta`/`delete_meta` raise, `certs.write_material` + `collectors.agent_tls_map`), and the four host tests now go through `fetch_snapshot`/`fetch_host_snapshot`/`fetch_disk_snapshot` with `tls=` → **11 passed, 0 failed**.
|
||||
|
||||
**Docs**: `README.md` (rollout order, reverse-tunnel route, `--status --bind` caveat, pre-SKI CA rule, rollback rule, live-suite note) and `AGENTS.md` (fleet record now mTLS + corrected sudo/staging facts, gap 10 trap closed, gap 11 resolved into rotation hygiene, gap 12, testing policy, `--status` address rule).
|
||||
|
||||
**Gates**: `bash .agents/validate.sh` → **EXIT=0** (1536 passed / 15 skipped, ruff clean, pyright clean, `node --check` clean); `pytest -m live --live` → EXIT=0; evidence in gitignored `data/logs/` (`NOTES-24-rollout.md`, `24-*-install.log`, `24-fleet-{before,after,end-state}.txt`, `24-certs-end-state.txt`, `24-units-after-rollout.txt`, `24-mtls-{system,certs}.png`). Only the advisory `fastapi 0.153.0 → 0.157.0` was left alone (no dependency was touched here). Nothing staged, committed, or moved in `.agents/`.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,66 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 23%]
|
||||
.........................................................s.............. [ 27%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 64%]
|
||||
..................................................................ssssss [ 69%]
|
||||
sssss................................................................... [ 74%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 92%]
|
||||
.....................................................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 440 0 100%
|
||||
src/dashboards/app.py 186 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 3495 37 99%
|
||||
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||
1536 passed, 15 skipped, 1 warning in 167.02s (0:02:47)
|
||||
== 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.153.0 0.157.0 wheel
|
||||
@@ -0,0 +1,19 @@
|
||||
Everything is green. Final state of the work:
|
||||
|
||||
**Implemented (task 04 — read-only verification of the mTLS end state)**
|
||||
- `data/logs/verify-24-mtls.py` + `24-task04-endstate.txt`: 57 signed-in GET assertions — nine sources `ok`; `/api/certs` all three `active` with `deployed_bundle_rev == bundle_rev == 2`; `agent.tls: true` + `agent.bundle_rev` + `agent.version 1.1.0` on every host; driveripper 16/16 drives with temp+hours (same single `nvme5n1` alarm chip, no `smart: null`), deskwork `nvme0n1`; gateway absent from `/api/disks/snapshot` while `disks:gateway` still `ok`; `/api/summary` fleet block identical, **no transport/certificate alert**; `/api/config` + `.env.dev` all `https://`, no userinfo; pre-flip backup present; bundles in gitignored `data/certs/` at `0600`.
|
||||
- `data/logs/24-task04-verify.cjs` → `24-task04-{certs,disks,system}.png` (+ `.log`/`.out`): the same claims as rendered — `#2 → #2` with no `stale` chip on all three rows, deskwork's GPU card with four live values, four masthead dots all green.
|
||||
- Negative control per the task: named phase 15's `verify-15-tls-e2e.py`/`.out` as the standing proof and ran the two suite pins (`tests/test_collectors.py`, `tests/test_api.py`) → 3 passed, logged in `24-task04-revocation-pins.log`. **Nothing revoked, posted or restarted on the fleet.**
|
||||
- Doc-consistency pass (this phase's final pass): README dev quick-start and `HOST_TARGETS` config row now lead with bundle registration; `AGENTS.md` transport bullet, `Commands` block, gap 11 (verification record + pins named) and gap 12 (phase closed) updated, plus a pre-existing duplicated sentence in gap 11 de-duplicated; `.env.example`/`.env.dev.example` now document `https://`-no-userinfo as the shape a new host gets, token mode as the compatibility path.
|
||||
- Added 6 regression pins to `tests/test_deploy_scripts.py` so the docs can't slide back: setup docs must name `--fetch-bundle`/`--cert-name`/`--session`, and every template `HOST_TARGETS` example URL must be `https://` with no userinfo.
|
||||
|
||||
**Gates (exact)**
|
||||
- `uv run pytest` → **1542 passed, 15 skipped**, exit 0; `uv run pytest -m live --live` → **11 passed**, exit 0.
|
||||
- `bash .agents/validate.sh` → **EXIT=0**: coverage **98.94%** (floor 94; `certs.py`/`api.py`/`app.py`/`auth.py` 100%), ruff `All checks passed!`, pyright `0 errors, 0 warnings, 0 informations`, `node --check` clean, repo-hygiene clean.
|
||||
- `./scripts/dev-server.sh status` → `status: ok`, nine sources ok, three bundles exported.
|
||||
|
||||
**Docs**: `README.md`, `AGENTS.md`, `.env.example`, `.env.dev.example` (see above); transcript `data/logs/NOTES-24-verification.md`.
|
||||
|
||||
**Decisions**: no live revoke/restart (read-only by rule); left the advisory `fastapi 0.153.0 → 0.157.0` alone — no dependency was touched.
|
||||
|
||||
**Next pending task**: none in `todo/24_dev_mtls_rollout` (this completes the phase); `25_masthead_control_placement` is next.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,66 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 23%]
|
||||
.........................................................s.............. [ 27%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 69%]
|
||||
sssssssssss............................................................. [ 73%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 92%]
|
||||
...........................................................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 440 0 100%
|
||||
src/dashboards/app.py 186 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 3495 37 99%
|
||||
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||
1542 passed, 15 skipped, 1 warning in 165.02s (0:02:45)
|
||||
== 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.153.0 0.158.0 wheel
|
||||
+23
-17
@@ -1,29 +1,35 @@
|
||||
# Local development overrides. Copy to .env.dev and edit the addresses and tokens to yours.
|
||||
# This file is gitignored and holds the fleet's bearer tokens: NEVER commit it.
|
||||
# Local development overrides. Copy to .env.dev and edit the addresses to yours.
|
||||
# This file is gitignored and holds the fleet's credentials: NEVER commit it.
|
||||
# cp .env.dev.example .env.dev
|
||||
# chmod 0600 .env.dev
|
||||
#
|
||||
# Dev dials the fleet exactly the way the deployed dashboard does — no tunnels, no
|
||||
# forwarded ports, nothing listening on localhost. Per monitored host, once:
|
||||
# forwarded ports, nothing listening on localhost. Per monitored host, once: register it
|
||||
# with the bundle this dashboard signs (issue one on /certs, or POST
|
||||
# /api/certs/<name>/rotate, if the name has none yet):
|
||||
#
|
||||
# sudo deploy/install-agent.sh --bind <address the dashboard dials> --port 9120
|
||||
# sudo deploy/install-agent.sh --bind <address the dashboard dials> --port 9120 \
|
||||
# --fetch-bundle <dashboard url> --cert-name <HOST_TARGETS name> --session <token>
|
||||
#
|
||||
# It installs agent/host_agent.py as the dashboards-agent systemd service, bound to the
|
||||
# address you dial, and prints its bearer token exactly once. No root shell on the host?
|
||||
# deploy/agent-sudoers is the one-line grant that lets the fleet's `ops` account run the
|
||||
# installer (and running the installer without root prints that exact line). Put each
|
||||
# printed token into HOST_TARGETS below as URL userinfo; config.parse_targets moves it
|
||||
# into an Authorization header, so Target.url, /api/config and every error message stay
|
||||
# credential-free.
|
||||
# That installs agent/host_agent.py as the dashboards-agent systemd service, bound to the
|
||||
# address you dial, with this dashboard's own root CA and the host's client certificate in
|
||||
# /etc/dashboards-agent.certs — so the URL below is https:// with NO userinfo: the
|
||||
# handshake is the auth, the agent never reads a token on that path, and a host revoked on
|
||||
# /certs is refused before a socket opens (certs.py, collectors.AgentTls). A monitored host
|
||||
# that cannot route back to a dev box registers over a reverse tunnel instead:
|
||||
# `ssh -R 18099:127.0.0.1:8090 <host>` with `--fetch-bundle http://127.0.0.1:18099`.
|
||||
# No root shell on the host? deploy/agent-sudoers is the one-line grant that lets the
|
||||
# fleet's `ops` account run the installer (and running the installer without root prints
|
||||
# that exact line).
|
||||
#
|
||||
# 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 compatibility path is a token-mode agent: install with no --cert-bundle / --fetch-
|
||||
# bundle and it prints its bearer token exactly once, which goes in the URL as userinfo
|
||||
# (`http://TOKEN@host:9120`); config.parse_targets moves it into an Authorization header,
|
||||
# so Target.url, /api/config and every error message stay credential-free. Nothing in this
|
||||
# fleet runs that way any more.
|
||||
#
|
||||
# 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"
|
||||
export HOST_TARGETS="driveripper=https://driveripper.example.net:9120 deskwork=https://deskwork.example.net:9120 gateway=https://gateway.example.net:9120"
|
||||
|
||||
# Physical-disk health (phase 09): scripts/capture-fixtures.sh records one
|
||||
# `smartctl -i -A -H` per whole disk per host into tests/fixtures/smartctl_<host>_<dev>.txt.
|
||||
|
||||
+9
-6
@@ -48,13 +48,16 @@ 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. 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.
|
||||
# Space or comma separated name=url. The shape a new host gets is https:// with NO userinfo:
|
||||
# register it with `install-agent.sh --fetch-bundle <this dashboard> --cert-name <name>
|
||||
# --session <token>` and the fetch verifies the agent against this app's own root CA and
|
||||
# presents this app's own client certificate (certs.py); a host revoked on /certs is refused
|
||||
# before any connection. The compatibility shape is a token-mode agent's URL userinfo
|
||||
# (http://TOKEN@host:9120), which config.parse_targets moves into an Authorization header and
|
||||
# never sends to the browser. The choice is per host, so a rollout can leave one host on
|
||||
# tokens while its neighbour is on certificates.
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HOST_TARGETS=driveripper=http://SECRET@driveripper.example.net:9120 deskwork=http://SECRET@deskwork.example.net:9120
|
||||
# HOST_TARGETS=driveripper=https://driveripper.example.net:9120 deskwork=https://deskwork.example.net:9120
|
||||
HOST_TARGETS=
|
||||
HOST_INTERVAL_SECONDS=10
|
||||
HOST_TIMEOUT_SECONDS=4
|
||||
|
||||
@@ -103,6 +103,19 @@ page still renders.
|
||||
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.
|
||||
**A session token used to fetch a bundle is the same case** (phase 24): the installer's
|
||||
`--fetch-bundle <url> --cert-name <name> --session <token>` sends that token on exactly one
|
||||
request, hands it to its `python3` child through the **environment** rather than argv, prints
|
||||
only the URL in its log line, `unset`s it before anything is written, and never repeats it in
|
||||
the sudoers hint's re-quoted command (which names `--bind`/`--port` and nothing else).
|
||||
`tests/test_deploy_scripts.py` signs in for a real token and then greps every file the run
|
||||
could have touched plus both output streams.
|
||||
**A bundle exported for the dev loop is the same case** (phase 24):
|
||||
`scripts/dev-certs-export.py` writes only into the gitignored `data/certs/` tree, creates each
|
||||
file with `os.open` so `0600` is its *birth* mode (and re-chmods a file that already existed
|
||||
looser), dials `GET /api/certs/<name>/bundle` over HTTP instead of importing `ensure_certs` — so
|
||||
it cannot mint even by accident — and prints one line per host with no PEM, no token and no
|
||||
password in it (`tests/test_deploy_scripts.py` greps the output for all three).
|
||||
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
|
||||
@@ -463,7 +476,14 @@ Responsibilities, one line each:
|
||||
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.
|
||||
module is what stops the dial — no restart, no cache, no second list to keep in sync. There is a
|
||||
**third door and it is deliberately outside this module** (phase 24):
|
||||
`scripts/dev-certs-export.py` is the dev loop's exporter — stdlib `urllib`, signs in through
|
||||
`POST /api/auth/login`, reads the token out of the `Set-Cookie`, and downloads each
|
||||
`HOST_TARGETS` name's bundle into gitignored `data/certs/` at `0600`. It cannot mint because it
|
||||
imports neither read; it dials the route the `/certs` page downloads from, which is what makes the
|
||||
exported file byte-identical to the row. `scripts/dev-server.sh` calls it after the readiness
|
||||
probe and treats a failure as a warning line — the server being up is the thing the probe proved.
|
||||
- `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
|
||||
@@ -917,8 +937,10 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
- **Transport is direct dial: dev is the prod topology, repo-wide (phase 10 task 05).** The
|
||||
designed shape is one `dashboards-agent` **systemd service per host**, installed by `ops`
|
||||
through the `deploy/agent-sudoers` grant with `deploy/install-agent.sh`, bound to
|
||||
the address the dashboard dials, its bearer token carried in the `HOST_TARGETS` URL
|
||||
userinfo; driveripper's upsd serves the LAN on 3051 with the host and laptop ranges allowed
|
||||
the address the dashboard dials — since phase 24 registered **with a certificate bundle**
|
||||
(`--fetch-bundle`, so its `HOST_TARGETS` URL is `https://` and carries no userinfo); what was
|
||||
once the token in the URL is the compatibility path for a host nobody has bundled. driveripper's
|
||||
upsd serves the LAN on 3051 with the host and laptop ranges allowed
|
||||
by the **firewall**, never by a `upsd.conf` line. The old ssh-tunnel script is deleted and
|
||||
neither `.env.dev` nor `.env.dev.example` declares a forwarded port — dev dials the same
|
||||
addresses the deployed dashboard does, and `scripts/capture-fixtures.sh` reads
|
||||
@@ -953,11 +975,28 @@ 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).
|
||||
**The fleet is mTLS since 2026-10-21 (phase 24 task 03), so the bearer-token transport is now
|
||||
history rather than the designed shape.** All three hosts run `agent/host_agent.py` **1.1.0**
|
||||
from an installed bundle, every unit carries `--cert-bundle /etc/dashboards-agent.certs` (plus
|
||||
`RuntimeDirectory=dashboards-agent`, and still its `--token-file` line), and `HOST_TARGETS` is
|
||||
`"driveripper=https://10.1.0.126:9120 deskwork=https://10.1.0.51:9120
|
||||
gateway=https://10.1.1.27:9120"` — **no `http://` entry and no userinfo anywhere**: the
|
||||
certificate is the credential (the pre-flip `.env.dev` is backed up in `data/env-backup/`). `agent.tls: true` + `agent.bundle_rev: 2` in
|
||||
every payload, `deployed_bundle_rev == bundle_rev` for all three on `/certs`, and
|
||||
`./scripts/dev-server.sh status` reads the nine sources `ok`. The per-host order was the safety:
|
||||
install agent 1.1.0 + bundle → `systemctl restart` → the CA-only `--status` probe answers →
|
||||
*then* that host's URL flips, so no source was red longer than its own host's restart.
|
||||
`data/logs/NOTES-24-rollout.md` is the transcript (sha256 of what was staged where, the unit
|
||||
diffs the re-run guard printed, the before/after SMART tables, the screenshots). Two facts in it
|
||||
are load-bearing for the next host: the fetch needed an **SSH reverse tunnel** (no monitored host
|
||||
can route back to a dev box, so `ssh -R 18099:127.0.0.1:8090 <host>` +
|
||||
`--fetch-bundle http://127.0.0.1:18099` is how registration reaches the dashboard), and
|
||||
`--status` takes its address from `--bind`, so run bare it dials `127.0.0.1` and reports "no
|
||||
answer" about a healthy host. **Rolling a host back is not just the URL**: a TLS-mode agent
|
||||
serves https only, so the `--cert-bundle` line and `/etc/dashboards-agent.certs` both have to go
|
||||
with it. And the fleet now trusts **this** dashboard's CA — a second deployment with its own
|
||||
`certs.*` rows cannot dial these agents until it carries the same authority or re-issues and
|
||||
re-installs every bundle.
|
||||
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.
|
||||
@@ -1015,16 +1054,25 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
`upsc`, `udevadm`, the `/etc/ups` reads, and the `tee /etc/ups/upsd.users|upsd.conf` +
|
||||
`systemctl restart|reload nut-server` pair documented in `deploy/nut-sudoers`); `deskwork`
|
||||
grants an unrelated set (`btrfs scrub`, `snapper`, `setfacl /srv/*`, `osbuild-auto.sh`).
|
||||
`deskwork` grants `ops` neither the installer nor `visudo`, so its install went through root by
|
||||
the operator. **`driveripper` does grant the installer** — `(root) NOPASSWD:
|
||||
/home/ops/agent-install/deploy/install-agent.sh`, an Oct-18 snapshot of the script staged under
|
||||
the operator's home (`sudo -n -l`, re-checked 2026-10-20) — and that grant is still the trap,
|
||||
not the shortcut: it points at that Oct-18 **snapshot**, which carries neither this phase's
|
||||
flags, nor the filter removal, nor the re-run guard, so running the granted path replaces the
|
||||
`User=root` + `CapabilityBoundingSet` unit that gap 10 resolved with the snapshot's own
|
||||
`User=dashboards-agent`, capability-less unit and re-breaks all 16 SMART reads — **silently**,
|
||||
because the guard that refuses a differing re-run (task 03) lives only in this repo's script,
|
||||
which therefore has to be re-staged before any of it means anything there. The template now writes the live unit's shape: no device filter at all
|
||||
Neither `deskwork` nor `driveripper` grants `visudo`, so a sudoers change is the operator's
|
||||
action. **`deskwork` and `driveripper` both grant the installer** —
|
||||
`(root) NOPASSWD: /home/ops/agent-install/deploy/install-agent.sh` — and both also grant
|
||||
`(root) NOPASSWD: /usr/bin/systemctl restart *`. The earlier record
|
||||
here said deskwork's `ops` had no installer grant and that its install went through the
|
||||
operator's root; that is out of date — `sudo -n -l` on 2026-10-21 lists the grant, and phase 24
|
||||
task 03 installed deskwork's bundle as `ops`. The grant names a **path**, and until phase 24
|
||||
that path held an Oct-18 snapshot of the script which carried neither the later flags, nor the
|
||||
filter removal, nor the re-run guard — running it would have replaced driveripper's `User=root`
|
||||
+ `CapabilityBoundingSet` unit with the snapshot's capability-less one, **silently**. That trap
|
||||
is now closed on all three hosts: `/home/ops/agent-install/` is `ops`-owned, so the repo's
|
||||
`deploy/install-agent.sh` (sha256 `ffbcd91f…`) and `agent/host_agent.py` (**1.1.0**) were staged
|
||||
over it without root, and each snapshot was left beside the grant as
|
||||
`install-agent.sh.oct18-backup`, which sudo will not run. **Re-staging is therefore the first
|
||||
step of any install, not an optional one** — the grant is only as current as the file it points
|
||||
at. The `systemctl restart *` grant on those two hosts is the other half of an unattended
|
||||
install: a bundle changes the unit, and the unit only takes effect on a restart that
|
||||
`systemctl enable --now` will not perform.
|
||||
The template now writes the live unit's shape: no device filter at all
|
||||
(phase 17 task 01 — systemd's default `auto`, with `PrivateDevices=false`) and `--as-root`
|
||||
plus `--caps CAP_SYS_RAWIO,CAP_SYS_ADMIN` for the `User=root` and
|
||||
`CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` lines (task 02). The old allow-list's
|
||||
@@ -1121,15 +1169,24 @@ uv sync --python 3.14 # deps + editable install
|
||||
cp .env.example .env # production-shaped config (DASH_ADMIN_PASSWORD seeds
|
||||
# the login on the first boot of a database — with no
|
||||
# seed and no stored verifier the server exits non-zero)
|
||||
cp .env.dev.example .env.dev # dev-only: the real hosts, tokens in URL userinfo
|
||||
cp .env.dev.example .env.dev # dev-only: the real hosts (this fleet's URLs are
|
||||
# `https://` with no userinfo — the bundle is the cred)
|
||||
|
||||
set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no tunnels
|
||||
./scripts/dev-server.sh restart # keep http://127.0.0.1:8090 up; sign in with the
|
||||
# DASH_ADMIN_PASSWORD from .env.dev (`testpass`)
|
||||
# DASH_ADMIN_PASSWORD from .env.dev (`testpass`), then
|
||||
# export every issued HOST_TARGETS bundle to data/certs/
|
||||
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
|
||||
./scripts/dev-server.sh bundles # what that export left: names, sizes, modes
|
||||
./scripts/dev-server.sh logs
|
||||
|
||||
uv run pytest # hermetic: 1494 passed + 15 skipped (11 live,
|
||||
uv run python scripts/dev-certs-export.py --url http://127.0.0.1:8090 \
|
||||
--password "$DASH_ADMIN_PASSWORD" --names <the HOST_TARGETS names>
|
||||
# the export on its own (phase 24): reads
|
||||
# GET /api/certs/<name>/bundle and writes
|
||||
# data/certs/<name>.bundle at 0600 — mints nothing
|
||||
|
||||
uv run pytest # hermetic: 1542 passed + 15 skipped (11 live,
|
||||
# 4 store_perf), no network
|
||||
uv run pytest --cov # coverage gate (98.94%; floor 94%)
|
||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||
@@ -1177,12 +1234,13 @@ 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`/
|
||||
`--cert-bundle`/`--as-root`/`--caps`/`--force`/`--status`/`--uninstall`; it stores the token
|
||||
`--cert-bundle`/`--fetch-bundle`/`--cert-name`/`--session`/`--as-root`/`--caps`/`--force`/
|
||||
`--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
|
||||
touched (with a local file this script dials nothing; the fetched path is the one dial, below), 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 **no device
|
||||
@@ -1202,7 +1260,19 @@ PEM blocks there and the agent deletes the key once the SSL context holds it), k
|
||||
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 — and neither is guarded, because both are deliberate. What is guarded is the
|
||||
it with the token — and neither is guarded, because both are deliberate. **Registration can fetch
|
||||
that bundle** (phase 24 task 02): `--fetch-bundle <dashboard url> --cert-name <HOST_TARGETS name>
|
||||
--session <signed-in token>` dials `GET /api/certs/<name>/bundle` with `python3` `urllib` (no
|
||||
`curl` assumption on a monitored host) in the install run and hands the bytes to that same
|
||||
`--cert-bundle` path, so the shape check, the `0640` install, the unit's TLS lines and the
|
||||
`--status` probe behave identically for a fetched and a downloaded bundle. The three flags are
|
||||
all-or-nothing, are refused together with a local `--cert-bundle` and with `--status`/`--uninstall`,
|
||||
and the fetch plus the shape check run **before** `need_root`: a 401, an unissued name, a proxy's
|
||||
200-with-HTML or a dashboard that never answers is one line naming the status and no partial
|
||||
install. The session is a **fetch credential only** — it travels to python in the environment (argv
|
||||
is world-readable through `ps`, which is why the agent's token is a file), is `unset` as soon as
|
||||
the bytes are in hand, appears in no written artefact and not even in the sudoers hint's re-quoted
|
||||
command, and the fetched temp file is removed by an EXIT trap on every path. What is guarded is the
|
||||
unit itself (phase 17 task 03): it is rendered to a temp file first, and if an installed `$UNIT`
|
||||
differs the run prints `refusing to overwrite …` plus the `diff -u` on stdout and exits 1 before
|
||||
the unit write, the `daemon-reload` and the `enable`, so a redeploy cannot undo a hand-fix and an
|
||||
@@ -1212,7 +1282,9 @@ which is what the TLS rollout's `--cert-bundle` step needs on a token-mode host,
|
||||
so a run that rewrote `ExecStart` leaves the already-running agent on its old settings until
|
||||
`systemctl restart dashboards-agent` — and `--status` chooses http from https by reading the unit
|
||||
file, so probing before that restart reports "no answer" for a change that landed correctly (the
|
||||
rollout in README restarts for exactly this reason). No root shell? The one sudoers
|
||||
rollout in README restarts for exactly this reason). It takes its **address** from the flags rather
|
||||
than the unit, so a bare `--status` probes `127.0.0.1:9120` and answers "no answer" about an agent
|
||||
bound to a LAN address that is healthy — always pass the same `--bind`/`--port` the install did. 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
|
||||
@@ -1293,6 +1365,16 @@ branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only
|
||||
fleet, engines, and upsd. Skipped unless `--live`; each test skips when its source is
|
||||
unset, so partial fleet availability is normal. **Live tests must not write** — a battery
|
||||
test on hardware something is plugged into is not a test.
|
||||
**An `https://` agent is dialled with the stored authority, through `fetch_snapshot` and never a
|
||||
hand-rolled `client.get`** (phase 24 task 03). The module-scoped `agent_tls` fixture reads the
|
||||
`certs.*` rows out of the same SQLite file the app uses — through a read-only `sqlite3` URI whose
|
||||
`set_meta`/`delete_meta` **raise**, because "live tests do not write" includes not minting or
|
||||
repairing an authority from a test — and hands `collectors.agent_tls_map` the material
|
||||
`certs.write_material` laid down, exactly as `app.build_hub` does. The failure this removes is the
|
||||
misleading kind: a raw `get` against a healthy mTLS agent dies with `CERTIFICATE_VERIFY_FAILED:
|
||||
self-signed certificate in certificate chain`, which reads like a dead host and is really a test
|
||||
that forgot to present a certificate. No store file, or no authority in it, skips — the same
|
||||
partial-environment rule as an unreachable host.
|
||||
- The agent's real-`/proc` test (`tests/test_agent.py`) touches only the box running pytest,
|
||||
so it stays in the default run and is *not* marked `live`.
|
||||
- `tests/auth_contract.py` is why the HTTP suite can start a server at all: every app in a test
|
||||
@@ -1346,6 +1428,29 @@ branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only
|
||||
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 bundle source is validated once, whichever source it is** (phase 24 task 02). The
|
||||
installer's `--fetch-bundle` is tested through the same `served_dashboard` socket as the export,
|
||||
because the claim is the same claim: the file a host installs is the route's bytes. The pins that
|
||||
go with it are the ones a second install path would break — byte-identical `0640` install with
|
||||
the unit's TLS lines; `issued: false`, a 401 and a 200-with-HTML each refused with their own
|
||||
sentence, no stub call logged and no file written (the stub log is the proof the refusal came
|
||||
*before* the root boundary); a temp `TMPDIR` left empty on the success path and on every refusal
|
||||
path, including the one where the shape check is what stopped the run; the three flags
|
||||
all-or-nothing and refused beside a local `--cert-bundle`, beside `--status`/`--uninstall`; and
|
||||
one label-aware validator, pinned by demanding that the local path still reads `--cert-bundle …`
|
||||
and never names the flag that was not used.
|
||||
- **A script that is an HTTP client is tested over a real socket** (phase 24). The dev bundle
|
||||
exporter is run as a subprocess against a real `create_app(...)` served by uvicorn on
|
||||
`127.0.0.1:0` in a thread, not through `TestClient`: what has to be proven is that it signs in
|
||||
through the public route, reads the session token back out of a `Set-Cookie` header, and sends it
|
||||
as `Authorization: Bearer` — none of which a transport-level test client has to serialise. The
|
||||
pins that go with it are the ones that keep the artifact honest: the file byte-identical to the
|
||||
route's bytes, `0600` (including the re-`chmod` of a file somebody loosened), no file and one
|
||||
note for `issued: false` and for `configured: false`, exit 2 with nothing written for a refused
|
||||
sign-in, **no mint** (the serials, revisions and root are the same after the run), and no PEM,
|
||||
token or password in the output. The dev-server composition is pinned on the served text too —
|
||||
export after the readiness probe, failure as `|| echo` and no `return 1` — because "it runs
|
||||
later, and never fails `start`" is the part a refactor loses.
|
||||
- **A live measurement is re-derivable, so a verdict can be re-checked without re-watching**
|
||||
(phase 23). `scripts/watch-llm-window.py` writes the series it judged (value, the `Δ<window>`
|
||||
the payload carried, the engine's own busy count, the raw token counter) beside its verdict, and
|
||||
@@ -2086,19 +2191,73 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
`/snapshot`. So the operator's `--force` run lands the new rationale prose and changes nothing
|
||||
systemd acts on (the three captures and the exact probe list are in
|
||||
`data/logs/NOTES-17-installer-repro.md`; no live change was made while pinning this phase). Two facts that came out of reading the live host, both load-bearing for the
|
||||
next install: the sudo-granted `/home/ops/agent-install` snapshot is the Oct-18 file (no
|
||||
next install: the sudo-granted `/home/ops/agent-install` path held the Oct-18 file (no
|
||||
`--as-root`, no `--caps`, no `--force`, no `--cert-bundle`, still nine `DeviceAllow` lines) and
|
||||
it **refuses** the recipe with `unknown argument: --as-root` and exit 2 before `need_root`, so
|
||||
that path cannot apply this recipe at all until the repo's script is re-staged — while a
|
||||
re-run of it *without* the flags is still the silent clobber the guard exists for. (The other
|
||||
it **refused** the recipe with `unknown argument: --as-root` and exit 2 before `need_root`, so
|
||||
that path could not apply this recipe at all — while a re-run of it *without* the flags was
|
||||
still the silent clobber the guard exists for. **Resolved on 2026-10-21:** phase 24 task 03
|
||||
staged the repo's script and agent 1.1.0 into `/home/ops/agent-install/` on all three hosts
|
||||
(the directory is `ops`-owned, so no root needed) before running the granted path, and left the
|
||||
snapshot beside it as a file sudo will not execute. The rule that came out of it is in the
|
||||
fleet record above: re-stage first, because a path grant is only as current as the file. (The other
|
||||
fact from that read — an applied change reaches the file but not the running process until
|
||||
something restarts it — is in the Deployment paragraph, and README's rollout now restarts.)
|
||||
`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
|
||||
11. **The fleet runs on installed bundles; what is left is rotation hygiene**
|
||||
(phase 15 tasks 01-05 landed 2026-10-20; phase 24 task 03 rolled the fleet out 2026-10-21).
|
||||
The rollout is done and verified: gateway, deskwork and driveripper each run `agent/host_agent.py`
|
||||
**1.1.0** with `/etc/dashboards-agent.certs` installed `0640 root:dashboards-agent`, their units
|
||||
carry `--cert-bundle` + `RuntimeDirectory=dashboards-agent` (and keep `--token-file`),
|
||||
`HOST_TARGETS` is `https://` for all three with no userinfo, `agent.bundle_rev` reads `2` on
|
||||
every host and equals what `/api/certs` issued, and the nine sources are `ok`. The transcript is
|
||||
`data/logs/NOTES-24-rollout.md`, and task 04 re-read the whole end state **read-only** afterwards
|
||||
(`data/logs/verify-24-mtls.py` → `24-task04-endstate.txt`, plus the rendered proof
|
||||
`data/logs/24-task04-verify.cjs` → `24-task04-{certs,disks,system}.png`): nine sources `ok`,
|
||||
`status: active` with `deployed_bundle_rev == bundle_rev` on all three rows, `agent.tls` +
|
||||
`agent.bundle_rev` + `agent.version 1.1.0` in every host payload, driveripper's 16 drives and
|
||||
deskwork's `nvme0n1` reporting temperature, hours and wear, deskwork's GPU card drawn on
|
||||
`/system`, gateway still absent from `/disks` with `disks:gateway` still `ok`, no `http://` and
|
||||
no userinfo in any URL the dashboard serves, and **no transport or certificate row in
|
||||
`/api/summary`'s alerts**. It revoked nothing: the standing negative control for "a revoked
|
||||
serial is refused" remains phase 15's `data/logs/verify-15-tls-e2e.py` (transcript
|
||||
`verify-15-tls-e2e.out`) plus the two suite pins — `tests/test_collectors.py`'s
|
||||
`test_a_revoked_agent_is_refused_before_anything_is_dialled` and `tests/test_api.py`'s
|
||||
`test_a_revoked_agent_fails_both_of_its_sources_without_a_dial`.
|
||||
|
||||
The **docs** are part of this end state, because a fleet comes back to `http://` through a
|
||||
setup file somebody copies: `tests/test_deploy_scripts.py` now pins that `README.md`,
|
||||
`AGENTS.md` and both tracked env templates name the registration trio (`--fetch-bundle`,
|
||||
`--cert-name`, `--session`), and that every `HOST_TARGETS` example line in the templates is
|
||||
`https://` with no userinfo (an empty `HOST_TARGETS=` stays allowed — unconfigured is not
|
||||
misconfigured). The token-mode dial itself is unchanged and still documented as the
|
||||
compatibility path; what may not come back is it being the shape a template seeds.
|
||||
|
||||
Two things that gap learned belong with this record, because both bite before anybody reads a
|
||||
dashboard:
|
||||
|
||||
* **A `certs.root.cert` row older than the SKI/AKI fix signs chains nobody can verify.** "A
|
||||
stored row always wins" is the invariant that keeps a CA from rotating with the process, and
|
||||
its cost is that a root minted before the extension keeps being used forever: a leaf issued
|
||||
afterwards carries a correct AKI pointing at an identifier its issuer does not have, and
|
||||
`ssl.create_default_context()` — strict mode, the context `AgentTls.ssl_context()` builds —
|
||||
answers `CERTIFICATE_VERIFY_FAILED: Missing Subject Key Identifier`. The agent is healthy,
|
||||
`--status` answers over TLS, and the dashboard calls the host unreachable. The repair is
|
||||
`POST /api/certs/root/rotate`, a dashboard restart (the CA and client pair are startup
|
||||
material) and a fresh bundle per host — never `DELETE FROM meta`, which throws away the
|
||||
authority instead of replacing it. Pinned by
|
||||
`tests/test_certs.py::test_a_stored_root_without_an_identifier_still_boots_and_root_rotate_repairs_it`,
|
||||
which also pins the other half: reading such a table must **not** fail closed, because
|
||||
`/certs` is the page that fixes it.
|
||||
* **These bundles came from the dev-box authority.** The fleet trusts the `certs.*` rows in the
|
||||
database `./scripts/dev-server.sh` runs against, and nothing else dials these agents (a
|
||||
`ss` sweep on each host saw only the dev box on 9120). A second deployment with its own
|
||||
authority cannot read this fleet until it carries the same root or re-issues and reinstalls;
|
||||
that is a deployment-repo decision, and the honest version is one authority per fleet.
|
||||
|
||||
Everything below is the phase-15/24 history that led here.
|
||||
`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
|
||||
@@ -2109,9 +2268,22 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
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
|
||||
no hardware. What phase 15 left missing — an **installed** bundle, which is what makes
|
||||
`DELETE FROM meta` the expensive command the reset message warns about — is what task 03
|
||||
supplied: deleting `certs.*` now costs the fleet's identity, and every host has to be
|
||||
re-bundled. Phase 24
|
||||
task 01 has since closed the **dev-side** half of that sentence: `./scripts/dev-server.sh
|
||||
restart` exports every issued bundle into gitignored `data/certs/<name>.bundle` at `0600`, so
|
||||
putting one on a host is an `scp` plus `install-agent.sh --cert-bundle` rather than a hand-run
|
||||
signed `curl` (`scripts/dev-certs-export.py`, invariant 6's export rules, pinned by
|
||||
`tests/test_deploy_scripts.py` against a real uvicorn-served `create_app`). Task 02 has since
|
||||
removed even that hand-run from the rollout: `install-agent.sh --fetch-bundle <url> --cert-name
|
||||
<name> --session <token>` fetches `GET /api/certs/<name>/bundle` during the install and feeds
|
||||
the bytes to the unchanged validator + `0640` install + TLS-unit path, refusing a 401, an
|
||||
unissued name or a non-bundle body before it writes anything, with the session token absent from
|
||||
every artefact the run leaves (pinned the same way, over the same socket) — which is the path
|
||||
all three hosts were installed through, over an SSH reverse tunnel because no monitored host
|
||||
can route back to a laptop. 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
|
||||
@@ -2155,23 +2327,25 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
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 is reproducible only with
|
||||
phase 17's `--as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force` line, gap 10), and flipping
|
||||
the URL before the bundle is installed turns that host red by construction.
|
||||
Re-checked from the dev box the same day, and worth knowing before that rollout: all three
|
||||
agents answer `/health` with `version` **1.0.0** (`agent_version` is the `/snapshot` spelling),
|
||||
so none of them carries task 02's TLS code either — the rollout ships the repo's
|
||||
`agent/host_agent.py` (1.1.0) *together with* the
|
||||
bundle, never the bundle alone. `driveripper`'s installed copy differs from the repo's by
|
||||
exactly that TLS block; its `_smart_pass` and the phase-16 budget scale-up are already there.
|
||||
Copying 1.1.0 into place is not an `install-agent.sh` re-run on that host (gap 10) — even
|
||||
though `ops` there is sudo-granted a staged copy of the installer (see the fleet record above),
|
||||
which is the one route that must not be taken until that snapshot is re-staged from this repo:
|
||||
the Oct-18 copy predates phase 17's template *and* its re-run guard, so it would rewrite that
|
||||
unit silently — and no live change was made while pinning phase 16.
|
||||
place (`data/logs/verify-15-tls-e2e.py`, transcript `verify-15-tls-e2e.out`).
|
||||
What was left was the live rollout, and phase 24 task 03 ran it on 2026-10-21, host by host, in
|
||||
the order that keeps every number honest: install **1.1.0 together with the bundle** (never the
|
||||
bundle alone — the installed agents were 1.0.0 and do not know `--cert-bundle`), restart the
|
||||
unit, let the CA-only `--status` probe answer, and only *then* flip that host's URL, because a
|
||||
URL flipped before its bundle is installed turns the host red by construction. All three: the
|
||||
trivial `gateway`, the drop-in-carrying `deskwork`, and `driveripper` with the gap-10 line
|
||||
(`--as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force`), whose dry-run diff moved comments and
|
||||
the TLS lines only — `User=root` and `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` came
|
||||
through byte-identical, and all 16 drives read live on both sides of the rewrite (same single
|
||||
`nvme5n1` alarm chip). Every host ran the installer **twice**, once without `--force` to read
|
||||
what task 17's guard would refuse before letting it apply. Staging the repo's script and agent
|
||||
into the sudo-granted `/home/ops/agent-install/` path was step one on all three (gap 10), and
|
||||
fetching was reached through an SSH reverse tunnel because no monitored host can route back to
|
||||
the dev box. `data/logs/NOTES-24-rollout.md` is the transcript: commands, unit diffs, the
|
||||
before/after SMART tables, the screenshots, and the two operational traps named at the head of
|
||||
this gap. What the fleet now *never* does is answer an agent over plain `http` or accept a
|
||||
bearer token on the certificate path — the TODO's "the client should never allow non-mtls
|
||||
communication" is the shipped state, and a new host has to be registered the same way.
|
||||
12. **The nav collapses on a phone; the page content below it does not** (phase 18's verification
|
||||
pass, 2026-10-20). At 360px `/`'s fleet table measures 450-510 px inside a 320 px
|
||||
`section.card`, and `.card { overflow: hidden }` clips the tail columns (`WORST DISK`,
|
||||
@@ -2191,8 +2365,11 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
left-justified rows at every width, it is no longer sticky, the phone menu slides, and both
|
||||
`⚙ Account` and `⚷ Certs` render on all seven signed-in pages including the two they name
|
||||
(task 04, reversing phase 15's "no self-link" rule at the operator's request). The other,
|
||||
`24_dev_mtls_rollout` (never accepting a non-mTLS agent — fleet gap 11's rollout, half of it),
|
||||
is still in `todo/`.
|
||||
`24_dev_mtls_rollout` (never accepting a non-mTLS agent) is closed: its fleet half landed on
|
||||
2026-10-21 and task 04 re-read the end state read-only the same day — gap 11. No `http://`
|
||||
agent URL and no token-mode agent is left on this fleet, and the setup templates no longer
|
||||
seed one (`tests/test_deploy_scripts.py` keeps it that way). What remains of phase 24 is
|
||||
ordinary rotation hygiene, not a missing step.
|
||||
|
||||
## Locked decisions (and why)
|
||||
|
||||
|
||||
@@ -44,25 +44,46 @@ Developing against the real fleet from a laptop — dev dials exactly the addres
|
||||
dashboard dials, so there is nothing to tunnel and no port to forward:
|
||||
|
||||
```bash
|
||||
cp .env.dev.example .env.dev # your host addresses + agent tokens (chmod 0600)
|
||||
sudo deploy/install-agent.sh --bind <addr> --port 9120
|
||||
cp .env.dev.example .env.dev # your host addresses (chmod 0600); this fleet's
|
||||
# entries are `https://` and carry no userinfo
|
||||
sudo deploy/install-agent.sh --bind <addr> --port 9120 --fetch-bundle <dashboard-url> \
|
||||
--cert-name <name> --session <signed-in token>
|
||||
# once per monitored host: the agent as a systemd
|
||||
# service, printing its bearer token exactly once
|
||||
# service, registered with the bundle this dashboard
|
||||
# signs for it (the token-mode install — no
|
||||
# `--cert-bundle` — still works and prints its bearer
|
||||
# token exactly once; see *Protecting an agent*)
|
||||
set -a; . ./.env.dev; set +a
|
||||
uv run dashboards # foreground
|
||||
./scripts/dev-server.sh restart # or backgrounded, with a pidfile + logs
|
||||
./scripts/dev-server.sh bundles # the bundles it exported, with sizes and modes
|
||||
```
|
||||
|
||||
Local dev signs in with the `DASH_ADMIN_PASSWORD` in that file — the word the template's comment
|
||||
names (`testpass`, what the hermetic suite uses) — and without it a fresh database exits non-zero
|
||||
for the same reason production does. `.env.dev` is gitignored and `0600`: it holds the agent
|
||||
tokens and the upsd password too.
|
||||
for the same reason production does. `.env.dev` is gitignored and `0600`: it holds the upsd
|
||||
password, and it held the agent bearer tokens until every host of this fleet moved onto an
|
||||
installed bundle — a TLS `HOST_TARGETS` entry carries no userinfo, so the credential that used to
|
||||
sit in the URL is now a certificate on the host and a `meta` row here.
|
||||
|
||||
Each `HOST_TARGETS` entry carries that token as URL userinfo
|
||||
`dev-server.sh restart` also **exports the agent bundles** while it is up: one line per
|
||||
`HOST_TARGETS` name, and each host's stored bundle written to `data/certs/<name>.bundle` at `0600`
|
||||
(`data/` is gitignored, so no key this writes can be committed). It is a read of
|
||||
`GET /api/certs/<name>/bundle` and nothing else — the authority stays the one in the database — so
|
||||
the file is byte-identical to the row and ready to `scp` into `install-agent.sh --cert-bundle` —
|
||||
though the normal path now needs neither: `install-agent.sh --fetch-bundle <url> --cert-name <name>
|
||||
--session <token>` downloads that same route on the host during its own install (see
|
||||
*Protecting an agent, host by host*). A host with nothing issued writes no file and prints the route
|
||||
that issues one. See *Managing the certificates*.
|
||||
|
||||
Each **token-mode** `HOST_TARGETS` entry carries its token as URL userinfo
|
||||
(`driveripper=http://<token>@<addr>:9120`); `parse_targets` moves it into an `Authorization`
|
||||
header, so `Target.url`, `/api/config` and every error message stay credential-free. No root
|
||||
shell on a host? `deploy/agent-sudoers` is the one-line grant that lets `ops` run the installer,
|
||||
and running the installer without root prints that exact line. upsd is dialled at
|
||||
header, so `Target.url`, `/api/config` and every error message stay credential-free. A host that
|
||||
runs from an installed bundle is `https://<addr>:9120` with **no userinfo at all** — the client
|
||||
certificate is the credential there, and this fleet is all `https://` today (see
|
||||
*Protecting an agent, host by host*). No root shell on a host? `deploy/agent-sudoers` is the
|
||||
one-line grant that lets `ops` run the installer, and running the installer without root prints
|
||||
that exact line. upsd is dialled at
|
||||
`NUT_HOST`/`NUT_PORT` on the port it really answers on (3051 on driveripper, not the stock
|
||||
3493) — see the NUT section below for who may connect.
|
||||
|
||||
@@ -81,6 +102,9 @@ python3 host_agent.py --host 0.0.0.0 --port 9120 --token-file /etc/dashboards-ag
|
||||
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
|
||||
sudo deploy/install-agent.sh --cert-bundle agent.bundle # ...or the TLS install (bundle from /certs)
|
||||
sudo deploy/install-agent.sh --fetch-bundle https://dashboards.example.com \
|
||||
--cert-name deskwork --session <signed-in token> --bind <addr> --port 9120
|
||||
# ...the same TLS install, bundle fetched in the run
|
||||
sudo deploy/install-agent.sh --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN
|
||||
# ...or a kernel-gated host (SMART passthrough, below)
|
||||
sudo deploy/install-agent.sh --force # ...only when it must overwrite a unit it would change
|
||||
@@ -123,6 +147,19 @@ 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.
|
||||
|
||||
`deploy/install-agent.sh --fetch-bundle <url> --cert-name <name> --session <token>` is the same
|
||||
TLS install with the download done inside the registration run: it asks
|
||||
`GET <url>/api/certs/<name>/bundle`, writes the answer to a temp file, and hands it to the
|
||||
identical validator + `0640` install + unit path. The three flags are all-or-nothing, they are
|
||||
refused together with a local `--cert-bundle` (one install, one bundle source) and with
|
||||
`--status`/`--uninstall`, and the fetch and the shape check both run **before** the root
|
||||
boundary — a dashboard that answers 401, or that has issued nothing for that name, stops the run
|
||||
with its own one-line sentence and no partial install. The session token is a *fetch credential
|
||||
only*: it is sent on that one request, never printed (the sudoers hint re-quotes the command
|
||||
without it), never written to the token file, the unit, the bundle or the log, and unset the
|
||||
moment the bytes are in hand. The temp copy is removed on every exit path, so the installed
|
||||
`/etc/dashboards-agent.certs` is the only place the fetched key survives.
|
||||
|
||||
**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
|
||||
@@ -219,42 +256,101 @@ that the *next poll* refuses to dial (see `collectors.AgentTls` above):
|
||||
| `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 |
|
||||
|
||||
**During development there is no second authority.** The CA the first boot minted into the
|
||||
`meta` table is the one everything reads, and `scripts/dev-certs-export.py` is the dev loop's half
|
||||
of that: `./scripts/dev-server.sh restart` runs it after the readiness probe, it signs in to the
|
||||
local server, downloads every `HOST_TARGETS` name's bundle through `GET /api/certs/<name>/bundle`,
|
||||
and writes each one to `data/certs/<name>.bundle` at `0600` (`os.open`, so the mode is the file's
|
||||
birth mode) in gitignored `data/`. Per host it prints exactly one line — the file it wrote with its
|
||||
size, or `no bundle issued yet — issue one on /certs` — and nothing at all is written for a host
|
||||
with nothing issued. It is a **read** of the same route the `/certs` download uses, never the
|
||||
startup path, so a restart cannot rotate an identity: the bytes on disk are the bytes in the row.
|
||||
A sign-in that fails is exit 2 and a `!` line (a `429` says so with its wait), and
|
||||
`dev-server.sh` prints that as a warning rather than failing `start`, because the server is
|
||||
provably up and the authority is untouched. `status` — or `dev-server.sh bundles` — lists what is
|
||||
there with sizes and modes.
|
||||
|
||||
### Protecting an agent, host by host
|
||||
|
||||
Three steps, and they are order-dependent because the dashboard picks its dial from the URL:
|
||||
Four steps, and they are order-dependent because the dashboard picks its dial from the URL. **A URL
|
||||
flipped before its bundle is installed turns that host red by construction**, so the URL flips
|
||||
last, after the probe answers.
|
||||
|
||||
```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
|
||||
# 0. issue the host a bundle if it has none: POST /api/certs/<name>/rotate (the Rotate button on
|
||||
# /certs) is also how a host gets its *first* one, and `GET …/bundle` then answers the bytes.
|
||||
# An unissued name is what makes step 1 say "no bundle issued for <name>".
|
||||
|
||||
# 2. install it beside the token — as root on the host, 0640 root:dashboards-agent. The same run
|
||||
# copies the repo's `agent/host_agent.py`, which is how a pre-1.1.0 agent gains TLS mode at all
|
||||
# (an older file does not know the `--cert-bundle` flag). The unit gains `--cert-bundle` and its
|
||||
# RuntimeDirectory — and then restart the service as root, because the installer's
|
||||
# `systemctl enable --now` only *starts* a unit, so an already-running agent keeps serving its
|
||||
# old mode until something restarts it.
|
||||
scp agent-deskwork.bundle <host>-ops:/tmp/
|
||||
ssh <host>-ops sudo /tmp/install-agent.sh --cert-bundle /tmp/agent-deskwork.bundle --force
|
||||
# 1. register the host: one run installs the agent *and* fetches its bundle from the dashboard
|
||||
# that owns the authority. The name is the HOST_TARGETS name; the token is a signed-in
|
||||
# session and it buys this one GET — it is not stored on the host. As root, 0640
|
||||
# root:dashboards-agent, after a shape check on the fetched bytes that runs before anything
|
||||
# is written, so a 401 or an unissued name leaves this host exactly as it was.
|
||||
# Stage the repo's script and agent first (`../agent/host_agent.py` is where the installer
|
||||
# reads the agent from, and the fleet's sudo grant names a path, not a checkout):
|
||||
scp deploy/install-agent.sh <host>-ops:/home/ops/agent-install/deploy/
|
||||
scp agent/host_agent.py <host>-ops:/home/ops/agent-install/agent/
|
||||
ssh <host>-ops sudo /home/ops/agent-install/deploy/install-agent.sh --bind <addr> --port 9120 \
|
||||
--fetch-bundle https://dashboards.example.com --cert-name deskwork --session "$SESSION" \
|
||||
--force
|
||||
|
||||
# the fallback, for a host that cannot reach the dashboard: the signed-in GET by hand (its
|
||||
# bytes are 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 — and against a local dev
|
||||
# server this file already exists, written by `./scripts/dev-server.sh restart`), then
|
||||
# --cert-bundle <file> instead of --fetch-bundle.
|
||||
# curl -s -H "Authorization: Bearer $SESSION" -o agent-deskwork.bundle \
|
||||
# http://127.0.0.1:8090/api/certs/deskwork/bundle
|
||||
# scp agent-deskwork.bundle <host>-ops:/tmp/
|
||||
# A *dev* dashboard on a laptop is usually unreachable from a monitored host in the other
|
||||
# direction too (the tunnel route is one-way). An SSH reverse tunnel keeps the registration
|
||||
# path rather than the scp: `ssh -R 18099:127.0.0.1:8090 <host>-ops` and then
|
||||
# `--fetch-bundle http://127.0.0.1:18099`.
|
||||
|
||||
# 2. restart it as root: the same run copies the repo's `agent/host_agent.py`, which is how a
|
||||
# pre-1.1.0 agent gains TLS mode at all (an older file does not know the `--cert-bundle`
|
||||
# flag), and the unit gains `--cert-bundle` and its RuntimeDirectory — but `systemctl enable
|
||||
# --now` only *starts* a unit, so an already-running agent keeps serving its old mode until
|
||||
# something restarts it.
|
||||
ssh <host>-ops sudo systemctl restart dashboards-agent
|
||||
ssh <host>-ops sudo /tmp/install-agent.sh --status # https, with the CA and no client cert
|
||||
# the CA-only https probe — pass the SAME --bind the unit uses, because the probe takes its
|
||||
# address from the flags and would otherwise dial 127.0.0.1 and report "no answer" for an
|
||||
# agent bound to a LAN address that is in fact healthy.
|
||||
ssh <host>-ops sudo /home/ops/agent-install/deploy/install-agent.sh --bind <addr> --port 9120 --status
|
||||
|
||||
# 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 …"
|
||||
```
|
||||
|
||||
`--force` in step 2 is not a shortcut: installing a bundle *is* a change to that host's unit (the
|
||||
`--force` in step 1 is not a shortcut: installing a bundle *is* a change to that host's unit (the
|
||||
`--cert-bundle` flag and its `RuntimeDirectory`), so without it the run prints the diff and stops
|
||||
by design — read it, then re-run with the flag. The same rule covers every change the installer
|
||||
applies, which is why the `--status` line above comes *after* the restart: the probe reads the
|
||||
unit file to decide between http and https, so pointing it at a process that has not been
|
||||
restarted yet reports "no answer" for a bundle that installed perfectly.
|
||||
`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.
|
||||
by design — read it, then re-run with the flag. Reading it first is cheap and worth doing on a host
|
||||
with a hand-fixed unit (run the same command once without the flag, study the diff, run it again
|
||||
with it). The same rule covers every change the installer applies, which is why the `--status` line
|
||||
above comes *after* the restart: the probe reads the unit file to decide between http and https, so
|
||||
pointing it at a process that has not been restarted yet reports "no answer" for a bundle that
|
||||
installed perfectly. `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.
|
||||
|
||||
Two things the first full rollout learned, both cheap to hit and expensive to guess at:
|
||||
|
||||
* **A CA row older than the identifier fix must be rotated before any bundle is installed.** A
|
||||
root minted without a `SubjectKeyIdentifier` is still readable, still starts, and still signs —
|
||||
but `ssl.create_default_context()` (the strict mode every dial here builds) rejects every chain
|
||||
it produces, with `CERTIFICATE_VERIFY_FAILED: Missing Subject Key Identifier`, so the host comes
|
||||
up healthy on `https` and reads `unreachable (ConnectError)` from the dashboard. `POST
|
||||
/api/certs/root/rotate` is the repair — a new root with its identifiers, a new client identity,
|
||||
every agent re-issued, then a dashboard restart and a fresh bundle per host. Never a
|
||||
`DELETE FROM meta` for this: the rotate button exists precisely so the operator keeps the
|
||||
authority in one place.
|
||||
* **Rolling back is not just the URL.** A TLS-mode agent serves `https` only, and the unit keeps
|
||||
its `--token-file` line (so the token survives on the box, ready for this). Restoring an old
|
||||
`http://<token>@…` entry does not bring the host back on its own: the `--cert-bundle` line has to
|
||||
come out of the unit *and* `/etc/dashboards-agent.certs` has to go, because the installer reuses
|
||||
an installed bundle on a run that was not given the flag.
|
||||
|
||||
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
|
||||
@@ -476,7 +572,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`). 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`) |
|
||||
| `HOST_TARGETS` | `name=url` per host running the agent. The shape a new host gets is `https://host:9120` with **no userinfo** — a certificate-authenticated agent, verified against this app's own root CA and presented with its client certificate, with a revoked host refused before the dial (`certs.py`, `collectors.AgentTls`). The compatibility shape is a token-mode agent's URL userinfo (`http://TOKEN@host:9120`), moved into an `Authorization` header by `parse_targets` so it never reaches `/api/config` or an error message |
|
||||
| `GUFO_BASE_URL` / `VLLM_BASE_URL` | LLM engines; unset means *not deployed here*, and its half of the page disappears |
|
||||
| `RATE_WINDOW_SECONDS` | the **host's** Δ window (default 10) — bytes/sec and jiffies→CPU %, measured over what a 10 s poll can fill; 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). The LLM rates do **not** use it: they are measured over `LLM_INTERVAL_SECONDS` (phase 23) |
|
||||
| `LLM_INTERVAL_SECONDS` | the `/llm` poll cadence (default 2 s) **and** the Δ window behind every token rate — one estimator per measuring cadence, so a slot that starts reads steady within two polls instead of ramping over five. Slowing the poll slows the window with it; there is no separate LLM window knob, and a window narrower than the interval would answer `n/a` every poll |
|
||||
@@ -806,7 +902,10 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
uv run pytest # unit + API tests, fully mocked, no network
|
||||
uv run pytest --cov # coverage gate
|
||||
set -a; . ./.env.dev; set +a # the live suite reads the same env as the app
|
||||
uv run pytest -m live --live # optional: real fleet / engines / upsd
|
||||
uv run pytest -m live --live # optional: real fleet / engines / upsd — an
|
||||
# `https://` HOST_TARGETS entry is dialled with the
|
||||
# authority in DASH_DB (read-only), so a box with no
|
||||
# store skips the host tests instead of failing them
|
||||
uv run pytest -m store_perf --store-perf -s # optional: the store's hot-path timings (phase 14)
|
||||
uv run python scripts/store_perf_probe.py # the same measurements as a report in data/logs/
|
||||
DASH_WATCH_SECONDS=1500 uv run python scripts/watch-store-perf.py
|
||||
@@ -885,7 +984,12 @@ completes a real client-certificate handshake with the material a startup would
|
||||
`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.
|
||||
proven without a root shell. The same file runs `scripts/dev-certs-export.py` against a real
|
||||
`create_app(...)` served by uvicorn on a loopback port (phase 24) — a thread and a socket rather
|
||||
than `TestClient`, because the unit under test is an HTTP client that reads its session out of a
|
||||
`Set-Cookie` header — and pins the export against the route: `0600`, byte-identical, one line per
|
||||
host, no file for an unissued one, no mint and no PEM in the output, and a wrong password exiting
|
||||
non-zero having written nothing.
|
||||
|
||||
`tests/store_perf.py` is the second opt-in marker's machinery (`live` is the other). It seeds a
|
||||
**production-shaped
|
||||
|
||||
+201
-12
@@ -4,6 +4,9 @@
|
||||
# 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)
|
||||
# sudo ./deploy/install-agent.sh --fetch-bundle https://dashboards.example.com \
|
||||
# --cert-name deskwork --session <signed-in token> --bind 10.1.0.51 --port 9120
|
||||
# # registration fetches its own bundle
|
||||
# sudo ./deploy/install-agent.sh --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN
|
||||
# # a kernel-gated host (see below)
|
||||
# ./deploy/install-agent.sh --status # unit state + /health, no root
|
||||
@@ -23,7 +26,7 @@
|
||||
# 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
|
||||
# the revocation denylist. With a local file this script dials nothing at all. 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
|
||||
@@ -35,6 +38,31 @@
|
||||
# the agent exempts for probes. Re-running without the flag leaves an installed bundle in
|
||||
# place, the token-reuse idiom.
|
||||
#
|
||||
# --fetch-bundle is that same install with the download done for you, because registration is
|
||||
# the moment a client should get its identity (TODO.md:3 — "distribute them to clients during
|
||||
# registration so we never need non-mtls servers"). It pulls
|
||||
# GET <url>/api/certs/<cert-name>/bundle and hands the bytes to the path above, so there is no
|
||||
# curl-a-key-across-the-network step for an operator to forget; the shape check, the 0640
|
||||
# install, the unit's TLS lines and the --status probe all then run on the fetched bytes
|
||||
# unchanged.
|
||||
#
|
||||
# --fetch-bundle URL the dashboard's own base URL — this is the one dial this script makes
|
||||
# --cert-name NAME the HOST_TARGETS name the certificate is issued for
|
||||
# --session TOKEN the signed-in session token, a **fetch credential only**
|
||||
#
|
||||
# The three are all-or-nothing, and they are refused together with a local --cert-bundle: one
|
||||
# install, one bundle source. The token is used for that one request and nothing else — never
|
||||
# printed, never written to the token file, the unit, the bundle or a log, and unset as soon as
|
||||
# the bundle is in hand (it is a bearer credential for a signed-in surface, so `ps` exposure
|
||||
# for the seconds of the fetch is the accepted cost on a single-admin box; the file it buys is
|
||||
# the artifact, not the credential). The fetch and the shape check both run before the root
|
||||
# boundary, so a dashboard that answers 401 — or says nothing is issued for that name — stops
|
||||
# the run with its own sentence and no partial install:
|
||||
#
|
||||
# sudo ./deploy/install-agent.sh --bind <addr> --port 9120 \
|
||||
# --fetch-bundle https://dashboards.example.com --cert-name <HOST_TARGETS name> \
|
||||
# --session <signed-in session token>
|
||||
#
|
||||
# The agent is read-only by construction (it answers GET and writes nothing), runs as its own
|
||||
# system user (unless --as-root says otherwise, below), and gets the usual systemd lockdown. It
|
||||
# still needs to read /proc, /sys and statvfs(), which the hardening below leaves alone.
|
||||
@@ -111,6 +139,17 @@ PORT="9120"
|
||||
TOKEN="${AGENT_TOKEN:-}"
|
||||
ROTATE=0
|
||||
BUNDLE_SRC=""
|
||||
# Which flag the bytes in $BUNDLE_SRC came from, so a shape refusal names the flag the operator
|
||||
# typed. Empty means `--cert-bundle` (the default inside `validate_bundle`).
|
||||
BUNDLE_LABEL=""
|
||||
# --fetch-bundle: the three parts of "fetch my bundle in this install run". All three or none
|
||||
# (checked below, before any write), and never together with a local --cert-bundle.
|
||||
FETCH_URL=""
|
||||
CERT_NAME=""
|
||||
SESSION_TOKEN=""
|
||||
# The temp file the fetched bytes land in, and the trap that guarantees it is gone whatever the
|
||||
# rest of the run does. Its name is never printed.
|
||||
FETCH_TMP=""
|
||||
# --as-root / --caps: the kernel-gate variant of the unit (phase 17 task 02). Both off means
|
||||
# the default install — dedicated user, empty CapabilityBoundingSet — byte-for-byte what this
|
||||
# script wrote before the flags existed.
|
||||
@@ -134,6 +173,9 @@ while [ $# -gt 0 ]; do
|
||||
--token) TOKEN="$2"; shift 2 ;;
|
||||
--rotate-token) ROTATE=1; shift ;;
|
||||
--cert-bundle) BUNDLE_SRC="$2"; shift 2 ;;
|
||||
--fetch-bundle) FETCH_URL="$2"; shift 2 ;;
|
||||
--cert-name) CERT_NAME="$2"; shift 2 ;;
|
||||
--session) SESSION_TOKEN="$2"; shift 2 ;;
|
||||
--as-root) AS_ROOT=1; shift ;;
|
||||
--caps)
|
||||
[ $# -ge 2 ] || {
|
||||
@@ -167,6 +209,43 @@ if [ -n "$CAPS" ] && [ "$AS_ROOT" -ne 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The same reasoning for the fetch: a flag combination is a mistake about what to install, and
|
||||
# it gets its own sentence before anything else happens. Three parts because a URL without a
|
||||
# name is a route nobody can guess and a name without a token is a 401 the operator would have
|
||||
# to diagnose — so all three or none. And `--fetch-bundle` beside `--cert-bundle` is two
|
||||
# answers to "where is the bundle?", which is a contradiction rather than a preference.
|
||||
FETCH_GIVEN=0
|
||||
for _flag in "$FETCH_URL" "$CERT_NAME" "$SESSION_TOKEN"; do
|
||||
# an `if`, not `[ -n … ] && FETCH_GIVEN=…`: under `set -e` a failing test as the last command
|
||||
# of the loop body would end the run on the first unset flag instead of counting them.
|
||||
if [ -n "$_flag" ]; then
|
||||
FETCH_GIVEN=$((FETCH_GIVEN + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$FETCH_GIVEN" -gt 0 ] && [ "$FETCH_GIVEN" -lt 3 ]; then
|
||||
echo "--fetch-bundle, --cert-name and --session go together (see --help)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$FETCH_GIVEN" -eq 3 ] && [ -n "$BUNDLE_SRC" ]; then
|
||||
echo "--fetch-bundle and --cert-bundle are two bundle sources; pick one" >&2
|
||||
exit 1
|
||||
fi
|
||||
# The fetch belongs to an install. `--status` probes a port and `--uninstall` deletes files; a
|
||||
# run of either would fetch a private key for nothing and leave it in a temp file.
|
||||
if [ "$FETCH_GIVEN" -eq 3 ] && [ "$MODE" != "install" ]; then
|
||||
echo "--fetch-bundle needs an install (it means nothing to --status or --uninstall)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup_fetch() {
|
||||
# The fetched bytes are somebody's private key. Registered as an EXIT trap when the fetch
|
||||
# starts, so no path — a refused shape, a refused unit re-run, a failing systemctl — leaves
|
||||
# them behind in /tmp. Runs before the trap is armed too, and does nothing then.
|
||||
if [ -n "$FETCH_TMP" ] && [ -e "$FETCH_TMP" ]; then
|
||||
rm -f "$FETCH_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
need_root() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
# The dev workflow deploys over ssh as the unprivileged `ops` account, so "run as root"
|
||||
@@ -269,23 +348,28 @@ validate_bundle() {
|
||||
# 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.
|
||||
#
|
||||
# $2 names the flag the bytes came from, so a refusal says what the operator typed rather than
|
||||
# a temp file they did not. One validator, two bundle sources: a local --cert-bundle file and
|
||||
# the fetched bytes of --fetch-bundle have to satisfy exactly the same shape, because the agent
|
||||
# cannot tell them apart either.
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "--cert-bundle needs python3 (3.10 or newer) to be validated" >&2
|
||||
echo "${2:---cert-bundle} needs python3 (3.10 or newer) to validate the bundle" >&2
|
||||
exit 2
|
||||
fi
|
||||
if python3 - "$1" <<'PY' 1>&2
|
||||
if python3 - "$1" "${2:---cert-bundle}" <<'PY' 1>&2
|
||||
import re
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
path, flag = sys.argv[1], sys.argv[2]
|
||||
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}")
|
||||
print(f"{flag} {path} cannot be read: {exc.strerror or exc}")
|
||||
raise SystemExit(1)
|
||||
if not text.strip():
|
||||
print(f"--cert-bundle {path} is empty")
|
||||
print(f"{flag} {path} is empty")
|
||||
raise SystemExit(1)
|
||||
revision = None
|
||||
for line in text.splitlines():
|
||||
@@ -299,7 +383,7 @@ for line in text.splitlines():
|
||||
revision = None
|
||||
break
|
||||
if revision is None:
|
||||
print(f"--cert-bundle {path} has no `# BUNDLE-REV <n>` line — that line is how the"
|
||||
print(f"{flag} {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)
|
||||
@@ -307,7 +391,7 @@ blocks = re.findall(r"-----BEGIN [A-Z0-9 ]+-----.*?-----END [A-Z0-9 ]+-----", te
|
||||
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"
|
||||
print(f"{flag} {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)
|
||||
@@ -318,15 +402,118 @@ PY
|
||||
exit 2
|
||||
}
|
||||
|
||||
fetch_bundle() {
|
||||
# Registration is where a client gets its identity, so the install run pulls this host's
|
||||
# bundle from the dashboard instead of an operator carrying a file across the network by hand
|
||||
# (phase 24: "distribute them to clients during registration so we never need non-mtls
|
||||
# servers"). Two properties are load-bearing, and both are the validator's reasoning.
|
||||
#
|
||||
# * **Before `need_root`.** A dashboard that answers 401, or says nothing is issued for that
|
||||
# name, or does not answer at all, stops the run here with its own sentence — no user, no
|
||||
# files, no unit. A refusal is not a sudoers problem, and a half-install is worse than none.
|
||||
# * **Then the existing shape check.** The bytes land in a temp file and become `$BUNDLE_SRC`,
|
||||
# so the PEM check, the `0640` install, the unit's TLS lines and the `--status` probe see a
|
||||
# fetched bundle exactly as they see a downloaded one. One install path, two sources.
|
||||
#
|
||||
# python3 is already required (the shape check and the TLS probe both use it), so this adds no
|
||||
# dependency and no assumption that a monitored host has `curl`. The token reaches python in
|
||||
# the **environment**, never on its argv: argv is world-readable through `ps`, which is the
|
||||
# same reason the agent's own token is a file rather than a flag — a second copy of a bearer
|
||||
# credential on a command line would only widen what `ps` shows.
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "--fetch-bundle needs python3 (3.10 or newer) to fetch the bundle" >&2
|
||||
exit 2
|
||||
fi
|
||||
FETCH_TMP="$(mktemp "${TMPDIR:-/tmp}/dashboards-agent-bundle.XXXXXX")"
|
||||
trap cleanup_fetch EXIT
|
||||
if ! FETCH_SESSION="$SESSION_TOKEN" python3 - "$FETCH_URL" "$CERT_NAME" "$FETCH_TMP" <<'PY' 1>&2
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
base, name, destination = sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3]
|
||||
token = os.environ.get("FETCH_SESSION", "")
|
||||
route = f"/api/certs/{urllib.parse.quote(name, safe='')}/bundle"
|
||||
request = urllib.request.Request(f"{base}{route}",
|
||||
headers={"authorization": f"Bearer {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
status, body = response.status, response.read()
|
||||
except urllib.error.HTTPError as refused:
|
||||
# A refusal is the answer, and it carries the reason: this app's 404 body says whether the
|
||||
# name is configured and whether anything was issued, which are three different operator
|
||||
# actions (fix the URL, issue a certificate, re-sign in).
|
||||
status, body = refused.code, refused.read()
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as unreachable:
|
||||
reason = getattr(unreachable, "reason", None) or unreachable
|
||||
print(f"--fetch-bundle: no dashboard answered at {base}: {reason}")
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
parsed = json.loads(body.decode("utf-8", "replace"))
|
||||
except ValueError:
|
||||
parsed = None
|
||||
payload = parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def detail() -> str:
|
||||
"""The dashboard's own sentence, or the first bytes of whatever it sent instead.
|
||||
|
||||
Every refusal this prints is text the app writes for an operator, and no response body of
|
||||
a *successful* fetch ever reaches the output — only the bytes land in the file.
|
||||
"""
|
||||
if isinstance(payload, dict) and isinstance(payload.get("detail"), str) and payload["detail"]:
|
||||
return str(payload["detail"])
|
||||
text = body.decode("utf-8", "replace").strip().replace("\n", " ")
|
||||
return text[:160] or "no body"
|
||||
|
||||
|
||||
if status == 200:
|
||||
if not body.strip():
|
||||
print(f"--fetch-bundle: GET {route} answered 200 with an empty body — nothing to install")
|
||||
raise SystemExit(1)
|
||||
with open(destination, "wb") as handle:
|
||||
handle.write(body)
|
||||
raise SystemExit(0)
|
||||
if (status == 404 and isinstance(payload, dict)
|
||||
and payload.get("configured") is True and payload.get("issued") is False):
|
||||
# The invariant-3 shape: a configured host with no certificate yet. Its own sentence, because
|
||||
# the fix is a click on /certs and not a re-typed URL or a new token.
|
||||
print(f"--fetch-bundle: no bundle issued for {name} — issue it from /certs first"
|
||||
f" (POST /api/certs/{name}/rotate mints one; GET {route} answered 404)")
|
||||
raise SystemExit(1)
|
||||
print(f"--fetch-bundle: GET {route} answered {status}: {detail()}")
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
then
|
||||
exit 2
|
||||
fi
|
||||
# A fetch credential only: from here the install needs the bytes, not the session. The env
|
||||
# assignment above was per-command, so the only copy left is the shell variable.
|
||||
unset SESSION_TOKEN
|
||||
BUNDLE_SRC="$FETCH_TMP"
|
||||
BUNDLE_LABEL="--fetch-bundle"
|
||||
echo " fetched the certificate bundle for $CERT_NAME from $FETCH_URL"
|
||||
}
|
||||
|
||||
# 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() {
|
||||
# Registration fetches its own bundle, before the root boundary for the same reason the shape
|
||||
# check is there: a dashboard that refuses is answered with its own sentence, and a run that
|
||||
# cannot get a bundle writes nothing at all. On success this is just another $BUNDLE_SRC.
|
||||
if [ -n "$FETCH_URL" ]; then
|
||||
fetch_bundle
|
||||
fi
|
||||
# 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"
|
||||
validate_bundle "$BUNDLE_SRC" "$BUNDLE_LABEL"
|
||||
fi
|
||||
need_root --bind "$BIND" --port "$PORT"
|
||||
[ -f "$AGENT_SOURCE" ] || { echo "no agent next to this script: $AGENT_SOURCE" >&2; exit 1; }
|
||||
@@ -534,9 +721,11 @@ 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.
|
||||
After a rotate or a root rotation on the dashboard, fetch the new bundle and re-run this
|
||||
script: --fetch-bundle <url> --cert-name <name> --session <token> does the download and the
|
||||
install in one go, and --cert-bundle <file> still works from a file already on this box. The
|
||||
agent picks a changed denylist up without a restart, but a new certificate needs one, and the
|
||||
run above only *starts* the unit — restart the service to put the new bundle into effect.
|
||||
NEXT
|
||||
else
|
||||
cat <<NEXT
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Materialise the dev dashboard's stored agent bundles in gitignored `data/certs/`.
|
||||
|
||||
.venv/bin/python scripts/dev-certs-export.py \
|
||||
--url http://127.0.0.1:8090 --password "$DASH_ADMIN_PASSWORD" \
|
||||
--names deskwork gateway --out-dir data/certs
|
||||
|
||||
Phase 24 task 01: the dev loop must stop producing `http://` agents, and the first step of
|
||||
that is making the bundle a host needs *be there* after `./scripts/dev-server.sh restart`
|
||||
instead of being a `curl` somebody remembers to run.
|
||||
|
||||
Two things this deliberately is not.
|
||||
|
||||
* **Not a second authority.** The CA is durable in the SQLite `meta` table (`certs.py`), and
|
||||
this script only reads the route that serves it (`GET /api/certs/<name>/bundle`), which is
|
||||
the request-time read (`certs.read_state`) and never the startup one (`ensure_certs`). So
|
||||
"generate certs during dev" means *export what the first boot minted*: running this against
|
||||
a dashboard changes no certificate, and a bundle it writes is byte-identical to the row.
|
||||
* **Not a place a key is committed.** Everything it writes lands under the repo's gitignored
|
||||
`data/` tree (the default), and a bundle carries that host's **private key**, so the file is
|
||||
created by `os.open` with `0600` as its birth mode and re-chmodded in case it already
|
||||
existed looser. Keep `--out-dir` inside `data/`.
|
||||
|
||||
Stdlib only (`urllib`), the way `install-agent.sh` reaches for `python3`: this runs on the dev
|
||||
box against a loopback URL, and a helper that needs the app's own dependency set to sign in
|
||||
would be a helper that cannot run before `uv sync`. The session token is read out of the
|
||||
`Set-Cookie` of the public `POST /api/auth/login` and sent back as `Authorization: Bearer` —
|
||||
the bearer carrier is the one the gate exempts from CSRF, and it never needs a cookie jar.
|
||||
|
||||
Exit codes: `0` every name resolved to a file or to the "nothing issued" note, `1` a host the
|
||||
route refused for some other reason, `2` no sign-in (wrong password, no password, no server).
|
||||
A sign-in failure is never silent, because the operator's next move is `install-agent.sh`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import email.message
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
#: The non-committed tree `TODO.md:3` asks for. `data/` is in `.gitignore`, so no key this
|
||||
#: writes can be committed by a scoped `git add` of a phase's work.
|
||||
DEFAULT_OUT_DIR = ROOT / "data" / "certs"
|
||||
TIMEOUT_SECONDS = 10.0
|
||||
|
||||
try: # the package owns the cookie name; importing it is what stops that drifting
|
||||
from dashboards.auth import SESSION_COOKIE
|
||||
except ImportError: # a bare interpreter outside the project venv can still run this
|
||||
SESSION_COOKIE = "dash_session"
|
||||
|
||||
#: `--names` accepts the shape `HOST_TARGETS` has (space or comma separated) so the caller can
|
||||
#: hand over what it already parsed instead of this script re-parsing a config it must not
|
||||
#: disagree with.
|
||||
_SPLIT_RE = re.compile(r"[,\s]+")
|
||||
|
||||
|
||||
class ExportError(Exception):
|
||||
"""One sentence for the operator: what failed, and where to look next."""
|
||||
|
||||
|
||||
def split_names(values: list[str] | None) -> list[str]:
|
||||
"""The host names to export, from one space/comma-separated string or several."""
|
||||
return [part for part in _SPLIT_RE.split(" ".join(values or []).strip()) if part]
|
||||
|
||||
|
||||
def _base(url: str) -> str:
|
||||
"""A base URL with no trailing slash, so joining a route path cannot double one."""
|
||||
return url.strip().rstrip("/")
|
||||
|
||||
|
||||
def _json(body: bytes) -> dict[str, object] | None:
|
||||
try:
|
||||
parsed = json.loads(body.decode("utf-8", "replace"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _detail(body: bytes) -> str:
|
||||
"""The server's own sentence, or the first bytes of whatever it sent instead.
|
||||
|
||||
Every refusal this script can print is one the app writes for an operator
|
||||
(`wrong password`, `too many failed sign-ins — try again in 120s`) and carries no
|
||||
credential, which is the only reason echoing it is allowed at all (AGENTS.md invariant 6).
|
||||
The response body of a *successful* sign-in never reaches the output.
|
||||
"""
|
||||
parsed = _json(body)
|
||||
if parsed is not None and isinstance(parsed.get("detail"), str):
|
||||
return str(parsed["detail"])
|
||||
text = body.decode("utf-8", "replace").strip().replace("\n", " ")
|
||||
return text[:160] or "no body"
|
||||
|
||||
|
||||
def _request(
|
||||
url: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
body: bytes | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> tuple[int, bytes, email.message.Message | None]:
|
||||
"""One HTTP call answered as `(status, body, headers)`.
|
||||
|
||||
urllib raises for every 4xx/5xx, and here a 404 **is** the answer: `{"configured": false}`
|
||||
and `{"configured": true, "issued": false}` are two different facts about a host. So the
|
||||
status comes back instead of an exception, and only a call that never reached the dashboard
|
||||
raises.
|
||||
"""
|
||||
request = urllib.request.Request(url, data=body, headers=headers or {}, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
|
||||
return response.status, response.read(), response.headers
|
||||
except urllib.error.HTTPError as exc: # a refusal from the app: read it, do not raise
|
||||
return exc.code, exc.read(), exc.headers
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
reason = getattr(exc, "reason", None) or exc
|
||||
raise ExportError(f"no dashboard answered at {url}: {reason}") from exc
|
||||
|
||||
|
||||
def _cookie(headers: email.message.Message | None, name: str) -> str:
|
||||
"""The value of one `Set-Cookie` from a response, with no cookie jar."""
|
||||
if headers is None:
|
||||
return ""
|
||||
for line in headers.get_all("set-cookie") or []:
|
||||
for chunk in line.split(";"):
|
||||
key, sep, value = chunk.strip().partition("=")
|
||||
if sep and key == name:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def sign_in(base: str, password: str) -> str:
|
||||
"""The session token for this password, or an :class:`ExportError`.
|
||||
|
||||
`/api/auth/login` is the one route an anonymous caller may POST (AGENTS.md invariant 12),
|
||||
and the token it mints travels **only** in its `Set-Cookie` — never in a body field a page
|
||||
could log — so reading it back out of the header is the whole job. A `429` says so with its
|
||||
`Retry-After` in the sentence: a dev loop that trips its own throttle should wait, not
|
||||
retype.
|
||||
"""
|
||||
status, body, headers = _request(
|
||||
f"{base}/api/auth/login",
|
||||
method="POST",
|
||||
body=json.dumps({"password": password}).encode("utf-8"),
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
if status != 200:
|
||||
raise ExportError(f"sign-in at {base} refused ({status}): {_detail(body)}")
|
||||
token = _cookie(headers, SESSION_COOKIE)
|
||||
if not token:
|
||||
raise ExportError(f"sign-in at {base} answered 200 with no {SESSION_COOKIE} cookie")
|
||||
return token
|
||||
|
||||
|
||||
def fetch_bundle(base: str, name: str, token: str) -> bytes | str:
|
||||
"""The bundle **bytes** for an issued host, or the one-line **note** for one with nothing.
|
||||
|
||||
The two 404 shapes are the invariant-3 pair and both mean "write no file": a host with no
|
||||
certificate yet, and a name this dashboard does not dial at all. The second cannot happen
|
||||
when the caller passes `HOST_TARGETS` names, so it is handled as a line rather than a
|
||||
crash — a config typo must not stop the other hosts' bundles.
|
||||
"""
|
||||
route = f"/api/certs/{urllib.parse.quote(name, safe='')}/bundle"
|
||||
status, body, _headers = _request(f"{base}{route}",
|
||||
headers={"authorization": f"Bearer {token}"})
|
||||
if status == 200 and body:
|
||||
return body
|
||||
payload = _json(body)
|
||||
if status == 404 and payload is not None:
|
||||
if payload.get("configured") is True:
|
||||
return (f"{name}: no bundle issued yet — issue one on /certs "
|
||||
f"(POST /api/certs/{name}/rotate mints it)")
|
||||
return f"{name}: not in this dashboard's HOST_TARGETS — nothing to export"
|
||||
raise ExportError(f"{name}: GET {route} answered {status}: {_detail(body)}")
|
||||
|
||||
|
||||
def export_one(base: str, name: str, token: str, out_dir: Path) -> str:
|
||||
"""One host's output line: where its bundle went, or why nothing was written.
|
||||
|
||||
Raises :class:`ExportError` for a host the route refused for a reason that is not "nothing
|
||||
issued" — the caller counts those and exits non-zero, because a bundle nobody downloaded is
|
||||
a host that stays on `http://`.
|
||||
"""
|
||||
result = fetch_bundle(base, name, token)
|
||||
if isinstance(result, str):
|
||||
return f" - {result}"
|
||||
path, unchanged = write_bundle(out_dir, name, result)
|
||||
return (f" ✓ {name}: {path} ({len(result)} bytes, mode 0600"
|
||||
f"{', unchanged' if unchanged else ''})")
|
||||
|
||||
|
||||
def write_bundle(out_dir: Path, name: str, data: bytes) -> tuple[Path, bool]:
|
||||
"""Write one bundle at `0600`; returns (path, whether the bytes were already there).
|
||||
|
||||
`os.open` rather than `open`, so `0600` is the file's **birth** mode: there is no window in
|
||||
which a private key exists on disk at the umask default. The `chmod` after covers the
|
||||
already-existing file, for which `O_CREAT`'s mode argument is ignored.
|
||||
"""
|
||||
if not name or Path(name).name != name:
|
||||
# Names come from HOST_TARGETS, which the route has already validated, but this is the
|
||||
# one function that decides where a key lands on disk. Say it plainly and write nothing.
|
||||
raise ExportError(f"{name!r} is not a usable host name — nothing written")
|
||||
out_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
path = out_dir / f"{name}.bundle"
|
||||
previous = path.read_bytes() if path.exists() else None
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(data)
|
||||
os.chmod(path, 0o600)
|
||||
return path, previous == data
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Export the dev dashboard's stored agent bundles to a non-committed "
|
||||
"directory (one file per configured host that has an issued bundle).",
|
||||
)
|
||||
parser.add_argument("--url", required=True,
|
||||
help="the dashboard's base URL, e.g. http://127.0.0.1:8090")
|
||||
parser.add_argument("--password", default=os.environ.get("DASH_ADMIN_PASSWORD", ""),
|
||||
help="the sign-in password (defaults to $DASH_ADMIN_PASSWORD, which "
|
||||
"dev-server.sh already loads from .env.dev)")
|
||||
parser.add_argument("--names", nargs="*", default=None, metavar="HOST",
|
||||
help="the configured host names, space or comma separated (from "
|
||||
"HOST_TARGETS — parsed by the caller, never re-parsed here)")
|
||||
parser.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR),
|
||||
help=f"where to write <name>.bundle (default: {DEFAULT_OUT_DIR}; keep "
|
||||
"it inside the gitignored data/ tree)")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
names = split_names(args.names)
|
||||
if not names:
|
||||
print(" - no host names given (HOST_TARGETS unset?) — nothing to export")
|
||||
return 0
|
||||
if not args.password:
|
||||
print(" ! no sign-in password: pass --password or set DASH_ADMIN_PASSWORD "
|
||||
"(dev-server.sh loads it from .env.dev)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
base = _base(args.url)
|
||||
try:
|
||||
token = sign_in(base, args.password)
|
||||
except ExportError as exc:
|
||||
print(f" ! {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
failed = 0
|
||||
for name in names:
|
||||
try:
|
||||
print(export_one(base, name, token, out_dir))
|
||||
except ExportError as exc:
|
||||
print(f" ! {exc}", file=sys.stderr)
|
||||
failed += 1
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+50
-3
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local development server: start|stop|status|restart|logs
|
||||
# Local development server: start|stop|status|restart|logs|bundles
|
||||
#
|
||||
# ./scripts/dev-server.sh restart # reads .env.dev if present, runs on DASH_PORT
|
||||
#
|
||||
@@ -11,10 +11,54 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
PIDFILE="$ROOT/data/dev-server.pid"
|
||||
LOG="$ROOT/data/logs/dev.log"
|
||||
# Phase 24: where the stored agent bundles are materialised for `install-agent.sh` to hand to a
|
||||
# host. Never committed — a bundle carries that host's private key — because `data/` is
|
||||
# gitignored, and the CA itself stays in the database (`certs.py`), never in this directory.
|
||||
CERTDIR="$ROOT/data/certs"
|
||||
mkdir -p "$ROOT/data/logs"
|
||||
|
||||
running() { [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; }
|
||||
|
||||
# The CA is durable in the database, so "generate certs during dev" means *export what the first
|
||||
# boot minted*: one <name>.bundle per configured host in gitignored data/certs/, ready to `scp`
|
||||
# to the host it belongs to. The names come out of HOST_TARGETS exactly the way `parse_targets`
|
||||
# reads it (space or comma separated `name=url`), so this script invents no host of its own and
|
||||
# re-parses nothing inside the exporter.
|
||||
#
|
||||
# A failure here is a warning line, never a start failure: the readiness probe proved the server
|
||||
# is up, and a bundle export that cannot sign in costs a dev convenience — not the dashboard, and
|
||||
# not the authority (the exporter only ever GETs the bundle route; it cannot mint).
|
||||
export_bundles() {
|
||||
local port="$1" names=() entry targets="${HOST_TARGETS:-}"
|
||||
for entry in ${targets//,/ }; do # space or comma separated, like config.parse_targets
|
||||
names+=("${entry%%=*}")
|
||||
done
|
||||
if [ ${#names[@]} -eq 0 ]; then
|
||||
echo " - no bundles exported (HOST_TARGETS unset)"
|
||||
return 0
|
||||
fi
|
||||
.venv/bin/python scripts/dev-certs-export.py \
|
||||
--url "http://127.0.0.1:${port}" \
|
||||
--password "${DASH_ADMIN_PASSWORD:-}" \
|
||||
--names "${names[@]}" \
|
||||
--out-dir "$CERTDIR" \
|
||||
|| echo " ! bundle export failed — check DASH_ADMIN_PASSWORD in .env.dev (the server is up and the CA is untouched)"
|
||||
}
|
||||
|
||||
# The exported-bundles block: names, sizes and modes, straight off the disk. `status` prints it,
|
||||
# because "is this host's bundle there yet?" is the question `status` gets asked; `bundles` is
|
||||
# the same block on its own.
|
||||
bundles() {
|
||||
echo "bundles exported ($CERTDIR):"
|
||||
local f found=0
|
||||
for f in "$CERTDIR"/*.bundle; do
|
||||
[ -e "$f" ] || continue
|
||||
found=1
|
||||
printf ' %s %s bytes mode 0%s\n' "${f##*/}" "$(wc -c <"$f")" "$(stat -c %a "$f")"
|
||||
done
|
||||
[ "$found" -eq 1 ] || echo " (none — restart the server, or run scripts/dev-certs-export.py)"
|
||||
}
|
||||
|
||||
start() {
|
||||
if running; then
|
||||
echo "already running (pid $(cat "$PIDFILE")) — http://127.0.0.1:${DASH_PORT:-8090}"
|
||||
@@ -33,6 +77,7 @@ start() {
|
||||
if running && curl -sf -o /dev/null "http://127.0.0.1:${PORT}/api/health"; then
|
||||
echo "started pid $(cat "$PIDFILE") — http://127.0.0.1:${PORT}"
|
||||
echo " sign in with DASH_ADMIN_PASSWORD from .env.dev"
|
||||
export_bundles "$PORT"
|
||||
else
|
||||
echo "FAILED to start; last lines of $LOG:"
|
||||
tail -20 "$LOG"
|
||||
@@ -80,7 +125,9 @@ case "${1:-start}" in
|
||||
| .venv/bin/python -c "import json,sys; d=json.load(sys.stdin); print('status:', d['status']); [print(' ', s['key'], 'ok' if s['ok'] else 'DOWN', (s['error'] or '')[:90]) for s in d['sources']]"
|
||||
else
|
||||
echo "not running"
|
||||
fi ;;
|
||||
fi
|
||||
bundles ;;
|
||||
bundles) bundles ;;
|
||||
logs) tail -n 40 "$LOG" ;;
|
||||
*) echo "usage: $0 start|stop|restart|status|logs"; exit 2 ;;
|
||||
*) echo "usage: $0 start|stop|restart|status|logs|bundles"; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -567,6 +567,19 @@ def ensure_certs(store: MetaStore, *, now: float | None = None) -> CertsState:
|
||||
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.
|
||||
|
||||
One consequence of that rule is worth reading before filing the next "every host is red"
|
||||
report: **a stored row also wins when it is older than this module's own shape.** A root minted
|
||||
before `SubjectKeyIdentifier` was added keeps being used forever — it still parses, its key
|
||||
still matches, its signatures are valid, so nothing here rejects it — and yet every leaf it
|
||||
signs is refused by `ssl.create_default_context()` (OpenSSL's strict mode, the context the
|
||||
collector's dial builds) with `Missing Subject Key Identifier`. That is a *shape* problem in the
|
||||
authority, not a corruption, so the answer is :func:`rotate_root` — a new root with its
|
||||
identifiers, a new client identity, every agent re-issued — rather than the `DELETE FROM meta`
|
||||
reset, which would throw the authority away along with the flaw. Phase 24 task 03 found this on
|
||||
a live store on the way to installing the fleet's first bundles, and
|
||||
`tests/test_certs.py::test_a_stored_root_without_an_identifier_still_boots_and_root_rotate_repairs_it`
|
||||
pins both halves: readable enough that `/certs` can still be reached, rotatable enough to fix.
|
||||
|
||||
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
|
||||
|
||||
+143
-1
@@ -25,13 +25,15 @@ import socket
|
||||
import ssl
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
||||
from cryptography.hazmat.primitives.asymmetric.types import PublicKeyTypes
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
@@ -371,6 +373,146 @@ def test_every_certificate_carries_the_identifiers_a_strict_verifier_requires()
|
||||
assert context.verify_flags & ssl.VERIFY_X509_STRICT, "this is the mode being satisfied"
|
||||
|
||||
|
||||
def _legacy_authority() -> FakeMeta:
|
||||
"""The meta rows a pre-identifier `certs.py` left in a database: a root **with no SKI**.
|
||||
|
||||
This is the shape phase 24 task 03 found on a live store. The CA row was minted before
|
||||
`SubjectKeyIdentifier` landed, and "a stored row always wins" means no later start re-mints
|
||||
it, so every leaf signed afterwards carries an AKI naming an identifier its issuer does not
|
||||
have. Written by hand here rather than by an old revision of the module, because the point is
|
||||
to read what a database can hold, not what this module would choose to write.
|
||||
|
||||
The real clock, for the same reason the handshake test below uses it: `ssl` judges the
|
||||
validity window itself, and the fixed `NOW` would read as an expired certificate.
|
||||
"""
|
||||
def pem_key(key: Any) -> str:
|
||||
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 subject(name: str) -> x509.Name:
|
||||
return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)])
|
||||
|
||||
moment = datetime.now(tz=UTC)
|
||||
root_key = ec.generate_private_key(ec.SECP256R1())
|
||||
root = (x509.CertificateBuilder().subject_name(subject(certs.ROOT_CN))
|
||||
.issuer_name(subject(certs.ROOT_CN))
|
||||
.public_key(root_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(moment - timedelta(days=1))
|
||||
.not_valid_after(moment + certs.ROOT_LIFETIME)
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||
.sign(root_key, hashes.SHA256()))
|
||||
client_key = ec.generate_private_key(ec.SECP256R1())
|
||||
client = (x509.CertificateBuilder().subject_name(subject(certs.CLIENT_CN))
|
||||
.issuer_name(subject(certs.ROOT_CN))
|
||||
.public_key(client_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(moment - timedelta(days=1))
|
||||
.not_valid_after(moment + certs.LEAF_LIFETIME)
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.sign(root_key, hashes.SHA256()))
|
||||
return FakeMeta({certs.META_ROOT_KEY: pem_key(root_key),
|
||||
certs.META_ROOT_CERT: pem_cert(root),
|
||||
certs.META_CLIENT_KEY: pem_key(client_key),
|
||||
certs.META_CLIENT_CERT: pem_cert(client),
|
||||
certs.META_AGENTS: "{}"})
|
||||
|
||||
|
||||
def _strict_dial_error(root_pem: str, leaf_pem: str, leaf_key_pem: str,
|
||||
tmp_path: Path) -> str | None:
|
||||
"""Serve `leaf_pem` and connect with the context the collector builds.
|
||||
|
||||
Returns `None` when the handshake completes, or the verification error's own sentence.
|
||||
`create_default_context` is the whole point: it is what `collectors.AgentTls.ssl_context`
|
||||
calls, so this is the dial the fleet actually makes and not a laxer stand-in.
|
||||
"""
|
||||
here = tmp_path / "strict"
|
||||
here.mkdir(parents=True, exist_ok=True)
|
||||
(here / "leaf.pem").write_text(leaf_pem)
|
||||
(here / "leaf.key").write_text(leaf_key_pem)
|
||||
(here / "ca.pem").write_text(root_pem)
|
||||
|
||||
server = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
server.load_cert_chain(str(here / "leaf.pem"), str(here / "leaf.key"))
|
||||
client = ssl.create_default_context(cafile=str(here / "ca.pem"))
|
||||
|
||||
listener = socket.socket()
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(1)
|
||||
listener.settimeout(15)
|
||||
port = listener.getsockname()[1]
|
||||
|
||||
def serve() -> None:
|
||||
try:
|
||||
raw, _peer = listener.accept()
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
with server.wrap_socket(raw, server_side=True) as tls:
|
||||
tls.recv(1)
|
||||
except OSError:
|
||||
pass # the server's half of a handshake the client refused
|
||||
|
||||
thread = threading.Thread(target=serve, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=15) as raw, \
|
||||
client.wrap_socket(raw, server_hostname="localhost") as tls:
|
||||
tls.sendall(b"x")
|
||||
return None
|
||||
except ssl.SSLCertVerificationError as exc:
|
||||
return str(exc)
|
||||
finally:
|
||||
listener.close()
|
||||
thread.join(timeout=15)
|
||||
|
||||
|
||||
def test_a_stored_root_without_an_identifier_still_boots_and_root_rotate_repairs_it(
|
||||
tmp_path: Path) -> None:
|
||||
"""A CA older than the identifier fix is a rotatable CA, not a bricked dashboard.
|
||||
|
||||
Three claims, all of them learned on 2026-10-21 while moving the fleet onto installed bundles:
|
||||
|
||||
* `ssl.create_default_context()` — strict mode — refuses a chain whose **CA** carries no
|
||||
Subject Key Identifier, so every leaf such a root signs is rejected by the dashboard's own
|
||||
dial. The live store hit exactly this: its `certs.root.cert` row predated the extension, and
|
||||
a leaf minted afterwards (with its own SKI and a correct AKI) still failed the handshake
|
||||
with `CERTIFICATE_VERIFY_FAILED: Missing Subject Key Identifier`.
|
||||
* Nothing may fail closed over it. `read_state` reads those rows because `/certs` is the page
|
||||
that fixes the problem — a startup that refused this table would leave the operator no
|
||||
**Rotate root** button, and `DELETE FROM meta` is the last resort rather than the first.
|
||||
* `rotate_root` is therefore the repair, and it has to actually repair the chain rather than
|
||||
only bump a revision: a new root with its identifiers, a new client identity, every agent
|
||||
re-issued under it, and the strict dial accepting the result.
|
||||
"""
|
||||
store = _legacy_authority()
|
||||
state = certs.read_state(store)
|
||||
with pytest.raises(x509.ExtensionNotFound):
|
||||
root_of(state).extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
|
||||
|
||||
# A leaf issued *now*, with every identifier current `certs.py` adds, is still unverifiable:
|
||||
# the gap is in the issuer, and no care at signing time reaches it.
|
||||
state = issue(store, state, "localhost", ["localhost", "127.0.0.1"])
|
||||
before = bundle_pems(entry_of(state, "localhost").bundle)
|
||||
refused = _strict_dial_error(state.root_cert, before["cert"], before["key"], tmp_path)
|
||||
assert refused is not None, (
|
||||
"a strict client accepted a chain from a CA with no Subject Key Identifier, so this "
|
||||
"OpenSSL is not the strict mode the fleet's dial runs in and the pin below means nothing")
|
||||
assert "certificate verify failed" in refused.lower()
|
||||
|
||||
rotated = certs.rotate_root(store, state)
|
||||
entry = entry_of(rotated, "localhost")
|
||||
assert entry.bundle_rev == 2, "every host is re-issued under the new root, one bump each"
|
||||
assert cert_of(rotated.root_cert).extensions.get_extension_for_class(
|
||||
x509.SubjectKeyIdentifier), "the rotated root carries the identifier its leaves name"
|
||||
after = bundle_pems(entry.bundle)
|
||||
accepted = _strict_dial_error(rotated.root_cert, after["cert"], after["key"], tmp_path)
|
||||
assert accepted is None, f"rotating the root must make the chain dialable: {accepted}"
|
||||
|
||||
|
||||
def test_the_client_certificate_chains_to_the_root_and_is_a_client_identity() -> None:
|
||||
_store, state = boot()
|
||||
client = cert_of(state.client_cert)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Contract tests for the deployment scripts in `deploy/`.
|
||||
"""Contract tests for the scripts an operator runs: `deploy/` and `scripts/dev-*.sh`/`.py`.
|
||||
|
||||
There is no shell test framework here — and adding one would buy nothing a subprocess does
|
||||
not — but phase 10 rests on a sentence that lives in **two** files: the sudoers grant in
|
||||
@@ -8,25 +8,41 @@ silently, and the failure is an install that cannot happen on a host nobody is s
|
||||
So these tests read the file, run the script the way the operator does (unprivileged, which
|
||||
exits before the script can touch anything), and require the two lines to be the same line
|
||||
apart from the path the script was invoked through.
|
||||
|
||||
The same reasoning covers `scripts/dev-certs-export.py` and the `scripts/dev-server.sh` call that
|
||||
runs it (phase 24): the exporter is what turns the durable CA into a file an operator can `scp`,
|
||||
and the composition — *after* the readiness probe, *as* a warning line — is as much of the
|
||||
contract as the bytes it writes. So both files are run here and read here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
from dashboards import certs
|
||||
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
||||
from dashboards import auth, certs
|
||||
from dashboards.app import create_app
|
||||
from dashboards.config import Settings, Target
|
||||
from dashboards.store import SampleStore
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEPLOY = ROOT / "deploy"
|
||||
@@ -227,6 +243,56 @@ def test_no_operational_file_mentions_the_tunnel_machinery() -> None:
|
||||
assert not offenders, "tunnel-era names/ports are back:\n" + "\n".join(offenders)
|
||||
|
||||
|
||||
# --- the mTLS registration is the documented default (phase 24) ---------------------------
|
||||
|
||||
#: The three flags that make a host's **install** its registration moment: the run that installs
|
||||
#: the agent also pulls that host's bundle from the dashboard that owns the authority. The
|
||||
#: token-mode install is still supported (a host nobody has bundled keeps its bearer token), but
|
||||
#: it is the compatibility path now, and a setup file that leads with it is a recipe for a fleet
|
||||
#: of `http://` agents — which is the exact state `TODO.md:3` asked to be rid of.
|
||||
REGISTRATION_FLAGS = ("--fetch-bundle", "--cert-name", "--session")
|
||||
|
||||
#: The files that tell an operator what to type, per host.
|
||||
REGISTRATION_DOCS = ("README.md", "AGENTS.md", ".env.example", ".env.dev.example")
|
||||
|
||||
|
||||
def _host_target_lines(path: Path) -> list[str]:
|
||||
"""Every `HOST_TARGETS` assignment line in a file, comments included.
|
||||
|
||||
The commented example in `.env.example` counts, because it is the line a reader copies.
|
||||
"""
|
||||
return [line for line in path.read_text().splitlines()
|
||||
if re.search(r"^\s*#?\s*(export\s+)?HOST_TARGETS\s*=", line)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", REGISTRATION_DOCS)
|
||||
def test_the_setup_docs_teach_registration_with_a_bundle(name: str) -> None:
|
||||
"""The per-host recipe a reader copies is the bundle fetch, not the bearer token."""
|
||||
text = (ROOT / name).read_text()
|
||||
for flag in REGISTRATION_FLAGS:
|
||||
assert flag in text, f"{name} stopped naming {flag} — the fetch is no longer documented"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", (".env.example", ".env.dev.example"))
|
||||
def test_the_template_target_urls_are_https_and_credential_free(name: str) -> None:
|
||||
"""A `HOST_TARGETS` line an operator copies is `https://` with no userinfo.
|
||||
|
||||
Pinning the *example* is what keeps this true: prose can and should still document the
|
||||
token-mode shape (it is a supported dial, `collectors.fetch_snapshot` keeps its path byte-for-
|
||||
byte), but the line that gets pasted into a real config must not seed a non-mTLS fleet. The
|
||||
empty `HOST_TARGETS=` is allowed — unconfigured is a different fact from misconfigured.
|
||||
"""
|
||||
lines = _host_target_lines(ROOT / name)
|
||||
assert lines, f"{name} lost its HOST_TARGETS documentation entirely"
|
||||
for line in lines:
|
||||
value = line.split("=", 1)[1]
|
||||
urls = re.findall(r"\w+://[^\s\"']+", value)
|
||||
if not urls:
|
||||
continue # the empty assignment
|
||||
bad = [u for u in urls if not u.startswith("https://") or "@" in u]
|
||||
assert not bad, f"{name} seeds a non-mTLS or userinfo-bearing target: {bad}"
|
||||
|
||||
|
||||
# --- shell syntax (the only gate these scripts get) -------------------------------------
|
||||
|
||||
|
||||
@@ -1544,3 +1610,727 @@ def test_a_token_mode_status_probe_stays_plain_http(tmp_path: Path) -> None:
|
||||
assert _installer_status(result.stdout).startswith("(no answer"), (
|
||||
"token mode must probe plain http — nothing listens here, and the refusal is the point"
|
||||
)
|
||||
|
||||
|
||||
# --- scripts/dev-certs-export.py (phase 24 task 01) ---------------------------------------
|
||||
|
||||
#: The dev exporter, and the dev server that calls it. Both are **run** below and **read**
|
||||
#: below: the bytes a host installs are the contract of the first, and the composition of the
|
||||
#: second (export *after* the readiness probe, failure *as* a warning) is the contract of the
|
||||
#: dev loop that has to stop producing `http://` agents.
|
||||
EXPORTER = ROOT / "scripts" / "dev-certs-export.py"
|
||||
DEV_SERVER = ROOT / "scripts" / "dev-server.sh"
|
||||
|
||||
#: The invariant-3 pair, as two configured hosts: one with a bundle in `meta`, one without.
|
||||
ISSUED_HOST = "issuedhost"
|
||||
UNISSUED_HOST = "unissuedhost"
|
||||
|
||||
|
||||
def _load_exporter() -> Any:
|
||||
"""The exporter as a module — it is a script, not a package member, so import it by path."""
|
||||
spec = importlib.util.spec_from_file_location("dev_certs_export", EXPORTER)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@contextmanager
|
||||
def served_dashboard(tmp_path: Path) -> Iterator[tuple[str, SampleStore]]:
|
||||
"""A real `create_app(...)` on a real socket, with one host's bundle already issued.
|
||||
|
||||
uvicorn in a thread rather than `TestClient`, because the unit under test is an **HTTP
|
||||
client**: the exporter signs in over the public route and reads the session token back out
|
||||
of a `Set-Cookie` header, and a transport-level test client can pass that without ever
|
||||
serialising a header. Port 0, so nothing here can collide with a port another test bound.
|
||||
|
||||
The store is injected and closed here, and the two `HOST_TARGETS` entries point at a port
|
||||
nothing listens on: the app under test must never dial, and a dead target is what keeps
|
||||
this hermetic. Every other source is switched off explicitly, so an operator running the
|
||||
suite with `.env.dev` sourced still gets a test of two hosts and no fleet.
|
||||
"""
|
||||
db = tmp_path / "export.sqlite3"
|
||||
store = SampleStore(db)
|
||||
state = certs.ensure_certs(store)
|
||||
certs.issue_agent_cert(store, state, ISSUED_HOST, san=[ISSUED_HOST])
|
||||
settings = Settings(
|
||||
db_path=str(db),
|
||||
admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=(Target(name=ISSUED_HOST, url="http://127.0.0.1:1"),
|
||||
Target(name=UNISSUED_HOST, url="http://127.0.0.1:1")),
|
||||
host_interval=3600,
|
||||
host_timeout=0.2,
|
||||
gufo_url="",
|
||||
vllm_url="",
|
||||
nut_host="",
|
||||
)
|
||||
server = uvicorn.Server(uvicorn.Config(create_app(settings, store), host="127.0.0.1",
|
||||
port=0, log_level="error", lifespan="on"))
|
||||
thread = threading.Thread(target=server.run, daemon=True, name="dev-certs-export-test")
|
||||
thread.start()
|
||||
try:
|
||||
for _ in range(2_000):
|
||||
if server.started:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert server.started, "the test dashboard never came up (see the captured stderr)"
|
||||
port = int(server.servers[0].sockets[0].getsockname()[1])
|
||||
yield f"http://127.0.0.1:{port}", store
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=60)
|
||||
store.close()
|
||||
|
||||
|
||||
def _export(base: str, out_dir: Path, names: list[str], *,
|
||||
password: str | None = ADMIN_PASSWORD) -> subprocess.CompletedProcess[str]:
|
||||
"""Run the exporter exactly the way `dev-server.sh` does, as a subprocess."""
|
||||
args = [sys.executable, str(EXPORTER), "--url", base, "--out-dir", str(out_dir)]
|
||||
if password is not None:
|
||||
args += ["--password", password]
|
||||
if names:
|
||||
args += ["--names", *names]
|
||||
return _run(args)
|
||||
|
||||
|
||||
def _served_bundle(base: str, name: str) -> bytes:
|
||||
"""What `GET /api/certs/<name>/bundle` answers, fetched the way the exporter fetches it."""
|
||||
with httpx.Client(base_url=base, timeout=10.0) as client:
|
||||
login = client.post("/api/auth/login", json={"password": ADMIN_PASSWORD})
|
||||
login.raise_for_status()
|
||||
token = login.cookies[auth.SESSION_COOKIE]
|
||||
answer = client.get(f"/api/certs/{name}/bundle",
|
||||
headers={"authorization": f"Bearer {token}"})
|
||||
answer.raise_for_status()
|
||||
return answer.content
|
||||
|
||||
|
||||
def test_the_export_writes_the_issued_bundle_byte_identical_at_0600(tmp_path: Path) -> None:
|
||||
"""The task's headline: the file is the stored bundle, at a mode that keeps it private.
|
||||
|
||||
Byte-identity is compared against the **route** rather than the `meta` row because the
|
||||
question this answers is "is the file I am about to `scp` the file the dashboard issued?",
|
||||
and a bundle that re-signed between the two would make that question unanswerable.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
out = tmp_path / "certs"
|
||||
result = _export(base, out, [ISSUED_HOST])
|
||||
assert result.returncode == 0, result.stderr
|
||||
path = out / f"{ISSUED_HOST}.bundle"
|
||||
assert path.is_file(), f"no bundle exported; stdout was:\n{result.stdout}"
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600, (
|
||||
"a bundle carries a private key; 0600 is its birth mode and stays its mode"
|
||||
)
|
||||
assert path.read_bytes() == _served_bundle(base, ISSUED_HOST)
|
||||
assert ISSUED_HOST in result.stdout and "0600" in result.stdout
|
||||
|
||||
|
||||
def test_an_unissued_host_writes_no_file_and_names_the_certs_route(tmp_path: Path) -> None:
|
||||
"""`issued: false` is a fact, not a failure: one line, no file, exit 0 (invariant 3)."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
out = tmp_path / "certs"
|
||||
result = _export(base, out, [ISSUED_HOST, UNISSUED_HOST])
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert not (out / f"{UNISSUED_HOST}.bundle").exists()
|
||||
note = next(line for line in result.stdout.splitlines() if UNISSUED_HOST in line)
|
||||
assert "no bundle issued" in note and "/certs" in note, note
|
||||
assert "!" not in note, f"an unissued host must not read as a failure: {note}"
|
||||
|
||||
|
||||
def test_a_name_outside_host_targets_is_a_note_and_not_a_crash(tmp_path: Path) -> None:
|
||||
"""The `{"configured": false}` arm: impossible from `HOST_TARGETS`, still never a crash."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
out = tmp_path / "certs"
|
||||
result = _export(base, out, ["nosuchhost"])
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert not out.exists(), "nothing was issued, so nothing — not even a directory"
|
||||
assert "HOST_TARGETS" in result.stdout
|
||||
|
||||
|
||||
def test_a_wrong_password_exits_non_zero_and_writes_nothing(tmp_path: Path) -> None:
|
||||
"""A dev server that cannot be signed in to says so loudly (and the CA stays untouched)."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
out = tmp_path / "certs"
|
||||
result = _export(base, out, [ISSUED_HOST], password="definitely-not-the-password")
|
||||
assert result.returncode == 2, f"a sign-in failure must not look like success: {result}"
|
||||
assert not out.exists()
|
||||
assert "sign-in" in result.stderr.lower()
|
||||
|
||||
|
||||
def test_no_password_at_all_is_a_refusal_and_not_a_hang(tmp_path: Path) -> None:
|
||||
"""`--password` is optional only because `$DASH_ADMIN_PASSWORD` is the dev source of it."""
|
||||
env = {key: value for key, value in os.environ.items() if key != "DASH_ADMIN_PASSWORD"}
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(EXPORTER), "--url", "http://127.0.0.1:1",
|
||||
"--out-dir", str(tmp_path / "certs"), "--names", ISSUED_HOST],
|
||||
capture_output=True, text=True, check=False, env=env, timeout=60,
|
||||
)
|
||||
assert result.returncode == 2
|
||||
assert "DASH_ADMIN_PASSWORD" in result.stderr
|
||||
assert not (tmp_path / "certs").exists()
|
||||
|
||||
|
||||
def test_no_names_at_all_is_a_quiet_zero(tmp_path: Path) -> None:
|
||||
"""A dashboard with no `HOST_TARGETS` has nothing to export and is not broken."""
|
||||
result = _export("http://127.0.0.1:1", tmp_path / "certs", [])
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "nothing to export" in result.stdout
|
||||
assert not (tmp_path / "certs").exists()
|
||||
|
||||
|
||||
def test_a_second_export_is_idempotent_and_says_unchanged(tmp_path: Path) -> None:
|
||||
"""`restart` runs this every time; a dev loop that rewrote keys on every boot would be noise."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
out = tmp_path / "certs"
|
||||
first = _export(base, out, [ISSUED_HOST])
|
||||
assert first.returncode == 0, first.stderr
|
||||
path = out / f"{ISSUED_HOST}.bundle"
|
||||
before = path.read_bytes()
|
||||
path.chmod(0o644) # somebody loosened it; the export is what puts the mode back
|
||||
second = _export(base, out, [ISSUED_HOST])
|
||||
assert second.returncode == 0, second.stderr
|
||||
assert path.read_bytes() == before, "the stored bundle must not move between exports"
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600, "an existing file is re-chmodded"
|
||||
assert "unchanged" in second.stdout
|
||||
|
||||
|
||||
def test_the_export_reads_the_authority_and_never_mints_anything(tmp_path: Path) -> None:
|
||||
"""The difference between `read_state` and `ensure_certs`, pinned as an observable.
|
||||
|
||||
"The dev server should generate certs" is the TODO's wording; the safe reading is *export
|
||||
what the first boot minted*. If this script ever issued, a stray `restart` would rotate a
|
||||
fleet's identities — so the serials, the revisions and the root all have to be the same
|
||||
bytes after the run as before it.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, store):
|
||||
def fingerprint() -> tuple[str, ...]:
|
||||
listed = certs.read_state(store)
|
||||
rows = tuple(f"{e.name}:{e.serial}:{e.bundle_rev}:{e.status}"
|
||||
for e in certs.read_agents(store))
|
||||
return (f"root:{listed.root_serial}", *rows)
|
||||
|
||||
before = fingerprint()
|
||||
assert _export(base, tmp_path / "certs",
|
||||
[ISSUED_HOST, UNISSUED_HOST, "nosuchhost"]).returncode == 0
|
||||
assert fingerprint() == before, "the exporter changed the certificate authority"
|
||||
|
||||
|
||||
def test_the_export_prints_no_key_material_and_no_password(tmp_path: Path) -> None:
|
||||
"""Invariant 6, on the one path that holds both a session token and a private key."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
result = _export(base, tmp_path / "certs", [ISSUED_HOST, UNISSUED_HOST])
|
||||
printed = result.stdout + result.stderr
|
||||
assert "BEGIN" not in printed, "a PEM block reached the terminal (and the dev log)"
|
||||
assert ADMIN_PASSWORD not in printed
|
||||
assert auth.SESSION_COOKIE not in printed, "the token itself must never be printed"
|
||||
|
||||
|
||||
def test_the_exporter_splits_names_the_way_host_targets_looks() -> None:
|
||||
"""One argument with spaces in it, three arguments, commas: all the same list."""
|
||||
module = _load_exporter()
|
||||
assert module.split_names(["alpha beta", "gamma,delta"]) == ["alpha", "beta", "gamma", "delta"]
|
||||
assert module.split_names([" alpha,,beta "]) == ["alpha", "beta"]
|
||||
assert module.split_names([]) == []
|
||||
assert module.split_names(None) == []
|
||||
|
||||
|
||||
def test_the_exporter_reads_the_cookie_the_app_sets() -> None:
|
||||
"""`auth.SESSION_COOKIE` is imported, not restated — a rename must not silently break dev."""
|
||||
module = _load_exporter()
|
||||
assert module.SESSION_COOKIE == auth.SESSION_COOKIE
|
||||
|
||||
|
||||
def test_write_bundle_refuses_a_name_that_would_leave_the_directory(tmp_path: Path) -> None:
|
||||
"""The route gates names long before this, but this is the function that picks a path."""
|
||||
module = _load_exporter()
|
||||
for bad in ("../escape", "nested/x", "/absolute", "", "."):
|
||||
with pytest.raises(module.ExportError):
|
||||
module.write_bundle(tmp_path, bad, b"secret")
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_the_bundles_only_ever_land_under_the_gitignored_data_tree() -> None:
|
||||
"""No key, bundle or PEM outside `data/`: the exporter's default and the caller's flag."""
|
||||
module = _load_exporter()
|
||||
assert module.DEFAULT_OUT_DIR == ROOT / "data" / "certs"
|
||||
gitignore = (ROOT / ".gitignore").read_text()
|
||||
assert re.search(r"^data/$", gitignore, re.MULTILINE), "data/ is the gitignored tree"
|
||||
dev_server = DEV_SERVER.read_text()
|
||||
assignments = [line for line in dev_server.splitlines() if line.startswith("CERTDIR=")]
|
||||
assert assignments == ['CERTDIR="$ROOT/data/certs"'], (
|
||||
f"the dev server must name exactly one export dir, under data/: {assignments}"
|
||||
)
|
||||
assert '--out-dir "$CERTDIR"' in dev_server, "the export must be told where it writes"
|
||||
|
||||
|
||||
def _shell_function(text: str, name: str) -> str:
|
||||
"""One function's body, so a pin can talk about *that* block and not the whole file."""
|
||||
match = re.search(rf"^{re.escape(name)}\(\) \{{$", text, re.MULTILINE)
|
||||
assert match is not None, f"{name}() is no longer a plain function in dev-server.sh"
|
||||
body = text[match.end():]
|
||||
end = body.index("\n}")
|
||||
return body[:end]
|
||||
|
||||
|
||||
def test_dev_server_exports_the_bundles_after_the_readiness_probe() -> None:
|
||||
"""The order is the whole safety of the dev loop: no bundle export against a dead server."""
|
||||
text = DEV_SERVER.read_text()
|
||||
probe = text.index("curl -sf -o /dev/null")
|
||||
call = text.index('export_bundles "$PORT"')
|
||||
assert probe < call, "the export must run only once /api/health has answered"
|
||||
assert "dev-certs-export.py" in _shell_function(text, "export_bundles")
|
||||
body = _shell_function(text, "export_bundles")
|
||||
assert "http://127.0.0.1:${port}" in body, "the exporter dials the dev server and nothing else"
|
||||
# The names are HOST_TARGETS' names, split by the caller: the exporter re-parses no config.
|
||||
assert "${entry%%=*}" in body
|
||||
|
||||
|
||||
def test_a_failed_bundle_export_never_fails_the_dev_server_start() -> None:
|
||||
"""A dev-convenience failure is a warning line: the readiness probe already proved the app."""
|
||||
body = _shell_function(DEV_SERVER.read_text(), "export_bundles")
|
||||
assert "|| echo" in body, "a failed export must print a warning rather than propagate"
|
||||
for escape in ("return 1", "exit 1", "set -e"):
|
||||
assert escape not in body, f"the export can now abort `start` ({escape})"
|
||||
|
||||
|
||||
def test_dev_server_status_prints_the_exported_bundles_block() -> None:
|
||||
"""`status` answers "is this host's bundle there yet?" without a signed-in curl."""
|
||||
text = DEV_SERVER.read_text()
|
||||
body = _shell_function(text, "bundles")
|
||||
assert "bundles exported" in body and ".bundle" in body
|
||||
assert "mode" in body, "the block shows the 0600, which is the thing worth checking"
|
||||
assert re.search(r"^ bundles ;;$", text, re.MULTILINE), "`status` must print the block"
|
||||
assert "bundles)" in text, "`bundles` is a subcommand of its own"
|
||||
|
||||
|
||||
def test_a_dashboard_that_never_answers_is_named_rather_than_guessed(tmp_path: Path) -> None:
|
||||
"""Nothing listened: the line says which URL, the exit is non-zero, nothing is written."""
|
||||
result = _export("http://127.0.0.1:1", tmp_path / "certs", [ISSUED_HOST])
|
||||
assert result.returncode == 2
|
||||
assert "no dashboard answered" in result.stderr and "127.0.0.1:1" in result.stderr
|
||||
assert not (tmp_path / "certs").exists()
|
||||
|
||||
|
||||
def test_an_unexpected_answer_on_the_bundle_route_is_not_read_as_unissued(tmp_path: Path) -> None:
|
||||
"""Only the two 404 shapes are notes. A 401 means the caller is not authorised, which is news."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
module = _load_exporter()
|
||||
with pytest.raises(module.ExportError) as raised:
|
||||
module.fetch_bundle(base, ISSUED_HOST, "not-a-session-token")
|
||||
assert "401" in str(raised.value)
|
||||
|
||||
|
||||
def test_one_host_the_route_refuses_never_stops_the_others(tmp_path: Any,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The aggregation `main` owns: every name is attempted, and a failure moves the exit code."""
|
||||
module = _load_exporter()
|
||||
attempted: list[str] = []
|
||||
|
||||
def fake_fetch(base: str, name: str, token: str) -> bytes | str:
|
||||
attempted.append(name)
|
||||
if name == "bad":
|
||||
raise module.ExportError("bad: GET /api/certs/bad/bundle answered 500: boom")
|
||||
return b"bundle-bytes"
|
||||
|
||||
monkeypatch.setattr(module, "sign_in", lambda base, password: "a-token")
|
||||
monkeypatch.setattr(module, "fetch_bundle", fake_fetch)
|
||||
code = module.main(["--url", "http://127.0.0.1:1", "--password", "p",
|
||||
"--names", "good bad later", "--out-dir", str(tmp_path / "certs")])
|
||||
assert attempted == ["good", "bad", "later"], "one refused host must not end the run"
|
||||
assert code == 1, "a bundle nobody exported is a host that stays on http:// — say so"
|
||||
assert (tmp_path / "certs" / "good.bundle").is_file()
|
||||
assert not (tmp_path / "certs" / "bad.bundle").exists()
|
||||
|
||||
|
||||
# --- fetching the bundle during registration (phase 24 task 02) ---------------------------
|
||||
|
||||
#: `install-agent.sh --fetch-bundle <url> --cert-name <name> --session <token>`: the install
|
||||
#: run pulls its own bundle from the dashboard, so a host is never registered onto a token URL
|
||||
#: because an operator forgot a `curl`. The contract has three parts — the bytes that land are
|
||||
#: the bytes the route serves, a refusal writes *nothing*, and the session token is a fetch
|
||||
#: credential that appears in no artefact the run leaves behind.
|
||||
FETCH_UNISSUED = "no bundle issued for"
|
||||
FETCH_ALL_OR_NOTHING = "--fetch-bundle, --cert-name and --session go together"
|
||||
FETCH_TWO_SOURCES = "two bundle sources"
|
||||
FETCH_MODE_REFUSAL = "--fetch-bundle needs an install"
|
||||
|
||||
|
||||
def _sign_in(base: str) -> str:
|
||||
"""A real session token for the served dashboard — the credential `--session` carries."""
|
||||
with httpx.Client(base_url=base, timeout=10.0) as client:
|
||||
login = client.post("/api/auth/login", json={"password": ADMIN_PASSWORD})
|
||||
login.raise_for_status()
|
||||
return login.cookies[auth.SESSION_COOKIE]
|
||||
|
||||
|
||||
def _fetch_run(tmp_path: Path, *flags: str,
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path, Path]:
|
||||
"""One sandboxed install with a sandbox `TMPDIR`, so the fetched temp file is observable.
|
||||
|
||||
The fetch writes to `${TMPDIR:-/tmp}`, so pointing TMPDIR into the sandbox is what turns
|
||||
"the fetched private key never stays behind" into an assertion instead of a hope — and it
|
||||
catches the temp file on the refusal paths too, where nothing else was written to look at.
|
||||
"""
|
||||
script, call_log, env = _sandbox_installer(tmp_path)
|
||||
tmpdir = tmp_path / "tmp"
|
||||
tmpdir.mkdir(parents=True, exist_ok=True)
|
||||
result = _install(script, {**env, "TMPDIR": str(tmpdir)}, *flags)
|
||||
return result, tmpdir, call_log
|
||||
|
||||
|
||||
def _fetch(base: str, name: str, token: str) -> list[str]:
|
||||
"""The three fetch flags, spelled once."""
|
||||
return ["--fetch-bundle", base, "--cert-name", name, "--session", token]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def served_body(*, status: int = 200, body: bytes = b"wrong",
|
||||
media_type: str = "text/html") -> Iterator[str]:
|
||||
"""A loopback port that answers every GET with one canned body.
|
||||
|
||||
Only for the shapes the real route cannot produce: a proxy's 200 error page, a 200 with no
|
||||
bytes at all. The issued host, the unissued host and a refused session are all real answers
|
||||
from `served_dashboard`, and those are the legs that matter most.
|
||||
"""
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("content-type", media_type)
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
pass # the installer's answer is the assertion, not this server's access log
|
||||
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_a_fetched_bundle_installs_byte_identical_at_0640(tmp_path: Path) -> None:
|
||||
"""The registration leg: fetch, validate, install, TLS unit — one run, no hand-carried file.
|
||||
|
||||
Byte-identity is against the same route's answer as task 01's export, because the claim the
|
||||
installer makes is the same one: the file on the host *is* the row in `meta`. Everything
|
||||
after the fetch is the existing `--cert-bundle` path, so the mode, the unit's TLS lines and
|
||||
the https hint are asserted here as unchanged rather than as new.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
token = _sign_in(base)
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", "--port", "9120", *_fetch(base, ISSUED_HOST, token))
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"the fetch install failed:\n{result.stdout}\n{result.stderr}")
|
||||
|
||||
cert_path = tmp_path / "target" / "certs"
|
||||
assert cert_path.read_bytes() == _served_bundle(base, ISSUED_HOST)
|
||||
mode = stat.S_IMODE(cert_path.stat().st_mode)
|
||||
assert mode == 0o640, f"bundle mode is {oct(mode)}, expected 0o640 (group = agent user)"
|
||||
|
||||
unit = (tmp_path / "target" / "dashboards-agent.service").read_text()
|
||||
assert f"--cert-bundle {cert_path}" in unit
|
||||
assert "RuntimeDirectory=dashboards-agent" in unit
|
||||
assert 'HOST_TARGETS="hostname=https://10.0.0.5:9120"' in result.stdout
|
||||
assert f"fetched the certificate bundle for {ISSUED_HOST} from {base}" in result.stdout
|
||||
assert "chown root:dashboards-agent" in call_log.read_text()
|
||||
assert list(tmpdir.iterdir()) == [], "the fetched private key outlived the install"
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_an_unissued_bundle_is_refused_before_anything_is_written(tmp_path: Path) -> None:
|
||||
"""`issued: false` is one sentence naming the fix, and the host stays exactly as it was.
|
||||
|
||||
This is the invariant-3 answer on the install path: the route says "configured, nothing
|
||||
issued", and the installer must not read that as a bundle, as a sudoers problem, or as a
|
||||
reason to create a service user. The stub log is the proof nothing privileged ran: `id -u`
|
||||
is the first privileged call a real install makes, and it is absent.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
token = _sign_in(base)
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", *_fetch(base, UNISSUED_HOST, token))
|
||||
assert result.returncode == 2, f"a refused fetch keeps exit 2: {result.returncode}"
|
||||
assert result.stdout == "", "a refusal is a failure: nothing on stdout"
|
||||
assert FETCH_UNISSUED in result.stderr and UNISSUED_HOST in result.stderr
|
||||
assert "/certs" in result.stderr, "the sentence names where the certificate is issued"
|
||||
assert GRANT_PREFIX not in result.stderr, "a refusal is not a sudoers problem"
|
||||
assert not call_log.exists(), "refused before the root boundary: no privileged command ran"
|
||||
assert not (tmp_path / "target").exists(), "a refused fetch writes no files at all"
|
||||
assert list(tmpdir.iterdir()) == [], "the temp file is gone even though it was written"
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_a_name_the_dashboard_does_not_dial_is_refused_with_its_own_404(tmp_path: Path) -> None:
|
||||
"""`{"configured": false}` is a third fact, and it is not read as "nothing issued".
|
||||
|
||||
The route refuses a name outside `HOST_TARGETS` before any signing, so a typo in
|
||||
`--cert-name` must say *that* — an operator who is told "no bundle issued" would go looking
|
||||
for a certificate that was never supposed to exist for a host this dashboard does not dial.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
token = _sign_in(base)
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", *_fetch(base, "nosuchhost", token))
|
||||
assert result.returncode == 2
|
||||
assert "404" in result.stderr, result.stderr
|
||||
assert FETCH_UNISSUED not in result.stderr, "not configured is not the same as unissued"
|
||||
assert "HOST_TARGETS" in result.stderr, "the dashboard's own sentence is echoed"
|
||||
assert not call_log.exists()
|
||||
assert list(tmpdir.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_a_refused_session_names_the_http_status(tmp_path: Path) -> None:
|
||||
"""A stale or wrong session is a 401 on the wire, and the run says 401.
|
||||
|
||||
Not "no bundle" and not a sudoers hint: the operator's move is a fresh sign-in, and the
|
||||
only way they learn that is the status the dashboard actually answered.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", *_fetch(base, ISSUED_HOST, "not-a-session-token"))
|
||||
assert result.returncode == 2
|
||||
assert "401" in result.stderr, result.stderr
|
||||
assert "sign in required" in result.stderr, "the dashboard's own sentence is echoed"
|
||||
assert not call_log.exists()
|
||||
assert not (tmp_path / "target").exists()
|
||||
assert list(tmpdir.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
@pytest.mark.parametrize(
|
||||
("status", "body", "reason"),
|
||||
[
|
||||
(200, b"<html><body>502 from the proxy</body></html>", "# BUNDLE-REV"),
|
||||
(200, b"", "empty body"),
|
||||
(500, b"upstream exploded", "500"),
|
||||
],
|
||||
ids=["proxy-error-page", "empty-200", "server-error"],
|
||||
)
|
||||
def test_a_body_that_is_not_a_bundle_never_becomes_an_install(
|
||||
tmp_path: Path, status: int, body: bytes, reason: str
|
||||
) -> None:
|
||||
"""The shapes the dashboard cannot answer with — and the check that catches each one.
|
||||
|
||||
A 200 with a proxy's HTML page is the dangerous one: it is a *success* to any client that
|
||||
only looks at the status, and installing it produces a unit that exits at startup. It falls
|
||||
through to the existing shape check, which is the reason one validator serves both bundle
|
||||
sources. The refusal names `--fetch-bundle` (the flag that was used), not the temp file.
|
||||
"""
|
||||
with served_body(status=status, body=body) as base:
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", *_fetch(base, ISSUED_HOST, "a-token"))
|
||||
assert result.returncode == 2, f"{status}/{len(body)} bytes must be refused"
|
||||
assert reason in result.stderr, result.stderr
|
||||
assert "--fetch-bundle" in result.stderr
|
||||
assert not call_log.exists()
|
||||
assert not (tmp_path / "target").exists()
|
||||
assert list(tmpdir.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_a_dashboard_that_never_answers_stops_the_fetch(tmp_path: Path) -> None:
|
||||
"""Nothing listening on the dashboard URL: the line names it, and no install happens."""
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5",
|
||||
*_fetch("http://127.0.0.1:1", ISSUED_HOST, "a-token"))
|
||||
assert result.returncode == 2
|
||||
assert "no dashboard answered" in result.stderr and "127.0.0.1:1" in result.stderr
|
||||
assert not call_log.exists()
|
||||
assert list(tmpdir.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_the_session_token_appears_in_no_written_artefact(tmp_path: Path) -> None:
|
||||
"""Completion criterion: the token is a fetch credential, not a stored one.
|
||||
|
||||
The test signs in for a real token and then searches **every** file the run could have
|
||||
touched plus both output streams. The agent's own bearer token is expected in the token
|
||||
file and on stdout — that is a different secret, deliberately printed once — so the pin is
|
||||
specifically that this session value is nowhere.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
token = _sign_in(base)
|
||||
assert token and token != ADMIN_PASSWORD
|
||||
result, _tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", "--port", "9120",
|
||||
*_fetch(base, ISSUED_HOST, token))
|
||||
assert result.returncode == 0, f"install failed:\n{result.stderr}"
|
||||
|
||||
where = tmp_path / "target"
|
||||
assert token not in (where / "token").read_text(), "the token file is the agent's, not ours"
|
||||
assert token not in (where / "certs").read_text(), "a bundle is not a place for a session"
|
||||
assert token not in (where / "dashboards-agent.service").read_text()
|
||||
assert token not in call_log.read_text(), "a privileged call must not carry it either"
|
||||
assert token not in result.stdout + result.stderr
|
||||
|
||||
hits = [str(path.relative_to(tmp_path)) for path in tmp_path.rglob("*")
|
||||
if path.is_file() and token in path.read_text(encoding="utf-8", errors="replace")]
|
||||
assert hits == [], f"the session token was written to {hits}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flags",
|
||||
[
|
||||
["--fetch-bundle", "http://127.0.0.1:8090"],
|
||||
["--cert-name", "deskwork"],
|
||||
["--session", "a-token"],
|
||||
["--fetch-bundle", "http://127.0.0.1:8090", "--cert-name", "deskwork"],
|
||||
["--cert-name", "deskwork", "--session", "a-token"],
|
||||
],
|
||||
ids=["url", "name", "token", "url+name", "name+token"],
|
||||
)
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_the_fetch_flags_are_all_or_nothing(tmp_path: Path, flags: list[str]) -> None:
|
||||
"""Half a fetch is a refusal, not a guess: no URL, no name, or no token means nothing dials.
|
||||
|
||||
A URL without a name has no route to fetch, and a name without a token is a 401 the
|
||||
operator would have to diagnose from a stack of unrelated output. Both are answered with
|
||||
one sentence before the root boundary and before any write — the `--caps` reasoning again.
|
||||
"""
|
||||
script, call_log, env = _sandbox_installer(tmp_path)
|
||||
result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199", *flags)
|
||||
assert result.returncode == 1, "a flag mistake keeps exit 1, not the fetch's 2"
|
||||
assert result.stdout == ""
|
||||
assert FETCH_ALL_OR_NOTHING in result.stderr
|
||||
assert GRANT_PREFIX not in result.stderr, "a flag mistake is not a sudoers problem"
|
||||
assert not call_log.exists(), "refused before a single privileged command"
|
||||
assert not (tmp_path / "target").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_two_bundle_sources_are_refused_rather_than_ordered(tmp_path: Path) -> None:
|
||||
"""`--fetch-bundle` beside `--cert-bundle` is a contradiction: one install, one answer.
|
||||
|
||||
"Which one wins" has no good answer here — a stale file and a fresh bundle are two
|
||||
different identities, and silently preferring either would install a certificate the
|
||||
operator did not mean to install. So the run stops.
|
||||
"""
|
||||
bundle = tmp_path / "agent.bundle"
|
||||
bundle.write_text(_fake_bundle_text(), encoding="ascii")
|
||||
script, call_log, env = _sandbox_installer(tmp_path)
|
||||
result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199",
|
||||
*_fetch("http://127.0.0.1:8090", "deskwork", "a-token"),
|
||||
"--cert-bundle", str(bundle))
|
||||
assert result.returncode == 1
|
||||
assert FETCH_TWO_SOURCES in result.stderr
|
||||
assert GRANT_PREFIX not in result.stderr
|
||||
assert not call_log.exists()
|
||||
assert not (tmp_path / "target").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["--status", "--uninstall"], ids=["status", "uninstall"])
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_fetching_a_bundle_is_refused_outside_an_install(tmp_path: Path, mode: str) -> None:
|
||||
"""The fetch belongs to the run that installs, or a key is fetched to be thrown away.
|
||||
|
||||
`--status` answers about a port and `--uninstall` removes files; neither has a use for the
|
||||
bytes, and a silent "flags ignored" on the uninstall path would leave an operator believing
|
||||
a bundle had been refreshed.
|
||||
"""
|
||||
script, call_log, env = _sandbox_installer(tmp_path)
|
||||
result = _install(script, env, mode,
|
||||
*_fetch("http://127.0.0.1:8090", "deskwork", "a-token"))
|
||||
assert result.returncode == 1
|
||||
assert FETCH_MODE_REFUSAL in result.stderr
|
||||
assert not call_log.exists(), "and refused before the mode's own privileged work"
|
||||
|
||||
|
||||
def test_help_teaches_the_registration_fetch() -> None:
|
||||
"""The three flags and the recipe are learnable from `--help`, like every other flag here."""
|
||||
stdout = _run([BASH, str(INSTALLER), "--help"]).stdout
|
||||
assert "--fetch-bundle URL" in stdout
|
||||
assert "--cert-name NAME" in stdout
|
||||
assert "--session TOKEN" in stdout
|
||||
assert "GET <url>/api/certs/<cert-name>/bundle" in stdout
|
||||
assert "fetch credential only" in stdout, "the token's scope has to be learnable, not folklore"
|
||||
recipe = "--fetch-bundle https://dashboards.example.com --cert-name <HOST_TARGETS name>"
|
||||
assert recipe in stdout.replace("\\\n", " ").replace(" ", " "), (
|
||||
"the recipe line must be copy-pasteable from --help"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_the_sudoers_hint_never_repeats_the_session_token(tmp_path: Path) -> None:
|
||||
"""The hint quotes a command, so it quotes only the flags that are safe to re-type.
|
||||
|
||||
`need_root` ends with `sudo <this script> …` so the operator can copy it. Handing that
|
||||
string the real argv would print a bearer credential into the terminal, the ssh log and
|
||||
whoever's scrollback — and the fetch runs *before* the root boundary, so this is the one
|
||||
path where a good fetch and a refusal share a run. The fetch succeeding here is also the
|
||||
proof of the order: an unprivileged run can only reach the hint after fetching.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
token = _sign_in(base)
|
||||
result = _installer_without_root("--bind", "10.0.0.5", "--port", "9120",
|
||||
*_fetch(base, ISSUED_HOST, token))
|
||||
assert result.returncode == 1
|
||||
assert _grant_lines(result.stderr), "the fetch succeeded, so what remains is the grant"
|
||||
assert f"fetched the certificate bundle for {ISSUED_HOST}" in result.stdout
|
||||
assert token not in result.stdout + result.stderr
|
||||
assert "--session" not in result.stderr, "the echoed command must not name the flag either"
|
||||
|
||||
|
||||
def test_the_session_token_is_confined_to_the_fetch() -> None:
|
||||
"""Structural pin: the install body — everything that writes — cannot see the token.
|
||||
|
||||
`unset SESSION_TOKEN` is the promise; this is the proof that the promise is enforceable
|
||||
rather than aspirational. Every file the installer writes is written inside `do_install`
|
||||
(the token file, the bundle, the unit), so if the name does not appear anywhere in that
|
||||
function or in the unit template, no artefact can carry it — the same style of pin
|
||||
`tests/test_auth.py` and `tests/test_certs.py` hold over the two modules that hold secrets.
|
||||
"""
|
||||
text = INSTALLER.read_text()
|
||||
assert 'unset SESSION_TOKEN' in text, "the fetch must forget the credential when it is done"
|
||||
assert 'FETCH_SESSION="$SESSION_TOKEN" python3' in text, (
|
||||
"the token reaches python in the environment: argv is world-readable through `ps`, so a"
|
||||
" second copy on a command line would widen the exposure instead of limiting it"
|
||||
)
|
||||
body = text.split("do_install() {", 1)[1]
|
||||
assert "SESSION_TOKEN" not in body, "nothing that writes may read the fetch credential"
|
||||
assert text.count("SESSION_TOKEN") <= 8, (
|
||||
"the name belongs to the parser, the all-or-nothing check and the fetch; every further"
|
||||
" mention is another place a bearer token could be printed or written"
|
||||
)
|
||||
|
||||
|
||||
def test_the_fetch_and_its_shape_check_both_run_before_the_root_boundary() -> None:
|
||||
"""Text pin for the order the no-partial-install promise depends on.
|
||||
|
||||
Behaviourally, a refusal as `ops` proves the refusal comes first; this proves the *shape*
|
||||
check is not quietly moved after `need_root` (where a bad fetched bundle would produce a
|
||||
sudoers hint instead of a reason, on the host where the operator can least afford it).
|
||||
"""
|
||||
body = INSTALLER.read_text().split("do_install() {", 1)[1]
|
||||
fetch = body.index("fetch_bundle\n")
|
||||
shape = body.index('validate_bundle "$BUNDLE_SRC"')
|
||||
root = body.index("need_root --bind")
|
||||
assert fetch < shape < root, "fetch, then shape, then root — in that order"
|
||||
assert 'trap cleanup_fetch EXIT' in INSTALLER.read_text(), (
|
||||
"the fetched key is cleaned up on every exit path, not only the happy one"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_a_local_bundle_still_validates_with_its_own_flag_name(tmp_path: Path) -> None:
|
||||
"""The validator grew a label, and the pre-existing path must read exactly as before.
|
||||
|
||||
`--fetch-bundle` needed the refusal to name the flag that was used; the `--cert-bundle`
|
||||
path must not start naming a flag nobody passed. That is the whole change, so this is the
|
||||
whole pin — the file-local behaviour around it is covered by the phase-15 tests.
|
||||
"""
|
||||
path = tmp_path / "revless.bundle"
|
||||
path.write_text(_fake_bundle_text().replace("# BUNDLE-REV 3\n", ""), encoding="ascii")
|
||||
result = _installer_without_root("--cert-bundle", str(path))
|
||||
assert result.returncode == 2
|
||||
assert result.stderr.startswith("--cert-bundle"), result.stderr
|
||||
assert "--fetch-bundle" not in result.stderr
|
||||
|
||||
+89
-14
@@ -22,12 +22,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from card_contract import assert_card_contract
|
||||
from dashboards import collectors
|
||||
from dashboards import certs, collectors
|
||||
from dashboards.config import Settings, load
|
||||
from dashboards.rates import RateEstimator
|
||||
from dashboards.severity import classify
|
||||
@@ -46,25 +48,88 @@ def settings() -> Settings:
|
||||
return load()
|
||||
|
||||
|
||||
class _StoredAuthority:
|
||||
"""`certs.MetaStore` over an existing database, opened **read-only**.
|
||||
|
||||
The live suite has to dial the fleet the way the app does, and since the phase-24 rollout that
|
||||
means presenting this dashboard's own client certificate. The authority lives in the `meta`
|
||||
table of the store the dev server owns, so this is the smallest honest reader of it: `mode=ro`
|
||||
on the URI, `get_meta` the only method that does anything, and the two write methods a
|
||||
*refusal* rather than a no-op — this file's docstring says nothing in here may write, and
|
||||
minting or repairing an authority from a test would be the worst write it could make.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self._conn = sqlite3.connect(f"file:{os.path.abspath(path)}?mode=ro", uri=True)
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def get_meta(self, key: str) -> str | None:
|
||||
row = self._conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
||||
return None if row is None else str(row[0])
|
||||
|
||||
def set_meta(self, key: str, value: str) -> None:
|
||||
raise AssertionError(f"live tests are read-only: refused to write {key!r}")
|
||||
|
||||
def delete_meta(self, key: str) -> int:
|
||||
raise AssertionError(f"live tests are read-only: refused to delete {key!r}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def agent_tls(settings: Settings) -> Iterator[dict[str, collectors.AgentTls]]:
|
||||
"""One :class:`collectors.AgentTls` per `https://` host, from the authority in the store.
|
||||
|
||||
Empty when no `HOST_TARGETS` entry is https — every host is then dialled on the pre-phase-15
|
||||
token path, which is what an `http://` fleet still is — and skipped when the store or its
|
||||
`certs.*` rows are absent, because a box running the live suite without the dev database is a
|
||||
normal partial environment rather than a failure (the same rule that lets half a fleet be
|
||||
reachable).
|
||||
|
||||
`certs.read_state` is the request-time read and never `ensure_certs`: a live test may not mint
|
||||
a root CA, and the material this writes is removed on teardown.
|
||||
"""
|
||||
targets = settings.host_targets
|
||||
if not any(target.url.startswith("https://") for target in targets):
|
||||
yield {}
|
||||
return
|
||||
if not os.path.exists(settings.db_path):
|
||||
pytest.skip(f"no store at {settings.db_path} to read the CA from")
|
||||
authority = _StoredAuthority(settings.db_path)
|
||||
try:
|
||||
state = certs.read_state(authority)
|
||||
except (RuntimeError, sqlite3.Error) as exc:
|
||||
authority.close()
|
||||
pytest.skip(f"no readable authority in {settings.db_path}: {type(exc).__name__}")
|
||||
material = certs.write_material(state)
|
||||
try:
|
||||
yield collectors.agent_tls_map(material, authority, targets)
|
||||
finally:
|
||||
certs.remove_material(material)
|
||||
authority.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host_collector() -> HostCollector:
|
||||
return HostCollector(rates=RateEstimator(span=10.0))
|
||||
|
||||
|
||||
async def test_every_host_agent_answers_and_parses(settings: Settings,
|
||||
host_collector: HostCollector) -> None:
|
||||
host_collector: HostCollector,
|
||||
agent_tls: dict[str, collectors.AgentTls]) -> None:
|
||||
"""Each HOST_TARGETS entry must answer /snapshot and yield the metrics the page renders."""
|
||||
if not settings.host_targets:
|
||||
pytest.skip("HOST_TARGETS is unset")
|
||||
required = {"cpu.usage_pct", "mem.used_pct", "disk.used_pct"}
|
||||
async with httpx.AsyncClient(timeout=settings.host_timeout) as client:
|
||||
for target in settings.host_targets:
|
||||
tls = agent_tls.get(target.name)
|
||||
# Poll twice: usage_pct is a Δ/jiffies rate, and a rate has no second point yet
|
||||
# on the first scrape. That is the design (the agent never remembers who polled
|
||||
# it), so a live test that scraped once would be asserting a false failure.
|
||||
await collectors.fetch_host_snapshot(client, target, host_collector)
|
||||
await collectors.fetch_host_snapshot(client, target, host_collector, tls=tls)
|
||||
await asyncio.sleep(1.2)
|
||||
result = await collectors.fetch_host_snapshot(client, target, host_collector)
|
||||
result = await collectors.fetch_host_snapshot(client, target, host_collector, tls=tls)
|
||||
metrics = {sample.metric for sample in result.samples}
|
||||
assert result.payload["info"]["hostname"], f"{target.name}: no hostname"
|
||||
missing = required - metrics
|
||||
@@ -76,7 +141,8 @@ async def test_every_host_agent_answers_and_parses(settings: Settings,
|
||||
|
||||
|
||||
async def test_every_host_serves_the_hero_card_contract(settings: Settings,
|
||||
host_collector: HostCollector) -> None:
|
||||
host_collector: HostCollector,
|
||||
agent_tls: dict[str, collectors.AgentTls]) -> None:
|
||||
"""The phase-03 `/system` card paths exist on the **real** agents, not just the captures.
|
||||
|
||||
Values may honestly be `n/a` on real hardware (gateway reports no CPU sensor and no GPU
|
||||
@@ -87,16 +153,18 @@ async def test_every_host_serves_the_hero_card_contract(settings: Settings,
|
||||
pytest.skip("HOST_TARGETS is unset")
|
||||
async with httpx.AsyncClient(timeout=settings.host_timeout) as client:
|
||||
for target in settings.host_targets:
|
||||
await collectors.fetch_host_snapshot(client, target, host_collector)
|
||||
tls = agent_tls.get(target.name)
|
||||
await collectors.fetch_host_snapshot(client, target, host_collector, tls=tls)
|
||||
await asyncio.sleep(1.2)
|
||||
result = await collectors.fetch_host_snapshot(client, target, host_collector)
|
||||
result = await collectors.fetch_host_snapshot(client, target, host_collector, tls=tls)
|
||||
assert_card_contract(result.payload)
|
||||
assert result.payload["cpu"]["temp_c"] == result.payload["temperatures"]["cpu_c"], (
|
||||
f"{target.name}: the card and /api/summary would report different CPU temps"
|
||||
)
|
||||
|
||||
|
||||
async def test_agent_disk_rows_carry_the_smart_shape(settings: Settings) -> None:
|
||||
async def test_agent_disk_rows_carry_the_smart_shape(
|
||||
settings: Settings, agent_tls: dict[str, collectors.AgentTls]) -> None:
|
||||
"""Phase-09: a redeployed agent answers `smart` per physical disk of `/snapshot`.
|
||||
|
||||
Agents are redeployed by hand, so an agent whose rows carry no `smart` key at all
|
||||
@@ -111,10 +179,16 @@ async def test_agent_disk_rows_carry_the_smart_shape(settings: Settings) -> None
|
||||
seen_smart = 0
|
||||
async with httpx.AsyncClient(timeout=settings.host_timeout) as client:
|
||||
for target in settings.host_targets:
|
||||
response = await client.get(f"{target.url}/snapshot", headers=target.headers)
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
# through `fetch_snapshot`, not a hand-rolled `get`, because that is the function
|
||||
# that owns the certificate dial: an `https://` agent needs this app's own CA and
|
||||
# client leaf, and a test dialling it with the platform trust store would fail on
|
||||
# a healthy fleet (`CERTIFICATE_VERIFY_FAILED: self-signed certificate in chain`).
|
||||
response = await collectors.fetch_snapshot(client, target,
|
||||
tls=agent_tls.get(target.name))
|
||||
except collectors.UpstreamError:
|
||||
continue # reachability has its own live test
|
||||
devices = (response.json().get("disk_io") or {}).get("devices") or []
|
||||
devices = (response.get("disk_io") or {}).get("devices") or []
|
||||
if devices and not all("smart" in device for device in devices):
|
||||
continue # agent predates the smart pass — redeploy it to test this
|
||||
for device in devices:
|
||||
@@ -138,7 +212,7 @@ async def test_agent_disk_rows_carry_the_smart_shape(settings: Settings) -> None
|
||||
|
||||
|
||||
async def test_disks_source_charts_the_physical_drives_the_agent_reports(
|
||||
settings: Settings) -> None:
|
||||
settings: Settings, agent_tls: dict[str, collectors.AgentTls]) -> None:
|
||||
"""Phase 09 task 02: the `disks` source over a real agent snapshot.
|
||||
|
||||
This is the production code path — the fetch the poller runs, not a hand-built dict —
|
||||
@@ -153,8 +227,9 @@ async def test_disks_source_charts_the_physical_drives_the_agent_reports(
|
||||
async with httpx.AsyncClient(timeout=settings.disks_timeout) as client:
|
||||
for target in settings.host_targets:
|
||||
try:
|
||||
result = await collectors.fetch_disk_snapshot(client, target, collector,
|
||||
timeout=settings.disks_timeout)
|
||||
result = await collectors.fetch_disk_snapshot(
|
||||
client, target, collector, timeout=settings.disks_timeout,
|
||||
tls=agent_tls.get(target.name))
|
||||
except collectors.UpstreamError:
|
||||
continue # reachability has its own live test
|
||||
checked += 1
|
||||
|
||||
Reference in New Issue
Block a user