From b02c13f7d350d9051a23786f6680b96ba2f5e2f0 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Tue, 20 Oct 2026 13:48:40 -0400 Subject: [PATCH] phase: 17_installer_device_filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Phase 17 — final verification pass: complete, all green.** Verified (criteria): - `grep -n "DevicePolicy\|DeviceAllow" deploy/install-agent.sh` → no match; `PrivateDevices=false` retained (line 472) ✓ - `install-agent.sh --help` names `--as-root`/`--caps` with the kernel-gate sentence; `--force` documented ✓ - Fresh fake-root install = pre-phase unit minus the filter lines: `test_a_default_install_unit_is_the_pre_phase_unit_minus_the_filter_lines` + `test_the_template_unit_has_no_device_filter_lines` pass ✓ - Re-run that differs → exit 1 with diff on stdout, `--force` applies, identical re-run silent: `test_a_re_run_that_differs_refuses_without_force_and_applies_with_it` (+ 3 sibling guard pins) pass ✓ - driveripper reproduction — verified **read-only**, no live change: `systemctl cat` shows `User=root`, `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`, no filter, same `ExecStart`; `active`, `CapEff 0000000000220000`; `/api/disks/snapshot` → 16/16 drives with temperatures ✓ - `uv run pytest` → **1451 passed, 15 skipped**; `bash .agents/validate.sh` → **exit 0** (coverage **98.94%** ≥ 94, `ruff` clean, `pyright` 0 errors, `node --check` + hygiene clean); `uv run pytest -m live --live` → **11 passed**; dev server 9/9 sources `ok` (one initial live failure was a transient gufo timeout, clean on re-run) Docs (inconsistencies found + fixed, incl. pre-existing): - `AGENTS.md` fleet record said driveripper's unit runs `User=dashboards-agent` while the same bullet (and the live host) says `User=root` — rewritten to separate the installed user from the running one. - `AGENTS.md` gap 10 header still claimed "the installer template is what is left" → now records phase 17 as the fix. - `README.md` "with no capabilities" → "with an empty `CapabilityBoundingSet` (unless `--caps`…)", since this phase added the capability flag. - `README.md` battery-test "gated four times" now notes the two session gates in front, matching AGENTS invariant 1. - `.env.example` was missing three README-documented settings (`DASH_ENV_FILE`, `DASH_RELOAD`, `NUT_SIMULATE`) and `AGENT_CERT_BUNDLE` in the agent-variables note — added, annotated; all template values re-checked against `config.py` defaults. Deviations: no live `--force` run on driveripper (needs the Oct-18 sudo-staged installer re-staged — it lacks the flags — and would push agent 1.1.0 mid phase-15 rollout); command + expected diff + post-checks recorded in AGENTS/gap 10. `uv pip list --outdated` still advisory (fastapi 0.153→0.156), untouched as in tasks 02/04 — this phase moved no dependency. Next pending phase: `.agents/phases/todo/18_nav_hamburger/`. --- .../17_installer_device_filter/00_phase.md | 0 .../01_drop_the_broken_device_filter.md | 0 .../02_root_and_caps_flags.md | 0 .../03_no_silent_clobber.md | 0 .../04_tests_docs_gate.md | 0 ...7_installer_device_filter__00_phase.a1.err | 0 ...17_installer_device_filter__00_phase.a1.md | 20 + ...aller_device_filter__00_phase.a1.md.status | 1 + ...taller_device_filter__00_phase.a1.validate | 65 ++ ...r__01_drop_the_broken_device_filter.a1.err | 0 ...er__01_drop_the_broken_device_filter.a1.md | 16 + ...drop_the_broken_device_filter.a1.md.status | 1 + ..._drop_the_broken_device_filter.a1.validate | 63 ++ ...vice_filter__02_root_and_caps_flags.a1.err | 0 ...evice_filter__02_root_and_caps_flags.a1.md | 11 + ...ilter__02_root_and_caps_flags.a1.md.status | 1 + ...filter__02_root_and_caps_flags.a1.validate | 65 ++ ...device_filter__03_no_silent_clobber.a1.err | 0 ..._device_filter__03_no_silent_clobber.a1.md | 18 + ..._filter__03_no_silent_clobber.a1.md.status | 1 + ...e_filter__03_no_silent_clobber.a1.validate | 65 ++ ...r_device_filter__04_tests_docs_gate.a1.err | 0 ...er_device_filter__04_tests_docs_gate.a1.md | 15 + ...ce_filter__04_tests_docs_gate.a1.md.status | 1 + ...ice_filter__04_tests_docs_gate.a1.validate | 65 ++ .env.example | 19 +- AGENTS.md | 127 ++- README.md | 57 +- deploy/install-agent.sh | 192 ++++- tests/test_deploy_scripts.py | 780 +++++++++++++++++- 30 files changed, 1502 insertions(+), 81 deletions(-) rename .agents/phases/{todo => complete}/17_installer_device_filter/00_phase.md (100%) rename .agents/phases/{todo => complete}/17_installer_device_filter/01_drop_the_broken_device_filter.md (100%) rename .agents/phases/{todo => complete}/17_installer_device_filter/02_root_and_caps_flags.md (100%) rename .agents/phases/{todo => complete}/17_installer_device_filter/03_no_silent_clobber.md (100%) rename .agents/phases/{todo => complete}/17_installer_device_filter/04_tests_docs_gate.md (100%) create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.err create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.md create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.md.status create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.validate create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.err create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.md create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.md.status create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.validate create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.err create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.md create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.md.status create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.validate create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.err create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.md create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.md.status create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.validate create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.err create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.md create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.md.status create mode 100644 .agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.validate diff --git a/.agents/phases/todo/17_installer_device_filter/00_phase.md b/.agents/phases/complete/17_installer_device_filter/00_phase.md similarity index 100% rename from .agents/phases/todo/17_installer_device_filter/00_phase.md rename to .agents/phases/complete/17_installer_device_filter/00_phase.md diff --git a/.agents/phases/todo/17_installer_device_filter/01_drop_the_broken_device_filter.md b/.agents/phases/complete/17_installer_device_filter/01_drop_the_broken_device_filter.md similarity index 100% rename from .agents/phases/todo/17_installer_device_filter/01_drop_the_broken_device_filter.md rename to .agents/phases/complete/17_installer_device_filter/01_drop_the_broken_device_filter.md diff --git a/.agents/phases/todo/17_installer_device_filter/02_root_and_caps_flags.md b/.agents/phases/complete/17_installer_device_filter/02_root_and_caps_flags.md similarity index 100% rename from .agents/phases/todo/17_installer_device_filter/02_root_and_caps_flags.md rename to .agents/phases/complete/17_installer_device_filter/02_root_and_caps_flags.md diff --git a/.agents/phases/todo/17_installer_device_filter/03_no_silent_clobber.md b/.agents/phases/complete/17_installer_device_filter/03_no_silent_clobber.md similarity index 100% rename from .agents/phases/todo/17_installer_device_filter/03_no_silent_clobber.md rename to .agents/phases/complete/17_installer_device_filter/03_no_silent_clobber.md diff --git a/.agents/phases/todo/17_installer_device_filter/04_tests_docs_gate.md b/.agents/phases/complete/17_installer_device_filter/04_tests_docs_gate.md similarity index 100% rename from .agents/phases/todo/17_installer_device_filter/04_tests_docs_gate.md rename to .agents/phases/complete/17_installer_device_filter/04_tests_docs_gate.md diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.err b/.agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.md b/.agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.md new file mode 100644 index 0000000..20d6136 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.md @@ -0,0 +1,20 @@ +**Phase 17 — final verification pass: complete, all green.** + +Verified (criteria): +- `grep -n "DevicePolicy\|DeviceAllow" deploy/install-agent.sh` → no match; `PrivateDevices=false` retained (line 472) ✓ +- `install-agent.sh --help` names `--as-root`/`--caps` with the kernel-gate sentence; `--force` documented ✓ +- Fresh fake-root install = pre-phase unit minus the filter lines: `test_a_default_install_unit_is_the_pre_phase_unit_minus_the_filter_lines` + `test_the_template_unit_has_no_device_filter_lines` pass ✓ +- Re-run that differs → exit 1 with diff on stdout, `--force` applies, identical re-run silent: `test_a_re_run_that_differs_refuses_without_force_and_applies_with_it` (+ 3 sibling guard pins) pass ✓ +- driveripper reproduction — verified **read-only**, no live change: `systemctl cat` shows `User=root`, `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`, no filter, same `ExecStart`; `active`, `CapEff 0000000000220000`; `/api/disks/snapshot` → 16/16 drives with temperatures ✓ +- `uv run pytest` → **1451 passed, 15 skipped**; `bash .agents/validate.sh` → **exit 0** (coverage **98.94%** ≥ 94, `ruff` clean, `pyright` 0 errors, `node --check` + hygiene clean); `uv run pytest -m live --live` → **11 passed**; dev server 9/9 sources `ok` (one initial live failure was a transient gufo timeout, clean on re-run) + +Docs (inconsistencies found + fixed, incl. pre-existing): +- `AGENTS.md` fleet record said driveripper's unit runs `User=dashboards-agent` while the same bullet (and the live host) says `User=root` — rewritten to separate the installed user from the running one. +- `AGENTS.md` gap 10 header still claimed "the installer template is what is left" → now records phase 17 as the fix. +- `README.md` "with no capabilities" → "with an empty `CapabilityBoundingSet` (unless `--caps`…)", since this phase added the capability flag. +- `README.md` battery-test "gated four times" now notes the two session gates in front, matching AGENTS invariant 1. +- `.env.example` was missing three README-documented settings (`DASH_ENV_FILE`, `DASH_RELOAD`, `NUT_SIMULATE`) and `AGENT_CERT_BUNDLE` in the agent-variables note — added, annotated; all template values re-checked against `config.py` defaults. + +Deviations: no live `--force` run on driveripper (needs the Oct-18 sudo-staged installer re-staged — it lacks the flags — and would push agent 1.1.0 mid phase-15 rollout); command + expected diff + post-checks recorded in AGENTS/gap 10. `uv pip list --outdated` still advisory (fastapi 0.153→0.156), untouched as in tasks 02/04 — this phase moved no dependency. + +Next pending phase: `.agents/phases/todo/18_nav_hamburger/`. diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.md.status b/.agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.validate b/.agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.validate new file mode 100644 index 0000000..8d73bc4 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__00_phase.a1.validate @@ -0,0 +1,65 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +...........................s............................................ [ 29%] +........................................................................ [ 34%] +........................................................................ [ 39%] +........................................................................ [ 44%] +........................................................................ [ 49%] +........................................................................ [ 54%] +........................................................................ [ 58%] +........................................................................ [ 63%] +................................................................ssssssss [ 68%] +sss..................................................................... [ 73%] +........................................................................ [ 78%] +........................................................................ [ 83%] +........................................................................ [ 88%] +........................................................................ [ 93%] +........................................sss............................. [ 98%] +.......................... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 436 0 100% +src/dashboards/app.py 185 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3490 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1451 passed, 15 skipped, 1 warning in 153.26s (0:02:33) +== 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.0 wheel diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.err b/.agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.md b/.agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.md new file mode 100644 index 0000000..1c6d1be --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.md @@ -0,0 +1,16 @@ +**Implemented (phase 17 task 01 — `deploy/install-agent.sh`)** +- Deleted `DevicePolicy=strict` + all nine `DeviceAllow=…` lines from the `do_install` unit heredoc; `PrivateDevices=false` stays explicit. +- Rewrote the rationale comment block (and the top-of-file/help paragraph) to the new truth: no device filter on purpose — systemd matches a device-filter glob by *name* against `/proc/devices`, kernel 7.2 lists NVMe as `blkext` (major 259), so `/dev/nvme*` matched nothing and a strict policy denied the whole namespace (driveripper, root included); device access is DAC via the `disk` group + SELinux. Kept the `status=208/STDIN` warning as the reason `PrivateDevices=false` is written out. +- `tests/test_deploy_scripts.py`: the sandbox-install test now pins the *absence* of the filter plus `PrivateDevices=false`; added `test_the_written_unit_is_only_comments_and_directives` — a defect found while verifying this task: the unit heredoc is unquoted (so phase 15's `$TLS_UNIT_LINES` expands), which **executes backticked prose** (`disk`, `blkext`, `/dev/nvme*` became `command not found` + a smartctl banner pasted into the unit). Mutation-checked: the pin catches it. +- Docs: README install paragraph and four AGENTS.md places (fleet divergence bullet, driveripper sudoers bullet, Deployment paragraph, gap 10) now say the template applies no device filter; the obsolete "missing `/dev/ng*n*`/`/dev/bsg/*` in the allow-list" warning is retired; gap 10 records `--as-root`/`--caps` + the re-run guard as the phase's remaining tasks. + +**Tests / lint / coverage** +- `uv run pytest` → **1428 passed, 15 skipped** (was 1427; +1 new test), exit 0. +- `bash .agents/validate.sh` → exit 0: `pytest --cov --cov-fail-under=94` **98.94%** total, `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` clean, repo hygiene clean. `uv pip list --outdated` empty (no deps moved). +- Task criteria: `grep -n "DevicePolicy\|DeviceAllow" deploy/install-agent.sh` → no match; `bash -n` clean; manual fake-root sandbox install (`--bind 127.0.0.1 --port 9199`) diffed against a HEAD-version sandbox install: directive lines differ **only** by the ten removed filter lines (comments rewritten per the task). + +**README/AGENTS.md**: updated as above (docs stayed in sync mid-phase). + +**Decisions/deviations**: the phase's grep criterion forbids the literal directive names in the script, so the comment explains them in prose ("device-filter glob", "strict device policy") instead of naming `DevicePolicy`/`DeviceAllow`. Added the heredoc-expansion pin (not in the task text) because it is a real pre-existing hazard this task's own comment nearly shipped. **No live-infrastructure changes** — the driveripper reproduction (`--as-root --caps … --force`, `systemctl diff`, 16/16 drives) is task 04's operator-gated step and needs the task 02 flags first. + +**Next pending task**: `.agents/phases/todo/17_installer_device_filter/02_root_and_caps_flags.md` diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.md.status b/.agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.validate b/.agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.validate new file mode 100644 index 0000000..2f089a3 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__01_drop_the_broken_device_filter.a1.validate @@ -0,0 +1,63 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +...........................s............................................ [ 29%] +........................................................................ [ 34%] +........................................................................ [ 39%] +........................................................................ [ 44%] +........................................................................ [ 49%] +........................................................................ [ 54%] +........................................................................ [ 59%] +........................................................................ [ 64%] +.........................................sssssssssss.................... [ 69%] +........................................................................ [ 74%] +........................................................................ [ 79%] +........................................................................ [ 84%] +........................................................................ [ 89%] +........................................................................ [ 94%] +.................sss.................................................... [ 99%] +... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 436 0 100% +src/dashboards/app.py 185 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3490 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1428 passed, 15 skipped, 1 warning in 127.90s (0:02:07) +== 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 == diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.err b/.agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.md b/.agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.md new file mode 100644 index 0000000..29fece5 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.md @@ -0,0 +1,11 @@ +**Task 02 — `--as-root` / `--caps` (phase 17) — done** + +- `deploy/install-agent.sh`: added `--as-root` (`User=root`) and `--caps LIST` (`CapabilityBoundingSet=`), both off by default → default unit byte-identical to before; comma→space normalisation drops empty entries and runs with globbing off (`--caps '*'` stays literal). +- Validation: `--caps` without `--as-root` → stderr refusal + exit 1 **before** `need_root` and any write (same reasoning as the bundle validator); `--caps` with no argument / empty / punctuation-only → exit 1 with its own sentence; `--as-root` alone proceeds with the exact CapEff-0 note on stdout. +- Unit heredoc gained the one `--caps` comment above `CapabilityBoundingSet=`; `--status` and the token-file `0640 root:dashboards-agent` logic deliberately untouched; `--help` (the header comment) now names both flags with the kernel-gate sentence and the copy-pasteable recipe line. +- Tests (`tests/test_deploy_scripts.py`, 8 new / 12 new cases): exact `User=`/`CapabilityBoundingSet=` lines for default, `--as-root --caps …`, `--as-root` alone; parametrized refusals asserting nothing was written and no privileged stub ran; missing-argument refusal; normalisation + no-glob; `--help` recipe; `status()` blind to `AS_ROOT`/`CAPS`. +- `bash .agents/validate.sh` → **1439 passed, 15 skipped**, coverage **98.94%** (floor 94), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` clean, `bash -n deploy/install-agent.sh` clean; `uv run pytest -m live --live` → **11 passed** (unchanged). +- Docs: README (install flag block + a new kernel-gate paragraph) and AGENTS.md (fleet record, sudoers/grant bullet, deployment flag list, gap 10 tail) now state the flags exist, the `driveripper` recipe `--as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN`, and that the unconditional unit rewrite is task 03's remaining guard. +- **No live changes made**: the driveripper re-run (`systemctl diff`, `CapEff`, 16/16 drives) is task 04's operator-verified criterion, and gap 10 warns the sudo-granted Oct-18 *staged* snapshot would clobber the working unit — it must be re-staged from the repo first (now noted in AGENTS.md). +- Advisory only (not touched, deps not in scope): `uv pip list --outdated` reports `fastapi 0.153.0 → 0.154.0`. +- Next pending task: `.agents/phases/todo/17_installer_device_filter/03_no_silent_clobber.md`. diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.md.status b/.agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.validate b/.agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.validate new file mode 100644 index 0000000..954f45e --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__02_root_and_caps_flags.a1.validate @@ -0,0 +1,65 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +...........................s............................................ [ 29%] +........................................................................ [ 34%] +........................................................................ [ 39%] +........................................................................ [ 44%] +........................................................................ [ 49%] +........................................................................ [ 54%] +........................................................................ [ 59%] +........................................................................ [ 64%] +....................................................sssssssssss......... [ 69%] +........................................................................ [ 74%] +........................................................................ [ 79%] +........................................................................ [ 84%] +........................................................................ [ 89%] +........................................................................ [ 94%] +............................sss......................................... [ 99%] +.............. [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 436 0 100% +src/dashboards/app.py 185 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3490 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1439 passed, 15 skipped, 1 warning in 135.34s (0:02:15) +== 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.154.0 wheel diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.err b/.agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.md b/.agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.md new file mode 100644 index 0000000..efc6626 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.md @@ -0,0 +1,18 @@ +All green. Report: + +**Implemented** +- `deploy/install-agent.sh`: new `--force` flag (`FORCE=0` default) + `--help` text (`--force overwrite an existing unit even if the generated unit differs (prints the diff first)`, aligned to the `--as-root`/`--caps` column). +- `do_install` now renders the unit heredoc to `mktemp "${TMPDIR:-/tmp}/dashboards-agent-unit.XXXXXX"`; if `$UNIT` exists and `! cmp -s`, it prints `refusing to overwrite $UNIT (generated unit differs); run with --force to apply:` + `diff -u` and `exit 1` **before** the unit write, `daemon-reload` and `enable --now`; with `--force` it prints the same diff then overwrites; identical unit → silent proceed. `--status`/`--uninstall` untouched. +- 8 new pins in `tests/test_deploy_scripts.py`: refuse (exit 1, diff on stdout, unit byte-identical to the hand-edit, token value+`0640` unchanged, **zero new `systemctl` calls**), force (diff then write + re-enable), silent idempotent re-run, fresh install unaffected (plain vs `--force` render identical), the `driveripper` kernel-gate line refuses once → applies with `--force` → silent afterwards, uninstall never asks, source-ordering pin (render < cmp < write < reload < enable, no `cat > "$UNIT" <<` remains), and a `--help` alignment pin. +- Docs: README install block + a re-run-guard paragraph, and TLS-rollout step 2 now runs `--cert-bundle … --force` with the reason (installing a bundle *is* a unit change); AGENTS.md fleet bullet, the Commands flag list + guard paragraph, gap 10's task-03 status, and gap 11's staged-snapshot warnings (the Oct-18 copy predates the guard, so it still clobbers silently). + +**Tests / lint / coverage** +- `uv run pytest` → **1447 passed, 15 skipped**; `bash .agents/validate.sh` → green (coverage **98.94%**, floor 94%), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` ok. +- `uv run pytest -m live --live` → 11 passed (read-only; dev server still 9/9 `ok`). **No live infra change** — the `driveripper` `--force` reproduction is task 04's operator-verified step. + +**Decisions / deviations** +- Guard sits immediately before the unit write (the task's own parenthetical), so the earlier idempotent writes still run; the refusal path never re-writes the unit nor touches the service, and the reused token's value/mode are pinned unchanged (mtime isn't — noted in the test and script comment). +- Refusal sentence *and* diff both go to stdout (the diff is the answer's payload; task-04 pins "diff on stdout"). Temp file gets a readable prefix so the diff's `+++` names the generated unit. +- Wrote the formal pins now rather than leaving the guard untested until task 04; task 04's remaining doc/live items are untouched except where my change made prose stale. + +**Next pending task:** `.agents/phases/todo/17_installer_device_filter/04_tests_docs_gate.md` diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.md.status b/.agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.validate b/.agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.validate new file mode 100644 index 0000000..3ce6fb9 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__03_no_silent_clobber.a1.validate @@ -0,0 +1,65 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +...........................s............................................ [ 29%] +........................................................................ [ 34%] +........................................................................ [ 39%] +........................................................................ [ 44%] +........................................................................ [ 49%] +........................................................................ [ 54%] +........................................................................ [ 59%] +........................................................................ [ 64%] +............................................................sssssssssss. [ 68%] +........................................................................ [ 73%] +........................................................................ [ 78%] +........................................................................ [ 83%] +........................................................................ [ 88%] +........................................................................ [ 93%] +....................................sss................................. [ 98%] +...................... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 436 0 100% +src/dashboards/app.py 185 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3490 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1447 passed, 15 skipped, 1 warning in 146.51s (0:02:26) +== 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.154.0 wheel diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.err b/.agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.md b/.agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.md new file mode 100644 index 0000000..651f609 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.md @@ -0,0 +1,15 @@ +**Implemented** +- `tests/test_deploy_scripts.py`: added the four task-named pins — `test_the_template_unit_has_no_device_filter_lines` (heredoc region + the whole-file `DevicePolicy`/`DeviceAllow` absence, `PrivateDevices=false` kept, rationale must name `/proc/devices`/`blkext`/status 208), `test_a_default_install_unit_is_the_pre_phase_unit_minus_the_filter_lines` (whole pre-phase unit recorded verbatim as `PRE_PHASE_DEFAULT_UNIT` + `DEVICE_FILTER_LINES`, unified-diff failure output), `test_as_root_and_caps_write_the_kernel_gate_unit_lines` (recipe changes **exactly** two directives vs a default install; `--caps` alone refused before any privileged call; `--as-root` alone proceeds with the CapEff note), `test_a_re_run_that_differs_refuses_without_force_and_applies_with_it` (refuse→force→silent on one host). +- Mutation-checked both new filter pins (re-adding `DevicePolicy=strict`+`DeviceAllow=/dev/nvme*`, dropping `RestrictRealtime`, flipping `ProtectHome`) fail; script restored and `bash -n` clean. +- Docs: README's TLS rollout now restarts the service after `--force` and says why (installer uses `systemctl enable --now`, which only *starts*; `--status` picks http/https by reading the unit file); AGENTS.md gap 10 + the Deployment paragraph record task 04's read-only reproduction, the Oct-18 staged snapshot refusing the recipe with `unknown argument: --as-root`/exit 2, and the test count 1427→1451. + +**Tests / lint** (`uv run`, exact): `bash .agents/validate.sh` → **exit 0** — `pytest --cov --cov-fail-under=94` **1451 passed, 15 skipped, coverage 98.94%**; `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` + repo hygiene clean. `uv run pytest tests/test_deploy_scripts.py` → 63 passed, 0 failed. `uv run pytest -m live --live` → **11 passed, 0 skipped**; dev server all 9 sources `ok`. + +**README/AGENTS changes:** as above (plus `data/logs/NOTES-17-installer-repro.md`, local/gitignored evidence). + +**Decisions / deviations** +- "Byte-identical minus the ten filter lines" is compared on **directives** (comments are the one thing task 01 rewrote deliberately); the new prose is pinned separately for its mechanism sentences. Documented in the test. +- **No live change made.** `driveripper`'s installed unit was diffed (read-only, `systemctl cat`) against the sandbox render of `--bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN`: **every directive identical**, only comments differ; host `active`, `CapEff 0000000000220000`, 16/16 drives with temps. Running the real `--force` needs re-staging the sudo-granted Oct-18 snapshot (which lacks the flags, so it can't run them) and would push agent 1.1.0 to a prod host mid phase-15 rollout — left to the operator with the exact command, expected diff and post-checks in the note. +- `uv pip list --outdated` advisory (fastapi 0.153→0.155) left untouched, same handling as task 02; deps are not in this task's scope. + +**Next pending task:** `.agents/phases/todo/18_nav_hamburger/01_hamburger_markup.md` (phase 17 has no other todo task). diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.md.status b/.agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.validate b/.agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.validate new file mode 100644 index 0000000..a917ea0 --- /dev/null +++ b/.agents/reports/17_installer_device_filter/17_installer_device_filter__04_tests_docs_gate.a1.validate @@ -0,0 +1,65 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +...........................s............................................ [ 29%] +........................................................................ [ 34%] +........................................................................ [ 39%] +........................................................................ [ 44%] +........................................................................ [ 49%] +........................................................................ [ 54%] +........................................................................ [ 58%] +........................................................................ [ 63%] +................................................................ssssssss [ 68%] +sss..................................................................... [ 73%] +........................................................................ [ 78%] +........................................................................ [ 83%] +........................................................................ [ 88%] +........................................................................ [ 93%] +........................................sss............................. [ 98%] +.......................... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 436 0 100% +src/dashboards/app.py 185 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3490 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1451 passed, 15 skipped, 1 warning in 154.14s (0:02:34) +== 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.155.0 wheel diff --git a/.env.example b/.env.example index 5222356..5847a5f 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,13 @@ DASH_PORT=8090 DASH_DB=data/dashboards.sqlite3 # DASH_READ_ONLY=1 kills every mutating endpoint regardless of other flags DASH_READ_ONLY=0 +# Where the seed file lives (default: .env next to the checkout). Set it when several installs +# share one box. A real environment variable always beats anything the file declares — the file +# seeds, it never overrides. +# DASH_ENV_FILE=/etc/dashboards/.env +# DASH_RELOAD=1 makes `uv run dashboards` watch the source and restart on a change — development +# only: every poll loop restarts with the process, so the charts stop for a second on each save. +# DASH_RELOAD=1 # --------------------------------------------------------------------------- # # Admin login (phase 11) @@ -110,6 +117,10 @@ NUT_TIMEOUT_SECONDS=4 # Instant commands (battery test, beeper, calibration) are the only write path in the # app. Requires NUT_USER plus this flag; the UI still confirms every command. NUT_ALLOW_ACTIONS=0 +# NUT_SIMULATE=1 replaces upsd with the labelled simulator (sources/nut_sim.py) so the UPS page +# is developable with no battery in the house. Every panel it produces says SIMULATED, and it is +# never the default: leave these two lines alone against a real upsd. +# NUT_SIMULATE=1 # --------------------------------------------------------------------------- # # History retention (raw -> 1m rollup -> 1h rollup) @@ -129,7 +140,7 @@ POLL_MS_UPS=5000 DEBUGPY=0 DEBUGPY_PORT=5678 -# The host agent reads its OWN variables (AGENT_HOST, AGENT_PORT, AGENT_TOKEN, -# AGENT_TOKEN_FILE). They belong on the monitored host — in the root-owned env/token files -# that deploy/install-agent.sh writes — not in this file: a dashboard config that everyone -# copies tends to end up with the fleet's tokens in it. +# The host agent reads its OWN variables (AGENT_HOST, AGENT_PORT, AGENT_TOKEN, AGENT_TOKEN_FILE, +# and AGENT_CERT_BUNDLE for the TLS bundle `--cert-bundle` installs). They belong on the monitored +# host — in the root-owned env/token/bundle files that deploy/install-agent.sh writes — not in this +# file: a dashboard config that everyone copies tends to end up with the fleet's tokens in it. diff --git a/AGENTS.md b/AGENTS.md index bee5cea..e9dec23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -782,13 +782,18 @@ That scheme is the history API's key — treat it as a public interface. **`ok`** and the `live` suite is **11 passed / 0 failed** (it was 9/2 on the missing token that morning, and 8/3 on the ConnectError before that). The unit reads `--host 10.1.0.126 --port 9120 --token-file /etc/dashboards-agent.token` (`640 - root:dashboards-agent`, the mode `deploy/install-agent.sh` writes) with `User=dashboards-agent`, - and `getent group disk` → `disk:x:6:dashboards-agent`. - **The deployed unit has since diverged from the template in the three ways the SMART - block below records** (the `DevicePolicy`/`DeviceAllow` lines removed, `User=root`, - `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`) — re-running `deploy/install-agent.sh` - on driveripper would clobber the working unit, because it rewrites the unit unconditionally - (gap 10). + root:dashboards-agent`, the mode `deploy/install-agent.sh` writes) and is still in the `disk` + group (`getent group disk` → `disk:x:6:dashboards-agent`) — but it does **not** run as that + user: gap 10's hand-fix set `User=root`, and re-read from the host on 2026-10-20 the installed + unit is `User=root` + `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` with no device filter. + **Those two extra lines are now template output, and the unit's rewrite is no longer + silent**: phase 17 took the + `DevicePolicy`/`DeviceAllow` lines out of the template (task 01), gave it `--as-root` and + `--caps` (task 02), and made a re-run whose generated unit differs from the installed one print + the diff and exit 1 unless `--force` was passed (task 03), so + `sudo deploy/install-agent.sh --bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force` + reproduces it and a re-run that would quietly replace a hand-fixed unit is now a refusal with a + diff attached rather than a clobber (gap 10). `/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`. @@ -857,17 +862,19 @@ That scheme is the history API's key — treat it as a public interface. `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 the trap, not the - shortcut: the installer rewrites the unit unconditionally, so running it there today replaces the - `User=root` + `CapabilityBoundingSet` unit that gap 10 resolved with this repo's - `User=dashboards-agent` + `DevicePolicy=strict` template and re-breaks all 16 SMART reads. The - live unit is not the one this repo writes (no `DevicePolicy`/`DeviceAllow` lines at all — - systemd's default `auto` — `PrivateDevices=false`, and the two named capabilities). - Worth knowing before the next install: the allow-list `install-agent.sh` - does write (`/dev/nvme*`, `/dev/sd*`, `/dev/vd*`, `/dev/sg*`) has **no `/dev/ng*n*` and no - `/dev/bsg/*`**, the nodes smartctl 7.5 reaches for NVMe admin and SATA pass-through, so a - `strict` unit there would deny exactly what a SMART read needs — whatever phase takes - driveripper's SMART on has to settle the node list and the grant together. + 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 + (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 + missing nodes are no longer a concern for the next install: with no filter, `/dev/ng*n*` and + `/dev/bsg/*` are reachable the same way `/dev/nvme*` and `/dev/sd*` are, subject to DAC and + SELinux. - **SMART: real captures, and one live host finally reads them.** `smartctl` 7.5 is installed on deskwork and driveripper. The `ops` account (which is who runs a hand-started dev agent) is in no `disk` group, driveripper grants it `(root) NOPASSWD: /usr/sbin/smartctl*` and deskwork @@ -953,7 +960,7 @@ set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no ./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health ./scripts/dev-server.sh logs -uv run pytest # hermetic: 1427 passed + 15 skipped (11 live, +uv run pytest # hermetic: 1451 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 @@ -993,23 +1000,42 @@ 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`/`--status`/`--uninstall`; it stores the token `0640 root:dashboards-agent` — the +`--cert-bundle`/`--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 0600 would fail the unit exactly like it fails the token file; the task file's `0600` is the intent — *root and the agent's own user only* — and 0640 is the mode that achieves it) — never -puts a token on argv, and runs under `ProtectSystem=strict` with -`DevicePolicy=strict` reopening only the disk nodes plus the `/dev/null` family, because strict -without `/dev/null` kills the unit at spawn (`status=208/STDIN`); a bundle-installed unit also -gets `--cert-bundle` plus `RuntimeDirectory=dashboards-agent` (mode 0700 — the bundle unpacks its +puts a token on argv, and runs under `ProtectSystem=strict` with **no device +filter** — SMART device access is DAC via the `disk` group + SELinux, because kernel 7.2 matches +a device-filter glob by name against `/proc/devices` (where NVMe is `blkext`), so the old +allow-list denied the whole namespace; `PrivateDevices=false` stays explicit because a private +`/dev` with no device list kills the unit at spawn (`status=208/STDIN`). Where DAC is not enough, +`--as-root` writes `User=root` and `--caps CAP1,CAP2` writes that `CapabilityBoundingSet` line +(space-separated, as systemd spells it): kernel 7.2's unprivileged-passthrough gate checks +capabilities rather than uid, so `--as-root` alone is capability-less (`CapEff 0`) and prints a +note saying so, while `--caps` without `--as-root` is refused with exit 1 before `need_root` and +before any write. Both default off, and neither touches the token file's mode or the `--status` +probe. A bundle-installed +unit also gets `--cert-bundle` plus `RuntimeDirectory=dashboards-agent` (mode 0700 — the bundle unpacks its PEM blocks there and the agent deletes the key once the SSL context holds it), keeps the `--token-file` line for a host-by-host rollout, and `--status` then probes `https` on the bundle's CA with **no** client certificate via a `python3` `ssl` one-liner — task 02's `CERT_OPTIONAL` + `/health` exemption is what makes that probeable, and there is no external TLS binary. A re-run without the flag leaves an installed bundle in place, and `--uninstall` removes -it with the token. No root shell? The one sudoers +it with the token — and neither is guarded, because both are deliberate. 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 +identical re-run stays the silent zero-noise path. `--force` prints the same diff and applies it — +which is what the TLS rollout's `--cert-bundle` step needs on a token-mode host, because that step +*is* a unit change. What it applies is the **file**: `systemctl enable --now` only *starts* a unit, +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 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 @@ -1712,7 +1738,8 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl would be a re-decision of that phase, not a bug fix. Do not "fix" the residual by hiding the short window or its label. 10. **`driveripper`'s 16 drives read `n/a`: kernel 7.2's unprivileged-passthrough gate - (2026-10-19, resolved the same day — the installer template is what is left).** + (2026-10-19, resolved the same day; the installer template that shipped the broken device + filter was cleared in phase 17).** The agent is installed, the `disk` group grant is in place, the block nodes open, and the page renders all 16 real drive rows — but kernel 7.2.11 blocks the *agent user's* `smartctl` at the command level (not the open; the probe matrix is in @@ -1745,11 +1772,39 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl `test_the_pass_budget_floor_is_twenty_seconds`, `test_the_pass_budget_error_line_names_the_scaled_figure` — which demands the line read `80s` for 16 disks, not the floor; the override test stays). Re-read live on 2026-10-20: the installed - agent answers 16/16 drives with temperatures and no `pass budget … exhausted` line. The - `deploy/install-agent.sh` template still ships the `DevicePolicy`/`DeviceAllow` lines that - misbehaved on this host - (a deny-all filter; the `nvme*` glob matches nothing on this kernel because - `/proc/devices` lists NVMe as `blkext`) — the template change is its own phase. + agent answers 16/16 drives with temperatures and no `pass budget … exhausted` line. + The `deploy/install-agent.sh` template no longer ships the device-filter lines that + misbehaved on this host — phase 17 task 01 removed them (the `nvme*` glob matches nothing + on this kernel because `/proc/devices` lists NVMe as `blkext`, and a strict device policy + denies everything a list fails to match), and the unit pins in `tests/test_deploy_scripts.py` + now assert their absence. Task 02 added the two flags that reproduce this host's remaining + lines — `--as-root` → `User=root`, `--caps CAP_SYS_RAWIO,CAP_SYS_ADMIN` → + `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`, both off by default, `--caps` refused + without `--as-root`, `--as-root` alone accepted with the CapEff-0 note — pinned by + `tests/test_deploy_scripts.py` (exact `User=` / `CapabilityBoundingSet=` lines in a fake-root + sandbox, the refusal before any write, the `--help` recipe). Task 03 removed the unconditional + rewrite: the unit is rendered to a temp file, and a re-run whose render differs from the + installed unit prints the diff on stdout and exits 1 unless `--force` is passed, so the flag + line above now needs `--force` to land and a routine re-run can no longer clobber this host's + unit (pinned by the refuse/force/silent sequence in `tests/test_deploy_scripts.py`, including + the `--force` leg of this host's own recipe — the same + `--bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force` line the + fleet record above prints). Task 04 verified the reproduction **read-only** rather + than by running it: the unit installed on `driveripper` (`systemctl cat`, 2026-10-20) differs + from what this template renders with that exact flag line in **comment lines only** — every + directive matches, `User=root`, `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`, no device + filter, `PrivateDevices=false`, the same `ExecStart` on the token file — and the host still + answers `active` with `CapEff: 0000000000220000` and 16/16 drives with temperatures in + `/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 + `--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 + 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** @@ -1814,8 +1869,9 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl place (`data/logs/verify-15-tls-e2e.py`, transcript `verify-15-tls-e2e.out`). What is left of the phase is the live rollout (07): the fleet's agents still run token mode, so `HOST_TARGETS` stays `http://` here until somebody installs a bundle on a host — which needs root on that host - (`ops` has no installer grant on `deskwork`, and `driveripper`'s unit must not be rewritten, gap - 10), and flipping the URL before the bundle is installed turns that host red by construction. + (`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 @@ -1824,8 +1880,9 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl 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 phase 17 fixes the unit template — and no - live change was made while pinning phase 16. + 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. ## Locked decisions (and why) diff --git a/README.md b/README.md index 45becea..1aa5696 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,21 @@ 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 --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 deploy/install-agent.sh --status # unit state and /health (https when TLS) sudo deploy/install-agent.sh --uninstall ``` +Re-running the installer is the silent, idempotent path it has always been **as long as the unit +it would write is the unit already on disk**. When the two differ the run stops: it prints +`refusing to overwrite …` with the unified diff on stdout and exits 1 before writing the unit, +before `daemon-reload` and before the service is re-enabled, so a routine redeploy can never undo +a fix somebody made by hand on the host (`--as-root`/`--caps` on a host already running the +dedicated user is exactly such a change, and installing a TLS bundle onto a token-mode host is +another). `--force` prints the same diff and then applies it. + No root shell on the monitored host? `deploy/agent-sudoers` is the single sudoers line that lets the fleet's unprivileged `ops` account run the installer; run it without root and it prints that exact line with the script's own resolved path instead of saying "run as root". @@ -149,9 +160,30 @@ hostname, mounts, disk and GPU models, and process names. `deploy/install-agent. a system user, generates a token once (re-runs reuse it; `--rotate-token` for a new one), stores it `0640 root:dashboards-agent` — the unit hands the agent the *path*, and the agent opens it as its own user, so the file must be readable by that user and by nobody else — -and runs the agent under `ProtectSystem=strict`, `DevicePolicy=strict` with only the disk -nodes and the `/dev/null` family reopened (SMART needs the former; without `/dev/null` in -the allow list systemd cannot start the service at all), with no capabilities. +and runs the agent under `ProtectSystem=strict` with an empty `CapabilityBoundingSet` (unless +`--caps` is asked for, below) and **no device filter**. +SMART device access is DAC: the installer adds the service user to the `disk` group and SELinux +stays in front of it. The strict device policy this script used to write is what locked +`driveripper`'s whole device namespace — systemd matches those globs by name against +`/proc/devices`, where kernel 7.2 lists NVMe as `blkext`, so `/dev/nvme*` matched nothing and a +strict policy denies whatever a list fails to match. `PrivateDevices=false` is still written out +explicitly, because a private `/dev` with no device list is what dies at spawn +(`status=208/STDIN`). + +When DAC is not enough — a **kernel gate** — `--as-root` and `--caps` write the two unit lines +that gate checks accept. Kernel 7.2 gates smartctl's raw passthrough behind *capabilities*, not +uid: an NVMe SMART-log read through the block node needs `CAP_SYS_ADMIN` unless the drive's +firmware advertises the page, and SATA SMART behind a SAT layer is an ATA pass-through the +unprivileged `SG_IO` allow-list does not include (`CAP_SYS_RAWIO` is what the kernel names for +it). So `--as-root` writes `User=root` and `--caps CAP_SYS_RAWIO,CAP_SYS_ADMIN` writes +`CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` (comma-separated in, space-separated out); +both default off, because a host whose drives answer through the `disk` group needs neither. +`--caps` without `--as-root` is refused with exit 1 — a capability grant on the dedicated +service user is not the kernel-gate fix — and `--as-root` alone proceeds with a note, because a +root process under the default empty bounding set is capability-less (`CapEff 0`) and reads no +more than the unprivileged service user did. The token file, its `0640 root:dashboards-agent` +mode and the `--status` probe are deliberately unchanged by these flags: root reads the token +either way, and a probe of a port cannot see who owns the process behind it. ### Managing the certificates @@ -197,9 +229,12 @@ curl -s -H "Authorization: Bearer $SESSION" -o agent-deskwork.bundle \ # 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 restarts into TLS mode. +# 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 -ops:/tmp/ -ssh -ops sudo /tmp/install-agent.sh --cert-bundle /tmp/agent-deskwork.bundle +ssh -ops sudo /tmp/install-agent.sh --cert-bundle /tmp/agent-deskwork.bundle --force +ssh -ops sudo systemctl restart dashboards-agent ssh -ops sudo /tmp/install-agent.sh --status # https, with the CA and no client cert # 3. point that host's HOST_TARGETS entry at https with NO userinfo token — the certificate is @@ -207,6 +242,12 @@ ssh -ops sudo /tmp/install-agent.sh --status # https, with the CA and no export HOST_TARGETS="deskwork=https://:9120 …" ``` +`--force` in step 2 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://: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. @@ -854,8 +895,10 @@ Instant commands are the only endpoint in the application that changes anything process** (`POST /api/ups/command`; the session and certificate-authority writes change only this app's own rows), and they are gated four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users` account that actually has the command, and a confirmation in the UI — destructive ones -(deep test, calibration, `load.off`) require typing `DANGER`. The server re-checks every -gate; hiding a button is not a safety control. Commands that would cut power are refused +(deep test, calibration, `load.off`) require typing `DANGER`. Two more sit in front of those +(since the login landed): a session, and this session's CSRF token when the write arrived on the +cookie — additive, never a replacement, so the typed `DANGER` is still what cuts power. The server +re-checks every gate on every request; hiding a button is not a safety control. Commands that would cut power are refused outright while the UPS is already on battery. Give the app a narrowly scoped NUT user instead of a god-mode one — per-command `instcmds`, diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh index f592484..caec073 100755 --- a/deploy/install-agent.sh +++ b/deploy/install-agent.sh @@ -4,8 +4,11 @@ # 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 --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 # sudo ./deploy/install-agent.sh --uninstall +# sudo ./deploy/install-agent.sh --force # re-apply over a unit this would change # # No root shell? `deploy/agent-sudoers` is the single sudoers line that lets the fleet's # unprivileged `ops` account run this script as root. Run it without root and it prints that @@ -32,17 +35,56 @@ # the agent exempts for probes. Re-running without the flag leaves an installed bundle in # place, the token-reuse idiom. # -# The agent is read-only by construction (it answers GET and writes nothing), runs as its -# own system user, and gets the usual systemd lockdown. It still needs to read /proc, /sys -# and statvfs(), which the hardening below leaves alone. +# 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. # # Disk health (`smartctl -i -A -H`, the `/disks` page) is the one add-on that needs more # than /proc and /sys: smartctl must open the disk device nodes. The installer therefore -# adds the agent user to the `disk` group when one exists, and the unit device-allows the -# disk nodes instead of PrivateDevices. This is raw *read* access to the disks — the -# standard arrangement for SMART monitoring, granted deliberately — and its absence is a -# documented degradation, never an install failure: no smartmontools (or no group) simply -# means every disk reports `smart: null` with an explanatory error entry. +# adds the agent user to the `disk` group when one exists, and the unit applies no device +# filter at all: kernel 7.2 matches a device-filter glob by name against /proc/devices, where +# NVMe is listed as `blkext`, so the old `/dev/nvme*` pattern denied the whole device +# namespace instead of opening four nodes. Device access is DAC via the `disk` group, with +# SELinux still in front of it — the standard arrangement for SMART monitoring, granted +# deliberately. Its absence is a documented degradation, never an install failure: no +# smartmontools (or no group) simply means every disk reports `smart: null` with an +# explanatory error entry. +# +# A kernel gate is a different grant, and --as-root / --caps are how the unit gets it. Kernel +# 7.2 gates smartctl's raw passthrough behind *capabilities*, not uid: an NVMe SMART-log read +# through the block node needs CAP_SYS_ADMIN unless the drive's firmware advertises the page, +# and SATA SMART behind a SAT layer is an ATA pass-through the unprivileged SG_IO allow-list +# does not include (CAP_SYS_RAWIO is what the kernel names for that). Those checks run against +# the process's capabilities, so a root service under the default empty CapabilityBoundingSet +# is capability-less (CapEff 0) and reads nothing — which is why the two flags belong together. +# +# --as-root run the service as root (for a kernel-gated host: kernel 7.2 gates +# unprivileged smartctl passthrough behind capabilities, not uid — a root +# process under the default empty bounding set is capability-less, so this +# flag needs --caps to mean anything) +# --caps LIST CapabilityBoundingSet line for the unit (comma-separated, e.g. +# CAP_SYS_RAWIO,CAP_SYS_ADMIN); default: empty (capability-less service) +# +# Both default off, because a host whose drives answer through the disk group needs neither. +# --caps without --as-root is refused (exit 1): a capability grant on the dedicated service +# user is not the kernel-gate fix. --as-root alone proceeds with a note, since a +# capability-less root is a legitimate if unusual choice. The recipe for the host this exists +# for — 16 drives, NVMe firmware without the SMART-log page and SATA behind SAT — is: +# +# sudo ./deploy/install-agent.sh --bind --port 9120 \ +# --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN +# +# A re-run is the zero-noise path it has always been as long as the unit this script would +# write is the unit already on disk. When the two differ the run stops: the diff between the +# installed unit and the generated one is printed and the exit code is 1, before the unit is +# written, before daemon-reload and before the service is re-enabled — so a routine redeploy +# cannot reset a unit somebody fixed by hand. driveripper's root + CapabilityBoundingSet unit +# is exactly that hand-fixed case (and the flags above are what reproduce it), which is why +# the refusal, not the overwrite, is the default. --force applies the change, with the same +# diff printed first so the operator sees what they are committing to. +# +# --force overwrite an existing unit even if the generated unit differs (prints the +# diff first) set -euo pipefail SERVICE="dashboards-agent" @@ -69,6 +111,14 @@ PORT="9120" TOKEN="${AGENT_TOKEN:-}" ROTATE=0 BUNDLE_SRC="" +# --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. +AS_ROOT=0 +CAPS="" +# --force: the re-run guard's opt-in (phase 17 task 03). 0 means a re-run whose generated unit +# differs from the installed one refuses after printing the diff. +FORCE=0 MODE="install" usage() { @@ -84,6 +134,22 @@ while [ $# -gt 0 ]; do --token) TOKEN="$2"; shift 2 ;; --rotate-token) ROTATE=1; shift ;; --cert-bundle) BUNDLE_SRC="$2"; shift 2 ;; + --as-root) AS_ROOT=1; shift ;; + --caps) + [ $# -ge 2 ] || { + echo "--caps needs a capability list (e.g. --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN)" >&2 + exit 1 + } + # An empty list is a typo, not "the default": the operator typed the flag, so refusing + # is kinder than writing the capability-less unit they did not ask for. + if [ -z "$(printf '%s' "$2" | tr -d '[:space:],')" ]; then + echo "--caps needs at least one capability name (e.g. --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN)" >&2 + exit 1 + fi + CAPS="$2" + shift 2 + ;; + --force) FORCE=1; shift ;; --status) MODE="status"; shift ;; --uninstall) MODE="uninstall"; shift ;; -h|--help) usage 0 ;; @@ -91,6 +157,16 @@ while [ $# -gt 0 ]; do esac done +# Refused here, before need_root and before any write, for the bundle validator's reason: a +# wrong flag combination is answered with its own sentence rather than sending the operator to +# add a sudoers line for a mistake in the flags. --caps on the dedicated service user is not +# the kernel-gate fix — the gate checks the process's capabilities, and a non-root user cannot +# hold them across exec — so the grant only means anything on a root service. +if [ -n "$CAPS" ] && [ "$AS_ROOT" -ne 1 ]; then + echo "--caps needs --as-root: a capability grant on the service user is not the kernel-gate fix" >&2 + exit 1 +fi + need_root() { if [ "$(id -u)" -ne 0 ]; then # The dev workflow deploys over ssh as the unprivileged `ops` account, so "run as root" @@ -327,7 +403,38 @@ RuntimeDirectory=dashboards-agent RuntimeDirectoryMode=0700" fi - cat > "$UNIT" < "$TMP_UNIT" < "$UNIT" + rm -f "$TMP_UNIT" + systemctl daemon-reload systemctl enable --now "$SERVICE" sleep 1 diff --git a/tests/test_deploy_scripts.py b/tests/test_deploy_scripts.py index a72bc01..6b5e33b 100644 --- a/tests/test_deploy_scripts.py +++ b/tests/test_deploy_scripts.py @@ -12,6 +12,7 @@ apart from the path the script was invoked through. from __future__ import annotations +import difflib import json import os import re @@ -372,11 +373,22 @@ def test_install_path_installs_into_its_own_paths_and_prints_the_token_once(tmp_ # the bundle unpacks into (the agent falls back to a private mkdtemp without one). assert "--cert-bundle" not in unit, "no bundle installed means no TLS flag on a token install" assert "RuntimeDirectory=dashboards-agent" not in unit - # DevicePolicy=strict denies every node not listed, and systemd opens /dev/null for stdin - # before it execs python: without this line the unit dies at spawn (status 208/STDIN). - assert "DevicePolicy=strict" in unit - assert "DeviceAllow=/dev/null" in unit, "strict device policy needs /dev/null or the unit cannot start" - assert "DeviceAllow=/dev/nvme*" in unit, "SMART reads need the disk nodes back" + # No device filter, on purpose (phase 17): a strict device policy denies every node the + # allow-list fails to match, and systemd matches those globs by *name* against + # /proc/devices — where kernel 7.2 lists NVMe as `blkext`, so the old `/dev/nvme*` entry + # locked the whole device namespace on driveripper. Device access is DAC (the `disk` group + # the installer grants) plus SELinux, the standard SMART-monitoring arrangement. + assert "DevicePolicy" not in unit, ( + "a strict device policy with a name-matched allow-list is the deny-all device filter " + "that locked driveripper's SMART reads — the unit must carry none" + ) + assert "DeviceAllow" not in unit, "no device filter means no allow-list either" + # PrivateDevices=false survives as the explicit statement of the systemd default: a + # private /dev with no device list dies at spawn (status 208/STDIN), because systemd + # opens /dev/null for the service stdin before it execs python. + assert "PrivateDevices=false" in unit, ( + "the explicit default is what keeps a private-/dev sandbox from dying at STDIN" + ) assert token and len(token) >= 32, "a generated token is expected" assert token in result.stdout, "first install prints the token, and never again" assert f'HOST_TARGETS="hostname=http://{token}@10.0.0.5:9120"' in result.stdout @@ -387,6 +399,473 @@ def test_install_path_installs_into_its_own_paths_and_prints_the_token_once(tmp_ assert "chown root:dashboards-agent" in calls, "the token file must become group-readable" +#: A line the unit heredoc may legitimately write: a comment, a `[Section]` header, or a +#: systemd directive assignment. Anything else is the unquoted heredoc having *expanded* +#: something — see `test_the_written_unit_is_only_comments_and_directives`. +_UNIT_LINE_RE = re.compile(r"^(?:#|\[[A-Za-z]+\]|[A-Za-z]+=)") + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_the_written_unit_is_only_comments_and_directives(tmp_path: Path) -> None: + """The unit heredoc is unquoted so `$TLS_UNIT_LINES` and `$TLS_EXEC_ARGS` can expand. + + That is also what executes its **comments**: writing the phase-17 rationale with backticks + around `disk`, `blkext` and `/dev/nvme*` made the install run them (`disk: command not + found`, smartctl's version banner) and paste the output into the unit file, with `bash -n` + and every text assertion still happy. Quote the heredoc and phase 15's interpolated lines + stop working, so the guard is on the *result*: a unit line is a comment, a section header, + or a directive assignment — never the output of something the prose mentioned. + """ + script, _call_log, env = _sandbox_installer(tmp_path) + result = subprocess.run( + [BASH, str(script), "--bind", "127.0.0.1", "--port", "9199"], + capture_output=True, + text=True, + env=env, + timeout=60, + check=False, + ) + assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}" + unit = (tmp_path / "target" / "dashboards-agent.service").read_text() + offenders = [line for line in unit.splitlines() if line.strip() and not _UNIT_LINE_RE.match(line)] + assert not offenders, "the unit heredoc expanded its own text into the unit file:\n" + "\n".join( + offenders + ) + assert result.stderr == "", "installing must not execute anything the unit's comments mention" + + +# --- the template's unit: no device filter, and the whole unit pinned (phase 17 task 04) ------ + +#: The ten lines the pre-phase template wrote between ``PrivateDevices=false`` and +#: ``ProtectSystem=strict`` — the device filter phase 17 removes. Pinned as the *old* text, in +#: order, because the fix is the absence of exactly these lines: re-adding +#: ``DeviceAllow=/dev/nvme* rw`` with ``DevicePolicy=strict`` is the deny-all returning, and a +#: test that only asserted "`DevicePolicy` is absent today" would pass while somebody added +#: the allow-list back under a default `auto` policy that ignores it. +DEVICE_FILTER_LINES = ( + "DevicePolicy=strict", + "DeviceAllow=/dev/null rw", + "DeviceAllow=/dev/zero rw", + "DeviceAllow=/dev/full rw", + "DeviceAllow=/dev/random rw", + "DeviceAllow=/dev/urandom rw", + "DeviceAllow=/dev/nvme* rw", + "DeviceAllow=/dev/sd* rw", + "DeviceAllow=/dev/vd* rw", + "DeviceAllow=/dev/sg* rw", +) + +#: The unit the template wrote **before** phase 17, rendered for a default token install +#: (bind `10.0.0.5`, port `9120`, no bundle), with the sandbox root spelled ``. +#: +#: This is the whole unit and not a highlight reel: it is the reference the default install is +#: compared against, so a template line added, dropped, reordered or reworded *outside* this +#: phase's remit fails a test instead of landing in the next host's service file. The comment +#: block is included verbatim (it is what the operator reads in `systemctl cat`) even though +#: phase 17 task 01 rewrote that prose on purpose — which is why the comparison below reads +#: directives, and the new rationale is pinned separately for the two facts it must carry. +PRE_PHASE_DEFAULT_UNIT = """[Unit] +Description=Homelab host agent (read-only system metrics) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=dashboards-agent +ExecStart=/usr/bin/env python3 /target/lib/host_agent.py --host 10.0.0.5 --port 9120 --token-file /target/token +Restart=on-failure +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 +SyslogIdentifier=dashboards-agent + +# The agent reads /proc, /sys and statvfs() and writes nothing, so everything writable can +# be taken away. smartctl is the one thing that needs a real /dev node, so instead of +# PrivateDevices the unit closes all devices and reopens only the disk nodes, read/write +# at the cgroup layer while the code (and smartctl -i -A -H) stays read-only by design. +# +# The pseudo nodes are not decoration: DevicePolicy=strict denies every node that is not +# listed, and systemd needs /dev/null for the service's stdin before it ever execs python — +# without it the unit dies at spawn with "Failed at step STDIN" (status 208), which is how +# the first phase-10 install failed on a live host running systemd 257. /dev/zero, full, +# random and urandom are the rest of the set a PrivateDevices= sandbox would have provided, +# and none of them is a disk. +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=false +DevicePolicy=strict +DeviceAllow=/dev/null rw +DeviceAllow=/dev/zero rw +DeviceAllow=/dev/full rw +DeviceAllow=/dev/random rw +DeviceAllow=/dev/urandom rw +DeviceAllow=/dev/nvme* rw +DeviceAllow=/dev/sd* rw +DeviceAllow=/dev/vd* rw +DeviceAllow=/dev/sg* rw +ProtectSystem=strict +ProtectHome=read-only +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictNamespaces=true +RestrictRealtime=true +LockPersonality=true +MemoryDenyWriteExecute=true +CapabilityBoundingSet= +SystemCallFilter=@system-service + +[Install] +WantedBy=multi-user.target +""" + + +def _unit_heredoc() -> str: + """The template's unit heredoc, exactly as the script holds it. + + Scoped on purpose: the rest of the script is prose about flags, and a pin that read the + whole file for `DeviceAllow` would be satisfied by deleting a sentence rather than by the + absence of a *unit line*. + """ + text = INSTALLER.read_text() + assert "< list[str]: + """The lines systemd acts on — every line that is neither a comment nor blank. + + Comments carry the phase's reasoning and *are* part of the file, but they are also the one + thing phase 17 task 01 rewrote deliberately (the old block justified the filter; the new + one explains its absence). Directives are what a host runs, so those are compared exactly. + """ + return [line for line in unit.splitlines() if line.strip() and not line.startswith("#")] + + +def test_the_template_unit_has_no_device_filter_lines() -> None: + """The fix is the absence, so the absence is what is pinned. + + Three layers, because the regression has three shapes: a `DevicePolicy=strict` or + `DeviceAllow=` line back in the heredoc (the deny-all returning on kernel 7.2, where + systemd matches those globs by *name* against `/proc/devices` and lists NVMe as `blkext`, + so `/dev/nvme*` matched nothing and strict denied the whole namespace); + `PrivateDevices=false` removed as "redundant" because it is the systemd default (a private + `/dev` with no device list dies at spawn with `status=208/STDIN`); and a rationale comment + that stops naming the mechanism, which is how the next operator re-adds the filter while + believing they are hardening the unit. + """ + text = INSTALLER.read_text() + # The phase's completion criterion is this exact grep, so it is pinned as one: neither + # directive may appear anywhere in the script, prose included. + assert "DevicePolicy" not in text, "no device policy line anywhere in the installer" + assert "DeviceAllow" not in text, "no device allow-list anywhere in the installer" + + heredoc = _unit_heredoc() + offenders = [line for line in heredoc.splitlines() if re.match(r"^\s*(DevicePolicy|DeviceAllow)\s*=", line)] + assert offenders == [], "the unit heredoc grew a device filter line again:\n" + "\n".join(offenders) + assert "PrivateDevices=false" in heredoc, ( + "the explicit default is the line that keeps the unit from dying at STDIN; the filter " + "going away does not make it redundant" + ) + # The rewritten rationale has to be an explanation, not a shrug (task 01's own criterion). + comment = "\n".join(line for line in heredoc.splitlines() if line.startswith("#")) + assert "/proc/devices" in comment and "blkext" in comment, ( + "the comment must name the mechanism, or the next reader cannot tell an omitted filter " + "from a forgotten one" + ) + assert "208" in comment, "and the STDIN failure that PrivateDevices=false still prevents" + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_a_default_install_unit_is_the_pre_phase_unit_minus_the_filter_lines(tmp_path: Path) -> None: + """No flags writes the unit this script has always written, minus exactly ten lines. + + `AGENTS.md` invariant 3 in deployment clothes: a host that needs nothing new must not get a + new unit from an unrelated phase. The reference is the whole pre-phase unit recorded above, + so every directive is compared (not a keyword spot-check) and the only allowed difference is + the device filter's absence — asserted as the ordered list comparison *and* as each of the ten + lines being absent by name, so a filter line silently renamed rather than removed fails too. + """ + script, _call_log, env = _sandbox_installer(tmp_path) + result = _install(script, env, "--bind", "10.0.0.5", "--port", "9120") + assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}" + installed = (tmp_path / "target" / "dashboards-agent.service").read_text() + + reference = PRE_PHASE_DEFAULT_UNIT.replace("", str(tmp_path)) + expected = [line for line in _unit_directives(reference) if line not in DEVICE_FILTER_LINES] + assert len(expected) == len(_unit_directives(reference)) - len(DEVICE_FILTER_LINES), ( + "DEVICE_FILTER_LINES must match ten real lines of the reference unit — a filter line " + "this list no longer names is a filter that would pass this test" + ) + got = _unit_directives(installed) + drift = "\n".join( + difflib.unified_diff( + expected, got, fromfile="pre-phase unit minus the filter", tofile="installed", lineterm="" + ) + ) + assert got == expected, f"the default unit drifted from the pre-phase unit by more than the filter:\n{drift}" + + # And the ten lines are gone rather than reworded into something equivalent. + assert not [line for line in installed.splitlines() if line.startswith(DEVICE_FILTER_LINES)] + + +# --- the kernel-gate unit: --as-root / --caps (phase 17 task 02) --------------------------- + +#: The exact sentence `--as-root` alone prints. It is not a refusal — a capability-less root is +#: a legitimate if unusual choice — but it is the fact gap 10 paid for: the first root-user +#: attempt on the kernel-7.2 host still read `n/a` on all 16 drives, because the bounding set +#: bounds even root (`CapEff 0`). Pinned verbatim so a paraphrase that drops "not uid" is a +#: test failure rather than an operator rediscovering it. +AS_ROOT_NOTE = ( + "note: --as-root with the default empty CapabilityBoundingSet leaves the process " + "capability-less (the kernel gates check capabilities, not uid); pair it with --caps " + "for a kernel-gated host" +) + +#: `--caps` on the dedicated service user is not the kernel-gate fix (a non-root process cannot +#: hold these capabilities across exec), so the combination is refused rather than written. +CAPS_REFUSAL = "--caps needs --as-root: a capability grant on the service user is not the kernel-gate fix" + +#: The two capabilities the kernel names for the two gates (NVMe SMART log / SG_IO), and the +#: `driveripper` recipe as `--help` must teach it (phase 17 completion criterion). +KERNEL_CAPS = "CAP_SYS_RAWIO,CAP_SYS_ADMIN" +KERNEL_GATE_RECIPE = f"--as-root --caps {KERNEL_CAPS}" + + +def _install(script: Path, env: dict[str, str], *flags: str) -> subprocess.CompletedProcess[str]: + """One sandboxed installer run: fake root boundary, real script body.""" + try: + return subprocess.run( + [BASH, str(script), *flags], + capture_output=True, + text=True, + env=env, + timeout=60, + check=False, + ) + except subprocess.TimeoutExpired as exc: # pragma: no cover - the recursion regression + pytest.fail(f"the installer did not return (flags {flags}, partial output: {exc.stdout!r})") + + +def _directive(unit: str, key: str) -> list[str]: + """Every line of the unit assigning `key` — a duplicate assignment is a heredoc bug.""" + return [line for line in unit.splitlines() if line.split("=", 1)[0] == key] + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_the_default_unit_keeps_the_service_user_and_an_empty_bounding_set(tmp_path: Path) -> None: + """No flags = the unit this script wrote before the flags existed, byte-for-byte. + + `CapabilityBoundingSet=` with nothing after the `=` is the capability-less default and what + every host whose drives answer through the `disk` group runs; it is also the line + `--caps` replaces, so its exact spelling is pinned here rather than as `in unit`. + """ + script, _call_log, env = _sandbox_installer(tmp_path) + result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199") + assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}" + unit = (tmp_path / "target" / "dashboards-agent.service").read_text() + assert _directive(unit, "User") == ["User=dashboards-agent"] + assert _directive(unit, "CapabilityBoundingSet") == ["CapabilityBoundingSet="] + assert AS_ROOT_NOTE not in result.stdout, "the note belongs to --as-root alone" + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_as_root_with_caps_writes_the_kernel_gate_unit(tmp_path: Path) -> None: + """The two lines that reproduce the working `driveripper` unit, exactly. + + Comma on the command line, space in the unit: that is systemd's own spelling for + `CapabilityBoundingSet`, and it is the whole reason the flag takes a list rather than the + line. Everything else in the unit — the token file, its `0640 root:dashboards-agent`, the + hardening block — is unchanged by these flags. + """ + script, call_log, env = _sandbox_installer(tmp_path) + kernel_gate = ["--bind", "10.0.0.5", "--port", "9120", "--as-root", "--caps", KERNEL_CAPS] + result = _install(script, env, *kernel_gate) + assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}" + unit = (tmp_path / "target" / "dashboards-agent.service").read_text() + assert _directive(unit, "User") == ["User=root"] + assert _directive(unit, "CapabilityBoundingSet") == ["CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN"] + assert AS_ROOT_NOTE not in result.stdout, "--caps answers the note: the grant is there" + # The token file's logic is deliberately untouched by --as-root: root reads it either way, + # and re-deriving the mode per service user would be a second rule for no new safety. + token = tmp_path / "target" / "token" + assert stat.S_IMODE(token.stat().st_mode) == 0o640 + assert "chown root:dashboards-agent" in call_log.read_text() + assert "--host 10.0.0.5 --port 9120" in unit + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_as_root_and_caps_write_the_kernel_gate_unit_lines(tmp_path: Path) -> None: + """The whole flag contract in one place, plus the part only a comparison can pin. + + The three flag shapes (`--as-root --caps LIST`, `--caps` alone, `--as-root` alone) each have + their own detailed pin nearby; what none of them shows is **how much** the recipe moves. So: + two installs in two sandboxes at the same bind and port, one default and one kernel-gated, and + their directive lists must differ by exactly the two lines this phase promises and nothing + else — the hardening block, the token file's path, the `ExecStart` and the `[Install]` section + are not these flags' business, and an `--as-root` that also slipped a `ProtectSystem` change in + would be a privilege change nobody asked for. The two half-recipes are then run in their own + sandboxes (one host re-run with different flags is the guard's decision, not the parser's). + """ + plain_script, _plain_log, plain_env = _sandbox_installer(tmp_path / "plain") + plain = _install(plain_script, plain_env, "--bind", "10.1.0.126", "--port", "9120") + assert plain.returncode == 0, f"default install failed:\n{plain.stdout}\n{plain.stderr}" + + gate_script, gate_log, gate_env = _sandbox_installer(tmp_path / "gate") + recipe = _install( + gate_script, gate_env, "--bind", "10.1.0.126", "--port", "9120", "--as-root", "--caps", KERNEL_CAPS + ) + assert recipe.returncode == 0, f"the driveripper recipe failed:\n{recipe.stdout}\n{recipe.stderr}" + + def directives(where: str) -> list[str]: + root = tmp_path / where + unit = (root / "target" / "dashboards-agent.service").read_text() + return _unit_directives(unit.replace(str(root), "")) + + changed = [ + f"{a} -> {b}" + for a, b in zip(directives("plain"), directives("gate"), strict=True) + if a != b + ] + assert changed == [ + "User=dashboards-agent -> User=root", + "CapabilityBoundingSet= -> CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN", + ], "--as-root --caps may write these two lines and nothing else" + assert "chown root:dashboards-agent" in gate_log.read_text(), ( + "--as-root leaves the token file's ownership alone: root reads it either way, and the " + "grant must not widen because of a flag that is about capabilities" + ) + + # The two half-recipes, in their own sandboxes (a differing re-run of one host is the + # guard's business, not the flag parser's). + caps_only_script, caps_only_log, caps_only_env = _sandbox_installer(tmp_path / "caps-only") + caps_only = _install( + caps_only_script, caps_only_env, "--bind", "127.0.0.1", "--port", "9199", "--caps", KERNEL_CAPS + ) + assert caps_only.returncode == 1, "--caps without --as-root is refused" + assert CAPS_REFUSAL in caps_only.stderr + assert not caps_only_log.exists(), "and refused before a single privileged command" + + root_only_script, _root_only_log, root_only_env = _sandbox_installer(tmp_path / "root-only") + root_only = _install(root_only_script, root_only_env, "--bind", "127.0.0.1", "--port", "9199", "--as-root") + assert root_only.returncode == 0, f"--as-root alone must proceed:\n{root_only.stderr}" + assert AS_ROOT_NOTE in root_only.stdout, "with the capability-less note" + root_unit = (tmp_path / "root-only" / "target" / "dashboards-agent.service").read_text() + assert _directive(root_unit, "User") == ["User=root"] + assert _directive(root_unit, "CapabilityBoundingSet") == ["CapabilityBoundingSet="] + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_as_root_alone_installs_and_prints_the_capability_note(tmp_path: Path) -> None: + """A capability-less root proceeds — with the reminder, on stdout, and no refusal.""" + script, _call_log, env = _sandbox_installer(tmp_path) + result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199", "--as-root") + assert result.returncode == 0, f"--as-root alone must proceed:\n{result.stdout}\n{result.stderr}" + assert AS_ROOT_NOTE in result.stdout, "the note is the operator's only warning here" + unit = (tmp_path / "target" / "dashboards-agent.service").read_text() + assert _directive(unit, "User") == ["User=root"] + assert _directive(unit, "CapabilityBoundingSet") == ["CapabilityBoundingSet="], ( + "--as-root alone writes no capability grant: the note is the whole effect" + ) + + +@pytest.mark.parametrize( + ("caps", "refusal"), + [ + ("CAP_SYS_RAWIO", CAPS_REFUSAL), + ("CAP_SYS_RAWIO,CAP_SYS_ADMIN", CAPS_REFUSAL), + ("", "--caps needs at least one capability name"), + (" , ", "--caps needs at least one capability name"), + ], + ids=["one-cap", "two-caps", "empty", "punctuation-only"], +) +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_caps_without_as_root_is_refused_before_anything_is_written( + tmp_path: Path, caps: str, refusal: str +) -> None: + """Exit 1, the reason on stderr, and no side effect at all. + + The check sits *before* `need_root` (the `--cert-bundle` validator's reasoning): a wrong + flag combination gets its own sentence instead of a sudoers hint, and nothing after it — + user, group membership, token, unit — can have run. + """ + script, call_log, env = _sandbox_installer(tmp_path) + result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199", "--caps", caps) + assert result.returncode == 1, "the refusal keeps exit 1" + assert result.stdout == "", "a refusal is a failure: nothing on stdout" + assert refusal in result.stderr + assert GRANT_PREFIX not in result.stderr, "a flag mistake is not a sudoers problem" + target = tmp_path / "target" + assert not (target / "dashboards-agent.service").exists(), "refused means nothing written" + assert not (target / "token").exists() + assert not call_log.exists(), "no privileged command ran either" + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_caps_without_an_argument_is_refused_rather_than_crashing(tmp_path: Path) -> None: + """`--caps` last on the command line: exit 1 naming the flag, not an `unbound variable`.""" + script, _call_log, env = _sandbox_installer(tmp_path) + result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199", "--as-root", "--caps") + assert result.returncode == 1 + assert result.stdout == "" + assert "--caps needs a capability list" in result.stderr + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_the_caps_list_is_normalised_to_systemd_spelling(tmp_path: Path) -> None: + """Empty entries and stray whitespace never reach the unit as a double space… + + …and a `*` stays a literal: the list is split with pathname expansion switched off, so an + operator typo cannot substitute the working directory's file names into `User=`'s partner. + """ + script, _call_log, env = _sandbox_installer(tmp_path) + messy = ["--bind", "127.0.0.1", "--port", "9199", "--as-root", "--caps", "CAP_SYS_RAWIO,, CAP_SYS_ADMIN "] + result = _install(script, env, *messy) + assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}" + unit = (tmp_path / "target" / "dashboards-agent.service").read_text() + assert _directive(unit, "CapabilityBoundingSet") == ["CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN"] + + script_glob, _log, env_glob = _sandbox_installer(tmp_path / "glob") + star = ["--bind", "127.0.0.1", "--port", "9199", "--as-root", "--caps", "*"] + result_glob = _install(script_glob, env_glob, *star) + assert result_glob.returncode == 0, f"install failed:\n{result_glob.stdout}\n{result_glob.stderr}" + unit_glob = (tmp_path / "glob" / "target" / "dashboards-agent.service").read_text() + assert _directive(unit_glob, "CapabilityBoundingSet") == ["CapabilityBoundingSet=*"], ( + "the capability list is words, not a glob" + ) + + +def test_help_teaches_the_kernel_gate_recipe() -> None: + """Completion criterion: the `driveripper` flags are learnable from `--help` alone. + + `--help` *is* the header comment, so the sentence that explains why a root service needs + its capabilities named is pinned here too — it is the one thing an executor on a fresh + clone cannot infer from a unit file. + """ + result = _run([BASH, str(INSTALLER), "--help"]) + assert result.returncode == 0 + assert KERNEL_GATE_RECIPE in result.stdout, "the recipe line must be copy-pasteable" + assert "--as-root run the service as root" in result.stdout + assert "--caps LIST CapabilityBoundingSet line for the unit" in result.stdout + assert "capabilities, not uid" in result.stdout, "the kernel gate is the why of both flags" + assert "capability-less" in result.stdout, "--as-root without --caps has to say what it is" + + +def test_the_status_probe_is_blind_to_who_the_service_runs_as() -> None: + """`--status` probes the agent over the wire; the unit's user is invisible to it, on purpose. + + A status path that read `AS_ROOT`/`CAPS` would make a health probe depend on flags the + operator may not have passed on this invocation — the probe answers about the port. + """ + status_body = INSTALLER.read_text().split("status() {", 1)[1].split("\n}", 1)[0] + assert "AS_ROOT" not in status_body + assert "CAPS" not in status_body + + def test_token_file_is_readable_by_the_service_user_and_nobody_else(tmp_path: Path) -> None: """`--token-file` is a path the *agent* opens, as its own user. @@ -409,6 +888,297 @@ def test_token_file_is_readable_by_the_service_user_and_nobody_else(tmp_path: Pa assert not mode & 0o007, "the token file must never be world-readable" +# --- the re-run guard: no silent clobber (phase 17 task 03) ---------------------------------- + +def _refusal(unit: Path) -> str: + """The sentence a refused re-run prints, with the unit path substituted. + + Pinned verbatim because it is the operator's only explanation of why a routine re-run + stopped, and because the diff follows it on **stdout** rather than stderr: the diff is the + payload of the answer (an operator pipes it into a pager or a file), and splitting a + sentence from its diff across two streams only interleaves them. + """ + return f"refusing to overwrite {unit} (generated unit differs); run with --force to apply:" + + +#: One line the operator can plausibly have edited on the host — `RestartSec` is a real knob, +#: it is not a line any flag writes, and it sits far from every other assertion in these tests. +OPERATOR_EDIT = ("RestartSec=5", "RestartSec=7 # hand-tuned on the host after a flapping start") + + +def _hand_edit(unit_path: Path) -> str: + """Edit the installed unit the way an operator does, and return what it now holds.""" + text = unit_path.read_text() + assert OPERATOR_EDIT[0] in text, "the template unit lost the line the operator edits" + edited = text.replace(*OPERATOR_EDIT) + unit_path.write_text(edited, encoding="utf-8") + return edited + + +def _systemctl_calls(call_log: Path) -> list[str]: + """Only the `systemctl` lines of the stub log — the ones that touch the running service. + + The privileged calls before the guard (`id`, `getent`, `usermod`, `chown`) do run again on + a refused re-run: they are the idempotent ones the guard's reasoning accepts. `systemctl` + is what a refused run must not do — no `daemon-reload`, no `enable --now` — so that is the + half compared here. + """ + return [line for line in call_log.read_text().splitlines() if line.startswith("systemctl ")] + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_a_rerun_that_would_change_the_installed_unit_refuses(tmp_path: Path) -> None: + """install → hand-edit → re-run: exit 1, the diff on stdout, nothing applied. + + This is the incident the guard exists for: `driveripper`'s unit was fixed by hand, and the + installer rewrote it unconditionally. A refused run must leave the unit on disk as the + operator left it and must not reload or re-enable anything, or the refusal arrives after + the thing it is refusing. + """ + script, call_log, env = _sandbox_installer(tmp_path) + unit_path = tmp_path / "target" / "dashboards-agent.service" + token_path = tmp_path / "target" / "token" + + first = _install(script, env, "--bind", "10.0.0.5", "--port", "9120") + assert first.returncode == 0, f"first install failed:\n{first.stdout}\n{first.stderr}" + edited = _hand_edit(unit_path) + token_before = token_path.read_bytes() + reloads_before = _systemctl_calls(call_log) + assert reloads_before, "the first install did reload and enable the service" + + result = _install(script, env, "--bind", "10.0.0.5", "--port", "9120") + assert result.returncode == 1, "a differing re-run refuses with exit 1" + assert result.stderr == "", "the refusal and its diff are one answer: stdout, not split" + assert _refusal(unit_path) in result.stdout + assert "--- " in result.stdout and "+++ " in result.stdout, "the diff is unified" + assert f"-{OPERATOR_EDIT[1]}" in result.stdout, "the diff shows the operator's line going away" + assert "+RestartSec=5" in result.stdout, "and the template's line coming back" + assert unit_path.read_text() == edited, "a refused run leaves the installed unit byte-identical" + # The reuse path re-reads the existing secret and rewrites the same bytes with the same + # mode; its *value* — what invariant 6 and the HOST_TARGETS entry depend on — never moves, + # which is the guarantee here (mtime is not: the write happens before the guard). + assert token_path.read_bytes() == token_before, "the reused token must not change" + assert stat.S_IMODE(token_path.stat().st_mode) == 0o640 + assert _systemctl_calls(call_log) == reloads_before, ( + "a refused run must not daemon-reload or enable: that would restart the service the " + "refusal exists to leave alone" + ) + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_force_prints_the_diff_then_writes_the_generated_unit(tmp_path: Path) -> None: + """`--force` is the opt-in, and the diff still arrives: an operator approves what they saw.""" + script, call_log, env = _sandbox_installer(tmp_path) + unit_path = tmp_path / "target" / "dashboards-agent.service" + _install(script, env, "--bind", "10.0.0.5", "--port", "9120") + _hand_edit(unit_path) + + result = _install(script, env, "--bind", "10.0.0.5", "--port", "9120", "--force") + assert result.returncode == 0, f"--force must proceed:\n{result.stdout}\n{result.stderr}" + assert _refusal(unit_path) not in result.stdout, "--force is not a refusal" + assert f"-{OPERATOR_EDIT[1]}" in result.stdout and "+RestartSec=5" in result.stdout, ( + "the diff is printed even when the answer is yes" + ) + unit = unit_path.read_text() + assert "RestartSec=5" in unit and OPERATOR_EDIT[1] not in unit, "the generated unit is written" + assert _systemctl_calls(call_log).count("systemctl enable --now dashboards-agent") == 2, ( + "the overwrite is followed by the usual reload/enable" + ) + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_an_unchanged_rerun_is_silent_and_proceeds(tmp_path: Path) -> None: + """Idempotence is the normal path: same flags, same unit, no diff, no complaint, exit 0.""" + script, call_log, env = _sandbox_installer(tmp_path) + unit_path = tmp_path / "target" / "dashboards-agent.service" + first = _install(script, env, "--bind", "10.0.0.5", "--port", "9120") + assert first.returncode == 0, f"first install failed:\n{first.stdout}\n{first.stderr}" + installed = unit_path.read_text() + + result = _install(script, env, "--bind", "10.0.0.5", "--port", "9120") + assert result.returncode == 0, f"an identical re-run must proceed:\n{result.stdout}\n{result.stderr}" + assert _refusal(unit_path) not in result.stdout + assert "+++ " not in result.stdout, "nothing differs, so nothing is printed about differences" + assert "reusing the existing token" in result.stdout, "the token idiom is unchanged" + assert unit_path.read_text() == installed + assert _systemctl_calls(call_log).count("systemctl enable --now dashboards-agent") == 2 + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_a_re_run_that_differs_refuses_without_force_and_applies_with_it(tmp_path: Path) -> None: + """The refuse → force → silent sequence on **one** host, in that order. + + Each leg has its own detailed pin beside it; what those cannot show is the transition — a + guard that refuses correctly the first time and then never lets an identical re-run through + silently is the same bug seen from the other side (an operator who has to type `--force` + every deploy stops reading the diff). So: install, hand-edit, re-run (refused, unit left + exactly as edited, no new `systemctl` call), re-run with `--force` (diff first, generated + unit on disk, service re-enabled), then re-run plain once more (silent, unit unmoved). + """ + script, call_log, env = _sandbox_installer(tmp_path) + unit_path = tmp_path / "target" / "dashboards-agent.service" + flags = ["--bind", "10.1.0.126", "--port", "9120", "--as-root", "--caps", KERNEL_CAPS] + + first = _install(script, env, *flags) + assert first.returncode == 0, f"first install failed:\n{first.stdout}\n{first.stderr}" + edited = _hand_edit(unit_path) + reloads = len(_systemctl_calls(call_log)) + + refused = _install(script, env, *flags) + assert refused.returncode == 1, "leg 1: a differing re-run refuses" + assert _refusal(unit_path) in refused.stdout and "+++ " in refused.stdout + assert unit_path.read_text() == edited, "leg 1: the hand edit survives" + assert len(_systemctl_calls(call_log)) == reloads, "leg 1: nothing was reloaded or re-enabled" + + applied = _install(script, env, *flags, "--force") + assert applied.returncode == 0, f"leg 2: --force failed:\n{applied.stdout}\n{applied.stderr}" + assert "+++ " in applied.stdout, "leg 2: the diff is printed even on a yes" + assert unit_path.read_text() != edited, "leg 2: the generated unit replaced the hand edit" + assert "RestartSec=5" in unit_path.read_text() + generated = unit_path.read_text() + # three calls per applied install: daemon-reload, enable --now, and the is-active check + assert len(_systemctl_calls(call_log)) == reloads + 3, "leg 2: reload + enable, as a normal install" + + settled = _install(script, env, *flags) + assert settled.returncode == 0, f"leg 3: a silent re-run failed:\n{settled.stdout}\n{settled.stderr}" + assert "refusing to overwrite" not in settled.stdout and "+++ " not in settled.stdout + assert "reusing the existing token" in settled.stdout + assert unit_path.read_text() == generated, "leg 3: the silent path leaves the applied unit in place" + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_a_fresh_install_never_meets_the_guard(tmp_path: Path) -> None: + """No installed unit → no comparison, no output about one, and `--force` changes nothing. + + The guard is about a *re*-run. A first install must stay exactly the run it was before this + task, with or without `--force` — hence the two sandboxes writing byte-identical units. + """ + plain_script, _plain_log, plain_env = _sandbox_installer(tmp_path / "plain") + forced_script, _forced_log, forced_env = _sandbox_installer(tmp_path / "forced") + + plain = _install(plain_script, plain_env, "--bind", "10.0.0.5", "--port", "9120") + forced = _install(forced_script, forced_env, "--bind", "10.0.0.5", "--port", "9120", "--force") + assert plain.returncode == 0, f"a first install failed:\n{plain.stdout}\n{plain.stderr}" + assert forced.returncode == 0, f"--force broke a first install:\n{forced.stdout}\n{forced.stderr}" + for stdout in (plain.stdout, forced.stdout): + assert "refusing to overwrite" not in stdout + assert "+++ " not in stdout + + def rendered(where: str) -> str: + """The unit that sandbox wrote, with its own sandbox root name blanked out. + + The two runs live in different directories, so the absolute paths in `ExecStart` and + `--token-file` differ while every *decision* in the unit is the same — and the decision + is what `--force` must not touch. + """ + root = tmp_path / where + return ( + (root / "target" / "dashboards-agent.service") + .read_text() + .replace(str(root), "") + ) + + assert rendered("plain") == rendered("forced"), "--force writes the unit the default install writes" + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_the_kernel_gate_reproduction_refuses_once_and_then_holds(tmp_path: Path) -> None: + """The `driveripper` case end to end: a token-mode unit is *not* the kernel-gate unit. + + Switching a host to `--as-root --caps` is a real change to a real unit, so it asks first, + applies with `--force`, and is silent afterwards — the flag line in `--help` is a + reproduction command only if running it twice lands the same unit and says nothing twice. + """ + script, _call_log, env = _sandbox_installer(tmp_path) + unit_path = tmp_path / "target" / "dashboards-agent.service" + kernel_gate = ["--bind", "10.1.0.126", "--port", "9120", "--as-root", "--caps", KERNEL_CAPS] + _install(script, env, "--bind", "10.1.0.126", "--port", "9120") + token_mode = unit_path.read_text() + + asked = _install(script, env, *kernel_gate) + assert asked.returncode == 1, "a flag change to an installed unit is a change, so it asks" + assert _refusal(unit_path) in asked.stdout + assert "-User=dashboards-agent" in asked.stdout and "+User=root" in asked.stdout + assert "+CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN" in asked.stdout + assert unit_path.read_text() == token_mode, "the question must not also be the answer" + + applied = _install(script, env, *kernel_gate, "--force") + assert applied.returncode == 0, f"--force failed:\n{applied.stdout}\n{applied.stderr}" + applied_unit = unit_path.read_text() + assert _directive(applied_unit, "User") == ["User=root"] + assert _directive(applied_unit, "CapabilityBoundingSet") == [ + "CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN" + ] + + again = _install(script, env, *kernel_gate, "--force") + assert again.returncode == 0, f"the same command twice failed:\n{again.stdout}\n{again.stderr}" + assert "+++ " not in again.stdout, "once applied, the same flags are the zero-noise path" + assert unit_path.read_text() == applied_unit + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root") +def test_uninstall_is_still_deliberate_and_never_asks(tmp_path: Path) -> None: + """The guard belongs to `do_install`: `--uninstall` removes a hand-edited unit without one. + + A guard on the teardown path would be a guard that keeps a unit nobody wants, and + `uninstall()` does not render a unit at all. + """ + script, call_log, env = _sandbox_installer(tmp_path) + unit_path = tmp_path / "target" / "dashboards-agent.service" + _install(script, env, "--bind", "10.0.0.5", "--port", "9120") + _hand_edit(unit_path) + + result = _install(script, env, "--uninstall") + assert result.returncode == 0, f"uninstall refused:\n{result.stdout}\n{result.stderr}" + assert "refusing to overwrite" not in result.stdout + assert not unit_path.exists(), "uninstall still removes the unit, edited or not" + assert "systemctl disable --now dashboards-agent" in _systemctl_calls(call_log)[-3:] + + +def test_the_guard_sits_between_the_render_and_the_unit_write() -> None: + """Text pin for the ordering the behaviour depends on. + + The comparison must read the *rendered* unit (every flag, bundle and capability decision + applied) and must come before the write, the `daemon-reload` and the `enable`; a `cat > + "$UNIT" < "$TMP_UNIT" < "$UNIT"') + reload = body.index("systemctl daemon-reload") + enable = body.index('systemctl enable --now "$SERVICE"') + assert render < guard < write < reload < enable + assert 'cat > "$UNIT" <<' not in text, "the template may only write $UNIT after comparing it" + assert 'if [ "$FORCE" -eq 1 ]; then' in text, "--force is the guard's own opt-in branch" + assert "rm -f \"$TMP_UNIT\"" in text, "the rendered temp file is cleaned up on every path" + assert 'mktemp "${TMPDIR:-/tmp}/dashboards-agent-unit.XXXXXX"' in text, ( + "the diff's +++ side must name the generated unit, not an anonymous temp file" + ) + + +#: The `--force` help line's sentence (phase 17 completion criterion: the flags are learnable +#: from `--help`, and the diff-first promise is the reason an operator trusts `--force`). +FORCE_HELP_SENTENCE = "overwrite an existing unit even if the generated unit differs" + + +def test_help_documents_force_and_keeps_the_flag_block_aligned() -> None: + stdout = _run([BASH, str(INSTALLER), "--help"]).stdout + lines = [line for line in stdout.splitlines() if FORCE_HELP_SENTENCE in line] + assert len(lines) == 1, f"exactly one --force flag line is expected, got {lines}" + assert lines[0].lstrip().startswith("--force") + assert "(prints the" in lines[0] and "diff first)" in stdout + # One column for every flag's description: the block is a table, and `--force` landing at + # its own column is how `--help` stays readable as a reference rather than a paragraph. + as_root = next(line for line in stdout.splitlines() if line.lstrip().startswith("--as-root")) + assert lines[0].index(FORCE_HELP_SENTENCE) == as_root.index("run the service as root") + + # --- the certificate bundle (phase 15 task 06) -------------------------------------------- #: Where the installed bundle lives — one constant in the script, pinned here so a rename