From 87042f8855642baff6dbccff6a878d25912a774b Mon Sep 17 00:00:00 2001 From: ducoterra Date: Fri, 16 Oct 2026 18:54:35 -0400 Subject: [PATCH] Add AGENTS.md and align the docs with the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.dev.example | 12 +- .env.example | 10 +- .gitignore | 10 +- AGENTS.md | 227 ++++++++++++++++++++++++++++++++++ README.md | 41 ++++-- deploy/nut-sudoers | 55 ++++++++ src/dashboards/__init__.py | 4 +- src/dashboards/sources/nut.py | 5 +- 8 files changed, 347 insertions(+), 17 deletions(-) create mode 100644 AGENTS.md create mode 100644 deploy/nut-sudoers diff --git a/.env.dev.example b/.env.dev.example index fa3b6c7..dbe93f3 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -4,14 +4,24 @@ # set -a; . .env.dev; set +a # then run the dashboard # # DEV_TUNNELS entries are ::. +# DEV_FORWARDS entries are :: 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: so the dashboard on # 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_FORWARDS="3494:driveripper-ops:3493" # 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 GUFO_BASE_URL=http://127.0.0.1:8731 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_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 diff --git a/.env.example b/.env.example index 0356ada..3f0f6a3 100644 --- a/.env.example +++ b/.env.example @@ -37,10 +37,16 @@ RATE_WINDOW_SECONDS=10 # --------------------------------------------------------------------------- # # 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] # 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_PORT=3493 diff --git a/.gitignore b/.gitignore index 6bb79e8..cf3b263 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ agent/*.tar.gz .idea/ .DS_Store -# Agent/pipeline scratch (the .agents/ roadmap itself IS tracked) -.agents/phase-sessions/ -.agents/pipeline.log +# Agent/pipeline scratch. This project's plan lives in AGENTS.md (tracked) — nothing under +# .agents/ is anything another checkout needs, so all of it stays out. +.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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1ffbb7d --- /dev/null +++ b/AGENTS.md @@ -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@`). +- `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, `Δ` 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. diff --git a/README.md b/README.md index 9738106..0bd458a 100644 --- a/README.md +++ b/README.md @@ -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 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 ```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(["scp", "agent/host_agent.py", f"{host}:~/lab-agent/"], check=True) 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 -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 `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 ```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 -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 ./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 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 -refusing a command, an engine returning 500, a host rebooting — is a fake. `-m live` -opt-in tests are the bridge: they run only when you point them at real hardware. +refusing a command, an engine returning 500, a host rebooting — is a fake. The `live` tests +(`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 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` -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 gate; 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`, +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 `Containerfile` is a two-stage build: `uv.lock` is resolved in the builder (exactly the diff --git a/deploy/nut-sudoers b/deploy/nut-sudoers new file mode 100644 index 0000000..1cd08c0 --- /dev/null +++ b/deploy/nut-sudoers @@ -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 diff --git a/src/dashboards/__init__.py b/src/dashboards/__init__.py index 10e6868..46cb783 100644 --- a/src/dashboards/__init__.py +++ b/src/dashboards/__init__.py @@ -1,7 +1,7 @@ """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 -.agent(s)/PLAN.md for the locked decisions. +One FastAPI app, one domain, one SQLite file. See AGENTS.md (repo root) for the rules, +the locked decisions, the fleet facts, and the known gaps. """ from .config import SETTINGS diff --git a/src/dashboards/sources/nut.py b/src/dashboards/sources/nut.py index b1505d2..647c203 100644 --- a/src/dashboards/sources/nut.py +++ b/src/dashboards/sources/nut.py @@ -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 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 -gated three times: `NUT_ALLOW_ACTIONS`, not `DASH_READ_ONLY`, and a confirmation in the UI -(destructive ones require typing the UPS name). +gated four times: `NUT_ALLOW_ACTIONS`, not `DASH_READ_ONLY`, an upsd.users account that +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: