**Phase 09 — final verification pass (all 4 task files already in `complete/`; nothing re-implemented)**
- Verified `GET /api/disks/snapshot` live: 3 hosts / 18 whole-`/sys/block` rows, `smartctl_available` three-way, per-row `severity`, API-summed `fleet`; 404 `{"configured": false}` proven *without dialling* (spies on `httpx.AsyncClient.get` + `collectors.fetch_snapshot`).
- Verified `/api/disks/history` live (`disk.temp_c@dev`, unknown names → empty series) + `test_store.py`'s 300 s rollup/prune buckets; `/api/metrics?source=disks` answers the `disks` family.
- Browser against live data: 18 drive cards (never a partition/mount), collapsed per-host `Charts` with `aria-label`, 3 `.stale` grids + one `.banner.info` why-line and **0 `.fail` classes / green `disks` dot** → criteria 3 and 4 hold; new shots `data/logs/09-verify-disks-live-dimmed.png`, `09-verify-overview-live.png` (13 total for the phase).
- Real-hardware criterion: read 4 driveripper drives through `ops`' `sudo -n smartctl -i -A -H` and ran the live output through `host_agent.parse_smart_output` → `sources/disks.parse_disks_snapshot`: `sda` 35 °C / 30 423 h, `nvme1n1` 58 % wear, `nvme5n1` 13 uncorrectable → `alarm_disks: 1`, temps graded `warn`; consistent with the 16 committed fixtures.
- `bash .agents/validate.sh` → **EXIT=0**; `uv run pytest --cov` → **769 passed, 11 skipped, coverage 98.33%** (floor 94); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean; hygiene + dependency advisories clean. `uv run pytest -m live --live` → 10 passed, 1 skipped (agents still predate the SMART pass). Dev server healthy: `/api/health` `ok`, 9/9 sources.
- Docs fixed: AGENTS.md test count 698→769 and coverage 98.2→98.33 %; `disks` added to the event dashboard-name list and to the "every feed renders `Dash.eventText`" sentence; README `POLL_MS_*` row now states `/disks`' deliberate fixed 30 s; new Known gap 4 records the dev-DB `database is locked` writes (reported/recovered per invariant 4, ~590 MB DB / ~70 MB WAL, not reproduced by a controlled 25 s external read txn).
- Deviations: **no live-infra change** — granting the agent user the `disk` group and redeploying `host_agent.py` on driveripper/deskwork stay the documented paired operator action (redeploying without the grant yields 16 permission-denied lines; a sudo-spawning agent would break the confirmed "direct read, no sudo" assumption). Store-lock issue left to its own phase rather than patched speculatively.
- Next pending phase: `10_dev_direct_dial`.
208 lines
7.1 KiB
Bash
Executable File
208 lines
7.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Install agent/host_agent.py as a systemd service on a monitored host.
|
|
#
|
|
# sudo ./deploy/install-agent.sh # loopback only (ssh tunnel in)
|
|
# sudo ./deploy/install-agent.sh --bind 10.0.0.5 # serve the LAN, token required
|
|
# sudo ./deploy/install-agent.sh --status
|
|
# sudo ./deploy/install-agent.sh --uninstall
|
|
#
|
|
# The token is stored in a root-owned 0600 file and passed to the agent as --token-file,
|
|
# never on the command line: argv is world-readable through `ps`, and this token guards
|
|
# hostname, mounts, disk models, GPU model and process names. Re-running the installer
|
|
# reuses the existing token — use --rotate-token when the old one leaked.
|
|
#
|
|
# 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.
|
|
#
|
|
# 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.
|
|
set -euo pipefail
|
|
|
|
SERVICE="dashboards-agent"
|
|
AGENT_USER="dashboards-agent"
|
|
AGENT_DIR="/usr/local/lib/dashboards-agent"
|
|
AGENT_TARGET="$AGENT_DIR/host_agent.py"
|
|
TOKEN_FILE="/etc/dashboards-agent.token"
|
|
UNIT="/etc/systemd/system/$SERVICE.service"
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
AGENT_SOURCE="$SCRIPT_DIR/../agent/host_agent.py"
|
|
|
|
BIND="127.0.0.1"
|
|
PORT="9120"
|
|
TOKEN="${AGENT_TOKEN:-}"
|
|
ROTATE=0
|
|
MODE="install"
|
|
|
|
usage() {
|
|
# the header comment *is* the help text, so it cannot drift from the flags
|
|
awk 'NR > 1 { if (/^#/) { sub(/^# ?/, ""); print } else { exit } }' "$0"
|
|
exit "${1:-0}"
|
|
}
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--bind) BIND="$2"; shift 2 ;;
|
|
--port) PORT="$2"; shift 2 ;;
|
|
--token) TOKEN="$2"; shift 2 ;;
|
|
--rotate-token) ROTATE=1; shift ;;
|
|
--status) MODE="status"; shift ;;
|
|
--uninstall) MODE="uninstall"; shift ;;
|
|
-h|--help) usage 0 ;;
|
|
*) echo "unknown argument: $1" >&2; usage 2 ;;
|
|
esac
|
|
done
|
|
|
|
need_root() {
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
echo "run as root: sudo $0 $*" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
status() {
|
|
systemctl status "$SERVICE" --no-pager 2>&1 || true
|
|
echo
|
|
if command -v curl >/dev/null 2>&1; then
|
|
printf 'health: '
|
|
curl -s --max-time 4 "http://$BIND:$PORT/health" || echo "(no answer)"
|
|
echo
|
|
fi
|
|
}
|
|
|
|
uninstall() {
|
|
need_root "$0 --uninstall"
|
|
systemctl disable --now "$SERVICE" 2>/dev/null || true
|
|
rm -f "$UNIT" "$TOKEN_FILE" "$AGENT_TARGET"
|
|
rmdir "$AGENT_DIR" 2>/dev/null || true
|
|
systemctl daemon-reload
|
|
echo "removed $SERVICE (the $AGENT_USER user was left in place; remove it with userdel)"
|
|
}
|
|
|
|
new_token() {
|
|
# urlsafe and no padding: the token goes into HOST_TARGETS as URL userinfo
|
|
# (`name=http://TOKEN@host:9120`), where '+' and '=' are annoying to quote.
|
|
python3 -c 'import secrets; print(secrets.token_urlsafe(32).rstrip("="))'
|
|
}
|
|
|
|
install() {
|
|
need_root "$0 --bind $BIND --port $PORT"
|
|
[ -f "$AGENT_SOURCE" ] || { echo "no agent next to this script: $AGENT_SOURCE" >&2; exit 1; }
|
|
if ! command -v python3 >/dev/null 2>&1; then
|
|
echo "python3 is required on the monitored host (3.10 or newer)" >&2
|
|
exit 1
|
|
fi
|
|
# The agent needs no privileges, so give it a user with nothing to lose.
|
|
if ! id "$AGENT_USER" >/dev/null 2>&1; then
|
|
useradd --system --home-dir / --shell /usr/sbin/nologin "$AGENT_USER"
|
|
fi
|
|
|
|
# SMART (phase 09): the usual distro answer for reading drive health is the `disk`
|
|
# group — no sudoers entry, no root. Re-runs are idempotent.
|
|
if getent group disk >/dev/null 2>&1; then
|
|
if id -nG "$AGENT_USER" 2>/dev/null | tr ' ' '\n' | grep -qx disk; then
|
|
echo " $AGENT_USER is already in the disk group (SMART reads enabled)"
|
|
else
|
|
usermod -aG disk "$AGENT_USER"
|
|
echo " added $AGENT_USER to the disk group — raw device reads, which is what smartctl needs"
|
|
fi
|
|
else
|
|
echo " no disk group on this host — SMART data will read null (harmless; see README)"
|
|
fi
|
|
if ! command -v smartctl >/dev/null 2>&1; then
|
|
echo " note: smartctl is not installed — every disk reports smart: null until smartmontools is"
|
|
fi
|
|
|
|
install -d -m 0755 "$AGENT_DIR"
|
|
install -m 0755 "$AGENT_SOURCE" "$AGENT_TARGET"
|
|
|
|
if [ -f "$TOKEN_FILE" ] && [ "$ROTATE" -eq 0 ] && [ -z "$TOKEN" ]; then
|
|
TOKEN="$(cat "$TOKEN_FILE")"
|
|
echo "reusing the existing token in $TOKEN_FILE (--rotate-token for a new one)"
|
|
elif [ -z "$TOKEN" ]; then
|
|
TOKEN="$(new_token)"
|
|
fi
|
|
# 0600 root:root — the service runs as $AGENT_USER, and systemd (PID 1, root) reads the
|
|
# file for it, so the agent process never needs read permission on it itself.
|
|
(umask 0177; printf '%s\n' "$TOKEN" > "$TOKEN_FILE")
|
|
chmod 0600 "$TOKEN_FILE"
|
|
chown root:root "$TOKEN_FILE"
|
|
|
|
cat > "$UNIT" <<UNIT_FILE
|
|
[Unit]
|
|
Description=Homelab host agent (read-only system metrics)
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=$AGENT_USER
|
|
ExecStart=/usr/bin/env python3 $AGENT_TARGET --host $BIND --port $PORT --token-file $TOKEN_FILE
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
Environment=PYTHONUNBUFFERED=1
|
|
SyslogIdentifier=$SERVICE
|
|
# 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.
|
|
NoNewPrivileges=true
|
|
PrivateTmp=true
|
|
PrivateDevices=false
|
|
DevicePolicy=strict
|
|
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
|
|
UNIT_FILE
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable --now "$SERVICE"
|
|
sleep 1
|
|
if systemctl is-active --quiet "$SERVICE"; then
|
|
echo " $SERVICE is running on $BIND:$PORT"
|
|
else
|
|
echo " $SERVICE failed to start — last lines:" >&2
|
|
journalctl -u "$SERVICE" -n 20 --no-pager >&2
|
|
exit 1
|
|
fi
|
|
cat <<NEXT
|
|
|
|
Point the dashboard at it (the token is the URL userinfo, so it never needs a variable of
|
|
its own, and the API strips it before anything reaches a browser):
|
|
|
|
HOST_TARGETS="hostname=http://$TOKEN@$BIND:$PORT"
|
|
|
|
Paste the token now if this is the first install — it is not printed again without
|
|
--rotate-token, and it is only ever readable by root on this box:
|
|
|
|
$TOKEN
|
|
NEXT
|
|
}
|
|
|
|
case "$MODE" in
|
|
install) install ;;
|
|
status) status ;;
|
|
uninstall) uninstall ;;
|
|
esac
|