Add AGENTS.md and align the docs with the code

The brief asked for an AGENTS.md capturing the overall plan and it never
landed, while two files already pointed readers at it. It now holds the
invariants (read-only by construction, never invent a number,
unconfigured is not broken, no CDN, no guessed metric names), the locked
decisions with their reasons, the verified fleet facts, the commands, and
the four known gaps.

Also corrected the docs that had drifted:
- upsd config lives in /etc/ups on driveripper, not /etc/nut, and the
  real blocker is that upsd binds nothing on 3493 — with the sudo rules
  still needed written down in deploy/nut-sudoers.
- .env.example told operators to set `LISTEN 0.0.0.0` and `actions = -`;
  both are worse than needed. Loopback + ssh forward, and per-command
  instcmds instead of granting load.off.
- nut.py claimed the command path is gated three times and that the
  confirmation asks for the UPS name; it is gated four times and asks
  for DANGER.
- .gitignore described a tracked .agents/ roadmap that does not exist.

LLM-Generated: true
This commit is contained in:
2026-10-16 18:54:35 -04:00
parent 426682a999
commit 87042f8855
8 changed files with 347 additions and 17 deletions
+11 -1
View File
@@ -4,14 +4,24 @@
# set -a; . .env.dev; set +a # then run the dashboard # set -a; . .env.dev; set +a # then run the dashboard
# #
# DEV_TUNNELS entries are <local_port>:<ssh_host>:<remote_agent_port>. # DEV_TUNNELS entries are <local_port>:<ssh_host>:<remote_agent_port>.
# DEV_FORWARDS entries are <local_port>:<ssh_host>:<remote_port> for a service already
# running there — upsd is the usual one, so the UPS page can read a real battery without
# 3493 being reachable from anywhere but this laptop.
# scripts/dev-tunnels.sh forwards each one to 127.0.0.1:<local_port> so the dashboard on # scripts/dev-tunnels.sh forwards each one to 127.0.0.1:<local_port> so the dashboard on
# the laptop reads live fleet data with nothing listening on the hosts. # the laptop reads live fleet data with nothing listening on the hosts.
export DEV_TUNNELS="9121:driveripper-ops:9120 9122:deskwork-ops:9120 9123:gateway-ops:9120" export DEV_TUNNELS="9121:driveripper-ops:9120 9122:deskwork-ops:9120 9123:gateway-ops:9120"
export DEV_FORWARDS="3494:driveripper-ops:3493"
# Point the dashboard at those tunnels. # Point the dashboard at those tunnels.
export HOST_TARGETS="driveripper=http://127.0.0.1:9121 deskwork=http://127.0.0.1:9122 gateway=http://127.0.0.1:9123" export HOST_TARGETS="driveripper=http://127.0.0.1:9121 deskwork=http://127.0.0.1:9122 gateway=http://127.0.0.1:9123"
export GUFO_BASE_URL=http://127.0.0.1:8731 export GUFO_BASE_URL=http://127.0.0.1:8731
export VLLM_BASE_URL=http://127.0.0.1:8000 export VLLM_BASE_URL=http://127.0.0.1:8000
# The UPS source: the forwarded upsd above. NUT_PORT is the LOCAL port (3494), not 3493.
# Leave NUT_SIMULATE=1 only while upsd is unreachable — it labels every panel SIMULATED.
export NUT_HOST=127.0.0.1 export NUT_HOST=127.0.0.1
export NUT_PORT=3493 export NUT_PORT=3494
export NUT_USER=dashboards
export NUT_PASSWORD=replace-me-on-the-host
export NUT_ALLOW_ACTIONS=1
# export NUT_SIMULATE=1
export DASH_PORT=8090 export DASH_PORT=8090
+8 -2
View File
@@ -37,10 +37,16 @@ RATE_WINDOW_SECONDS=10
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# UPS dashboard: the NUT data server (upsd) # UPS dashboard: the NUT data server (upsd)
# Needs upsd reachable: `LISTEN 0.0.0.0` + PORT 3493 in upsd.conf, and in upsd.users # upsd must be reachable from this process. Keep it off the public interface: bind it to
# loopback (`LISTEN 127.0.0.1` in upsd.conf) and reach it with an ssh forward or a tailnet
# address, or bind a LAN address only if a firewall rule limits it to this host.
#
# Reads need no user in stock NUT. For the battery-test buttons add one, scoped as narrowly
# as the buttons need — `instcmds` beats `actions = -`, which grants load.off and shutdown.*
# too:
# [dashboards] # [dashboards]
# password = "..." # password = "..."
# actions = none # add `actions = -` (or specific cmds) for test buttons # instcmds = test.battery.start.quick test.battery.start.deep test.battery.stop
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
NUT_HOST= NUT_HOST=
NUT_PORT=3493 NUT_PORT=3493
+7 -3
View File
@@ -31,6 +31,10 @@ agent/*.tar.gz
.idea/ .idea/
.DS_Store .DS_Store
# Agent/pipeline scratch (the .agents/ roadmap itself IS tracked) # Agent/pipeline scratch. This project's plan lives in AGENTS.md (tracked) — nothing under
.agents/phase-sessions/ # .agents/ is anything another checkout needs, so all of it stays out.
.agents/pipeline.log .agents/
# The original brief for this project. Kept in the checkout as local context, not history:
# README.md and AGENTS.md carry the decisions that came out of it.
original_prompt.txt
+227
View File
@@ -0,0 +1,227 @@
# AGENTS.md — homelab dashboards
The plan, the rules, and the environment facts an agent needs to work on this repo without
re-deriving them. `README.md` is the operator-facing how-to; this file is the why, the
invariants, and the state of the fleet.
## What this is
Three dashboards for one homelab, behind one domain, in one process, backed by one SQLite
file — plus an overview page that says which of the three you need:
| Route | Question it answers | Source |
| --------- | ------------------------------------------------------------------------------ | ------------------------------------------------- |
| `/` | Is anything wrong, and where do I look first? | all three, in one request |
| `/system` | What are my machines doing — CPU, memory, disk, GPU, network, and the history | `agent/host_agent.py`, our own stdlib agent/host |
| `/llm` | Tokens/sec, active sessions, queue depth, KV pressure, latency percentiles | gufo (llama.cpp family) + vLLM `/metrics` |
| `/ups` | Battery health, runtime left, power events, battery-test buttons | NUT `upsd` on 3493 (our own wire-protocol client) |
History is a first-class feature, not a sparkline afterthought: every number worth graphing
is stored, rolled up (raw → 1m → 1h), and served from `/api/*/history`.
## Non-negotiable rules
These are the things that make the numbers trustworthy. Breaking one is a bug even if the
page still renders.
1. **Read-only by construction, with exactly one exception.** Nothing in this app writes to
an upstream except `POST /api/ups/command`. That endpoint is gated four times
(`NUT_ALLOW_ACTIONS`, not `DASH_READ_ONLY`, the upsd.users account actually having the
command, and a UI confirmation — `DANGER` for anything that can cut power) and the server
re-checks every gate on every request. Hiding a button is not a safety control.
Commands that would cut output are refused outright while the UPS is already on battery.
2. **Never invent a number.** A value the upstream does not publish renders as `n/a`, never
as `0`. Every derived value carries the `source` string it came from and `derived: true`
so the UI can print *how* it was obtained (`computed:Δtokens/Δt`, `Δ10.0s`). Aggregating
two different series into one rate is the failure mode this rule exists to catch — the
rate estimator is keyed by the *resolved* upstream series name for exactly that reason.
3. **Unconfigured is not broken.** A source with no env config answers
`404 {"configured": false}` **without dialling**, and the page says "not configured".
A red panel for a server the operator never installed destroys the signal from the
panels that matter.
4. **One dead source must not blink another.** Each source is its own asyncio task with its
own timeout and health record (`poller.py`). A failed poll keeps the last good payload and
marks it stale in the UI instead of zeroing it. Transitions write events; repeated failure
writes one line, not 500.
5. **No CDN, no build step, no internet at runtime.** Every asset is served by this app;
charts are hand-rolled SVG (`web/static/charts.js`). The engines live on a network with
no outbound access — a CDN reference is a request that never comes back.
6. **Never leak a credential.** Target URLs may carry a userinfo token; `/api/config`
exposes them only through `Target.display_url`, which strips userinfo. No token,
password, or personal hostname goes into tracked files, fixtures, or the browser.
7. **Verify against the deployed upstream; never guess a metric name.** Field maps in
`sources/llm.py` and `sources/nut.py` were captured from the running engines and NUT
2.8.5, and are pinned by fixtures in `tests/fixtures/`. Re-capture with
`scripts/capture-fixtures.sh` (and `scripts/capture-node-exporter.sh` when comparing
exporters) rather than trusting a README or a memory of an upstream.
8. **Keep dependencies current on purpose.** LLM-written version floors are usually stale.
Check after touching dependencies: `uv pip list --outdated` must come back empty.
Python is 3.14 (`requires-python >=3.14`, ruff `py314`, pyright `3.14`, Containerfile
`python3.14`) — do not lower these to make something else work.
9. **SQLite owns the history and must stay bounded.** Writes go through `store.py`; new
metrics need a rollup path and a retention story, or a year of charts becomes a disk
emergency. Put the DB on a volume in deployment.
10. **The dev server stays up while work happens** (`./scripts/dev-server.sh restart`,
http://127.0.0.1:8090), and UI changes are verified in a real browser with a screenshot
before they are called done. `data/logs/*.png` is where those land.
## Architecture
```
agent/host_agent.py (each host) gufo / vLLM upsd (NUT)
│ GET /snapshot │ GET /metrics │ LIST UPS / LIST VAR / LIST CMD
▼ ▼ ▼
collectors.py ── fetch, one UpstreamError type, no writes except run_ups_command ──┐
│ parse + Δcounter/Δt │
▼ ▼
sources/host.py · sources/llm.py · sources/nut.py poller.py (task per source)
│ Result(payload, samples, events) │
▼ ▼
store.py ── SQLite (WAL): sample(source, host, metric, res, ts) + event ──► api.py ──► web/
```
Responsibilities, one line each:
- `agent/host_agent.py` — stdlib-only (3.10+), reads `/proc`, `/sys/block`, `/sys/class/drm`,
`statvfs`; serves `GET /snapshot` + `GET /info` + `GET /health`. Refuses to bind
off-loopback without a token (exit 2). Counters stay counters: the dashboard computes
rates so the agent never remembers who polled it last.
- `config.py` — env only, no config file format, no CLI flags. `.env` is a *seed*; a real
env var always wins. Defaults are safe except the sources, which default to unset.
- `poller.py` — job loop, health records, stale/last-good semantics, transition events.
- `collectors.py` — the fetch half. `NutMonitor` owns per-UPS transition bookkeeping.
- `sources/host.py` — snapshot → payload + samples; reboot detection; GPU series per card
(`gpu.busy_pct@<index>`).
- `sources/llm.py` — gufo + vLLM field maps (`GUFO_SERIES`, `VLLM_SERIES`,
`VLLM_HISTOGRAMS`), measured Δ rates, prefill speed as
`mean(prompt tokens)/mean(prefill time)`, session list from gufo `/slots`.
- `sources/nut.py` — upsd client, `UpsReading`, `VAR_MAP`, `assess()` (explicit checks, not
a mystery score), `COMMANDS` catalog with risk tiers, `available_commands()` filtering,
kelvin-vs-celsius normalisation.
- `sources/nut_sim.py` — a **labelled** simulator so the UPS page is developable without a
battery. It must never be the default, and every payload it produces says `simulated`.
- `prom.py` — Prometheus text-exposition parser (in-house; only for engines we don't own).
- `rates.py` — Δ/window estimator, keyed by resolved series name.
- `store.py` — `sample(source,host,metric,res,ts)` + `event` + `meta`, rollups, pruning.
- `api.py` — GETs everywhere plus the one POST. `/api/config` is redaction boundary.
- `app.py` — assembly; `create_app()` takes injectable settings/store so tests build an app
with an in-memory DB and no targets.
- `web/` — four static pages, one stylesheet, `charts.js`. No framework.
Metric naming: dotted (`cpu.usage_pct`, `mem.used_pct`, `disk.used_pct@/srv`,
`gpu.busy_pct@0`, `llm.decode_tps`, `ups.charge_pct`), `@stream` for per-device series.
That scheme is the history API's key — treat it as a public interface.
## The fleet (real environment, verified 2026-10)
| Host | ssh alias | What it gives |
| ----------------- | ------------------ | -------------------------------------------------------------------- |
| `driveripper` | `driveripper-ops` | Threadripper 24c/48t + Radeon AI PRO R9700, vLLM `:8000`, **upsd**, the dashboards |
| `deskwork` | `deskwork-ops` | Strix Halo (Radeon 8060S iGPU), gufo `:8731` |
| `gateway` | `gateway-ops` | router, no GPU |
- Agents run from `~/lab-agent/host_agent.py` on each host, forwarded to `127.0.0.1:9121/9122/9123`
by `scripts/dev-tunnels.sh` (`DEV_TUNNELS`). Nothing listens on the hosts in dev.
- Engines are reached directly: gufo `deskwork.reeselink.com:8731`, vLLM `driveripper.reeselink.com:8000`.
- NUT 2.8.5 on driveripper: **configs live in `/etc/ups/`, not `/etc/nut/`**. `nut-server`
is active and `nut-driver@apc` + `nut-driver@cyberpower` are both running, so `ups.conf`
already exports two driver instances; a CyberPower PR1500LCDRT2U is attached on USB
(`0764:0601`). **`upsd` does not bind 3493**, so reads fail with *Connection refused* and
the dashboard falls back to the labelled simulator. A restart of `nut-server` does not fix
it. See *Known gaps* — this needs root logs and a config read.
- Host access is via ssh keys as `ops`. Passwordless sudo exists but is command-scoped; the
set we rely on is documented in `deploy/nut-sudoers` (additions are listed there for the
work that is still blocked).
## Commands
```bash
uv sync --python 3.14 # deps + editable install
cp .env.example .env # production-shaped config
cp .env.dev.example .env.dev # dev-only: ssh tunnels + live targets
./scripts/dev-tunnels.sh start # agents (9121-9123) + DEV_FORWARDS (upsd 3494)
./scripts/dev-server.sh restart # keep http://127.0.0.1:8090 up
./scripts/dev-server.sh status # per-source ok/DOWN
./scripts/dev-server.sh logs
uv run pytest # hermetic: 258 tests, no network
uv run pytest --cov # coverage gate (~94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
uv run ruff check src tests agent && uv run pyright
./scripts/capture-fixtures.sh # re-record ground truth
```
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, `--token-file`, `ProtectSystem=strict`).
The quadlet unit + env template + nginx/SNI live in the deployment repo, like every other
service here.
## Testing policy
**Real captures for shape, fakes for behaviour, `live` for drift.**
- Anything that must match an upstream — `/proc` layouts, vLLM series names, NUT framing —
is asserted against a verbatim capture in `tests/fixtures/`. If the schema drifts, a
fixture test fails before a dashboard does.
- Anything that needs a failure mode — upsd refusing a command, an engine 500ing, a host
rebooting mid-poll, a counter reset — is a fake in `tests/conftest.py`.
- `live` (`tests/test_live_fleet.py`) re-runs the production code paths against the real
fleet, engines, and upsd. Skipped unless `--live`; each test skips when its source is
unset, so partial fleet availability is normal. **Live tests must not write** — a battery
test on hardware something is plugged into is not a test.
- The agent's real-`/proc` test (`tests/test_agent.py`) touches only the box running pytest,
so it stays in the default run and is *not* marked `live`.
- Coverage gate: 80% is the floor, ~94% is where we are; keep it there. API tests use
`create_app()` with an in-memory DB, never a real target.
## UI conventions
Hand-rolled SVG (`charts.js`), one stylesheet, dark/light toggle, `Δ<window>` labels on
measured rates, `n/a` for the unknown, `SIMULATED` badges on modelled data, per-source status
dots in the header, window chips (1h…90d) on history pages. A panel that cannot get data says
why in the panel — the operator should never have to open devtools to learn that a source is
unconfigured. Escape anything upstream-provided through `DashCharts.escapeHtml`.
## Known gaps / next work
1. **Real UPS data (blocking issue, host-side).** `upsd` on driveripper runs but binds
nothing. Needed: root journal for `nut-server` (`journalctl -u nut-server`), a read of
`/etc/ups/{upsd.conf,upsd.users,ups.conf}`, then `LISTEN 127.0.0.1` (plus a `dashboards`
user with scoped `instcmds`). Then: `DEV_FORWARDS="3494:driveripper-ops:3493"`,
`NUT_HOST=127.0.0.1 NUT_PORT=3494`, unset `NUT_SIMULATE`, and re-verify `/ups` against a
real battery. Until then every UPS number is modelled, and the page says so.
2. **GPU coverage is AMD only.** `host_agent.gpus()` reads amdgpu DRM sysfs
(`gpu_busy_percent`, `mem_info_vram_*`, hwmon temps, clocks). That covers the current
fleet (R9700 + Strix Halo iGPU; gateway has none). An NVIDIA host would report no GPU at
all — needs a `nvidia-smi`/NVML or DCGM path, and a fixture from a real card before the
field map is written.
3. **No remote yet.** Two commits, no `origin` — the user is providing it.
4. **UPS history depth.** Events and the numeric series are stored; long-window battery
health (capacity fade over months) has the rollups but no dedicated view.
## Locked decisions (and why)
- **SQLite, not Prometheus/Grafana.** Handful of boxes, 1–10s resolution, one file the
container owns. Rollups keep a year of charts at a few hundred MB.
- **Our own stdlib host agent, not node_exporter.** One JSON snapshot with exactly the
fields the page needs (GPU model strings, per-mount usage, temperature labels) versus
~1500 series and a re-labeling layer; no dependency to install, `ProtectSystem=strict`
friendly, and read-only by construction. `scripts/capture-node-exporter.sh` remains to
compare ground truth if that is ever revisited.
- **Our own NUT client, not `pynut`/`upsc`.** The protocol is ~10 line commands, the
third-party libs are subprocess-based or stalled, and we need instant commands with
explicit gating.
- **In-house Prometheus parser.** Only two upstreams use the format; keeps `prometheus_client`
out of the dependency set.
- **Port 8090**, inherited from the `llm_monitor` prototype this started from: 8000 is vLLM
and 8731 is gufo in this fleet.
- **`.env` is a seed, never an override** — container/systemd env must win.
## Definition of done (per change)
`uv run pytest` green · coverage not down · `ruff` + `pyright` clean · `uv pip list --outdated`
empty if deps moved · dev server restarted and `/api/health` sane · UI change screenshotted
in a browser against **live** data · fixtures re-captured if an upstream contract changed ·
this file and `README.md` updated when a rule, command, gap, or fleet fact changed.
+34 -7
View File
@@ -12,6 +12,10 @@ Three dashboards, one domain, one process, one SQLite file:
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
runtime; every asset is served by this app. runtime; every asset is served by this app.
Working on this repo? **[`AGENTS.md`](AGENTS.md)** holds the invariants (read-only by
construction, never invent a number, unconfigured ≠ broken), the locked decisions, the real
fleet facts, and the known gaps.
## Quickstart ## Quickstart
```bash ```bash
@@ -33,11 +37,17 @@ for host in ("driveripper-ops", "deskwork-ops", "gateway-ops"):
subprocess.run(["ssh", host, "mkdir -p ~/lab-agent"], check=True) subprocess.run(["ssh", host, "mkdir -p ~/lab-agent"], check=True)
subprocess.run(["scp", "agent/host_agent.py", f"{host}:~/lab-agent/"], check=True) subprocess.run(["scp", "agent/host_agent.py", f"{host}:~/lab-agent/"], check=True)
PY PY
./scripts/dev-tunnels.sh start # 9121/9122/9123 -> each host's agent ./scripts/dev-tunnels.sh start # 9121/9122/9123 -> each host's agent,
# + DEV_FORWARDS -> upsd on 127.0.0.1:3494
set -a; . ./.env.dev; set +a set -a; . ./.env.dev; set +a
uv run dashboards uv run dashboards # foreground
./scripts/dev-server.sh restart # or backgrounded, with a pidfile + logs
``` ```
`DEV_TUNNELS` starts the agent remotely and forwards it; `DEV_FORWARDS` forwards a service
that already listens there (upsd) — that is how the UPS page reads a real battery without
3493 being reachable from anywhere but this laptop.
## The host agent ## The host agent
`agent/host_agent.py` is one file, **stdlib only**, Python 3.10+. It reads `/proc`, `agent/host_agent.py` is one file, **stdlib only**, Python 3.10+. It reads `/proc`,
@@ -115,9 +125,9 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ──
## Testing ## Testing
```bash ```bash
uv run pytest # unit + API tests, fully mocked uv run pytest # unit + API tests, fully mocked, no network
uv run pytest --cov # coverage gate uv run pytest --cov # coverage gate
uv run pytest -m live --live # optional: hit the real fleet/engines uv run pytest -m live --live # optional: real fleet / engines / upsd
uv run ruff check src tests agent && uv run pyright # lint + types uv run ruff check src tests agent && uv run pyright # lint + types
./scripts/capture-fixtures.sh # re-record ground-truth captures ./scripts/capture-fixtures.sh # re-record ground-truth captures
``` ```
@@ -125,18 +135,35 @@ uv run ruff check src tests agent && uv run pyright # lint + types
Test philosophy: **real captures as fixtures, mocks for behaviour.** Anything that must Test philosophy: **real captures as fixtures, mocks for behaviour.** Anything that must
match an upstream (`/proc` layouts, vLLM metric names, NUT protocol framing) is asserted match an upstream (`/proc` layouts, vLLM metric names, NUT protocol framing) is asserted
against a verbatim capture in `tests/fixtures/`; behaviour that needs a failure — upsd against a verbatim capture in `tests/fixtures/`; behaviour that needs a failure — upsd
refusing a command, an engine returning 500, a host rebooting — is a fake. `-m live` refusing a command, an engine returning 500, a host rebooting — is a fake. The `live` tests
opt-in tests are the bridge: they run only when you point them at real hardware. (`tests/test_live_fleet.py`) are the bridge: they SKIP unless you pass `--live`, each one
also skips when its source is unset, they drive the production code paths (`collectors.*`,
`NutMonitor.collect`) against whatever hardware is reachable, and they never write — a
battery test on live hardware is not a unit test.
## Battery-test buttons ## Battery-test buttons
Instant commands are the only mutating endpoint in the application (`POST /api/ups/command`) Instant commands are the only mutating endpoint in the application (`POST /api/ups/command`)
and they are gated four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users` and they are gated four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users`
account that actually has `actions`, and a confirmation in the UI — destructive ones 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 (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 gate; hiding a button is not a safety control. Commands that would cut power are refused
outright while the UPS is already on battery. 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`,
not `actions = -`:
```ini
# /etc/ups/upsd.users
[dashboards]
password = "..."
instcmds = test.battery.start.quick test.battery.start.deep test.battery.stop
```
Bind upsd to loopback (`LISTEN 127.0.0.1` in `upsd.conf`) and reach it with an ssh forward
(`DEV_FORWARDS`) or a tailnet address. `NUT_SIMULATE=1` stands in for upsd during
development; everything it produces is badged `SIMULATED`, and it is never the default.
## Deployment ## Deployment
`Containerfile` is a two-stage build: `uv.lock` is resolved in the builder (exactly the `Containerfile` is a two-stage build: `uv.lock` is resolved in the builder (exactly the
+55
View File
@@ -0,0 +1,55 @@
# driveripper-ops sudo rules for the UPS dashboard work
#
# Install as /etc/sudoers.d/dashboards-nut (visudo -cf it first):
#
# sudo visudo -cf - <<'EOF' ... EOF # syntax check
# install -m 0440 deploy/nut-sudoers /tmp/dashboards-nut
# sudo mv /tmp/dashboards-nut /etc/sudoers.d/dashboards-nut
#
# Keep it scoped: paths are absolute (sudoers uses `secure_path`), arguments are wildcards
# only where the command is read-only or idempotent, and nothing here allows a shell,
# `EDITOR=`, `dnf install` of arbitrary code, or `upsd -c` with an attacker-chosen file.
# ---- already present on driveripper (this is what the dashboard setup used) ---------- #
# (root) NOPASSWD: /usr/bin/tee /etc/ups/* # write upsd.conf / upsd.users
# (root) NOPASSWD: /usr/bin/cp /etc/ups/* /home/ops/* # back up a config before editing
# (root) NOPASSWD: /usr/bin/systemctl start|restart|reload|enable *
# (root) NOPASSWD: /usr/bin/udevadm * # USB device discovery/replug
# (root) NOPASSWD: /usr/bin/firewall-cmd * # only if upsd must serve the LAN
# (root) NOPASSWD: /usr/bin/dnf install * # nut, nut-client, nut-dev
# ---- still needed: diagnose why upsd runs but binds nothing -------------------------- #
# Reading is the blocker. `upsd` is active, both drivers are up, and 3493 has no listener,
# but /etc/ups/* is 0640 root:nut and the journal needs group membership:
# $ sudo cat /etc/ups/upsd.conf -> Permission denied
# $ journalctl -u nut-server -n 50 -> No entries (not in systemd-journal/wheel)
# $ upsc -l -> Error: Connection failure: Connection refused
Cmnd_Alias NUT_DIAGNOSE = \
/usr/bin/cat /etc/ups/*, \
/usr/bin/systemctl status *, \
/usr/bin/journalctl -u nut-server *, /usr/bin/journalctl -u nut-driver@*, \
/usr/bin/journalctl -u nut*, \
/usr/sbin/ss -ltnp, \
/usr/sbin/getenforce, \
/usr/bin/ausearch *, /usr/sbin/sealert -l *, \
/usr/sbin/semanage port -l, \
/usr/bin/upsdrvctl status, /usr/bin/upsdrvctl status *, \
/usr/bin/upsc *, \
/usr/bin/lsusb
# ---- still needed: apply the fix and verify ------------------------------------------ #
# `tee /etc/ups/*` already writes configs; these cover the reload path, SELinux booleans,
# and a bounded cleanup of the .bak files `tee`-based edits leave behind.
Cmnd_Alias NUT_APPLY = \
/usr/bin/tee /etc/ups/upsd.conf, /usr/bin/tee /etc/ups/upsd.users, \
/usr/bin/tee /etc/ups/ups.conf, /usr/bin/tee /etc/ups/nut.conf, \
/usr/bin/rm -f /etc/ups/*.bak-*, \
/usr/bin/upsdrvctl restart, /usr/bin/upsdrvctl restart *, \
/usr/sbin/setsebool -P allow_upsd_tcp_server 1, \
/usr/sbin/setsebool -P nis_enabled 0
# NOTE: deliberately NOT allowed, so an agent cannot power-cycle anything by accident:
# /usr/bin/upscmd (any form) — the dashboard's own gated POST is the only command path
# systemctl stop * — stopping nut-server would blind the UPS view
# editors, shells, `sh -c` — no escape hatches
+2 -2
View File
@@ -1,7 +1,7 @@
"""Homelab dashboards: system resources, LLM engines, and UPS battery health. """Homelab dashboards: system resources, LLM engines, and UPS battery health.
One FastAPI app, one domain, one SQLite file. See AGENTS.md for the rules and One FastAPI app, one domain, one SQLite file. See AGENTS.md (repo root) for the rules,
.agent(s)/PLAN.md for the locked decisions. the locked decisions, the fleet facts, and the known gaps.
""" """
from .config import SETTINGS from .config import SETTINGS
+3 -2
View File
@@ -6,8 +6,9 @@ and the protocol is a line protocol with ~10 commands. So: talk to upsd on 3493
Reading is unauthenticated in stock NUT (`LIST UPS` / `LIST VAR`), so the dashboard works Reading is unauthenticated in stock NUT (`LIST UPS` / `LIST VAR`), so the dashboard works
with a read-only upsd. Instant commands (the battery-test buttons) require a user with with a read-only upsd. Instant commands (the battery-test buttons) require a user with
`actions` in upsd.users — that is the ONLY write path in this whole application, and it is `actions` in upsd.users — that is the ONLY write path in this whole application, and it is
gated three times: `NUT_ALLOW_ACTIONS`, not `DASH_READ_ONLY`, and a confirmation in the UI gated four times: `NUT_ALLOW_ACTIONS`, not `DASH_READ_ONLY`, an upsd.users account that
(destructive ones require typing the UPS name). actually has the command, and a confirmation in the UI (the ones that can cut power require
typing `DANGER`; see `ACTION_CATALOG` and `available_commands`).
Verified against NUT 2.8.5 (`/usr/bin/upsc` on driveripper). Protocol shape: Verified against NUT 2.8.5 (`/usr/bin/upsc` on driveripper). Protocol shape: