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:
+162
-30
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user