phase: 17_installer_device_filter

**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/`.
This commit is contained in:
2026-10-20 13:48:40 -04:00
parent 7e367a9014
commit b02c13f7d3
30 changed files with 1502 additions and 81 deletions
+50 -7
View File
@@ -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 <host>-ops:/tmp/
ssh <host>-ops sudo /tmp/install-agent.sh --cert-bundle /tmp/agent-deskwork.bundle
ssh <host>-ops sudo /tmp/install-agent.sh --cert-bundle /tmp/agent-deskwork.bundle --force
ssh <host>-ops sudo systemctl restart dashboards-agent
ssh <host>-ops sudo /tmp/install-agent.sh --status # https, with the CA and no client cert
# 3. point that host's HOST_TARGETS entry at https with NO userinfo token — the certificate is
@@ -207,6 +242,12 @@ ssh <host>-ops sudo /tmp/install-agent.sh --status # https, with the CA and no
export HOST_TARGETS="deskwork=https://<addr>:9120 …"
```
`--force` in step 2 is not a shortcut: installing a bundle *is* a change to that host's unit (the
`--cert-bundle` flag and its `RuntimeDirectory`), so without it the run prints the diff and stops
by design — read it, then re-run with the flag. The same rule covers every change the installer
applies, which is why the `--status` line above comes *after* the restart: the probe reads the
unit file to decide between http and https, so pointing it at a process that has not been
restarted yet reports "no answer" for a bundle that installed perfectly.
`install-agent.sh --bind … --port 9120` prints the `https://<addr>:9120` line for a bundle install,
so step 3 is a copy rather than a guess. A host left on `http://` keeps working over its bearer
token — the rollout is per host, and one revoked or expired certificate never blinks the others.
@@ -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`,