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
+162 -30
View File
@@ -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 <addr> --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" <<UNIT_FILE
# The kernel-gate variant (--as-root / --caps). Default: the dedicated user and an empty
# CapabilityBoundingSet, i.e. the capability-less unit this script has always written.
UNIT_USER="$AGENT_USER"
if [ "$AS_ROOT" -eq 1 ]; then
UNIT_USER="root"
fi
# systemd wants the capabilities space-separated; the flag takes the comma-separated form
# that reads like a list on a command line. Empty entries are dropped so `A,,B` cannot put
# a double space in the unit file.
CAPS_LINE=""
set -f # a capability name is a word, never a glob: keep `*` out of the unit file
for _cap in $(printf '%s' "$CAPS" | tr ',' ' '); do
CAPS_LINE="${CAPS_LINE:+$CAPS_LINE }$_cap"
done
set +f
if [ "$AS_ROOT" -eq 1 ] && [ -z "$CAPS_LINE" ]; then
# Not a refusal: a capability-less root is a legitimate if unusual choice, and the operator
# may have a reason this script cannot see. The note is the reminder gap 10 paid for — the
# first root-user attempt on the kernel-7.2 host still read n/a on all 16 drives, and this
# empty bounding set was the only thing that had changed.
echo "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"
fi
# The unit is rendered to a temp file rather than straight to $UNIT so the guard below can
# compare the guard's input with what would actually be written — byte for byte, with every
# flag, bundle and capability decision already applied, not a model of them. The prefix is so
# the +++ header of the diff the guard prints reads as the generated unit and not as an
# anonymous /tmp file.
TMP_UNIT="$(mktemp "${TMPDIR:-/tmp}/dashboards-agent-unit.XXXXXX")"
cat > "$TMP_UNIT" <<UNIT_FILE
[Unit]
Description=Homelab host agent (read-only system metrics)
After=network-online.target
@@ -335,7 +442,7 @@ Wants=network-online.target
[Service]
Type=simple
User=$AGENT_USER
User=$UNIT_USER
ExecStart=/usr/bin/env python3 $AGENT_TARGET --host $BIND --port $PORT --token-file ${TOKEN_FILE}${TLS_EXEC_ARGS}
Restart=on-failure
RestartSec=5
@@ -343,29 +450,26 @@ Environment=PYTHONUNBUFFERED=1
SyslogIdentifier=$SERVICE
$TLS_UNIT_LINES
# The agent reads /proc, /sys and statvfs() and writes nothing, so everything writable can
# be taken away. smartctl is the one thing that needs a real /dev node, so instead of
# PrivateDevices the unit closes all devices and reopens only the disk nodes, read/write
# at the cgroup layer while the code (and smartctl -i -A -H) stays read-only by design.
# be taken away. smartctl is the one thing that needs a real /dev node, and it gets one the
# way every SMART monitor on Linux does: DAC — the installer adds the service user to the
# disk group — with SELinux still in front of it. The unit therefore carries no device
# filter of its own, and 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.
# No filter is the fix, not an omission. systemd resolves a device-filter glob by name
# against /proc/devices (fnmatch in bpf_devices_allow_list_major), and kernel 7.2 lists NVMe
# there as blkext (major 259) rather than nvme, so the old /dev/nvme* glob matched nothing —
# and a strict device policy denies every node the list failed to match, so on driveripper
# that unit locked the whole device namespace, root included
# (data/logs/NOTES-driveripper-agent.md). A glob that silently matches nothing is the failure
# mode; having no filter has none, and it is what the live hosts run today.
#
# PrivateDevices=false is the systemd default and is written out for one reason: a private
# /dev with no device list is what dies at spawn with "Failed at step STDIN" (status 208),
# because systemd opens /dev/null for the service stdin before it ever execs python — that is
# how the first phase-10 install failed on a live host running systemd 257.
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
@@ -376,13 +480,41 @@ RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
CapabilityBoundingSet=
# --caps: kernel 7.2 gates unprivileged smartctl passthrough (NVMe admin log: CAP_SYS_ADMIN;
# SG_IO allow-list: CAP_SYS_RAWIO) and those checks look at capabilities, not uid — so a root
# service needs them named here, because an empty bounding set bounds even root to CapEff 0.
CapabilityBoundingSet=$CAPS_LINE
SystemCallFilter=@system-service
[Install]
WantedBy=multi-user.target
UNIT_FILE
# No silent clobber (phase 17 task 03). $UNIT is the operator's file as much as this
# template's output: it gets edited on the host to fix something the template did not know
# (driveripper: User=root plus the two capabilities the kernel-7.2 passthrough gate needs),
# and an unconditional rewrite would undo that fix while looking like a routine redeploy. So
# a re-run whose unit differs stops, with the diff on stdout so the difference is readable
# rather than guessed, and --force is the opt-in to apply it anyway. The diff is printed on
# both paths and on stdout, not stderr: it is the payload of the answer, and splitting it
# across two streams interleaves it with the sentence above it. The writes before this point
# are the idempotent ones — the service user, its disk-group membership, the agent file, and
# the token/bundle reuse, which re-reads the existing secret instead of replacing it — and a
# refusal does nothing after it: no unit write, no daemon-reload, no enable, so the unit on
# disk and the service running against it stay exactly as they were.
if [ -f "$UNIT" ] && ! cmp -s "$UNIT" "$TMP_UNIT"; then
if [ "$FORCE" -eq 1 ]; then
diff -u "$UNIT" "$TMP_UNIT" || true
else
echo "refusing to overwrite $UNIT (generated unit differs); run with --force to apply:"
diff -u "$UNIT" "$TMP_UNIT" || true
rm -f "$TMP_UNIT"
exit 1
fi
fi
cat "$TMP_UNIT" > "$UNIT"
rm -f "$TMP_UNIT"
systemctl daemon-reload
systemctl enable --now "$SERVICE"
sleep 1