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: