Compare commits

..
2 Commits
Author SHA1 Message Date
ducoterra 1734d81e48 phase: 11_admin_login
**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.

**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).

**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.

**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.

**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.

**Next pending phase**: `12_nut_self_test_verdict`.
2026-10-19 07:45:44 -04:00
ducoterra eb4788cba1 phases: convert TODO.md to phases 12-15 (store, NUT verdicts, LLM page, TLS)
The five TODO items become four phases: 12 NUT self-test verdict wording
(assess() passes through 'Done and passed' to warn), 13 LLM page fleet/strip
spacing plus the decode tok/s spike (labelled counter sums, unbounded
estimator span), 14 store performance (full-scan read paths, maintain()
rollup write window, WAL tuning), 15 NUT privileged-action security
(collector-managed root CA, STARTTLS, account gate documented).

TODO.md is cleared; .agents/phases/todo/ is the source of truth.

LLM-Generated: true
2026-10-19 01:36:07 -04:00
95 changed files with 8272 additions and 126 deletions
@@ -0,0 +1,55 @@
# Phase 12 — A passed battery self-test is news, not a warning
**Source:** `TODO.md` L3 — ""Last self-test: Done and passed" still shows up as a warning in the overview and UPS section"
**Story:** n/a
**Context:** `src/dashboards/sources/nut.py` (`assess()` — the explicit `health.checks[]` list, the "Last self-test" check at the `test_result` branch), `src/dashboards/alerts.py` (the overview alert rule that reads `health.checks[]`), `tests/test_nut.py` (the two existing "Last self-test" assertions, the `SimUps` test-result tests), `tests/fixtures/nut_apc.vars` / `nut_cyberpower.vars` (verbatim `LIST VAR` captures), `src/dashboards/sources/nut_sim.py` (the simulator's `PASSED`/`FAILED` window), `src/dashboards/web/pages/ups.html` + `src/dashboards/web/static/page-ups.js` (the check list the `/ups` card renders), `data/logs/` (screenshots)
## Objective
The `Last self-test` check grades the driver's own word about the last battery test, and a
passed test — the real drivers answer **`Done and passed`** — must read `ok`, not `warn`.
The current word lists (`"ok", "passed", "pass", "completed"` / `"failed", "fail", "error",
"aborted", "test incomplete"`) let the one phrasing this fleet's hardware actually uses fall
through to `warn`, so a *healthy* UPS carries a warning on the overview and the UPS page.
After this phase the verdict follows the test's outcome: pass → `ok`, fail → `fail`,
never-tested / unset → `unknown`, and the overview's alert feed stops naming it.
## Dependencies
- `05_ups_hero_battery_glyph` (complete) — the `health.checks[]` contract and the `/ups`
check list this phase changes.
- `11_admin_login` (todo) — **no shared files**: 11 touches `app.py`/`config.py`/`store.py`/
the auth surface; this phase touches `sources/nut.py` + its tests + fixtures. Either order
runs; the only coupling is the shared `tests/test_nut.py` — apply whichever runs second on
top of the file state at that time.
## Tasks
1. `01_pin_self_test_wording.md` — capture the real drivers' `ups.test.result` values and pin the current (wrong) verdict in a failing test, so the fix has a before/after.
2. `02_verdict_map_for_test_results.md` — the `assess()` word map: pass phrasings → `ok`, fail phrasings → `fail`, "No test initiated"/unset → `unknown` (never `warn`); fixtures + `tests/test_nut.py` + the simulator's wording stay consistent.
3. `03_no_false_warning_overview_and_ups.md` — end-to-end: a passed test produces no alert on `/api/summary` and an `ok` chip on `/ups`; pinned by API test and a live screenshot.
## Testing & Quality
- Unit: `tests/test_nut.py` — the full word map (each pass phrasing `ok`, each fail phrasing
`fail`, `No test initiated` and unset `unknown`, case/whitespace-insensitive), driven from
the verbatim fixture wording where it exists and synthetic `UpsReading`s elsewhere.
- Integration: `tests/test_api.py` — a `health.checks[]` entry with a passed self-test
produces no alert in the `/api/summary` payload; a failed one does.
- The `SimUps` test-result tests (`tests/test_nut.py`) keep passing: the simulator's
`PASSED`/`FAILED` window words were already in the right tiers.
- Coverage: **>90%** on new/modified code; the overall gate (94%) must hold.
## Completion Criteria
- [ ] `printf 'LIST VAR apc ups.test.result\n' | nc <driveripper> 3051` (run from a host
that can reach upsd — the laptop still cannot, per the fleet facts) captured and
recorded in `tests/test_nut.py` / the fixture; the captured word asserts `ok`
- [ ] the "Last self-test" check renders `ok` (dimmed, no warning class) on `/ups` and does
not appear in the overview's Warnings panel; screenshots in `data/logs/`
(`self-test-passed-overview.png`, `self-test-passed-ups.png`)
- [ ] `uv run pytest` green, `uv run pytest --cov --cov-fail-under=94` green,
`uv run ruff check src tests agent && uv run pyright` clean, `bash .agents/validate.sh`
green
- [ ] no behavior change in completed phases: every other `health.checks[]` check (power
path, charge, runtime, battery age/temperature, load, alarms) grades exactly as before
@@ -0,0 +1,50 @@
# Task 01 — Pin the self-test word the real drivers use
**Phase:** `12_nut_self_test_verdict` · **Source:** `TODO.md:3` — ""Last self-test: Done and passed" still shows up as a warning in the overview and UPS section"
## Objective
Prove the bug in a test before touching the fix: the `Last self-test` check in
`assess()` must be shown to grade the drivers' real wording (`Done and passed`) as
`warn`, and the verbatim `ups.test.result` value must be captured from the running
upsd so the fixture/test asserts the hardware's actual sentence, not a memory of it
(AGENTS.md rule 7 — verify against the deployed upstream; the factory state both UPSes
answer is `No test initiated`, pinned in `AGENTS.md`'s fleet facts).
## Work
1. `tests/test_nut.py` — add the pinning test **first**: build the `UpsReading` the way
the two existing "Last self-test" tests do (the `SimUps`/`vars` helpers in the file),
with `test_result="Done and passed"`, and assert the current behaviour
(`check["status"] == "warn"`) with a comment that the *fix* flips it to `ok`. This is
the red test the whole phase turns green.
2. Capture the real value — from a host that can reach upsd (the laptop cannot yet,
per the fleet facts; `driveripper` itself, or the deployed dashboard's network):
`printf 'LIST VAR apc ups.test.result\nLIST VAR cyberpower ups.test.result\n' | nc
<upsd-host> 3051` — record the exact words in `tests/test_nut.py` (an assertion per
UPS where the value is a stable driver constant) and note the capture date. If a test
has been run on the hardware, the value is the driver's pass word; if not, it is
`No test initiated` — either way it goes into the test, because the verdict map must
cover both.
3. `tests/fixtures/nut_apc.vars` / `nut_cyberpower.vars` — if the live `ups.test.result`
line differs from the captured fixture (a test has since run), re-capture with
`./scripts/capture-fixtures.sh` (the script scrubs serials; the test-result line is
not a secret) so the fixtures stay verbatim ground truth.
4. Confirm the symptom end-to-end while it is live: `curl -s
http://127.0.0.1:8090/api/summary` (signed in, per phase 11) shows the alert row for
the `Last self-test` check, and `/api/ups/snapshot`'s `health.checks[]` carries the
`warn` entry — the two surfaces the TODO names.
## Testing & Quality
- Unit: the pinning test (red now, green after task 02).
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] `uv run pytest tests/test_nut.py -k self_test` shows the new test **failing** on
the current code (or passing if the wording already graded `ok` — in which case
record that and task 02 narrows to the `No test initiated` branch only)
- [ ] the real `ups.test.result` word is captured from the running upsd and recorded in
the test suite with its capture date
- [ ] fixtures re-captured if the live value differs from the recorded one
@@ -0,0 +1,62 @@
# Task 02 — Grade the test outcome, not the phrasing
**Phase:** `12_nut_self_test_verdict` · **Source:** `TODO.md:3` — ""Last self-test: Done and passed" still shows up as a warning in the overview and UPS section"
## Objective
The `Last self-test` branch in `assess()` maps the driver's word to a verdict with two
hardcoded tuples, and any phrasing outside both falls through to `warn` — which is how
`Done and passed` (the real drivers' pass word) and `No test initiated` (the factory
state) both read as warnings. Rewrite the branch as an explicit outcome map: pass
phrasings → `ok`, fail phrasings → `fail`, "no test has run" phrasings and unset →
`unknown`. A warning for a *passed* test is the bug; a warning for a machine that never
ran a test is noise this phase removes too.
## Work
1. `src/dashboards/sources/nut.py` — the `test_result` branch in `assess()`:
- pass tier: the existing `("ok", "passed", "pass", "completed")` **plus**
`done and passed` (the live word captured in task 01, kept lowercase/trimmed —
keep the existing `strip().lower()` normalisation);
- fail tier: unchanged (`"failed", "fail", "error", "aborted", "test incomplete"`);
- new `unknown` tier: `no test initiated` (and the empty-string case) — reuse the
existing `None`-branch sentence ("no self-test result reported — run a battery
test") so the two "never tested" states say the same thing;
- the fall-through for any *other* phrasing stays `warn` (an unrecognised word is
the one thing worth a warning — a driver inventing a new word is drift worth
noticing), and the check keeps printing the raw word plus the `test_date` suffix.
- ASSUMPTION: `No test initiated` (the factory state on both UPSes, per AGENTS.md
fleet facts) maps to `unknown`, not `warn` — a pack that has never been
self-tested is a suggestion to run one, not a fault; the `None` branch already
says exactly that, so the two states read identically.
2. `tests/test_nut.py` — the task-01 red test now asserts `ok`; add the full map:
every pass phrasing (including the captured live word) → `ok`, every fail phrasing
→ `fail`, `No test initiated` / empty / unset → `unknown` with the run-a-test
sentence, an unknown phrasing (e.g. `mystery`) → `warn`; case and whitespace
variations of the live word.
3. `tests/fixtures/nut_apc.vars` / `nut_cyberpower.vars` — if the captured live value
(task 01) is the factory `No test initiated`, the fixtures already carry it; if a
test has run, the fixture carries `Done and passed` — either way the fixture-driven
tests in `tests/test_ups_contract.py` / `test_nut.py` must pass against the new map
(the fixture's word is part of the hero contract's check list).
4. `src/dashboards/sources/nut_sim.py` — no code change expected: the simulator's
`PASSED` / `FAILED` window words are already in the pass/fail tiers; run the
`SimUps` test-result tests to prove it. If the simulator ever answers a *third*
word, add it to the map — do not special-case the simulator.
## Testing & Quality
- Unit: the full word-map table in `tests/test_nut.py` (the tiers above, plus
`test_date` suffix still printed on the pass/fail detail line).
- Regression: every pre-existing `test_nut.py` / `test_ups_contract.py` test passes
unmodified in intent (the fixtures' words land in the right tier).
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] `uv run pytest tests/test_nut.py tests/test_ups_contract.py` green, including the
task-01 test now asserting `ok` for `Done and passed`
- [ ] no phrasing the two real drivers answer (verified against the task-01 capture)
falls into `warn`
- [ ] the `unknown` tier's detail sentence is the existing "run a battery test" one, so
unset and never-initiated read the same
@@ -0,0 +1,60 @@
# Task 03 — No false warning on the overview and the UPS page
**Phase:** `12_nut_self_test_verdict` · **Source:** `TODO.md:3` — ""Last self-test: Done and passed" still shows up as a warning in the overview and UPS section"
## Objective
Prove the fix end-to-end on the two surfaces the TODO names: a passed self-test must
produce **no alert** in `/api/summary` (the overview's Warnings panel) and an `ok`
entry in `/api/ups/snapshot`'s `health.checks[]` (the `/ups` check list), while a
*failed* test still alerts — the check keeps its reason for existing.
## Work
1. `tests/test_api.py` — the alert-absence pin: an app (the shared `create_app`
test harness, `auth_contract` seeding) whose UPS payload carries a `Last self-test`
check with a passed word answers `/api/summary` with **no** alert row naming it
(the `alerts` list is the thing the overview renders — a row there is the warning
the TODO sees); the same harness with a `fail`-tier word **does** produce the
alert, so the test pins both directions.
2. `src/dashboards/web/static/page-ups.js` / `src/dashboards/web/pages/ups.html` — no
code change expected (the check list renders `health.checks[]` verdicts verbatim);
verify the `ok` chip styling for the "Last self-test" row is the existing dim path
(`ok` stays quiet ink, per the shared verdict vocabulary). If the row needs no
markup, say so in the completion note — do not restyle.
3. Live verification (AGENTS.md invariant 10 — UI changes are screenshotted): the dev
server (`./scripts/dev-server.sh status` first; the source must be `ok`), signed in
with the dev password:
- `curl -s http://127.0.0.1:8090/api/summary` — no `Last self-test` alert while the
live UPSes report a passed (or never-initiated) result;
- the `/` overview: Warnings & errors panel shows no self-test row;
- the `/ups` page: the "Last self-test" row renders the driver's word with the `ok`
(or `unknown`) class.
4. `data/logs/` — `self-test-passed-overview.png` and `self-test-passed-ups.png`
(the overview with the Warnings panel visible, and the `/ups` card with the check
list visible). If the live UPSes are at the factory `No test initiated`, the
screenshots show the `unknown` dim state — the *absence of the warning* is the
point, in either state.
- ASSUMPTION: no battery test is run on the live hardware to force a `Done and
passed` state — a self-test puts real load on a battery things are plugged into,
which stays the operator's call (the AGENTS.md known-gaps rule). The hermetic
`fail`/`ok` pins in `tests/test_api.py` carry the behaviour; the live screenshots
carry whichever state the hardware currently reports.
## Testing & Quality
- Integration: the two-direction alert pin in `tests/test_api.py`.
- Live: the two surfaces checked per step 3, screenshots recorded.
- Coverage: **>90%** on this task's new/modified code (mostly test code — the overall
94% gate must hold).
## Completion Criteria
- [ ] `curl -s http://127.0.0.1:8090/api/summary | grep -i "self-test"` returns nothing
while the live `health.checks[]` entry is `ok`/`unknown`, and returns the alert
row when a `fail`-tier word is injected (the `tests/test_api.py` pin)
- [ ] `/` renders no self-test row in Warnings & errors; `/ups` renders the check with
its verdict class — both screenshotted in `data/logs/`
- [ ] `uv run pytest` green, `bash .agents/validate.sh` green
- [ ] the `Dev server stays up` rule held: `./scripts/dev-server.sh status` shows the
`ups` source `ok` before and after the screenshots
@@ -0,0 +1,60 @@
# Phase 13 — The LLM page: fleet spacing and an honest decode tok/s
**Source:** `TODO.md` L4–L5 — "In the LLM engines page the 'fleet' section has no spacing between it and the engine summaries" / "Decode tok/s sometimes jumps unrealistically high, could be a calculation error"
**Story:** n/a
**Context:** `src/dashboards/web/pages/llm.html` (the `#banners`/`#fleet`/`#strip`/engines section stack), `src/dashboards/web/static/dash.css` (`.strip`, the `.fleet` block, the shared `.85rem` rhythm), `src/dashboards/rates.py` (`RateEstimator` — the one piece of mutable state in the app), `src/dashboards/sources/llm.py` (`parse_gufo_metrics`/`parse_vllm_metrics`, the `measured()` Δcounter/Δt cells), `src/dashboards/prom.py` (`scalar(agg="sum")` over label sets), `tests/test_rates.py` / `tests/test_llm_source.py`, `tests/fixtures/metrics_gufo.txt` / `metrics_vllm.txt`, `data/logs/` (findings + screenshots)
## Objective
Two defects on the `/llm` page, one phase. (1) The `#fleet` aggregate block and the
`#strip` engine summaries sit flush — the page's shared `.85rem` vertical rhythm skips
the two sections that have no inline margin, so the page reads as one unbroken wall
above the engine panels. (2) The `decode tok/s` hero/chart occasionally prints values
far above anything the engines can produce: the measured Δcounter/Δt path is keyed
correctly by *name* (rule 2), but the *value* it diffs is a **sum over the label
set**, and the estimator's window-empty fallback divides by a `dt` it never bounds —
so a counter's carried value can leak into one interval's delta, or a stall can
rescale the window. The fix keeps every number measured and labelled (`Δ<window>`),
just makes the measurement honest.
## Dependencies
- `07_llm_monitor_style_pages` (complete) — the fleet block, the strip, and the
one-`/api/llm/history`-request-per-refresh contract this phase must not break.
- `04_llm_chart_readability` (complete) — the hero row and the `provenance()` tags.
- `11_admin_login` (todo) — no shared files (this phase touches `dash.css`, `llm.html`
optionally, `rates.py`, `sources/llm.py`, `prom.py` and their tests; 11 touches
`app.py`/`config.py`/`auth.py`). Either order runs.
## Tasks
1. `01_fleet_strip_spacing.md` — restore the shared `.85rem` rhythm between `#fleet` and `#strip` (and above `#fleet` when no banners are shown) in `dash.css`; screenshot.
2. `02_pin_decode_spike_mechanism.md` — diagnose the spike against the live engines and the stored history (AGENTS.md rule 7 — never guess the trigger); written findings in `data/logs/`.
3. `03_bounded_estimator_and_per_series_rates.md` — the `rates.py`/`sources/llm.py` fix: per-label-series diffing plus a bounded effective window, so a label-set change contributes zero and a stall contributes an `n/a`, never a spike.
4. `04_live_stable_verification.md` — the dev-server proof: sustained watch of the live `decode tok/s` with its `Δ<window>` label, screenshots, fixtures re-captured if the live shape changed.
## Testing & Quality
- Unit: `tests/test_rates.py` — the new window/label-set behaviours (see task 03); the
existing pinned behaviour (`test_window_is_labelled_with_the_span_actually_used`)
keeps passing — the labelled-window contract is load-bearing.
- Unit/integration: `tests/test_llm_source.py` — a metrics text whose label set gains a
series between two polls produces no spike in `decode_tokens_per_second`; the
fixture-driven parse tests pass unmodified in intent.
- Page: `tests/test_api.py` — the served `llm.html` + `dash.css` pin the spacing rule
(the same device that pins the other dash.css invariants).
- Coverage: **>90%** on new/modified code; the 94% gate must hold.
## Completion Criteria
- [ ] `/llm` shows the shared gap between the fleet block and the engine summaries, and
between the page head and the fleet block when no banners render — screenshots in
`data/logs/` (`llm-fleet-spacing.png`)
- [ ] the task-02 findings file exists and names the confirmed mechanism
- [ ] `tests/test_rates.py` + `tests/test_llm_source.py` pin: no spike on a label-set
change, no unbounded `dt`, the `Δ<window>` label always the window actually used
- [ ] a sustained live watch shows no `decode tok/s` value above the confirmed
physical ceiling for the fleet's cards (the task-02 note records the ceiling)
- [ ] `uv run pytest` green, `bash .agents/validate.sh` green
- [ ] no behavior change in completed phases: the strip, the fleet block, the slot grid
and the trend charts render exactly as phase 07/04 shipped them, apart from the gap
@@ -0,0 +1,59 @@
# Task 01 — The gap between the fleet block and the engine summaries
**Phase:** `13_llm_page_and_decode_rate` · **Source:** `TODO.md:4` — "In the LLM engines page the 'fleet' section has no spacing between it and the engine summaries"
## Objective
`#llm`'s top-level sections break the page's shared `.85rem` vertical rhythm:
`#banners` ends with `margin-bottom:.85rem`, the engine section below `#strip` carries
an inline `margin-top:.85rem`, but `#fleet` and `#strip` carry **no** margin at all —
so the fleet block and the engine summaries sit flush (and, with no banners, the fleet
block also sits flush against the page head). Restore the rhythm with two CSS rules;
no markup or JS change.
## Work
1. `src/dashboards/web/static/dash.css` — in the fleet/strip section:
- `#strip { margin-top: .85rem; }` — the gap the TODO names (fleet → engine
summaries). Unconditional on purpose: when `#fleet` is `hidden` (the
below-two-engines case) the strip is the first visible section and still needs
the same top gap.
- `#banners:empty ~ #fleet { margin-top: .85rem; }` — the gap above the fleet block
*only* when no banner occupies the slot above it; with a banner present, the
banner's own `margin-bottom:.85rem` already is the gap (never two stacked gaps).
- The engine section's existing inline `margin-top:.85rem` stays (it already is the
strip → panels gap).
2. `src/dashboards/web/pages/llm.html` — no change expected; if the executor finds the
sections are not siblings the way the markup suggests, fix the CSS selectors to the
actual structure rather than re-flowing the markup (the section order is a phase-07
contract).
3. `tests/test_api.py` — pin the two rules on the served bytes (the existing dash.css
pin device: a test that fails if the rules are missing from the served stylesheet),
so a later "cleanup" that drops them fails a test.
4. Screenshot (invariant 10): dev server up, `/llm` with **≥2 engines answering** (the
fleet block visible — below two engines it stays hidden and the fix is invisible),
full page top-to-bottom: `data/logs/llm-fleet-spacing.png`. If the live fleet cannot
show two engines (the transport gap in AGENTS.md's fleet facts), pin the spacing on
the simulated path is **not** an option for a layout claim — instead screenshot the
one-engine page (strip visible, `#fleet` hidden, strip's `margin-top` doing its job)
and record in the completion note that the two-engine gap was verified by the CSS
rule + the served-bytes test, with the live two-engine capture deferred to the
operator's engine restore.
- ASSUMPTION: the fix is CSS-only in `dash.css`, not an HTML wrapper change — the
page's section stack is a phase-07 layout contract and the page already uses
inline `margin-top:.85rem` for the same rhythm, so a rule (not a restructure) is
the change in the page's own vocabulary.
## Testing & Quality
- Page: the served-bytes pin in `tests/test_api.py`.
- Live: the screenshot per step 4.
- Coverage: **>90%** on new/modified code (CSS + test — the 94% gate must hold).
## Completion Criteria
- [ ] `#strip` has a `.85rem` top gap in all states (fleet visible, fleet hidden);
`#fleet` has the gap above it exactly when no banner is shown
- [ ] no double gap (banner + rule) and no flush pair anywhere in the section stack
- [ ] `tests/test_api.py` pin green; screenshot in `data/logs/`
- [ ] `bash .agents/validate.sh` green
@@ -0,0 +1,72 @@
# Task 02 — Pin the decode-tok/s spike against the live upstream
**Phase:** `13_llm_page_and_decode_rate` · **Source:** `TODO.md:5` — "Decode tok/s sometimes jumps unrealistically high, could be a calculation error"
## Objective
Rule 7: never guess the trigger. Before touching `rates.py`, confirm **which** mechanism
produces the spike, from the stored history and the live engines, and record the
findings in a file the fix task (03) and the verification task (04) both read.
## Work
1. Find the spikes in the stored history (the dev DB is the source of truth — AGENTS.md
known gap 4 records it at ~610 MB with a year of raw/1m/1h rollups):
`sqlite3 data/… "<db>" "SELECT ts, value FROM sample WHERE metric LIKE
'decode_tokens_per_second%' ORDER BY ts DESC LIMIT 40000"` — list the top-N values
and their timestamps. Note for each: which engine (`source`/`host`), and what the
neighbouring values look like (a one-poll spike vs a sustained step).
2. For each spike, read the **provenance** of the cell that produced it — the payload
carries it (rule 2): `computed:Δ<series>/Δt` + `Δ<window>` (our estimator) vs the
engine's own gauge name (the native `decode_tokens_per_second_native` source string).
A spike from the *engine's* gauge is the engine's rolling average, not our math —
the fix there is a label, not a code change. `sqlite3` the engine's native gauge
series alongside to separate the two paths.
3. For the estimator path, inspect the live exposure (from a host that can reach the
engines — the laptop can reach both, per the fleet facts):
- `curl -s <gufo>/metrics | grep -E "tokens_(predicted|generated)_total"` and the
vLLM equivalent — are the counters **plain** (as in `tests/fixtures/`), or
**labelled per slot/engine** in the live build? A label set that changes between
polls is the leading suspect: `prom.Prom.scalar(agg="sum")` sums whatever label
sets exist, so a series that appears with a carried cumulative value adds that
whole value to one interval's delta (a `value < samples[-1]` decrease would reset
the window — an *increase* from a new series does not).
- Cross-check the spike timestamps against `GET /slots` history (which slots were
starting/turning over) and the event feed (`/api/events`) for engine restarts in
the same minutes.
4. Record the findings in `data/logs/NOTES-decode-spike.md`: the confirmed mechanism
(one of the candidates below, or a new one), the evidence (values, timestamps,
captures), and the fleet's physical decode ceiling for the task-04 comparison —
the observed max of the native gauge over 90d **and** the cards' spec ceiling
(R9700 / Strix Halo 8060S), so "unrealistically high" has a number attached.
Candidates, in suspected order (the findings file must name the one that fired):
- (a) **label-set sum**: `scalar(agg="sum")` over a changing set of labelled
series — a new series' carried counter enters the delta as a one-interval spike;
- (b) **window-empty fallback**: `rates.py`'s `base = samples[-2]` when fewer than
two samples sit inside the span — the effective window is the gap to the previous
sample, unbounded above by 2×span (the trim keeps the last two however old);
- (c) **a lineage jump that is not a decrease**: a counter that restarts *above*
zero (a renamed/relabelled series resolving under the same key, a pre-loaded
counter) — `value < samples[-1]` never fires, so the carried value is diffed;
- (d) **the engine's own native gauge** (vLLM's `avg_*_throughput` rolling average) —
not our math; label-level fix only.
## Testing & Quality
- No code changes in this task — diagnostics only, with the findings file as the
artifact. If a candidate is confirmed hermetically reproducible, add the red test
here (a `tests/test_llm_source.py` / `tests/test_rates.py` test that fails on the
current code) so task 03 turns it green.
- Coverage: **>90%** on any test added (the 94% gate must hold).
## Completion Criteria
- [ ] `data/logs/NOTES-decode-spike.md` exists: confirmed mechanism, evidence, and the
physical ceiling with its two sources (observed native-gauge max + card spec)
- [ ] every spike in the stored top-N is classified (our estimator / the engine's
gauge / a real burst) — a spike that cannot be classified is a finding, not a
skipped row
- [ ] a red test exists for the confirmed mechanism if it is hermetically reproducible
- [ ] the fixtures were compared against the live exposure and any shape drift is
recorded (drift → `./scripts/capture-fixtures.sh` re-capture noted for task 04)
@@ -0,0 +1,85 @@
# Task 03 — A rate that cannot be a carried counter
**Phase:** `13_llm_page_and_decode_rate` · **Source:** `TODO.md:5` — "Decode tok/s sometimes jumps unrealistically high, could be a calculation error"
## Objective
Fix the confirmed mechanism from task 02, plus the two generic weaknesses that make
any mechanism possible: a summed counter whose *label set* changes between polls, and
an effective window the estimator never bounds. After this task, a delta that includes
a carried counter value is impossible by construction — a new series contributes
nothing until it has a baseline, a stalled window answers `n/a` for one poll instead of
rescaling, and a jump that looks like a lineage break is confirmed over two polls
before it is published. Every published number still carries its `Δ<window>` label and
`source` string (rule 2).
## Work
1. `src/dashboards/prom.py` — expose the **per-label-set** view the fix needs (the
`series` map already holds them; `by_label` shows the shape): a `values_by_series`
(or reuse `values` with labels) accessor that returns `[(labels, value)]` per
resolved name, so the source can diff each labelled series individually. Keep
`scalar` untouched (the gauges still sum/avg/max the same way — a *gauge's* sum is
a real aggregate; only *counter* deltas may not mix series).
2. `src/dashboards/sources/llm.py` — the `measured()` path (gufo's
`tokens_generated_total`/`tokens_prompt_total` and vLLM's
`generation_tokens_total`/`prompt_tokens_total`): resolve the per-label-set values
and run **each series through the estimator under its own key** (the key is the
resolved name *plus* its label set — `llamacpp:tokens_predicted_total{slot="2"}`),
then `rate = Σ per-series rates`, with the cell's `window` the max of the
contributing series' windows (the window label says the widest window actually
used). A series with no previous sample contributes 0 tokens this interval — never
its carried value. The `Δ<window>` label and the `computed:Δ…/Δt` source string stay
in the `provenance()` format the page already prints (the source string names the
resolved series, as today).
- ASSUMPTION: per-series diffing is the fix for candidates (a) and (c) — a
label-set change then changes *nothing* in the delta until the new series has two
samples, which is exactly the "a renamed counter starts a fresh window" doctrine
the estimator docstring already states, extended from names to label sets.
3. `src/dashboards/rates.py` — the two generic bounds:
- **stalled-window bound**: in the `len(inside) < 2` fallback, accept `samples[-2]`
as the base only while its age is ≤ 1.5 × `span`; older than that, return
`(None, None)` for this poll — one `n/a` on the poll after a stall is the honest
answer (a 20-second window on a 10-second panel is a different claim), and the
next poll recovers normally. The existing `test_window_is_labelled_with_the_span_actually_used`
(a 9s fallback) keeps passing: 9s ≤ 15s.
- **jump confirmation**: a published rate ≥ 8× the previous poll's published rate
(previous > 0) is held — the previous value is repeated for one more poll, and the
jump publishes only if *confirmed* by the next interval; an unconfirmed jump is
dropped (the cell prints the held value, its `Δ<window>` label unchanged). This
suppresses a single-interval artifact without flattening a real burst: a genuine
step-up is confirmed one interval later and then publishes, and the confirmation
state is per-key in the estimator.
- ASSUMPTION: the 8×-over-previous threshold and the one-poll hold are the right
confirmation rule — a real LLM decode step (a batch of long outputs starting)
persists across a 10s interval, while a carried-counter artifact does not; the
thresholds are named constants at the top of the file so they can move without a
logic change.
4. `tests/test_rates.py` — the pins: a stalled window (previous sample > 1.5×span old)
answers `(None, None)` for one poll and recovers; a jump ≥ 8× holds for one poll
and publishes when confirmed / drops when not; the pinned pre-existing tests all
pass unmodified.
5. `tests/test_llm_source.py` — the label-set pin: two parses of a metrics text whose
counter series set differs between polls (a `{slot="2"}` series appears carrying
50 000) — the delta of the second poll contains **no** 50 000; the flat-counter and
counter-reset tests keep passing.
## Testing & Quality
- Unit: `tests/test_rates.py` + `tests/test_llm_source.py` per the work list.
- Regression: every pre-existing `test_llm_source.py` / `test_rates.py` test passes
unmodified in intent; the fixture-driven parse tests (`metrics_gufo.txt`,
`metrics_vllm.txt`) are the shape pins.
- Coverage: **>90%** on this task's new/modified code; the 94% gate must hold.
## Completion Criteria
- [ ] no delta can contain a value that was not generated inside the window: the
label-set test, the reset test and the stall test all green
- [ ] a real step-up (sustained over two polls) still publishes — the confirmation
path is tested, not just the suppression path
- [ ] every published rate still carries `Δ<window>` (the window actually used, possibly
a sum-of-windows label) and its `source` string — `tests/test_llm_source.py`
asserts the provenance shape
- [ ] the task-02 red test is green
- [ ] `bash .agents/validate.sh` green
@@ -0,0 +1,50 @@
# Task 04 — Live proof: a decode rate that stays under its ceiling
**Phase:** `13_llm_page_and_decode_rate` · **Source:** `TODO.md:5` — "Decode tok/s sometimes jumps unrealistically high, could be a calculation error"
## Objective
The dev-server proof that the fix holds on the real engines: a sustained watch of the
live `decode tok/s` (payload cell + stored history) with its `Δ<window>` label, every
observed value under the physical ceiling task 02 recorded, and the page itself
screenshotted with the task-01 spacing in view.
## Work
1. Dev server up and the LLM sources `ok` (`./scripts/dev-server.sh status`); record
the start time. Watch for at least one full poll-cycle cluster with traffic if the
engines are idle (the operator's own requests are the traffic — do **not**
generate load: a live test that puts real work on the engines' batteries is not a
test, the AGENTS.md live-suite rule):
- `curl -s http://127.0.0.1:8090/api/llm/snapshot` every poll for ≥ 10 minutes:
log `decode_tokens_per_second` (value, `source`, `window`) per engine;
- then `sqlite3` the `decode_tokens_per_second` raw series for the same period and
confirm no stored value exceeds the task-02 ceiling.
2. `data/logs/llm-decode-stable.png` — the `/llm` page showing the hero row with the
`Δ<window>` provenance tag, the strip card, and (when ≥ 2 engines answer) the fleet
block with the task-01 gap; a second screenshot if a real burst was observed
mid-watch (the confirmed-and-published path — the burst's `Δ` label visible).
3. `./scripts/capture-fixtures.sh` — re-capture **only if** task 02 recorded a live
shape drift (a label set the fixtures lack); otherwise leave the fixtures
untouched and say so.
4. `uv run pytest -m live --live` — the opt-in live suite (read-only per its rule;
the LLM engine tests skip when their source is unset). Record the tally in the
completion note (the fleet's transport gap means the host/disks live tests may
still fail — the LLM and UPS ones are this task's bar).
## Testing & Quality
- Live: the watch + `sqlite3` comparison per step 1, screenshots per step 2.
- No code changes expected in this task — if the watch finds a residual spike the
fix missed, the findings go back into `data/logs/NOTES-decode-spike.md` and task 03
is re-opened (record the re-open in the completion note rather than fixing inline).
## Completion Criteria
- [ ] ≥ 10 minutes of live polls: no `decode_tokens_per_second` value (payload or
stored) above the task-02 ceiling; the `Δ<window>` label present on every
non-`n/a` value
- [ ] `data/logs/llm-decode-stable.png` (and the burst screenshot if one occurred)
- [ ] fixture re-capture decision recorded; `bash .agents/validate.sh` green
- [ ] the live tally recorded, with the known transport gap acknowledged as the reason
any host/disks live failures are out of scope here
@@ -0,0 +1,66 @@
# Phase 14 — The store: fast reads, short writes, no "database is locked"
**Source:** `TODO.md` L7 — "Pages take forever to load and seem to lock up the database. Fix that."
**Story:** n/a
**Context:** `src/dashboards/store.py` (the `sample`/`event`/`meta` schema, `pick_res`, `history`, `series_names`, `stats`, `maintain`, the single `self._lock`, `busy_timeout=5000`), `src/dashboards/api.py` (the N-hosts × M-metrics history loops, `/api/metrics`, the polled `/api/health`), `src/dashboards/poller.py` (`add_many` on every successful poll — the write side that loses the `busy_timeout` race), `tests/test_store.py`, `tests/test_api.py`, `data/logs/` (the ~610 MB dev DB per AGENTS.md known gap 4 — the `store-error: OperationalError: database is locked` line)
## Objective
The page load time and the `database is locked` lines are one disease: the hot read
paths of a 610 MB / 140 MB-WAL database are **full-table operations on every request**
(`series_names()` scans for `DISTINCT metric`, the browser-polled `/api/health` runs
`stats()` which counts the whole `sample` table, `pick_res()` pays three COUNT
round-trips per series), while `maintain()`'s rollups are long whole-table write
transactions that hold the WAL write lock long enough for a poller's `add_many` to
outlive `busy_timeout=5000` and surface as the reported `store-error` (AGENTS.md
known gap 4). This phase makes every hot path bounded (indexes, one grouped query,
short-TTL caches) and every write short (windowed rollups, WAL checkpoint tuning),
with timing budgets pinned as tests so a regression is a red test, not a slow page.
## Dependencies
- the foundation (complete) — `store.py`/`api.py`/`poller.py` as shipped; the rollup
and retention contract in AGENTS.md invariant 9.
- `11_admin_login` (todo) — **shared files, run-order note**: 11 rewrites `api.py`
(the session gate) and adds `get_meta`/`set_meta`/`delete_meta` to `store.py`. Apply
this phase's `store.py`/`api.py` changes on top of the file state at the time it
runs; never restore a line the other phase owns. The two phases touch disjoint
*regions* (11: the meta helpers + the auth routes; 14: the query paths + the
maintain rollups) — the coupling is the file, not the logic.
## Tasks
1. `01_baseline_store_costs.md` — a production-shaped seeded store + the timing/EXPLAIN baseline for every hot path, recorded in `data/logs/`.
2. `02_no_full_scan_on_hot_paths.md` — `series_names`/`stats` off the per-request full scan (bounded caches), `pick_res` to one grouped query, the N×M history endpoints to one grouped round trip.
3. `03_windowed_rollups_and_wal_tuning.md` — `maintain()` rolls up only the window since `last_maintain`; WAL checkpoint + journal-size tuning so the 140 MB WAL stops growing between checkpoints.
4. `04_budget_gates_and_live_proof.md` — the timing budgets pinned as tests, the live dev-server proof (page load measured, zero `store-error` over a watch window), docs updated.
## Testing & Quality
- Unit/integration: `tests/test_store.py` — the budget tests (a seeded
production-shaped store: the task-01 recipe), the cache-invalidation behaviour, the
windowed-rollup correctness (a rollup run twice rolls the new window only, and the
1m/1h values match a whole-table rollup on the same data — the windowing must not
change a single averaged value).
- Integration: `tests/test_api.py` — the N×M history endpoints' response shape is
byte-identical to today (only the cost changes), under the new budget.
- Live: the dev-server watch per task 04 (AGENTS.md invariant 10 — the dev server stays
up while work happens; invariant 9 — the DB stays bounded, on a volume in
deployment).
- Coverage: **>90%** on new/modified code; the 94% gate must hold.
## Completion Criteria
- [ ] the task-01 baseline and the task-04 "after" numbers are both in
`data/logs/NOTES-store-perf.md`, side by side
- [ ] the budget tests are green: the full `/system` history request, `/api/metrics`,
`/api/health` and a 90d `history()` call all inside their pinned budgets on the
seeded store
- [ ] a 25-minute dev-server watch (the task-04 recipe) shows **zero** `store-error`
events and page loads inside the budget — the known-gap-4 failure mode is gone,
not hidden
- [ ] `maintain()`'s write transaction is measurably shorter (the task-03 note) and
the WAL stays bounded across two maintain cycles
- [ ] `uv run pytest` green, `bash .agents/validate.sh` green
- [ ] no behavior change in completed phases: every history payload, rollup value and
event answer is identical to today's shape (the `tests/test_api.py` pins)
@@ -0,0 +1,68 @@
# Task 01 — What the hot paths actually cost
**Phase:** `14_store_performance` · **Source:** `TODO.md:7` — "Pages take forever to load and seem to lock up the database. Fix that."
## Objective
A before/after exists only if the "before" is measured, not remembered. Seed a
production-shaped store, time every hot path of `store.py`/`api.py` against it,
`EXPLAIN QUERY PLAN` the queries that will change, and record the baseline in
`data/logs/NOTES-store-perf.md` — the numbers task 04 compares against.
## Work
1. `tests/test_store.py` — the seeded-store recipe (a fixture the budget tests of this
phase all reuse): an on-disk SQLite file (WAL, like production — the in-memory DB
hides the lock behaviour) seeded to the dev DB's *shape and retention semantics*
(`raw_keep_hours=48`, `minute_keep_days=30`, `hour_keep_days=400` — the
`SampleStore` defaults, i.e. what a 90 d chart actually reads: 1m/1h, never raw
beyond 48 h):
- ~3 hosts × ~30 series per host, 10 s cadence for the 48 h raw window;
- their 1m rollups for the 30 d window and 1h rollups for the 400 d window
(generated by calling `maintain()` — a few hundred MB, the dev DB's order of
magnitude, a temp file, never the dev DB itself: tests stay hermetic per the
AGENTS.md testing policy);
- ~2 000 event rows and the `meta` rows the app writes (`last_maintain` included).
The test asserts the row counts, not the wall time of seeding.
- ASSUMPTION: "production-shaped" means the dev DB's *shape and magnitude* (~610 MB,
per AGENTS.md known gap 4), reproduced in a temp file — the dev DB itself is
never opened by a test (tests stay hermetic, the AGENTS.md testing policy), and
a temp DB of the same shape is the honest substitute.
2. Measure (a script or the test's `--store-perf` marker; record in
`data/logs/NOTES-store-perf.md` with the machine's specs):
- `store.series_names()` (what `/api/metrics` runs per call) — wall time +
`EXPLAIN QUERY PLAN` (expected: a full scan of `sample`);
- `store.stats()` (what the browser-polled `/api/health` runs) — wall time +
EXPLAIN (expected: a full COUNT scan of `sample` + `event`);
- `store.pick_res()` × 3 + `store.history()` for one 90 d window (what one chart
series costs) — wall time + EXPLAIN;
- the full `/api/system/history` request shape (3 hosts × 9 series — the
`tests/test_system_contract.py` table, 27 `_history` calls) through the test app
— total wall time;
- a `maintain()` run on the seeded store — the write transaction's wall time
(the lock-holding window; note the `to_hour` step already scans the *entire*
30 d 1m window on every run — the suspected long write) and the WAL file's
size before/after;
3. Record the baseline table in `data/logs/NOTES-store-perf.md`: path, wall time,
EXPLAIN shape, and the wall time of the poller's `add_many` (a 30-sample insert)
issued **concurrently** with a `stats()`/`series_names()` call — the race known gap
4 describes, made visible as a number (how often does the insert wait, and does it
ever outlive `busy_timeout=5000`? run the race loop ~100× and count the waits).
## Testing & Quality
- Unit: the seeded-store fixture in `tests/test_store.py` (asserts row counts and the
rollup values match a second `maintain()` pass — determinism check).
- No behaviour changes in this task — measurement only.
- Coverage: **>90%** on the fixture code; the 94% gate must hold.
## Completion Criteria
- [ ] `data/logs/NOTES-store-perf.md` exists: the full baseline table (wall times,
EXPLAIN shapes, the concurrent-insert wait count), the machine's specs, and the
seeded DB's row counts
- [ ] the baseline confirms (or refutes — record the refutation) the three suspect
mechanisms: `series_names` full scan, `stats` full scan, `maintain`'s long write
window
- [ ] the fixture is green and deterministic (two runs, same rollup values)
- [ ] `bash .agents/validate.sh` green
@@ -0,0 +1,70 @@
# Task 02 — No full table scan on a per-request path
**Phase:** `14_store_performance` · **Source:** `TODO.md:7` — "Pages take forever to load and seem to lock up the database. Fix that."
## Objective
Three per-request paths pay a whole-table price on a 610 MB database:
`series_names()` (a `SELECT DISTINCT metric` scan, on every `/api/metrics` call),
`stats()` (a `COUNT(*) GROUP BY res` over all of `sample` plus `event`, on every
browser-polled `/api/health`), and the N-hosts × M-metrics history endpoints
(`pick_res()` is three COUNT round-trips per series, so one `/system` chart request
is ~108 serialized round-trips through the single `self._lock`). Make every hot path
bounded: short-TTL caches for the two scanned answers, one grouped query where three
stand, and one grouped round trip where N×M stand. The response shapes are byte-
identical — only the cost moves.
## Work
1. `src/dashboards/store.py` — `series_names()`: an in-memory cache of the distinct
metric set (per `source`/`host` argument tuple, a 60 s TTL, invalidated on
`add`/`add_many`/`delete_series` via a dirty flag — a cache that outlives a write
answers "why is this chart empty" with yesterday's truth). `stats()`: cache the
`sample`/`event` counts the same way (30 s TTL) — the `/api/health` poll (the
browser's `app.js` masthead loop) must never run a full COUNT scan; `last_maintain`
stays read through `get_meta()` as today (the meta table is tiny; do not cache
what a three-row table costs nothing to read).
2. `src/dashboards/store.py` — `pick_res()`: replace the three per-`res` COUNT queries
with **one** `SELECT res, COUNT(*) … WHERE … ts >= ? GROUP BY res` (the three
resolutions in a single index-range pass), same decision logic, same return value.
3. `src/dashboards/api.py` — the four history endpoints' loops (system/disks/llm/ups —
the `[host, metric]` nesting in each) call `store.history()` per pair, which is
`pick_res()` (now 1 query) + the thinning query. Add the batched door in
`store.py`: `pick_res_many(hosts, metrics, span, max_points)` and
`history_many(hosts, metrics, span, max_points)` — one grouped COUNT over the whole
`host IN (…) AND metric IN (…) AND res IN (…) AND ts >= ?` range, and one
per-resolution thinning query (the per-series `LIMIT ?` tail-thinning kept by a
window function or a per-series subquery — the shape of each series' answer must
stay identical: ascending `(ts, value)`, `tail=True` semantics preserved). The
endpoints' response dicts are unchanged (the same series, the same order); the
`tests/test_api.py` pins are the contract.
- ASSUMPTION: one grouped round trip (plus one thinning query per resolution
present) is the target for the N×M endpoints — the alternative (N×M separate
queries, each now cheaper) leaves ~100 serialized round-trips on a 3-host request,
which on a 610 MB file is still the page-load tail; the grouped shape is the fix
the TODO asks for. The per-series thinning keeps `tail=True` (the "the chart must
have 'now'" docstring) exactly as today.
4. `tests/test_store.py` — the pins: `series_names()`/`stats()` answer from cache
within the TTL (a write after the read invalidates `series_names`; `stats` expires
on the clock); `pick_res_many`/`history_many` equal the per-series answers on the
seeded store (the byte-identical-shape test); a cache hit makes a second
`series_names()` call measurably not scan (assert via a query-counter hook or the
wall-time budget of task 04).
## Testing & Quality
- Unit/integration: per the work list, on the task-01 seeded store.
- Regression: every pre-existing `tests/test_store.py` / `tests/test_api.py` test
passes unmodified in intent — the response shapes are the contract.
- Coverage: **>90%** on this task's new/modified code; the 94% gate must hold.
## Completion Criteria
- [ ] no per-request path in `api.py` triggers a full-table scan of `sample` or
`event` (the task-04 budgets are the proof; `EXPLAIN QUERY PLAN` of the grouped
queries shows index-range access, recorded in the notes file)
- [ ] the N×M endpoints' answers are byte-identical to the per-series path on the
seeded store (the equality test)
- [ ] a fresh `series_names()`/`stats()` call costs the full scan (documented — the
first poll after a process start pays it once), a repeated call costs the cache
- [ ] `bash .agents/validate.sh` green
@@ -0,0 +1,78 @@
# Task 03 — A rollup that holds the write lock for seconds, not minutes
**Phase:** `14_store_performance` · **Source:** `TODO.md:7` — "Pages take forever to load and seem to lock up the database. Fix that."
## Objective
`maintain()` is "safe to call every minute" and is idempotent — but on a real-sized
store the `to_hour` step's `SELECT … GROUP BY` scans the **entire 30-day 1m window**
on every run (and `to_minute` the entire 48 h raw window), and that GROUP BY is
inside the one `with self._lock, self._db:` transaction: a multi-second write
transaction that holds the WAL write lock long enough for a poller's `add_many` to
outlive `busy_timeout=5000` and surface as the `store-error: OperationalError:
database is locked` line (AGENTS.md known gap 4). Window each step to *what became
due since the last run* — the idempotent `INSERT OR REPLACE` makes a slightly wider
re-scan safe, so the window is a cost bound, not a correctness device — and tune the
WAL so its 140 MB growth stops being a per-run surprise.
## Work
1. `src/dashboards/store.py` — `maintain()`, both rollup steps:
- read `last_maintain` through `get_meta()` **before** the transaction (the
existing `stats()` note applies: the meta read is outside the non-reentrant
lock);
- `to_minute`: the raw scan bound moves from `ts >= now - raw_keep_seconds` to
`ts >= max(now - raw_keep_seconds, last_maintain - MINUTE - 60)` — steady state
(a run per minute) scans ~one minute of raw rows; a missed interval scans the
catch-up window; a first run on a fresh DB (no `last_maintain` row) scans the
full retention window exactly as today;
- `to_hour`: the same pattern against the 1m rows (`ts >= max(now -
minute_keep_seconds, last_maintain - HOUR - 60)`). The `ts < now - {MINUTE|HOUR}
- 60` incompleteness guard stays — a bucket is still only rolled once its whole
window is in the past.
- the `DELETE … pruned` step is unchanged (the retention window is the correct
bound for a prune; it is `DELETE`s on indexed ranges, not a GROUP BY).
2. `src/dashboards/store.py` — the WAL tuning in `__init__` (after the existing
pragmas): `PRAGMA wal_autocheckpoint = 1000` (the default 1000 pages ≈ 4 MB
auto-checkpoint is already the default — the 140 MB WAL means checkpoints lose to
the write rate; the real lever is the journal-size cap) and
`PRAGMA journal_size_limit = 67108864` (64 MB — the WAL stops growing past it;
checkpoints then truncate it). Keep `journal_mode=WAL` and
`synchronous=NORMAL` (the "a lost sample is fine, corruption is not" comment
stands); keep `busy_timeout=5000` — the fix is a shorter write, not a longer wait.
- ASSUMPTION: 64 MB is the journal-size cap — the dev DB's 140 MB WAL is the
measured "too big" (AGENTS.md known gap 4), 64 MB is the nearest power of two
below it, and the cap is a pragma, not a migration; if the live watch (task 04)
shows checkpoint truncation starving writers, the cap moves (named constant in
`__init__`, one-line change).
3. `tests/test_store.py` — the pins:
- **windowing correctness**: seed a store, run `maintain()` (full window, no
`last_maintain` yet), advance time and add new 1m buckets, run `maintain()`
again — the second run's `to_hour`/`to_minute` scan (assert via the EXPLAIN/row
count hook, or a `sqlite3` trace) touches only the since-`last_maintain` window,
**and** the rolled 1m/1h values are byte-identical to a reference whole-window
rollup on the same data (the windowing must not change a single averaged value —
`INSERT OR REPLACE` idempotency is what makes this safe, prove it);
- **catch-up**: no `maintain` for 10 minutes, then one run — the 10 minutes of
buckets are all rolled (the wider window, not a gap);
- **the lock race**: the task-01 concurrent loop (a poller-style `add_many` during
a `maintain()` run) now completes with **zero** `OperationalError`s over the
100× loop (the baseline's wait count is the before, this is the after).
## Testing & Quality
- Unit: per the work list, on the task-01 seeded store.
- Regression: every pre-existing `tests/test_store.py` test passes unmodified in
intent (the idempotency/retention tests are the load-bearing ones).
- Coverage: **>90%** on this task's new/modified code; the 94% gate must hold.
## Completion Criteria
- [ ] the second `maintain()` run's write transaction is measured shorter than the
task-01 baseline (both numbers in `data/logs/NOTES-store-perf.md`), and the
rollup values are unchanged (the equality test)
- [ ] the 100× concurrent `add_many`-during-`maintain` loop: zero `OperationalError`
(baseline wait count vs after, in the notes file)
- [ ] two `maintain()` cycles on the seeded store: the WAL file size stays bounded by
the 64 MB cap (before/after sizes recorded)
- [ ] `bash .agents/validate.sh` green
@@ -0,0 +1,78 @@
# Task 04 — The budgets, and the dev server that proves them
**Phase:** `14_store_performance` · **Source:** `TODO.md:7` — "Pages take forever to load and seem to lock up the database. Fix that."
## Objective
The "after" numbers, pinned as tests so a regression is red instead of slow: the
task-01 baseline vs the fixed store, side by side in `data/logs/NOTES-store-perf.md`,
plus the live dev-server proof on the real 610 MB DB — page loads inside budget, zero
`store-error` events over a watch window (the known-gap-4 failure mode gone, not
hidden), and the docs updated.
## Work
1. `tests/test_store.py` / `tests/test_api.py` — the budget tests (the task-01 recipe
on the seeded store; budgets are the task-01 baseline's *target* — record the
chosen numbers in the notes file with the reasoning):
- the full `/api/system/history` request (3 hosts × 9 series, 1 h and 90 d):
wall time budget (the 90 d case reads the 1m/1h rollups — the one that used to
be the page-load tail);
- `store.series_names()` / `store.stats()` second call (cache hit): wall time
budget, asserted to be a fraction of the first call's (the scan is gone);
- one 90 d `store.history()` call: wall time budget;
- the budgets are generous (2–3× the measured "after"), so a slower CI machine does
not flake red — the notes file records both the machine's specs and the margin.
2. The live proof (AGENTS.md invariant 10 — the dev server stays up while work
happens, and a fix is screenshotted, not claimed; invariant 9 — the DB is the
volume's business in deployment, the dev DB is the test bench):
- `./scripts/dev-server.sh restart` (its `stop` waits for the old pid to die —
known gap 4's "two servers on one file" cause stays fixed), `status` shows the
sources;
- page loads: `curl -o /dev/null -s -w '%{time_total}'` on `/` and on the
history-heavy pages' API calls (signed in, per phase 11), five runs each,
recorded; the browser's `/llm` or `/system` page with a 90 d window selected is
the worst case — screenshot `data/logs/store-perf-live.png` (the page loaded,
the charts drawn, the masthead dots `ok`);
- the watch: ≥ 25 minutes of `./scripts/dev-server.sh logs` (longer than the
gap-4 baseline's "a few times an hour" rate) with **zero**
`store-error: OperationalError: database is locked` lines; the `store.stats()`
`event` count for `kind='store-error'` over the window is 0 (the `sqlite3`
check on the dev DB — read-only);
- the WAL file's size after the watch (the cap from task 03, in the real DB).
- ASSUMPTION: the watch window is 25 minutes against a "few times an hour"
baseline rate — enough to cover > 0.4 of the baseline's expected occurrences at
the mean rate; if the baseline was at the mean, a zero-count 25-minute window
already moves the needle, and the `sqlite3` event-table check over *the whole
dev DB's* `store-error` rows (not just the window) shows when the last
occurrence predates this phase.
3. Docs: `README.md` — the *Configuration*/*Development* notes gain the two new
`PRAGMA`s' one-line "why" (the journal cap, and that it is a cap not a target);
`AGENTS.md` — known gap 4's entry is updated to record the fix (the windowed
rollups, the journal cap, the budget tests) and the gap's "what remains" line
(checkpoint starvation was never shown to be a cause — the controlled 25 s read
transaction produced none — so the entry's honest wording stays, with the
`database is locked` line now attributed to the rollup's write window). (The
AGENTS.md/README update is the phase's *normal work* per the repo's definition of
done — this instruction is for the phase's executor, not for the conversion.)
## Testing & Quality
- Unit/integration: the budget tests per step 1.
- Live: the step-2 watch, screenshots and log evidence in `data/logs/`.
- Coverage: **>90%** on new/modified code; the 94% gate must hold.
## Completion Criteria
- [ ] `data/logs/NOTES-store-perf.md` has the baseline and the after table side by
side (every task-01 path, wall time before/after, EXPLAIN shapes)
- [ ] the budget tests are green with the recorded margins; `bash .agents/validate.sh`
green
- [ ] the 25-minute watch: zero `store-error` lines in the dev-server logs and zero
new `store-error` rows in the dev DB's `event` table; `data/logs/` holds the
page-load numbers and `store-perf-live.png`
- [ ] the WAL's post-watch size is inside the 64 MB cap on the real DB
- [ ] `README.md`/`AGENTS.md` carry the updated, accurate wording (gap 4: fixed, with
its "what remains" honest)
- [ ] no behavior change in completed phases: the `tests/test_api.py` payload pins all
pass — the answers are the same, only faster
@@ -0,0 +1,90 @@
# Phase 15 — The upsd wire: a managed root CA, STARTTLS, and the account gate
**Source:** `TODO.md` L6 — "For privileged actions (UPS commands) the exporter should be installed with password protection and that password must be used to register it with the collector. Effectively the exporters become protected web servers. Additionally, the collector should generate and manage a root certificate that needs to be installed with each exporter. In fact, just use the certs as auth rather than a password."
**Story:** n/a
**Context:** `src/dashboards/sources/nut.py` (the ~10-line `NutClient` wire protocol, `login()`'s `USERNAME`/`PASSWORD` verbs pinned by `test_login_uses_the_verbs_upsd_2_8_5_answers_to`, `assess()`/`COMMANDS` risk tiers), `src/dashboards/collectors.py` (`NutMonitor`, the redacted-URL error doctrine), `src/dashboards/api.py` (the one `POST /api/ups/command` and its four gates), `src/dashboards/config.py` (env-only settings), `tests/test_nut.py` (the fake upsd), `tests/fixtures/nut_apc.vars` / `nut_cyberpower.vars` (the wire-verb ground truth, incl. `HELP`'s advertised verbs: `GET LIST SET INSTCMD LOGIN LOGOUT USERNAME PASSWORD STARTTLS`), `scripts/`, `README.md` → *NUT*
## Objective
The privileged action in this app is `POST /api/ups/command`, and its security today
rests on the `upsd.users` account (the password the operator installed on the
exporter, carried in the collector's config and used at `LOGIN` before every
`INSTCMD` — the "register with the collector" half is already true) plus the four
gates. What it does **not** have is transport security: the account password and the
command travel in cleartext on the wire. This phase gives the wire what the TODO asks
for, in the form NUT 2.8.5 can express: the collector **generates and manages a root
CA**, issues a server certificate for the exporter (installed on it by the operator)
and a client certificate for itself; the `NutClient` upgrades the connection with
`STARTTLS`, verifies the exporter's certificate against the local CA (the collector
dials the real upsd, not a lookalike) and presents its own client certificate.
- ASSUMPTION (the load-bearing one): "just use the certs as auth rather than a
password" is implemented as **certs authenticate the transport, the account
password still gates the command** — the deployed upsd 2.8.5's command gate *is* the
`upsd.users` account (`SET`/`INSTCMD` still need it, per AGENTS.md's verified fleet
facts; NUT has no wire-level client-certificate check for commands, and its own
`HELP` verb list shows `USERNAME`/`PASSWORD` as the auth pair). A client
certificate presented to a server that does not check it is a trust slot for the
next NUT, not the gate today; the gate that exists today is made *usable only over
TLS* in the documented deployment, which is the protection the TODO is after
(password + commands off the cleartext wire, both endpoints authenticated). The
`available_commands()` limitation (the wire cannot tell what the account is
granted, AGENTS.md known gap 1) is unchanged — the risk tiers and the typed
`DANGER` stay load-bearing.
## Dependencies
- `05_ups_hero_battery_glyph` (complete) — the `NutClient`/`NutMonitor`/`COMMANDS`
surface this phase extends.
- `01…04` (complete) — `collectors.py`'s redacted-URL error doctrine (invariant 6)
that the TLS error messages must follow.
- `11_admin_login` (todo) — **shared file `config.py`**: 11 appends the four `DASH_*`
auth settings, this phase appends the `NUT_TLS*` block; whichever runs second
applies on top of the file state at that time, never restoring the other's lines.
`api.py` is also shared (11's gate vs this phase's none — this phase adds no route
and changes no route; the one POST stays the one POST).
## Tasks
1. `01_ca_and_certificate_tooling.md` — the collector's root CA: generation script, the 0600 material layout, and the upsd-side install steps verified against the deployed upsd 2.8.5 (rule 7: no guessed `upsd.conf` line).
2. `02_nutclient_starttls.md` — `STARTTLS` in the `NutClient`: stdlib `ssl`, server verified against the local CA, client certificate presented, the rest of the protocol byte-identical, and the cleartext path unchanged when TLS is off.
3. `03_config_gates_and_redaction.md` — the `NUT_TLS*` settings (off by default), the one-POST-four-gates story made explicit (the account password *is* the registration credential, unchanged), and invariant 6: no key material in anything that leaves the process.
4. `04_hermetic_tls_tests.md` — the loopback-TLS fake upsd, the `STARTTLS` framing pin (a server without the branch fails exactly as the `USER`/`PASS` regression does), wrong-CA rejection, the gate over TLS, the no-leak test, and the read-only `live` test.
## Testing & Quality
- Unit: `tests/test_nut.py` — `STARTTLS` framing (the fake upsd's `STARTTLS` branch),
server-cert verification (a self-signed server the local CA did not issue →
refused, with a redacted message), client-certificate presentation (the loopback
server with `CERT_REQUIRED` sees the dashboard's client cert), the
`USERNAME`/`PASSWORD` login **over TLS** (the gate still works through the
upgrade), and the cleartext path unmodified (`NUT_TLS` off → no `STARTTLS` sent,
every pre-existing test passes unmodified in intent).
- Unit: the no-leak test — a TLS failure's message, the health record it becomes and
the `event` row that carries it contain no key material and no `NUT_TLS_DIR`
contents (the URL redaction doctrine, extended to key material).
- Live (`-m live --live`, read-only per the AGENTS.md live rule): a
`STARTTLS`-capable probe of the real upsd (does the deployed 2.8.5 answer the
verb — `HELP` says it does; the probe records the answer), **no `INSTCMD`** sent
from the test (a battery test on hardware something is plugged into is not a
test).
- Coverage: **>90%** on new/modified code; the 94% gate must hold.
## Completion Criteria
- [ ] `scripts/nut-tls-certs.sh` (or the Python equivalent in `scripts/`) generates
the CA + both certificates on a clean machine; the material is 0600, outside
git, and the README's *NUT* section walks the operator through installing the
server certificate on the exporter (verified against the deployed upsd 2.8.5 —
the exact config in `data/logs/`, not a memory of a man page)
- [ ] with `NUT_TLS=1` and the material in place, `NutClient` completes `STARTTLS`,
verifies the server, and the `LOGIN` + a `GET` reading succeed over the
upgraded socket (the hermetic loopback; the `live` probe for the real verb
answer)
- [ ] with `NUT_TLS` unset (the default), every pre-existing `tests/test_nut.py`
behaviour is byte-identical — no `STARTTLS` on the wire, cleartext reads
unchanged
- [ ] no key material in any health record, event row, error message or `/api/config`
answer (the no-leak test + the redaction suite)
- [ ] `uv run pytest` green, `bash .agents/validate.sh` green
- [ ] `README.md`'s NUT section documents the CA, the install steps, the
password-still-gates-the-command reality, and why (the NUT 2.8.5 constraint)
@@ -0,0 +1,77 @@
# Task 01 — The collector's root CA: tooling and the verified upsd-side install
**Phase:** `15_nut_tls_credentials` · **Source:** `TODO.md:6` — "the collector should generate and manage a root certificate that needs to be installed with each exporter"
## Objective
The certificate side of "the collector manages the root certificate": a script that
generates the local CA, the exporter's server certificate and the collector's client
certificate with sane defaults, the material layout on disk, and — per rule 7
(verify against the deployed upstream, never guess) — the **actual** upsd 2.8.5
server-side configuration, verified on the host and recorded, not a man-page memory.
No key material ever enters a tracked file, and the generator is re-runnable (a
rotation is a re-run, not a migration).
## Work
1. `scripts/nut-tls-certs.sh` (bash + `openssl`, stdlib tooling — no new dependency,
the locked-decisions doctrine): generate, under `NUT_TLS_DIR` (default
`~/.config/dashboards/nut-tls/`, mode `0700`, files `0600`):
- `ca.key`/`ca.crt` — a local root CA (RSA 4096 or Ed25519, 20 y, `CA:TRUE`);
- `upsd.key`/`upsd.crt` — the exporter's server certificate, signed by the CA
(`CN` + `subjectAltName` for the address the collector dials —
`driveripper.reeselink.com` and `10.1.x.x` per the fleet facts; the SAN list is
a script argument, defaulting to the `NUT_HOST` value);
- `dashboard.key`/`dashboard.crt` — the collector's client certificate (the one the
`NutClient` will present);
- re-runnable: existing material is verified (the certs chain to the CA) and
left alone; `--rotate` regenerates (a rotation re-issues all three — the old
server cert stops verifying, which is the intended eviction, like the
session-secret rotation in phase 11).
2. Verify the upsd-side configuration **on the deployed upsd** (from
`driveripper`, where `sudo` grants the `/etc/ups` reads per the fleet facts):
read the running `upsd`'s TLS capability — `upsd -V`, its conf's current `LISTEN`
lines, and the 2.8.5 documentation *as installed on the host* (`man upsd` / the
package's docs) — and record the exact configuration that serves `STARTTLS` on
3051 with the server certificate, in `data/logs/NOTES-upsd-tls.md` (the exact
directive name(s), the cert/key paths upsd expects, and whether the 2.8.5 build
verifies *client* certificates at all — the phase-15 ASSUMPTION says it does not;
the note records the evidence either way). If the installed 2.8.5 **cannot**
serve `STARTTLS` (a `--without-tls` build — check `upsd -V`'s feature list),
record that and the phase's task 02/04 narrow to "the client is ready, the
server-side is the operator's upgrade call" — do **not** install a different NUT
(a fleet change is the operator's action, the AGENTS.md fleet-facts doctrine).
- ASSUMPTION: the script uses `openssl` (present on every fleet host, the
`deploy/install-agent.sh` doctrine — stdlib/tooling only, no new package) and
the default `NUT_TLS_DIR` lives in the operator's home config tree, not the
repo (invariant 6: the material is a credential, never tracked).
3. `README.md` → *NUT*: the operator walkthrough — run the script on the machine that
dials upsd, copy `upsd.crt`/`upsd.key` to the exporter, apply the task-1-recorded
upsd configuration (the `sudo` grant for the `/etc/ups` writes exists per the
fleet facts; the write itself stays the operator's action, like every other
`upsd.users` edit in this repo — the known-gap-1 doctrine: never grant `actions =
-`, never write it blind), restart `nut-server`, and confirm the collector
connects (task 04's `live` probe).
## Testing & Quality
- Script test (the repo's `tests/test_deploy_scripts.py` device — it already fails
the day a script regresses): the script's output layout (file names, modes
0700/0600, the certs chain to the CA via `openssl verify`), re-run idempotency,
and `--rotate` changing the key fingerprints; no tracked file receives key material
(the test asserts the generated dir is the only place the material is).
- Coverage: **>90%** on new/modified code (script + its test; the 94% gate must
hold).
## Completion Criteria
- [ ] a clean run of the script produces the CA + both certificates with the pinned
layout/modes; `openssl verify -CAfile ca.crt upsd.crt` passes; a re-run is a
no-op; `--rotate` changes fingerprints
- [ ] `data/logs/NOTES-upsd-tls.md` records the deployed upsd 2.8.5's actual TLS
capability and the exact server-side configuration (or the finding that this
build cannot serve `STARTTLS` — the note, not the assumption, decides task
02's scope)
- [ ] `tests/test_deploy_scripts.py` pin green; `bash .agents/validate.sh` green
- [ ] the README walkthrough exists and names the operator actions that stay the
operator's (the upsd config write, the restart)
@@ -0,0 +1,82 @@
# Task 02 — `STARTTLS` in the NutClient: verified server, presented client
**Phase:** `15_nut_tls_credentials` · **Source:** `TODO.md:6` — "just use the certs as auth rather than a password" (implemented per the phase ASSUMPTION: certs authenticate the transport; the `upsd.users` account still gates the command)
## Objective
The `NutClient` upgrades the connection with the verb the server's own `HELP`
advertises (`STARTTLS` — pinned in `tests/test_nut.py`'s verb-list test): after the
upgrade the socket is wrapped in stdlib `ssl` (no new dependency), the exporter's
certificate is verified against the local CA (the collector dials the real upsd, not
a lookalike), the collector's client certificate is presented (the trust slot for a
server that will check it), and **every other byte of the ~10-line protocol is
unchanged** — the `USERNAME`/`PASSWORD` login, the `LIST`/`GET` reads, the
`INSTCMD` write. When `NUT_TLS` is off (the default, and the only state the current
fleet can use — the laptop cannot reach 3051 at all, per the fleet facts), the
client behaves exactly as today: no `STARTTLS` sent, cleartext, every pre-existing
test passing unmodified in intent.
## Work
1. `src/dashboards/sources/nut.py` — `NutClient` (the socket half, where the
existing `connect`/`_read_line`/`login`/`instcmd` live):
- a `tls: bool` + `ca_path`/`cert_path`/`key_path` constructor path (built by
`NutMonitor` from the task-03 settings; the client itself stays testable with
explicit paths);
- on connect, when `tls`: send `STARTTLS\n`, read the OK line (the framing the
fake upsd in `tests/test_nut.py` already models for the other verbs — the same
one-line-per-command discipline), then wrap the **existing** socket with
`ssl.SSLContext(verify_mode=CERT_REQUIRED, purpose=server_auth)`:
`load_verify_locations(ca)`, `load_cert_chain(cert, key)` (the client
certificate), `server_hostname` from the dialled host — the wrap happens on the
connected socket, so `_read_line`/the writers are unchanged after the upgrade
(they already read/write `self._sock`);
- when `tls` is off: the constructor path is not even built (no `SSLContext`
object, no file reads — the cleartext path touches no TLS code at runtime);
- the `LOGIN`/`LOGOUT`/`GET`/`LIST`/`INSTCMD` sequence is unchanged in both modes
(the login happens after the upgrade — the password is the first thing that
travels over the encrypted socket, which is the point).
- The scope guard from task 01: if `data/logs/NOTES-upsd-tls.md` recorded that
the deployed 2.8.5 build **cannot** serve `STARTTLS`, this task still ships
the client (the hermetic loopback proves it; the README says the server side is
the operator's upgrade call) — the client capability is independent of one
host's build.
2. `src/dashboards/collectors.py` — `NutMonitor` takes the task-03 settings and
builds the TLS half (one place, the `from_env()` single-source doctrine `nut_sim`
already follows: the assembly and the model cannot disagree about what is
enabled). A TLS failure (refused upgrade, unverified server, expired cert) is an
`UpstreamError` like any other dial failure — the source goes `DOWN` with the
real reason (invariant 4), and the message follows the existing redacted-URL
doctrine (the dialled address redacted, and **no key material** — task 04's
no-leak test pins it).
3. `src/dashboards/sources/nut_sim.py` — no change expected (the simulator is a
labelled fake, invariant 3/its own doctrine); the `SimUps` tests keep passing —
TLS is a transport property the simulator does not model, and that is correct.
## Testing & Quality
- Unit: `tests/test_nut.py` — the fake upsd's `STARTTLS` branch (a scripted
loopback `ssl` server, the task-01-generated certificates in a temp dir — the
test generates its own material with the task-01 script, never a tracked
fixture): the upgrade framing (`STARTTLS` → OK → the wrapped socket answers the
same verbs), the `LOGIN` over TLS, a `GET` reading over TLS, and the
`CERT_REQUIRED` rejection: a loopback server with a certificate the local CA did
not issue → the connection is refused with an `UpstreamError` whose message
names the redacted host and **no** key material.
- Regression: `NUT_TLS` off (the default in every pre-existing test) → every
pre-existing `test_nut.py` / `test_collectors.py` / `test_api.py` behaviour
passes unmodified in intent; no `STARTTLS` string ever appears on the cleartext
wire (the fake upsd's scripted log asserts it was never sent).
- Coverage: **>90%** on this task's new/modified code; the 94% gate must hold.
## Completion Criteria
- [ ] the loopback-TLS fake upsd: `STARTTLS` framing, verified server, presented
client certificate (the server with `CERT_REQUIRED` sees it), login + read
over the upgraded socket — all green
- [ ] a wrong-CA server is refused with a redacted, key-free `UpstreamError`; the
source state path (task 04's poller test) shows `DOWN` with the reason, last
good payload kept (invariant 4)
- [ ] the cleartext path is byte-identical: the pre-existing suite passes and the
fake upsd records no `STARTTLS` verb in cleartext mode
- [ ] `bash .agents/validate.sh` green
@@ -0,0 +1,84 @@
# Task 03 — `NUT_TLS*` settings, the registration story, and the no-leak boundary
**Phase:** `15_nut_tls_credentials` · **Source:** `TODO.md:6` — "the exporter should be installed with password protection and that password must be used to register it with the collector"
## Objective
The config surface: `NUT_TLS` (off by default — the current fleet cannot reach upsd
from the laptop at all, and the deployed dashboard reads cleartext today; enabling
is the operator's action per the README walkthrough) and `NUT_TLS_DIR` (the
0600 material tree task 01 lays out). Plus the "registration" half made explicit in
code and docs: the `upsd.users` account password the operator installed on the
exporter is carried in the collector's env (the existing `NUT_USER`/`NUT_PASSWORD`
settings — the `.env.dev` already holds the credentials per known gap 1c) and is
used at `LOGIN` before every `INSTCMD`; TLS changes *how* it travels, never *that*
it is the gate. And invariant 6 extended: a key is a credential, so no key material
may appear in anything that leaves the process (health records, event rows, error
messages, `/api/config`).
## Work
1. `src/dashboards/config.py` — append the block (the phase-11 `DASH_*` block is the
pattern; whichever phase runs second appends on top, never restoring the
other's lines): `NUT_TLS` (the `_env()` truthy rule, same as `NUT_SIMULATE`'s
`from_env()` doctrine — one place the rule lives, the settings object carries
the parsed flag) and `NUT_TLS_DIR` (path, default `~/.config/dashboards/nut-tls`,
expanded). The `Target`/redaction half is unchanged: the TLS settings are not
URLs and carry no userinfo.
- ASSUMPTION: `NUT_TLS_DIR` is a *directory* (the script's layout:
`ca.crt`/`dashboard.crt`/`dashboard.key` inside it), not three path variables —
one knob, one layout, the rotation is a re-run of the script (task 01), and a
mistyped path is a clear `UpstreamError` ("TLS material not found in …"), not
three silent defaults.
2. `src/dashboards/sources/nut.py` — the no-leak boundary (invariant 6, the existing
doctrine's exact pattern: `strip_userinfo`/`Target.display_url` for URL tokens):
- every `UpstreamError` message built in the TLS path is checked against a
**key-free list** — the message may name the redacted dial address, the verb,
and a fixed failure kind (`tls: server certificate not signed by the local
CA`, `tls: client refused the certificate`, `tls: material not found in <dir>`)
— nothing interpolated from the key files themselves (they are opened for the
`SSLContext` and never read into a string; the one place a path may appear is
`NUT_TLS_DIR`'s *path*, which is not secret material);
- the `tests/test_auth.py` device for fixed-text raise/log sites (the structural
pin that fails the day someone interpolates a credential) is extended to the
`nut.py` TLS error-construction sites — the same structural test shape,
applied to the new code.
3. `.env.example` / `.env.dev.example` — the `NUT_TLS*` block (commented, off, the
walkthrough pointer to the README's NUT section); the existing
`NUT_USER`/`NUT_PASSWORD` comments gain the one line that names the registration
story: "this is the account the exporter was installed with — it is used at
`LOGIN` before every `INSTCMD`; with `NUT_TLS=1` it travels over the encrypted
socket, without it the wire is cleartext (the firewall, not the password, is
what keeps strangers off 3051 today — the fleet-facts doctrine: the gate is the
account + the four gates on `POST /api/ups/command`, and `NUT_ALLOW_ACTIONS`
stays the first of them)."
4. `README.md` → *NUT*: the settings table rows + the one-paragraph "how the
privileged action is protected" that states the whole story in operator words:
the account gates the command (the upsd side), the four gates gate the app side
(`NUT_ALLOW_ACTIONS`, the account actually having the command, the UI
confirmation, the on-battery refusal), TLS (when enabled) keeps the password and
the command off the cleartext wire and authenticates both ends by certificate —
and says *why the password remains the gate* (the NUT 2.8.5 constraint, the
phase ASSUMPTION, with the task-01 note as the evidence).
## Testing & Quality
- Unit: `tests/test_config.py` — the `NUT_TLS` truthy rule (the `NUT_SIMULATE`
test's shape), the `NUT_TLS_DIR` default/expansion, and the "unset means
cleartext" default (an app built with no TLS settings never opens a key file —
a `monkeypatch` on the file reads asserts zero opens in the default suite).
- Unit: the structural no-leak pin (the `test_auth.py` device, task-02's error
sites) — a regression that interpolates a key file's *contents* into a message
fails the test, not a log line.
- Coverage: **>90%** on new/modified code; the 94% gate must hold.
## Completion Criteria
- [ ] `NUT_TLS` unset (the default in every pre-existing test and in both env
templates): no key file is opened, no `STARTTLS` is sent — the pre-existing
suite is byte-identical in behaviour
- [ ] `NUT_TLS=1` + the task-01 layout: the settings resolve to the three files;
a missing file is a clear `UpstreamError` naming the directory, nothing more
- [ ] the env templates + README carry the registration story and the NUT 2.8.5
"why the password remains the gate" paragraph
- [ ] `bash .agents/validate.sh` green
@@ -0,0 +1,85 @@
# Task 04 — The loopback-TLS suite, the no-leak pin, and the read-only live probe
**Phase:** `15_nut_tls_credentials` · **Source:** `TODO.md:6` — "For privileged actions (UPS commands) the exporter should be installed with password protection…" (the whole phase, proven)
## Objective
The proof layer: a hermetic loopback TLS fake upsd (stdlib `ssl`, the task-01
script's certificates in a temp dir — never tracked), the `STARTTLS` framing pin
that fails the day the server-side verb stops being answered (the exact device
`test_login_uses_the_verbs_upsd_2_8_5_answers_to` uses for the `USER`/`PASS`
regression), the wrong-CA rejection, the client-certificate presentation, the
`LOGIN` gate **over TLS**, the no-leak pin for key material, and the read-only
`live` probe of the deployed upsd's `STARTTLS` answer. No `INSTCMD` from any test
(a battery test on hardware something is plugged into is not a test — the
AGENTS.md live rule).
## Work
1. `tests/test_nut.py` — the fake upsd's TLS half (the existing fake's scripted-
reply doctrine, one socket upgrade away):
- a loopback `ssl.SSLContext` server (the task-01 `upsd.crt`/`upsd.key`,
`CERT_REQUIRED` + `load_verify_locations(dashboard.crt)` so it **demands** the
client certificate) answering the same one-line-per-command script: `STARTTLS`
→ OK, then the post-upgrade verbs;
- `test_starttls_upgrade_framing` — the client sends `STARTTLS` exactly once,
before the `LOGIN`, and the post-upgrade `LIST`/`GET` answers are parsed
identically to the cleartext suite (the `UpsReading` contract,
`tests/test_ups_contract.py`, holds over TLS);
- `test_client_certificate_is_presented` — the server context's
`CERT_REQUIRED` passes only because the client presented `dashboard.crt`
(the test asserts the server saw a verified peer cert, not just a completed
handshake);
- `test_unverified_server_is_refused` — a second loopback server with a
self-signed certificate the local CA did not issue → `UpstreamError`, the
message redacted and key-free (asserted against the no-leak list), the
connection closed, no `LOGIN` sent (the password never travelled);
- `test_login_gate_over_tls` — a wrong `PASSWORD` over the upgraded socket
still answers the upsd's `ERR` exactly as the cleartext suite pins it (the
account gate is TLS-agnostic — the phase ASSUMPTION's core, proven).
2. `tests/test_collectors.py` — the poller half (invariant 4's exact doctrine, the
existing `store-error`/stale device): a `NutMonitor` whose loopback server
dies mid-session (the TLS handshake succeeds, then the socket closes) → the
source is `DOWN` with the real reason, the last good payload kept, one
transition event, no exception ends the task; a TLS-missing-material start
(task 03's `NUT_TLS_DIR` with no files) → the same `DOWN` path with the clear
reason, **without dialling** (invariant 3's shape: a source that cannot even
build its material does not pretend to be configured-and-dead — the event says
the material is missing, once, not per poll).
3. `tests/test_api.py` — the no-leak pin at the boundary that leaves the process:
with a TLS failure injected, `/api/health`'s job record, the `event` table row
and `/api/events`' answer contain none of the key material (the temp-dir
`dashboard.key`'s contents, the `ca.key`'s contents, and any PEM marker are all
absent) — the same test shape that pins the URL-token redaction, applied to
key material.
4. `tests/test_live_fleet.py` — the `live` probe (skipped unless `--live` and
`NUT_TLS`-capable material exists; **read-only**): the deployed upsd's
`STARTTLS` answer — `HELLO`-style: open a connection, send `STARTTLS`, record
the one-line answer (OK or the 2.8.5 build's refusal — both are findings for
`data/logs/NOTES-upsd-tls.md`'s "what the live server does" line, the task-01
note's running update), **no `LOGIN` and no `INSTCMD`** (the account password
is not a test input; the known-gap-1 doctrine: a live test never writes).
## Testing & Quality
- Unit: per the work list — framing, presentation, refusal, the gate over TLS,
the poller's `DOWN` path, the no-leak pin.
- Live: the read-only probe, its answer recorded (the tally in the completion
note, the known transport gap acknowledged: from the laptop the probe skips on
the 3051 unreachability, per the fleet facts).
- Coverage: **>90%** on new/modified code; the 94% gate must hold.
## Completion Criteria
- [ ] `uv run pytest tests/test_nut.py tests/test_collectors.py tests/test_api.py`
green — the whole task-02 matrix (framing/presentation/refusal/gate) plus
the poller `DOWN` path plus the no-leak pin
- [ ] no `INSTCMD` string in any `live` test (the suite's read-only rule, greppable
and pinned by the `test_live_fleet.py` convention the existing live tests
follow)
- [ ] `uv run pytest -m live --live` run (skips recorded for the unreachable
paths); the `STARTTLS` live answer recorded in `data/logs/NOTES-upsd-tls.md`
when reachable
- [ ] `bash .agents/validate.sh` green — the phase's final pass: the whole suite
100% (the phase-11 completion doctrine: "Complete" only when the entire
project test suite passes, including the opt-in live run)
@@ -0,0 +1,15 @@
**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.
**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).
**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.
**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.
**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.
**Next pending phase**: `12_nut_self_test_verdict`.
@@ -0,0 +1,61 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 6%]
........................................................................ [ 12%]
........................................................................ [ 18%]
........................................................................ [ 25%]
........................................................................ [ 31%]
........................................................................ [ 37%]
........................................................................ [ 43%]
........................................................................ [ 50%]
........................................................................ [ 56%]
........................................................................ [ 62%]
........................................................................ [ 68%]
...............sssssssssss.............................................. [ 75%]
........................................................................ [ 81%]
........................................................................ [ 87%]
........................................................................ [ 93%]
....................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 348 0 100%
src/dashboards/app.py 178 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 130 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 390 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 498, 510, 545, 578, 582, 639-640, 645, 657
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 188 2 99% 255-256
------------------------------------------------------------------
TOTAL 2820 39 99%
Required test coverage of 94% reached. Total coverage: 98.62%
1140 passed, 11 skipped, 1 warning in 61.66s (0:01:01)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
certifi 2026.7.22 2026.10.18 wheel
fastapi 0.143.5 0.147.0 wheel
starlette 1.8.0 1.9.0 wheel
@@ -0,0 +1,17 @@
Task complete. Summary:
**Implemented**
- `src/dashboards/auth.py` — pure, stdlib-only auth primitives: `hash_password`/`verify_password`/`needs_rehash` (self-describing `pbkdf2_sha256$<iters>$<salt>$<hash>`, 600 000 default, `PBKDF2_MAX_ITERATIONS` DoS ceiling, `compare_digest`, **never raises**); `new_secret` + `Session` + `mint_session`/`read_session` (HMAC-signed stateless cookie, signature-before-expiry, rejects tampered username/expiry, rotated secret, non-UTF-8 payload, NaN/inf expiry); `mint_csrf`/`verify_csrf` bound to the session token; `LoginThrottle` (5 attempts → 60 s base, doubling, capped at 3600 s, `success()` clears, `prune()` + inline `THROTTLE_MAX_KEYS` size guard for spoofed-address swarms); `origin_matches` + `sec_fetch_site_allowed` (unknown → reject).
- `tests/test_auth.py` — 99 tests incl. 21-shape malformed-verifier table; structural pins: no third-party import, no log/stdout call, no `raise` anywhere.
**Verification**
- `uv run pytest tests/test_auth.py --cov=dashboards.auth --cov-branch` → 99 passed, **100 % stmts + branches** (194/194, 46/46).
- `bash .agents/validate.sh` → exit 0: **892 passed, 11 skipped**, coverage **98.46 %** (floor 94), ruff clean, pyright 0 errors, `node --check` clean, repo hygiene clean.
- `uv pip list --outdated` empty; `pyproject.toml`/`uv.lock` untouched (no new dependency).
- `uv run pytest -m live --live` → same 6 pre-existing transport failures documented in AGENTS.md (laptop cannot reach `:9120`/`:3051`); engines pass. No live change made or needed.
**Docs** — `AGENTS.md`: invariant 6 extended to the admin's own credential; `auth.py` added to the module-responsibility list (explicitly noting the gate itself arrives in tasks 03-04); new Known gap 5 for the peer-address throttle key; test/coverage counts refreshed. `README.md`: no doc impact yet (no env var, route, or page until tasks 02-07).
**Decisions** — `needs_rehash` returns `True` for off-shape/unknown-algo verifiers ("replace it"); `origin_matches("")` is `False` and the caller gates on header *presence* (documented, tested); base64url is decoded with `validate=True`; blocks never shorten on a failure during a lockout.
**Next pending task:** `.agents/phases/todo/11_admin_login/02_verifier_config_fail_closed.md`
@@ -0,0 +1,54 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 7%]
........................................................................ [ 15%]
........................................................................ [ 23%]
........................................................................ [ 31%]
........................................................................ [ 39%]
........................................................................ [ 47%]
........................................................................ [ 55%]
.............................................................sssssssssss [ 63%]
........................................................................ [ 71%]
........................................................................ [ 79%]
........................................................................ [ 87%]
........................................................................ [ 95%]
....................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 221 0 100%
src/dashboards/app.py 101 0 100%
src/dashboards/auth.py 194 0 100%
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
src/dashboards/config.py 115 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 130 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 390 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 498, 510, 545, 578, 582, 639-640, 645, 657
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 178 2 99% 250-251
------------------------------------------------------------------
TOTAL 2530 39 98%
Required test coverage of 94% reached. Total coverage: 98.46%
892 passed, 11 skipped, 1 warning in 60.28s (0:01:00)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,24 @@
All green. Final state:
**Implemented**
- `store.py`: `get_meta`/`set_meta`/`delete_meta` (rowcount-returning, one key per call) over the existing `meta` table; `stats()`'s `last_maintain` now reads through `get_meta()` (outside its own non-reentrant lock), `maintain()`'s write stays inline.
- `config.py`: four settings — `DASH_ADMIN_PASSWORD` (seed, `_env` strips so `VAR=` = unset), `DASH_SESSION_HOURS` (720), `DASH_COOKIE_SECURE`, `DASH_PBKDF2_ITERATIONS` (imports `auth.PBKDF2_ITERATIONS`).
- `auth.py`: `MetaStore` Protocol, frozen `AuthState`, `load_auth_state()` (env seeds once → stored verifier wins → `env_ignored` + one fixed-wording warning; no row + no seed → `RuntimeError` naming the variable, the key and the `sqlite3 … DELETE FROM meta …` reset command; unusable secret is regenerated *and* persisted), `update_verifier()` (secret written first, empty/whitespace password refused).
- `app.py`/`api.py`: lifespan builds `ctx.auth` after the context and before the first poll (one call site for both store branches); `AppContext.auth: AuthState | None`. Fixed `__main__.py` to pass `SETTINGS` into `create_app()` so the served app is configured by the same object the bind address came from.
- Tests: 6 in `test_store.py`, 4 in `test_config.py`, new `tests/test_auth_bootstrap.py` (23), 2 in `test_api.py` (fail-closed start / seeded start); `tests/auth_contract.py` = `testpass` + `FAST_PBKDF2` applied to the 14 app-starting call sites; `test_auth.py`'s two structural pins now allow logging + exactly the two documented raise sites and prove neither interpolates a credential.
- Docs: README quickstart, config table, new *Admin login* section (reset procedure), architecture + Testing; AGENTS.md new invariant 11, `auth.py`/`config.py`/`store.py`/`app.py` responsibility lines, invariant 6 corrected; `.env.example` + `.env.dev.example` gained the four vars (empty — validate.sh 2b rejects a non-placeholder secret in a tracked template); appended `DASH_ADMIN_PASSWORD=testpass` to the gitignored `.env.dev` so the dev server keeps coming up.
**Verification**
- `bash .agents/validate.sh` → exit 0: `927 passed, 11 skipped`, coverage **98.50%** (floor 94; baseline 98.46), ruff clean, pyright `0 errors`, `node --check` clean, hygiene clean. Per-module: `auth.py` 100%, `config.py` 100%, `app.py` 100%, `store.py` 99% (2 pre-existing misses in `recent_events`).
- `uv run pytest tests/test_store.py tests/test_config.py tests/test_auth.py tests/test_auth_bootstrap.py -q` → 182 passed.
- Live probe A (`DASH_DB=/tmp/auth-probe.sqlite3`, `DASH_ADMIN_PASSWORD` unset): **exit 3**, `RuntimeError: no admin password configured — refusing to start a public dashboard open.` … `sqlite3 "/tmp/auth-probe.sqlite3" "DELETE FROM meta WHERE key IN ('auth.verifier', 'auth.session_secret')"` — put this line in the commit message.
- Live probe B (`DASH_ADMIN_PASSWORD=testpass`): boots, `/api/health` 200, `meta` holds `auth.verifier` = `pbkdf2_sha256$600000$…` + `auth.session_secret`, `grep -c testpass` = 0 (same checks on the real dev DB). No `sqlite3` CLI on this box, so the `SELECT key FROM meta` listing ran through Python's stdlib `sqlite3` — same query.
- `./scripts/dev-server.sh restart` → running, `/api/health` 200, `/` 200 (fleet still `degraded` on the known transport block, unchanged).
- `uv run pytest -m live --live`: the same 6 pre-existing transport failures (host agents + upsd unreachable from the laptop), no new ones.
**Decisions / deviations**
- `load_auth_state()` takes `db_path: str | None` instead of the task's `now: float | None` — nothing in the loader reads a clock (a session's expiry lives in its signed payload), and the real DB path makes the reset command copy-pasteable (`<db>` when unknown).
- Chose an explicit `admin_password`/`pbkdf2_iterations` at each test call site (via `tests/auth_contract.py`) rather than ambient env, so no test depends on the shell.
- No live infra changes were made or needed beyond the local dev server restart.
**Next pending task:** `.agents/phases/todo/11_admin_login/03_session_gate_and_public_surface.md`
@@ -0,0 +1,55 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 7%]
........................................................................ [ 15%]
........................................................................ [ 23%]
........................................................................ [ 30%]
........................................................................ [ 38%]
........................................................................ [ 46%]
........................................................................ [ 53%]
........................................................................ [ 61%]
..................sssssssssss........................................... [ 69%]
........................................................................ [ 76%]
........................................................................ [ 84%]
........................................................................ [ 92%]
........................................................................ [ 99%]
.. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 223 0 100%
src/dashboards/app.py 103 0 100%
src/dashboards/auth.py 247 0 100%
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 130 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 390 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 498, 510, 545, 578, 582, 639-640, 645, 657
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 188 2 99% 255-256
------------------------------------------------------------------
TOTAL 2602 39 99%
Required test coverage of 94% reached. Total coverage: 98.50%
927 passed, 11 skipped, 1 warning in 48.88s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,19 @@
**Implemented** (task 03 — session gate & public surface)
- Completed/verified the gate in `app.py`: `PUBLIC_PATHS`/`PUBLIC_PREFIXES` + `PAGES["/login"]`, `session_gate` middleware (503 without `ctx.auth` → router-404 passthrough → public → 303/401 → origin + CSRF on writes), `_own_origin` via `X-Forwarded-Proto`, `auth.safe_next`, `no-store` on every gate answer.
- `api.py`: `auth_router()` (`POST /api/auth/login` throttle-first, one `wrong password` sentence, 422 before hashing, cookie flags + `Secure` on proxied https; `GET /api/auth/status`; CSRF-gated `POST /api/auth/logout`), `AppContext.login_throttle` (one per process), `get_session`/`get_auth_state` refusals.
- `web/static/app.js`: memoised `ensureAuth()`, CSRF on every non-GET/HEAD `fetchJSON`, one-shot `bounceToLogin()`, exports `authStatus`/`ensureAuth`; `/login` page + `page-login.js` (posts without `fetchJSON`).
- Tests: renamed `test_auth_gate.py` → **`tests/test_auth_api.py`** (the name every code comment and the phase mandate use), dropped a duplicated helper, and added the missing required test `test_every_route_that_is_not_public_requires_a_session` (walks the *served* table via `walk_routes`, GET+POST, pins the public set exactly), the wire proof that a tokenless `POST /api/ups/command` reaches **no** driver while the tokened one does (`driver.killpower` still needs `DANGER`), non-object JSON body 422, 503-on-public-paths pin, route-404-while-signed-in, and a direct call of the login route's no-credential branch.
- Docs: README gained *What the session covers / Writes need the CSRF token / Scripting the API (bearer) / Lockout* plus testing notes; AGENTS.md gained invariant **12** (default-deny + the four-entry public surface), `api.py`/`app.py`/`web/`/`auth.py` line updates, the invariant-1 and invariant-11 forward references corrected, test/coverage figures, and a re-verified fleet/live-tally correction.
**Verification**
- `bash .agents/validate.sh` → exit 0: **1081 passed, 11 skipped**, coverage **98.59%** (floor 94); `api.py`, `app.py`, `auth.py` each **100%**; ruff `All checks passed!`; pyright `0 errors`; `node --check` clean.
- Live: `curl -si /system` → `303` + `location: /login?next=/system`; `/api/summary` & `/api/config` → `{"ok":false,"detail":"sign in required","login":"/login"}`; `/api/health`, `/static/dash.css`, `/login` → 200; login POST returns `ok:true` + `csrf` + `HttpOnly; SameSite=lax; Max-Age`; bearer token opens `/api/summary` (200); cookie write without `X-CSRF-Token` → `403 missing or invalid CSRF token`.
- Browser (headless Chromium, screenshots in `data/logs/auth-gate-*.png`): `/system?range=1h` → login form, wrong password shows inline "wrong password" without navigating, `testpass` lands back on `/system?range=1h` with live heroes, signed-in `/login` → `/`, no page JS errors.
- `uv run pytest -m live --live` → 8 passed / 3 failed, all three the documented `driveripper` agent `ConnectError` (no auth involvement).
**Notable decisions / deviations**
- Gate answers `503` for **all** paths (public included) when no `AuthState` exists — task text said "non-public"; `get_context` would otherwise 500 on `/api/health`, and "healthy" from a server that can't name its admin is the invariant-4 failure. Pinned by test + AGENTS invariant 12; the login route's own 503 branch is now driven directly.
- `_wants_page` treats `Accept: */*` (curl) as a page request so the phase's own acceptance check sees a 303, while `/api/*` always answers JSON.
- No live infra changes: lighting up `driveripper` needs its installer + firewall line (operator-only per AGENTS fleet notes), so the 3 live failures stay; validate.sh's dependency advisory (fastapi/starlette/certifi) left alone — this task touched no dependency, and a FastAPI bump would move the route-table shape the new audit walks.
**Next pending task:** `.agents/phases/todo/11_admin_login/04_change_password.md`
@@ -0,0 +1,61 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 6%]
........................................................................ [ 13%]
........................................................................ [ 19%]
........................................................................ [ 26%]
........................................................................ [ 32%]
........................................................................ [ 39%]
........................................................................ [ 46%]
........................................................................ [ 52%]
........................................................................ [ 59%]
........................................................................ [ 65%]
............................sssssssssss................................. [ 72%]
........................................................................ [ 79%]
........................................................................ [ 85%]
........................................................................ [ 92%]
........................................................................ [ 98%]
............ [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 287 0 100%
src/dashboards/app.py 178 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 130 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 390 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 498, 510, 545, 578, 582, 639-640, 645, 657
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 188 2 99% 255-256
------------------------------------------------------------------
TOTAL 2759 39 99%
Required test coverage of 94% reached. Total coverage: 98.59%
1081 passed, 11 skipped, 1 warning in 61.09s (0:01:01)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
certifi 2026.7.22 2026.10.18 wheel
fastapi 0.143.5 0.146.0 wheel
starlette 1.8.0 1.9.0 wheel
@@ -0,0 +1,18 @@
All green. Final report:
**Implemented** (`src/dashboards/api.py`)
- `POST /api/auth/password`: validates `new` first (`MIN_PASSWORD_LENGTH = 8`, non-empty once stripped, must differ from `current` → `422` naming the rule), throttles the *current* password on its own key `password:<address>` (`429`+`Retry-After`), wrong → `403` + `password-denied` event and the rows untouched, right → `auth.update_verifier` (verifier **and** secret), `ctx.auth = new_state`, throttle reset, `password-changed` event, response built from the **new** state with a fresh cookie + CSRF token.
- Shared `_session_answer()` so login and the change cannot disagree about cookie attributes (`HttpOnly`/`SameSite=lax`/`Path`/`Max-Age`/`Secure` via `DASH_COOKIE_SECURE` or `X-Forwarded-Proto`); `_password_body()` (`422` for any malformed body before hashing/throttle); `_new_password_problem()`.
- Login now upgrades a verifier minted below `DASH_PBKDF2_ITERATIONS` (`_upgrade_verifier`, secret kept); a refused store write is swallowed with one fixed-text log line so `database is locked` never costs a correct password its login.
**Tests / lint / coverage**
- `uv run pytest tests/test_auth_api.py` → 180 passed (26 new: refusal-leaves-rows-byte-identical, separate throttle key + sixth-with-correct-password `422`/`429`, the four `422` composition cases, 8 malformed bodies, both rows changed + old/other-browser cookie `401` + new cookie/CSRF reaching the scripted upsd wire, restart-with-seed, rehash + refused-rehash, cookie-attribute parity, POST-only/CSRF, no auth detail on `/api/health`|`/api/config`, no plaintext in the DB bytes).
- `bash .agents/validate.sh` → `1107 passed, 11 skipped`, coverage `98.62%` (floor 94; `api.py` 100%), `ruff` clean, `pyright` 0 errors, `node --check` clean.
- `uv run pytest -m live --live` → unchanged tally, 3 failures all the documented `driveripper` transport (`ConnectError 10.1.0.126:9120`), no regression.
- Live by hand on :8090 (server restarted): sign in → change → old cookie `401`, returned cookie `200`, its CSRF passes the gate, `meta.auth.verifier` is only `pbkdf2_sha256$600000$…`; password restored to `testpass` so later tasks keep working.
**Docs**: README — new *Changing the password* section, `DASH_PBKDF2_ITERATIONS` rehash note, "one write endpoint **that dials anything**", lockout section notes the second bucket. AGENTS.md — invariant 12 names the third gated write, `api.py`/`auth.py` bullets describe the route + revocation-by-rotation, Known gap 5 records both throttle keys (also fixed the `/api/config is redaction boundary` grammar slip).
**Deviations**: none of substance; deps untouched, so the advisory `uv pip list --outdated` (certifi/fastapi/starlette) was left alone rather than upgrading mid-phase.
**Next pending task**: `.agents/phases/todo/11_admin_login/05_login_page.md`.
@@ -0,0 +1,61 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 6%]
........................................................................ [ 12%]
........................................................................ [ 19%]
........................................................................ [ 25%]
........................................................................ [ 32%]
........................................................................ [ 38%]
........................................................................ [ 45%]
........................................................................ [ 51%]
........................................................................ [ 57%]
........................................................................ [ 64%]
......................................................sssssssssss....... [ 70%]
........................................................................ [ 77%]
........................................................................ [ 83%]
........................................................................ [ 90%]
........................................................................ [ 96%]
...................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 348 0 100%
src/dashboards/app.py 178 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 130 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 390 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 498, 510, 545, 578, 582, 639-640, 645, 657
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 188 2 99% 255-256
------------------------------------------------------------------
TOTAL 2820 39 99%
Required test coverage of 94% reached. Total coverage: 98.62%
1107 passed, 11 skipped, 1 warning in 60.86s (0:01:00)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
certifi 2026.7.22 2026.10.18 wheel
fastapi 0.143.5 0.146.0 wheel
starlette 1.8.0 1.9.0 wheel
@@ -0,0 +1,15 @@
**Task 05 complete.** Final report:
**Implemented**
- `web/pages/login.html` — `Homelab · sign in`, masthead brand link + theme toggle and **no** `.tabs`/dots/clock; one `.card`: `h1`, the `.sub` line, `label[for=admin-password]` + `input#admin-password[type=password autocomplete=current-password required]` + `button.action#sign-in`, `#login-error[role=alert][hidden]`, `#login-note.faint.mono[hidden]`, `app.js`→`page-login.js` both `defer`. No username field.
- `web/static/page-login.js` — house-style IIFE: `Dash.fetchJSON('POST /api/auth/login')` → `location.assign(target(body.next))`; `401` → inline `Wrong password.` + `field.select()`; `429` → the server `detail` + `Retry-After` ticking down with the button disabled until 0 (and the now-false refusal cleared with it); anything else prints the server's `detail`; all text via `textContent`; client copy of `safe_next`; `Dash.ensureAuth()` stops the form flash.
- `web/static/app.js` — `LOGIN_PATH` literal (pinned to `auth.LOGIN_PATH`) and `bounceToLogin()` returns when already on `/login`; `fetchJSON` now carries `response.headers` on the thrown `Error`, so the countdown is the server's number and not an invented one (rule 2).
- `dash.css` — `.login-card`/`.login-error`/`form`/`label` in the `.hero .label` vocabulary, existing `:root` tokens only; `input[type="text"]` extended to `input[type="password"]` instead of duplicated; the interim `.login-body/.login-panel/.login-form/.login-theme` rules deleted.
**Tests / gates** — 7 new-or-rewritten served-bytes tests in `tests/test_auth_api.py` (field/role/no-username, no tabs+dots+clock with the brand link present, posts *through* the shared fetch, the no-navigate guard, status-branched refusal + `Retry-After`, countdown/disable/clear, `next` judged before every `location.*`, CDN fragments) and the `/login` cross-reference in `tests/test_api.py`. `bash .agents/validate.sh` → **1113 passed, 11 skipped**, coverage **98.62 %** (floor 94), ruff clean, pyright 0 errors, `node --check` clean.
**Browser (live dev server :8090)** — anonymous `/` → `303 /login?next=/`; wrong password inline with no reload and the text selected; five wrong → lockout counting `Retry-After` down with a dimmed disabled button, then it cleared and `testpass` worked; `?next=/ups` landed on `/ups`; dots `hosts/disks=warn` (driveripper's agent is not installed — the documented state) `engines/UPS=ok`. Screenshots `data/logs/login-{dark,light,refusal,lockout,next-ups-landing,success-overview}.png` + `data/logs/NOTES-login-page.md`.
**Docs** — AGENTS.md `web/` bullet now describes the finished page and the bounce-guard rule (it previously claimed "no masthead" and "the only script that posts without `Dash.fetchJSON`"); README gained *The sign-in page*.
**Deviations** — the task-03 test "the login page posts without the shared fetch" was deliberately inverted (task 05 asks for `fetchJSON`; the guard makes it safe); `#login-note` carries one extra `login-note` class as the CSS hook. No new Python (coverage unchanged), no new deps (the advisory `uv pip list --outdated` is pre-existing), no task/phase/plan file touched, no git index changes; dev server left running.
@@ -0,0 +1,61 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 6%]
........................................................................ [ 12%]
........................................................................ [ 19%]
........................................................................ [ 25%]
........................................................................ [ 32%]
........................................................................ [ 38%]
........................................................................ [ 44%]
........................................................................ [ 51%]
........................................................................ [ 57%]
........................................................................ [ 64%]
............................................................sssssssssss. [ 70%]
........................................................................ [ 76%]
........................................................................ [ 83%]
........................................................................ [ 89%]
........................................................................ [ 96%]
............................................ [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 348 0 100%
src/dashboards/app.py 178 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 130 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 390 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 498, 510, 545, 578, 582, 639-640, 645, 657
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 188 2 99% 255-256
------------------------------------------------------------------
TOTAL 2820 39 99%
Required test coverage of 94% reached. Total coverage: 98.62%
1113 passed, 11 skipped, 1 warning in 62.14s (0:01:02)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
certifi 2026.7.22 2026.10.18 wheel
fastapi 0.143.5 0.146.0 wheel
starlette 1.8.0 1.9.0 wheel
@@ -0,0 +1,17 @@
Everything is green. Final state:
**Implemented (task 06 — `/settings`)**
- `app.py`: `"/settings": "settings.html"` in `PAGES` (gated by the middleware, not added to `PUBLIC_PATHS`).
- `web/pages/settings.html`: Session `<dl>` (name / local expiry timestamp / `in 30d`) + **Sign out**; Change-password form (current / new / confirm, real `<label for>`, shared `aria-describedby` hint naming the 8-char floor); `#settings-result` `role="status"`; static precedence paragraph; `.tabs` with **no** `aria-current`, no `#nav-status`.
- `web/static/page-settings.js`: status read → panel; logout → `resetAuth()` → `/login`; local pre-checks (non-empty current, ≥8 chars, confirm match, focus moved to the offending box) → `POST /api/auth/password`; `403`/`422`/`429` messages via `textContent`; on success `resetAuth()`, fields cleared, status re-read.
- `web/static/app.js`: exported `resetAuth()` (clears `authStatus` + in-flight promise, and an `authEpoch` guard so a status call started before a rotation can't repopulate a dead CSRF token); also called on the `401` bounce.
- `⚙ Account` link in `.masthead-right` of **all five** dashboards (task text predates `/disks`) — outside `.tabs`, no `id`; `dash.css` extends `button.ghost` → `button.ghost, a.ghost` + an `.settings-*` section using existing tokens only.
- Tests: 7 new served-bytes tests in `tests/test_api.py` (form fields, precedence copy, not-a-tab, anonymous `303`/`401`, `test_every_dashboard_page_links_to_settings`, `MIN_PASSWORD_LENGTH` ↔ JS parity, `resetAuth` contract); `/settings` added to `MARKUP_PAGES`/`ASSETS`/`PAGE_SCRIPTS` (orphan-`id` audit — mutation-checked) and to `PROTECTED_PAGES`/page-list assertions in `test_auth_api.py`/`test_api.py`.
**Checks**: `bash .agents/validate.sh` → **1121 passed, 11 skipped**, coverage **98.62 %** (floor 94), `ruff` clean, `pyright` 0 errors, `node --check` clean on all `web/static/*.js`. Live suite `-m live --live`: 8 passed / 3 failed — the pre-existing `driveripper` transport failures documented in AGENTS.md, unchanged.
**Docs**: README — new *The account page* section, `/login` + `/settings` in the intro, stale "task 06" pointer fixed; AGENTS.md — `web/` bullet rewritten for `/settings` + `resetAuth()`, test count 1081 → 1121.
**Live-infra decision**: the password-change flow was verified against a throwaway server (port 8091, own `/tmp` DB, `NUT_SIMULATE=1`), **not** the operator's dev DB or UPS. Proved in a real browser: success line, session kept, and a **same-page** `POST /api/ups/command` returning `{"ok":true,…}` after the rotation (i.e. `resetAuth()`), other sessions `401`, dead session → `/login?next=…`, and `/login?next=/settings` landing on `/settings`. Sim instance killed and its DB deleted; the dev server (8090) is up and its verifier still matches `testpass`. Screenshots: `data/logs/auth-settings.png` (dark), `auth-settings-light.png`, `auth-account-link.png` on `/system` with live data.
**Next pending task**: `.agents/phases/todo/11_admin_login/07_dev_config_docs_browser_verification.md`.
@@ -0,0 +1,61 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 6%]
........................................................................ [ 12%]
........................................................................ [ 19%]
........................................................................ [ 25%]
........................................................................ [ 31%]
........................................................................ [ 38%]
........................................................................ [ 44%]
........................................................................ [ 50%]
........................................................................ [ 57%]
........................................................................ [ 63%]
....................................................................ssss [ 69%]
sssssss................................................................. [ 76%]
........................................................................ [ 82%]
........................................................................ [ 89%]
........................................................................ [ 95%]
.................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 348 0 100%
src/dashboards/app.py 178 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 130 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 390 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 498, 510, 545, 578, 582, 639-640, 645, 657
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 188 2 99% 255-256
------------------------------------------------------------------
TOTAL 2820 39 99%
Required test coverage of 94% reached. Total coverage: 98.62%
1121 passed, 11 skipped, 1 warning in 61.04s (0:01:01)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
certifi 2026.7.22 2026.10.18 wheel
fastapi 0.143.5 0.147.0 wheel
starlette 1.8.0 1.9.0 wheel
@@ -0,0 +1,12 @@
**Implemented (task 07 — dev config, docs, live browser proof)**
- `.env.example` / `.env.dev.example`: `DASH_ADMIN_PASSWORD=change-me` (allow-listed placeholder), seed-vs-override + fail-closed prose, `testpass` named **only in comment prose**; `DASH_COOKIE_SECURE` deployment wording. `.env.dev` verified unchanged (`0600`, `testpass`, NUT creds intact).
- `scripts/dev-server.sh`: one sign-in hint line; probe + `status` still ride the public `/api/health`, no credential.
- README: `## Signing in` renamed **and moved before** *Configuration*, new HMAC-session paragraph, Quickstart dev-password line, deployment note (`DASH_COOKIE_SECURE=1`, quadlet env, nginx/HSTS live in the deployment repo), testing note.
- AGENTS.md: session-required prose in *What this is*, invariant 12 now says nothing may be added to `/api/health` without re-deciding, **two new known gaps** (single factor by design; per-process throttle/multi-worker), Commands/test-count updates, new testing-policy bullet.
- `tests/test_auth_docs.py` (19 tests): templates declare all four vars and carry no non-placeholder secret (hermetic twin of validate.sh 2b), dev script stays auth-free, README/AGENTS keep the public list, reset command, config rows and the three gaps.
- Screenshots in `data/logs/`: `auth-login`, `auth-login-rejected`, `auth-locked-out`, `auth-signed-in-overview`, `auth-settings`, `auth-account-link` (+4 walk shots), all live; `data/logs/NOTES-auth.md` records curl codes, the two `meta` keys (`pbkdf2_sha256$600000$…`, `auth.session_secret`, `testpass` = 0 hits in the DB), operator actions and the PLAN-anchor note.
- **Fleet drift found while verifying and corrected in AGENTS.md:** `deskwork` now runs the installed agent as `dashboards-agent` in `disk` (live SMART: `nvme0n1` 52 °C / 19 158 h / 11 % wear), gateway `:9120` and driveripper upsd `:3051` are reachable from the laptop (phase-10 inter-VLAN drop gone), driveripper still has no agent (the only live failure).
**Gates:** `bash .agents/validate.sh` → **1140 passed, 11 skipped**, coverage **98.62 %** (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene + template scan silent-pass. `uv run pytest -m live --live` → **8 passed / 3 failed**, all `driveripper: 10.1.0.126:9120 unreachable` (unchanged, auth-free). Leak checks: `/system`→`303 /login?next=/system`; `/api/summary|config|events|system/history|ups/snapshot`→`401`; `/api/health`,`/login`,`/static/dash.css`→`200`; `dev-server.sh status` unauthenticated. `git status` shows no `.env*`/screenshot entries.
**Deviations:** NOTES written to `data/logs/NOTES-auth.md` (project convention; `.agents/phases/todo/` is off-limits). The task's `curl /login | grep -c 'http'` returns **1** — the inline favicon's SVG `xmlns`, never a request — so NOTES records the honest off-origin check (`(src|href)="https?://` = 0 on all seven pages, DOM-verified too).
@@ -0,0 +1,61 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 6%]
........................................................................ [ 12%]
........................................................................ [ 18%]
........................................................................ [ 25%]
........................................................................ [ 31%]
........................................................................ [ 37%]
........................................................................ [ 43%]
........................................................................ [ 50%]
........................................................................ [ 56%]
........................................................................ [ 62%]
........................................................................ [ 68%]
...............sssssssssss.............................................. [ 75%]
........................................................................ [ 81%]
........................................................................ [ 87%]
........................................................................ [ 93%]
....................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 348 0 100%
src/dashboards/app.py 178 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 130 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 390 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 498, 510, 545, 578, 582, 639-640, 645, 657
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 188 2 99% 255-256
------------------------------------------------------------------
TOTAL 2820 39 99%
Required test coverage of 94% reached. Total coverage: 98.62%
1140 passed, 11 skipped, 1 warning in 61.73s (0:01:01)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
certifi 2026.7.22 2026.10.18 wheel
fastapi 0.143.5 0.147.0 wheel
starlette 1.8.0 1.9.0 wheel
+22
View File
@@ -58,3 +58,25 @@ export NUT_ALLOW_ACTIONS=1
# Leave NUT_SIMULATE=1 only while upsd is unreachable — it labels every panel SIMULATED. # Leave NUT_SIMULATE=1 only while upsd is unreachable — it labels every panel SIMULATED.
# export NUT_SIMULATE=1 # export NUT_SIMULATE=1
export DASH_PORT=8090 export DASH_PORT=8090
# --- the admin login -----------------------------------------------------------------------
# Every page and every /api route except /api/health needs a signed-in session. This value is
# the SEED only: the first start hashes it into the 'auth.verifier' row of DASH_DB and the
# stored verifier wins from then on, so changing the password in /settings needs no redeploy
# and deleting this line after the first boot changes nothing. With neither this line nor a
# stored verifier the server refuses to start instead of serving the fleet open — which is why
# a scratch or deleted database needs it.
# Local throwaway value: every dev box here and the hermetic suite sign in with the word
# `testpass` (it is the constant in tests/auth_contract.py), so that is what belongs on this
# line in your own .env.dev. It is a local-testing word and never a production fallback; a real
# password goes in the gitignored file only, because `bash .agents/validate.sh` scans every
# tracked template for a non-placeholder secret on an assignment line like the one below.
export DASH_ADMIN_PASSWORD=change-me
# Session lifetime (default 720 h = 30 days) and the `Secure` cookie flag. Dev is plain
# http://127.0.0.1, so the flag stays off; a request that arrived over
# X-Forwarded-Proto: https is treated as secure either way.
export DASH_SESSION_HOURS=720
export DASH_COOKIE_SECURE=0
# PBKDF2 rounds. The knob is for the test suite (microseconds instead of ~0.3 s per hash);
# leave the production figure alone.
export DASH_PBKDF2_ITERATIONS=600000
+28
View File
@@ -11,6 +11,34 @@ DASH_DB=data/dashboards.sqlite3
# DASH_READ_ONLY=1 kills every mutating endpoint regardless of other flags # DASH_READ_ONLY=1 kills every mutating endpoint regardless of other flags
DASH_READ_ONLY=0 DASH_READ_ONLY=0
# --------------------------------------------------------------------------- #
# Admin login (phase 11)
# Every page and every /api route except /api/health requires a signed-in session.
# --------------------------------------------------------------------------- #
# The SEED, not the credential: the first start hashes this into the 'auth.verifier' row of
# DASH_DB (PBKDF2-HMAC-SHA256, never as text) and from then on the stored verifier wins, so
# you can delete this line once the server has come up and /settings can change the password
# with no redeploy. It is required when the database has no verifier yet: with neither, the
# server refuses to start rather than serve the fleet open.
# `change-me` below is a placeholder, nothing else: replace it with your own word before this
# box answers anybody, because `cp .env.example .env` copies the line as it stands and a
# password printed in a template guards nothing. Leaving it empty is the other safe answer —
# empty reads as unset, and the server then refuses to start until you set one.
# reset a forgotten password: sqlite3 "$DASH_DB" "DELETE FROM meta WHERE key IN
# ('auth.verifier','auth.session_secret')"
# (deleting the secret also signs out every open browser)
DASH_ADMIN_PASSWORD=change-me
# Session lifetime in hours (default 720 = 30 days). Rotating the signing secret — which
# changing the password does — is what invalidates sessions early.
DASH_SESSION_HOURS=720
# 1 marks the session cookie Secure. Set it in deployment (the TLS/nginx front); off here
# because dev is plain http://127.0.0.1, and a request that arrived over
# X-Forwarded-Proto: https is treated as secure regardless.
DASH_COOKIE_SECURE=0
# PBKDF2 rounds for the verifier. It exists so the test suite can hash in microseconds;
# leave it alone in a deployment — it is the real cost of every guess at a stolen database.
DASH_PBKDF2_ITERATIONS=600000
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# System dashboard: hosts running agent/host_agent.py # System dashboard: hosts running agent/host_agent.py
# Space or comma separated name=url. A bearer token goes in the URL userinfo # Space or comma separated name=url. A bearer token goes in the URL userinfo
+354 -66
View File
@@ -33,6 +33,11 @@ file — plus an overview page that says which of the four you need:
History is a first-class feature, not a sparkline afterthought: every number worth graphing 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`. is stored, rolled up (raw → 1m → 1h), and served from `/api/*/history`.
None of it is public. One admin signs in at `/login` (the only page an anonymous visitor
reaches), and `/settings` — `⚙ Account` in the masthead — is where that session is read, ended,
and re-credentialled. Every page above, `/settings`, and every `/api` route but the four named
in invariant 12 require the session.
## Non-negotiable rules ## Non-negotiable rules
These are the things that make the numbers trustworthy. Breaking one is a bug even if the These are the things that make the numbers trustworthy. Breaking one is a bug even if the
@@ -43,7 +48,10 @@ page still renders.
(`NUT_ALLOW_ACTIONS`, not `DASH_READ_ONLY`, the upsd.users account actually having the (`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 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. 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. Commands that would cut output are refused outright while the UPS is already on battery. Since
phase 11 there are two more in front of it — a session, and this session's CSRF token for a
cookie write (invariant 12) — and they are additive: `tests/test_auth_api.py` proves the
`DANGER` requirement and the wire send still behave exactly as they did without them.
2. **Never invent a number.** A value the upstream does not publish renders as `n/a`, never 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` 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 so the UI can print *how* it was obtained (`computed:Δtokens/Δt`, `Δ10.0s`). Aggregating
@@ -75,7 +83,14 @@ page still renders.
`config.strip_userinfo`-redacted engine URLs, and every URL inside an `UpstreamError` message `config.strip_userinfo`-redacted engine URLs, and every URL inside an `UpstreamError` message
is redacted too — those strings go into the health record *and* the `event` table, which is redacted too — those strings go into the health record *and* the `event` table, which
`/api/events` serves to the browser and `page-system.js` prints. No token, password, or `/api/events` serves to the browser and `page-system.js` prints. No token, password, or
personal hostname goes into tracked files, fixtures, or the browser. personal hostname goes into tracked files, fixtures, or the browser. **The admin's own
credential is the same case** (phase 11): `auth.py` records nothing and answers
`False`/`None` to every rejection, because the *reason* a login was refused is a verifier, a
salt, or a session token — and this app turns reasons into health records and `event` rows
that a browser reads. Its one `logging` call and its two `raise` sites (the fail-closed
startup and the empty-new-password refusal) carry **fixed** operator text with nothing
interpolated, not even a prefix of a hash; `tests/test_auth.py` pins that structurally, so
the day someone adds `logger.warning(f"bad verifier {v}")` a test fails instead of a log line.
7. **Verify against the deployed upstream; never guess a metric name.** Field maps in 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 `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 2.8.5, and are pinned by fixtures in `tests/fixtures/`. Re-capture with
@@ -91,6 +106,49 @@ page still renders.
10. **The dev server stays up while work happens** (`./scripts/dev-server.sh restart`, 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 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. before they are called done. `data/logs/*.png` is where those land.
11. **The dashboard never starts open, and the stored password beats the environment**
(phase 11). The admin verifier and the session-signing key are durable rows in the SQLite
`meta` table (`auth.verifier`, `auth.session_secret`) and are the only place either secret
lives; `DASH_ADMIN_PASSWORD` is a **seed** that hashes into the row on the first boot of a
database and is ignored thereafter (one warning line, value never quoted), so a password is
changeable without a redeploy and no container env can roll it back. A server with neither a
stored verifier nor the seed **refuses to start** — a `RuntimeError` out of the lifespan, a
non-zero exit, and a message naming the variable, the meta key, and the
`sqlite3 <db> "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')"`
reset command. "Start open with a warning" is not an option: the domain is public and the
fleet's hostnames and UPS controls are behind that login. `testpass` is the local-testing
password (dev config and the hermetic suite) and never a production fallback. Invariant 12 is
the request-time half; this one is why there is always a credential to check against.
12. **The gate is default-deny and its public surface is four entries** (phase 11). `app.py`'s
`PUBLIC_PATHS` (`/login`, `/api/auth/login`, `/api/health`) plus `PUBLIC_PREFIXES`
(`/static/`) are the only things an anonymous caller can reach — every page, every `/api`
data route, and `/api/auth/status`/`logout` require a session read from the
`dash_session` cookie or an `Authorization: Bearer` token. `/api/health` is the one data
route that stays open on purpose (the Containerfile `HEALTHCHECK` and
`./scripts/dev-server.sh status` poll it unsigned, and it leaks nothing); its shape is now a
contract with deployment, which means **nothing may be added to it without re-deciding that**
— a new field there is a field the whole internet reads. Adding a path to that list is a
security decision, and
`tests/test_auth_api.py::test_every_route_that_is_not_public_requires_a_session` walks the
**served** route table so a route that forgets the gate fails a test instead of shipping —
the middleware guards by default, which is why no new route has to remember to. Rules the
gate keeps and each has a test: a page `GET` gets `303` to `/login?next=…` (query string
included) and everything else the one JSON refusal, because a redirect a script cannot render
is not an answer; every answer carries `no-store`; `?next=` passes `auth.safe_next` and is
never followed raw (an open redirect on a login page is a phishing kit with a homelab theme);
a write additionally needs a matching `Origin`/`Sec-Fetch-Site` and — when it came in on the
**cookie** — this session's `X-CSRF-Token`, because a bearer caller is something no other site
can forge. A process whose lifespan never produced `ctx.auth` answers `503` for **everything,
public paths included**: "healthy" from a server that cannot say who its admin is is the
invariant-4 failure mode, and a login form that cannot verify a password is a worse promise
than no form. `POST /api/ups/command` keeps all four of its own gates behind this one (the
`DANGER` word is still what cuts power), and `POST /api/auth/logout` is gated like any other
write because a sign-you-out link is a CSRF. `POST /api/auth/password` (task 04) is the third
gated write and the app's only revocation: guesses at the current password spend their own
`password:<address>` bucket, and a right one replaces `auth.verifier` **and**
`auth.session_secret` so every browser that is not in front of the form dies on its next
request — which is why the response is minted from the new state and never the one being
replaced.
## Architecture ## Architecture
@@ -133,7 +191,14 @@ Responsibilities, one line each:
is single-flight and keeps the old cache on failure, so a bad pass retries on the next is single-flight and keeps the old cache on failure, so a bad pass retries on the next
expiry instead of hammering the disk. expiry instead of hammering the disk.
- `config.py` — env only, no config file format, no CLI flags. `.env` is a *seed*; a real - `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. env var always wins. Defaults are safe except the sources, which default to unset. The auth
block (phase 11) is `DASH_ADMIN_PASSWORD` (the **seed**, and `_env()` strips, so `VAR=` means
*unset* rather than "the password is empty"), `DASH_SESSION_HOURS` (720 = 30 days: a dashboard
that logs the operator out mid-outage gets bypassed), `DASH_COOKIE_SECURE` (off, because dev is
`http://127.0.0.1`; task 03 also treats `X-Forwarded-Proto: https` as secure), and
`DASH_PBKDF2_ITERATIONS` (default `auth.PBKDF2_ITERATIONS`, imported so the primitive and the
setting cannot drift) — that knob exists so the hermetic suite hashes in microseconds instead
of 0.3 s per client and is not a production tuning parameter.
- `poller.py` — job loop, health records, stale/last-good semantics, transition events. - `poller.py` — job loop, health records, stale/last-good semantics, transition events.
`/disks` is a **source of its own** (`disks:<host>`, `DISKS_INTERVAL_SECONDS` 300 s, `/disks` is a **source of its own** (`disks:<host>`, `DISKS_INTERVAL_SECONDS` 300 s,
`DISKS_TIMEOUT_SECONDS` 30 s, events under the dashboard name `disks`) reading the same `DISKS_TIMEOUT_SECONDS` 30 s, events under the dashboard name `disks`) reading the same
@@ -208,6 +273,44 @@ Responsibilities, one line each:
monitor through it, so the assembly and the model cannot disagree about what is enabled. monitor through it, so the assembly and the model cannot disagree about what is enabled.
- `prom.py` — Prometheus text-exposition parser (in-house; only for engines we don't own). - `prom.py` — Prometheus text-exposition parser (in-house; only for engines we don't own).
- `rates.py` — Δ/window estimator, keyed by resolved series name. - `rates.py` — Δ/window estimator, keyed by resolved series name.
- `auth.py` — every authentication decision as a pure function, stdlib only (phase 11):
`hash_password`/`verify_password`/`needs_rehash` over a self-describing
`pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>` verifier (so the cost can rise without a
migration), `mint_session`/`read_session` for an HMAC-signed stateless cookie (no session
table and nothing to prune; rotating the stored secret signs every cookie ever issued out,
which is how changing the password evicts every other browser), `mint_csrf`/`verify_csrf`
bound to the session token instead of stored, `LoginThrottle` (in-process exponential
backoff), and `origin_matches`/`sec_fetch_site_allowed` as string functions so the gate is
testable without a `Request`. The primitives **never raise and record nothing**: a malformed
verifier, a tampered cookie, and a rotated secret all answer `False`/`None`, because the
reason an input was rejected is the credential (invariant 6). No HTTP, no SQLite driver, no
new dependency — `app.py`'s middleware and `api.py`'s `/api/auth/*` are the callers, so a
decision made here is made once and is unit-testable without a `Request`. It also carries the
two strings the gate and the pages share (`LOGIN_PATH`, `LOGIN_NEXT`) and `safe_next()`, the
only judge of a `?next=` value: kept verbatim (query string and all, because the range chips
live there) when it stays on this app, dropped otherwise — scheme-prefixed, scheme-relative,
the percent-encoded spellings of both, anything with a control character (not a path but a
second `Location` header), and anything without a leading `/`.
The **verifier bootstrap** (task 02) is the one deliberate exception to "never raises":
`load_auth_state(store, admin_password=…, iterations=…, session_hours=…, db_path=…)` reads the
`meta` rows `auth.verifier` / `auth.session_secret` through a three-method `MetaStore`
`Protocol` (so this module still imports nothing in the package, and its tests use a dict)
and returns a frozen `AuthState` — the environment **seeds** a password once and the stored
verifier wins thereafter (`env_ignored` and one fixed-line warning when both are present, so
`/settings` is the real answer and no redeploy can roll a changed password back); no row and
no seed raises a `RuntimeError` naming `DASH_ADMIN_PASSWORD`, the meta key, and the
`sqlite3 … DELETE FROM meta …` reset command, because "start open with a warning" is the
failure this phase exists to prevent; a stored secret that is not 256 bits of hex is
regenerated and persisted (the effect is one login prompt, never a lockout). Nothing here
reads a clock, so the state carries no expiry — a session's is inside its signed payload.
`update_verifier(store, state, new_password)` writes the new verifier **and** a fresh secret
(secret first, so a crash cannot leave sessions outliving the password they were issued
under) and refuses an empty/whitespace password; it is what `POST /api/auth/password` calls,
because rotating the secret **is** this app's revocation list — a stateless cookie has no row to
delete, so without it a stolen session would outlive the password that "revoked" it for another
`DASH_SESSION_HOURS`. Its two raises, like the loader's, carry
fixed text — `tests/test_auth.py` pins the file's raise sites and its one logging call
structurally, so no future edit can interpolate a credential into either.
- `severity.py` — the only place thresholds live: `THRESHOLDS`/`INVERTED` per metric, - `severity.py` — the only place thresholds live: `THRESHOLDS`/`INVERTED` per metric,
`classify()` → `ok`/`warn`/`fail`/`None`, plus `attach()`/`with_severity()` to put the verdict `classify()` → `ok`/`warn`/`fail`/`None`, plus `attach()`/`with_severity()` to put the verdict
beside a value. No measurement, no verdict: `None` in → `None` out, never a green pass for beside a value. No measurement, no verdict: `None` in → `None` out, never a green pass for
@@ -248,8 +351,45 @@ Responsibilities, one line each:
grading a drive here would be the bug, not the fix (phase 09). Its `HREF` table does carry grading a drive here would be the bug, not the fix (phase 09). Its `HREF` table does carry
`/disks`, so the one alert a disk job *can* raise — SMART failing while its host answers — `/disks`, so the one alert a disk job *can* raise — SMART failing while its host answers —
links to the page that names the drive. links to the page that names the drive.
- `store.py` — `sample(source,host,metric,res,ts)` + `event` + `meta`, rollups, pruning. - `store.py` — `sample(source,host,metric,res,ts)` + `event` + `meta`, rollups, pruning. The
- `api.py` — GETs everywhere plus the one POST. `/api/config` is redaction boundary. `meta` table is the only durable key/value state in the app and is reached through three
named helpers — `get_meta`/`set_meta`/`delete_meta` (the last returns its rowcount, because
the password-reset procedure has to be able to say whether a row was there, and one key per
call, because a reset that deletes a row it did not name deletes the operator's credential).
`stats()` reads `last_maintain` through `get_meta()` too — outside its own lock, which
`get_meta` takes itself and `threading.Lock` is not reentrant — while `maintain()`'s write
stays inline inside that method's transaction.
- `api.py` — GETs everywhere plus the one POST that dials an upstream, and the `auth_router()`
that creates, describes, **re-credentials**, and ends a session (`POST /api/auth/login` public;
`GET /api/auth/status`, `POST /api/auth/logout` and `POST /api/auth/password` protected). Those
three are the app's only other writes and they touch this process and its `meta` table and dial
nothing — invariant 1 is about the fleet, and `tests/test_api.py` pins the two lists separately
rather than widening the one that names `/api/ups/command`.
The login route is where a *sign-in* password is compared: it
throttles before it hashes, refuses an absent/empty/non-object body with `422` so `""` never
reaches `verify_password`, answers every failure with the one sentence `wrong password`, and
answers `429` + `Retry-After` while the per-address backoff runs (the only extra fact it adds,
because a wait is something the caller has to act on). After a good sign-in it also upgrades a
verifier minted at a lower `DASH_PBKDF2_ITERATIONS` (`auth.needs_rehash`), keeping the signing
secret — that knob has to actually buy something, and a login is the one moment the plaintext is
in hand. A refused upgrade write is swallowed with one fixed-text `logging` line (this module's
only one, carrying no credential: invariant 6) because `database is locked` on a bookkeeping row
must not turn a correct password into no login at all. `POST /api/auth/password` is task 04: it
validates `new` first (`MIN_PASSWORD_LENGTH = 8`, non-empty once stripped, different from
`current` — length is the whole policy, because composition mandates produce sticky notes),
throttles the *current* password on its own key `password:<address>`, refuses a wrong one with
`403` + a `password-denied` event, and on the right one calls `auth.update_verifier` and answers
from the **new** state with a cookie and CSRF token minted from the rotated secret. Building that
response from the old `ctx.auth` is the bug the order exists to prevent: the operator would be
signed out by their own form.
`AppContext` carries `auth` and the one
`LoginThrottle` per process — a throttle built per request is a throttle that never trips. A
refused sign-in writes a `login-denied` **event** and nothing else, and so do the two password
outcomes (`password-denied`, `password-changed`): `alerts.py` turns only
`kind: "unreachable"` into an overview row and every feed filters by `source`, so
`/api/events?source=auth` is the audit trail and the overview stays about outages (do not
"fix" that by hiding the event). Neither fact appears on `/api/health` — which is public — or
`/api/config`. `/api/config` is a redaction boundary.
`/api/disks/*` (phase 09) is the drive-health pair: `/snapshot` answers one entry per `/api/disks/*` (phase 09) is the drive-health pair: `/snapshot` answers one entry per
configured host (disk rows + `smart` + `smartctl_available` + per-row `severity` + a configured host (disk rows + `smart` + `smartctl_available` + per-row `severity` + a
`fleet` block summed by the API, with a configured-but-never-answered host as an `fleet` block summed by the API, with a configured-but-never-answered host as an
@@ -275,10 +415,61 @@ Responsibilities, one line each:
job with no payload yet is listed as a stub (`unreachable: true`, every value `null`) in job with no payload yet is listed as a stub (`unreachable: true`, every value `null`) in
`system.hosts`/`llm.engines` exactly as `/api/system/snapshot` does, so `hosts_total` `system.hosts`/`llm.engines` exactly as `/api/system/snapshot` does, so `hosts_total`
counts the machines there are and `hosts_live` the ones answering. counts the machines there are and `hosts_live` the ones answering.
- `app.py` — assembly; `create_app()` takes injectable settings/store so tests build an app - `app.py` — assembly, and the **session gate** (`session_gate` middleware, invariant 12) whose
with an in-memory DB and no targets. `PUBLIC_PATHS`/`PUBLIC_PREFIXES` are the whole anonymous surface. It answers in this order and
- `web/` — five static pages (`/`, `/system`, `/disks`, `/llm`, `/ups`), one stylesheet, the order is the design: no `AuthState` → `503` for everything; no session and no such route →
`charts.js`. No framework. `app.js`'s masthead status dots are one per dashboard job — the router's `404` (a typo is not a sign-in problem, and "sign in required" would otherwise be
the only answer anyone outside ever gets for a wrong URL); public → through, except that the
origin check still applies to a public write (a cross-site login post spends the throttle of
whoever is typing) and `GET /login` for someone already signed in goes to the dashboard, not
back to the form; no session → `303` to `/login?next=…` for a page and the JSON refusal for
everything else; a write → origin, then this session's CSRF token when it arrived on the
cookie. `_own_origin` reads `X-Forwarded-Proto` for the same reason the cookie's `Secure` flag
does, so behind nginx a real same-origin write is not a cross-site refusal — the bug that only
appears in production, which is why it has a test. `create_app()` takes injectable settings/store so tests build an app
with an in-memory DB and no targets. The lifespan builds `ctx.auth`
(`auth.load_auth_state`) after the context exists and **before** the first poll, for the
owned-store and injected-store branches alike — one call site, because two copies drift — and
a `RuntimeError` from it leaves the process non-zero with the operator's fix in the output.
That failure is never caught here: a dashboard that cannot say who its admin is does not start
the loops either (`__main__.py` passes `SETTINGS` to `create_app()` explicitly so the served
app is configured by the same object the bind address came from).
- `web/` — five dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`) plus the login page
(`/login`, `pages/login.html` + `static/page-login.js`), one stylesheet, `charts.js`. No
framework. `/login` renders the masthead's **brand link and theme toggle** and nothing else
from it: no tabs (every tab bounces straight back here, and a nav of links that all redirect is
a lie about what is available), no status dots and no clock (there is nothing signed in for them
to say). It posts through `Dash.fetchJSON` like every other script, which is only safe because
`bounceToLogin()` does nothing when the browser is already on `LOGIN_PATH` — without that guard
a `401` would reload the form instead of printing `Wrong password.`, and the page that exists to
end a bounce loop would be one. So the form branches on `err.status`: `401` is the inline
sentence plus `field.select()` (fixing a typo without retyping is the whole UX here), `429`
shows the server's `detail` and **counts down its `Retry-After`** with the button disabled until
the wait is over — `fetchJSON` therefore carries `response.headers` on the `Error` it throws,
because a wait this page made up would be an invented number (rule 2) — and anything else prints
the server's `detail` verbatim. Every refusal goes out through `textContent`, never
`innerHTML`: this is the one page a stranger reaches first. Its own copy of `auth.safe_next`
guards the one redirect followed on client strength (already-signed-in `?next=`), and
`Dash.ensureAuth()` up front stops a flash of the form for a browser that is already in. The
login page is audited for orphan `id`s and CDN references too (`tests/test_auth_api.py`), fetched
unsigned because a signed-in `GET /login` is a `303` and would audit `/`'s markup under the wrong
name. `/settings` (`pages/settings.html` + `static/page-settings.js`) is a **page and not a
sixth dashboard**: same `<head>` and masthead, the five tabs with **no** `aria-current` (it is
not one of them), no status dots, and it is reached from the `⚙ Account` link in
`.masthead-right` of every dashboard — outside `.tabs`, so the pinned tab structure is untouched
(`tests/test_api.py::test_every_dashboard_page_links_to_settings`). It prints
`/api/auth/status` (name, expiry as a local timestamp *and* as time left), signs out through
`POST /api/auth/logout`, and changes the password through `POST /api/auth/password` after
checking locally what it can judge itself (non-empty current, ≥ 8 new characters —
`tests/test_api.py` pins that number against `api.MIN_PASSWORD_LENGTH` so the two cannot drift —
new matching confirm), printing `403`/`422`/`429` through `textContent` as the server words them,
then re-reads the status so the panel cannot show a session the rotation ended. Its static
sub-line is the precedence rule (env seed → stored verifier wins → reset by deleting
`auth.verifier`), because that sentence is what stops the "I edited the env file and nothing
happened" call. So `app.js` exports **`resetAuth()`**: it clears the memoised `authStatus` and its
in-flight promise, and must be called after a password change (the secret rotated, so the cached
`csrf` is now invalid — every later write, the UPS buttons included, 403s until a reload) and on
any `401` bounce. `app.js`'s masthead status dots are one per dashboard job —
including `disks`, because the same agent can answer the 10-second utilisation poll and including `disks`, because the same agent can answer the 10-second utilisation poll and
have a wedged SMART pass, and one dot for both would be right about neither. `app.js` owns the have a wedged SMART pass, and one dot for both would be right about neither. `app.js` owns the
colour vocabulary: `Dash.severityFrom(cellOrSeverity)` turns a payload verdict into an colour vocabulary: `Dash.severityFrom(cellOrSeverity)` turns a payload verdict into an
@@ -320,27 +511,33 @@ That scheme is the history API's key — treat it as a public interface.
neither `.env.dev` nor `.env.dev.example` declares a forwarded port — dev dials the same neither `.env.dev` nor `.env.dev.example` declares a forwarded port — dev dials the same
addresses the deployed dashboard does, and `scripts/capture-fixtures.sh` reads addresses the deployed dashboard does, and `scripts/capture-fixtures.sh` reads
`HOST_TARGETS` from `.env.dev` instead of naming hosts of its own `HOST_TARGETS` from `.env.dev` instead of naming hosts of its own
(`tests/test_deploy_scripts.py` fails the day any tunnel name or port returns). Current state: `gateway` runs its installed service, bound to the address (`tests/test_deploy_scripts.py` fails the day any tunnel name or port returns).
`gateway.reeselink.com` resolves to, and answers `/health` + `/snapshot` **on the host** **Fleet transport, re-verified from the laptop on 2026-10-19.** The port-selective inter-VLAN
(re-verified 2026-10-18, and the token in `.env.dev` matches `/etc/dashboards-agent.token`), drop phase 10 recorded on the lab gateway `10.2.0.1` is **gone**: `gateway` (`10.1.1.27:9120`)
but the laptop cannot reach its `:9120` — and that is now proven to be nobody's firewall here: and `deskwork` (`10.1.0.51:9120`) both take a plain TCP connect and answer `/snapshot`, and
opening `9120/tcp` in that host's own firewalld (a runtime rule, added and reverted in the `driveripper`'s upsd answers `LIST UPS` on `10.1.0.126:3051` from this same laptop. Two hosts
phase-10 final pass; runtime and permanent port lists are back to their original set) changed run the **installed** service — `systemctl is-active dashboards-agent` is `active` on both,
nothing, because the SYN never arrives — `ss -tan sport = :9120` on the host shows only the with `/usr/local/lib/dashboards-agent/host_agent.py` and a `--token-file` unit bound to the
`LISTEN` — while the same laptop reaches `:22` and `:80` on the same address in milliseconds. address the dashboard dials. `driveripper` is the one host with no agent at all (unit
The drop is the port-selective inter-VLAN filter on the lab gateway `10.2.0.1`, the same one `inactive`, no installed file, nothing listening on `:9120` → `ConnectionRefused`), and it
that eats 3051/3493. On `driveripper` and `deskwork` the ad-hoc `~/lab-agent/host_agent.py` agents renders as an `unreachable` stub row: the designed degradation, recorded rather than hidden.
are gone (stopped with the old tunnel sessions, nothing replaced them, `:9120` is empty on `/api/health` today reads `host:deskwork ok`, `host:gateway ok`, `disks:deskwork ok`,
both) because their `deploy/agent-sudoers` grant is not installed yet, so the installer `disks:gateway ok`, `nut ok`, `llm:gufo ok`, `llm:vllm ok` with the two `driveripper` jobs
cannot run there. Every `system`/`disks` source is therefore `DOWN` with its real reason `DOWN`, and the `live` suite is 8 passed / 3 failed with every failure that one transport.
and renders as an `unreachable` stub row — the designed degradation, recorded rather than Phase 10's proof that the old drop was not this repo's firewall stands as history in
hidden — which is also why 6 of the `live` tests fail on transport while both engines pass. `data/logs/NOTES-verify.md` — do not re-add a tunnel on the strength of it, and re-run the
The per-host record, the exact root commands and the one firewall line each host still probe rather than trusting either note.
needs are in `data/logs/NOTES-agent-install.md`, how far the NUT wire reaches from each The per-host record and the exact root commands are in `data/logs/NOTES-agent-install.md`
(its firewall lines are the phase-10 state, not today's), how far the NUT wire reaches from each
network is in `data/logs/NOTES-upsd-acl.md`, and the end-to-end direct-dial verification network is in `data/logs/NOTES-upsd-acl.md`, and the end-to-end direct-dial verification
(health table, no-tunnel-port proof, live-suite tally, screenshots, and the probes that put (health table, no-tunnel-port proof, live-suite tally, screenshots, and the probes that put
the block on the lab gateway's port allow-list rather than in this app) is in the block on the lab gateway's port allow-list rather than in this app) is in
`data/logs/NOTES-verify.md`. `data/logs/NOTES-verify.md`, and the phase-11 login verification (the `curl` refusal codes, the
six screenshots, the two `meta` keys the database actually holds, and the operator's
deployment-repo actions) is in `data/logs/NOTES-auth.md`. Those notes are local evidence, not
tracked files: `data/` is gitignored (invariant 10's `data/logs/*.png` included), so a fresh
clone has the conclusions in the prose above and the captures live on the box that ran them —
re-run the probe rather than trusting either.
- Engines are reached directly: gufo `deskwork.reeselink.com:8731`, vLLM `driveripper.reeselink.com:8000`. - 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/`**, and **upsd is - NUT 2.8.5 on driveripper: **configs live in `/etc/ups/`, not `/etc/nut/`**, and **upsd is
on port 3051, not 3493**. Nothing listens on 3493 at all, so a default client reports on port 3051, not 3493**. Nothing listens on 3493 at all, so a default client reports
@@ -352,14 +549,15 @@ That scheme is the history API's key — treat it as a public interface.
2.8 does not refuse non-local clients the way older docs suggest: `LISTEN 0.0.0.0 3051` + 2.8 does not refuse non-local clients the way older docs suggest: `LISTEN 0.0.0.0 3051` +
`LISTEN :: 3051` with `3051/tcp` in the `FedoraServer` zone answers any client routed to it `LISTEN :: 3051` with `3051/tcp` in the `FedoraServer` zone answers any client routed to it
(reads are unauthenticated; `SET`/`INSTCMD` still need an `upsd.users` account). The host (reads are unauthenticated; `SET`/`INSTCMD` still need an `upsd.users` account). The host
range `10.1.0.0/21` does reach it today (verified from `deskwork`); the laptop's range `10.1.0.0/21` reaches it, and **since 2026-10-19 so does the laptop**: a raw
`10.2.0.0/22` does **not** — the lab gateway `10.2.0.1` forwards 22/1234/8000-8004 and `LIST UPS` to `10.1.0.126:3051` from the dev box answers `BEGIN LIST UPS`, which is why
silently drops 3051/3493 in both families, which no sudo on a fleet host can change, so the `nut` reads `ok` in `./scripts/dev-server.sh status` with two real UPSes and no
laptop cannot yet dial `NUT_PORT=3051` (probe evidence and the operator's one rule: `NUT_SIMULATE`. Phase 10 recorded `10.2.0.1` dropping 3051/3493 for this laptop; that is
`data/logs/NOTES-upsd-acl.md`). history now, and the probes behind both statements are in `data/logs/NOTES-upsd-acl.md`.
Two UPSes are exported over USB and dialled directly as `NUT_HOST` + `NUT_PORT=3051` (from the Two UPSes are exported over USB and dialled directly as `NUT_HOST` + `NUT_PORT=3051`, and that
laptop that is still the blocked path above; from the host itself, and from the deployed reads live from every vantage the dashboard has ever used — the host itself, the deployed
dashboard, it reads live): dashboard, and since 2026-10-19 the laptop (`/ups` shows both boxes at 100 % charge, `OL`,
with no `SIMULATED` badge anywhere):
`apc` = APC Back-UPS RS 1500MS, `cyberpower` = CyberPower PR1500LCDRT2U (that is `apc` = APC Back-UPS RS 1500MS, `cyberpower` = CyberPower PR1500LCDRT2U (that is
`ups.description`, the operator's label — the driver's own `identity.model` answers `ups.description`, the operator's label — the driver's own `identity.model` answers
`GX1500U`, so the description is what identifies the box). `GX1500U`, so the description is what identifies the box).
@@ -379,21 +577,22 @@ That scheme is the history API's key — treat it as a public interface.
`upsc`, `udevadm`, the `/etc/ups` reads, and the `tee /etc/ups/upsd.users|upsd.conf` + `upsc`, `udevadm`, the `/etc/ups` reads, and the `tee /etc/ups/upsd.users|upsd.conf` +
`systemctl restart|reload nut-server` pair documented in `deploy/nut-sudoers`); `deskwork` `systemctl restart|reload nut-server` pair documented in `deploy/nut-sudoers`); `deskwork`
grants an unrelated set (`btrfs scrub`, `snapper`, `setfacl /srv/*`, `osbuild-auto.sh`). grants an unrelated set (`btrfs scrub`, `snapper`, `setfacl /srv/*`, `osbuild-auto.sh`).
Neither `driveripper` nor `deskwork` can run the installer or `visudo`, so those two installs Neither host grants `ops` the installer or `visudo` (re-checked 2026-10-19), so every install
— and the `firewall-cmd` line each still needs — stay with the operator. that has happened on those two went through root by the operator — including the one still
- **SMART is captured from real hardware; the live agents still cannot read it.** `smartctl` outstanding, `driveripper`'s.
7.5 is installed on deskwork and driveripper. The `ops` account (which runs the dev agents) - **SMART: real captures, and one live host finally reads them.** `smartctl` 7.5 is installed on
is in no `disk` group; driveripper grants it `(root) NOPASSWD: /usr/sbin/smartctl*` and deskwork and driveripper. The `ops` account (which is who runs a hand-started dev agent) is in
deskwork grants nothing, so the agent's own unprivileged `smartctl -i -A -H` — it shells the no `disk` group, driveripper grants it `(root) NOPASSWD: /usr/sbin/smartctl*` and deskwork
binary and never calls sudo — answers *Permission denied* (exit 2) on every host and `smart` grants nothing, and `host_agent.py` shells the binary **as itself** — it never calls sudo — so
stays `null` end to end: the designed degradation, not a bug. The agents deployed on an agent run as `ops` still answers *Permission denied* (exit 2) and reports `smart: null`.
driveripper and deskwork also predate the SMART pass, so the `disks` source today yields one row What changed by 2026-10-19 is that `deskwork`'s **installed** service runs as
per physical disk (16 on driveripper, 1 on deskwork, `vda` on gateway) with `smart: null`, `dashboards-agent`, which is in the `disk` group (`getent group disk` → `disk:dashboards-agent`)
`smart_errors: []` and `smartctl_available: null` — "nobody told us either way", which is why on an agent installed after the SMART pass: `/disks` shows real numbers there —
that flag is three-way and not a boolean. `gateway`'s agent is the one exception as of phase 10: `smartctl_available: true`, `nvme0n1` healthy at 52 °C, 19 158 power-on hours, 11 % of rated
it is the installed systemd service, which does know about the SMART pass and answers wear — so the wear bar and the alarm chips are drawn from live hardware, not only fixtures.
`smartctl_available: false` there because smartmontools is not installed — the *install it* `gateway` answers `smartctl_available: false` (no smartmontools in its VM: the *install it*
half of the three-way flag, from a host the dashboard cannot yet reach (its firewall). half of the three-way flag), and `driveripper` answers nothing at all until its agent exists,
which is where `smartctl_available: null` — "nobody told us either way" — still comes from.
**Real data is in the repo now:** `tests/fixtures/smartctl_driveripper_<dev>.txt` is all 16 **Real data is in the repo now:** `tests/fixtures/smartctl_driveripper_<dev>.txt` is all 16
drives (8 NVMe, 8 spinning) read through that sudo rule, and `tests/test_agent.py` parses drives (8 NVMe, 8 spinning) read through that sudo rule, and `tests/test_agent.py` parses
every one — which is what found the parser's one real bug: this binary annotates the every one — which is what found the parser's one real bug: this binary annotates the
@@ -403,15 +602,16 @@ That scheme is the history API's key — treat it as a public interface.
honest look at this fleet's drives: `nvme5n1` reports 13 media-and-data-integrity errors and honest look at this fleet's drives: `nvme5n1` reports 13 media-and-data-integrity errors and
`nvme1n1` is at 58 % of its rated wear, so the alarm chip and the amber wear bar on `/disks` `nvme1n1` is at 58 % of its rated wear, so the alarm chip and the amber wear bar on `/disks`
are real features with real data behind them, not demo furniture. are real features with real data behind them, not demo furniture.
**Lighting up the live page is two operator actions that travel together:** give the agent **Lighting up the rest of it is the same two operator actions that travel together:** install
user device read access — the `disk` group (`sudo usermod -aG disk <agent-user>`, which is the agent on `driveripper`, and give its agent user device read access — the `disk` group
what `deploy/install-agent.sh` does on install), *not* a sudo rule, because the agent must (`sudo usermod -aG disk <agent-user>`, which is what `deploy/install-agent.sh` does on install),
read the device itself — and redeploy `agent/host_agent.py` (never redeploy without the *not* a sudo rule, because the agent must read the device itself — on an `agent/host_agent.py`
grant: 16 permission-denied lines would age nothing but are pure noise, while with the grant from phase 09 or later (never redeploy without the grant: 16 permission-denied lines would age
they are 16 drives' worth of temperature, hours and sector counters). Until then `/disks` nothing but are pure noise, while with the grant they are 16 drives' worth of temperature,
hours and sector counters). `gateway` needs smartmontools first and will still read no SMART
after that, being a VM with no physical disk behind `vda`. A host that cannot read its drives
renders the designed degradation: one dimmed card per physical drive, every number `n/a`, and renders the designed degradation: one dimmed card per physical drive, every number `n/a`, and
the one-line why — which is also what gateway shows permanently, being a VM with no physical the one-line why.
disk to read and no smartmontools at all.
`tests/fixtures/smartctl_reference_*.txt` stay as what they are: **reference shapes** (labels `tests/fixtures/smartctl_reference_*.txt` stay as what they are: **reference shapes** (labels
verbatim from the deployed binary, values synthetic), kept beside the real captures so the verbatim from the deployed binary, values synthetic), kept beside the real captures so the
synthetic edge cases stay pinned. Re-record the real ones with synthetic edge cases stay pinned. Re-record the real ones with
@@ -425,16 +625,19 @@ That scheme is the history API's key — treat it as a public interface.
```bash ```bash
uv sync --python 3.14 # deps + editable install uv sync --python 3.14 # deps + editable install
cp .env.example .env # production-shaped config cp .env.example .env # production-shaped config (DASH_ADMIN_PASSWORD seeds
# the login on the first boot of a database — with no
# seed and no stored verifier the server exits non-zero)
cp .env.dev.example .env.dev # dev-only: the real hosts, tokens in URL userinfo cp .env.dev.example .env.dev # dev-only: the real hosts, tokens in URL userinfo
set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no tunnels set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no tunnels
./scripts/dev-server.sh restart # keep http://127.0.0.1:8090 up ./scripts/dev-server.sh restart # keep http://127.0.0.1:8090 up; sign in with the
./scripts/dev-server.sh status # per-source ok/DOWN # DASH_ADMIN_PASSWORD from .env.dev (`testpass`)
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
./scripts/dev-server.sh logs ./scripts/dev-server.sh logs
uv run pytest # hermetic: 793 passed + 11 live-skipped, no network uv run pytest # hermetic: 1140 passed + 11 live-skipped, no network
uv run pytest --cov # coverage gate (98.33%; floor 94%) uv run pytest --cov # coverage gate (98.6%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd uv run pytest -m live --live # opt-in: real fleet / engines / upsd
uv run ruff check src tests agent && uv run pyright uv run ruff check src tests agent && uv run pyright
./scripts/capture-fixtures.sh # re-record ground truth (agents + UPS + gufo /slots) ./scripts/capture-fixtures.sh # re-record ground truth (agents + UPS + gufo /slots)
@@ -512,6 +715,42 @@ branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only
test on hardware something is plugged into is not a test. 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, - 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`. so it stays in the default run and is *not* marked `live`.
- `tests/auth_contract.py` is why the HTTP suite can start a server at all: every app in a test
is seeded with `admin_password="testpass"` (the local-testing password — a server with no
stored verifier and no seed refuses to start, so an app under test needs a credential exactly
like a deployment does) and hashed at `pbkdf2_iterations=1000`, because the real 600 000 costs
~0.3 s per client and the suite starts a hundred-odd of them. The real figure stays pinned in
`test_auth.py` and `test_config.py`, so the cheap cost here cannot become the default there.
- **The perimeter is its own file, and it is paranoid in two specific ways.**
`tests/auth_client.py::sign_in(client)` is the one way a test gets a session: it posts
`testpass`, asserts the sign-in worked (so a suite cannot silently start from a 401 and fail
three lines later on something that looks like an unrelated bug), and puts the returned CSRF
token on the client's default headers — which is what let the ~200 pre-existing HTTP assertions
in `tests/test_api.py` stay untouched, asserting UPS behaviour rather than the new gate.
`tests/test_auth_api.py` then runs the gate through a real ASGI request with
`follow_redirects=False` on every anonymous call: `TestClient` follows redirects by default, so
an unsigned `GET /api/config` would fetch the login page and report `200` — the way a gated
suite ends up passing while testing nothing. Two of its tests are the ones to keep if you keep
none: `test_every_route_that_is_not_public_requires_a_session` walks the **served** route table
(`walk_routes` flattens pages, `include_router` wrappers and the `/static` mount, and raises on
anything it does not recognise, so the audit cannot silently shrink) and demands `401`/`303` for
every path outside `PUBLIC_PATHS`, GET and POST — the test that protects the next phase rather
than this one; and the UPS pair prove the CSRF refusal on **the wire** (a scripted
`nut.NutClient` records nothing for a tokenless write and does record it once the header is
there, and `driver.killpower` still needs `DANGER` behind the session), because 403 after
`instcmd` would be a power cut with an error message attached.
- **The setup surface has its own pins, because prose rots quietly.**
`tests/test_auth_docs.py` reads the artefacts an operator actually holds when a dashboard will
not come up: both tracked env templates (every `DASH_*` auth setting declared; every
secret-shaped *assignment* a placeholder — the same allow-list `.agents/validate.sh` 2b uses,
so `uv run pytest` catches a committed password on the working tree and not only in the gate;
the local-testing word allowed in prose and forbidden as a value), `scripts/dev-server.sh` (its
probe and its `status` ride `/api/health`, no `dash_session`/`/api/auth/`/`Authorization:`
anywhere, the password named but never assigned), and `README.md`/`AGENTS.md` (the *Signing in*
section before *Configuration*, the four public entries, the reset `DELETE FROM meta …` command
verbatim, the four config rows, the `DASH_COOKIE_SECURE=1` deployment note, the three auth
gaps). No server starts and no request is made: a doc that stops naming the reset command is a
locked-out admin, and that is worth a test even though it asserts nothing about Python.
- Coverage gate: 80% is the repo floor, the phase gate in `.agents/validate.sh` enforces - Coverage gate: 80% is the repo floor, the phase gate in `.agents/validate.sh` enforces
94% (`--cov-fail-under`), and ~98% is where we are; keep it there. New/modified Python 94% (`--cov-fail-under`), and ~98% is where we are; keep it there. New/modified Python
still has to clear >90% on its own. API tests use `create_app()` with an in-memory DB, still has to clear >90% on its own. API tests use `create_app()` with an in-memory DB,
@@ -878,16 +1117,65 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
field map is written. field map is written.
3. **UPS history depth.** Events and the numeric series are stored; long-window battery 3. **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. health (capacity fade over months) has the rollups but no dedicated view.
4. **The dev DB is big enough to refuse a write now and then.** The dev store (~610 MB, WAL 4. **The dev DB is big enough to refuse a write now and then.** The dev store (~640 MB on
grown past 140 MB) logs `store-error: OperationalError: database is locked` a few times an 2026-10-19, its WAL grown past 140 MB at its worst) logs `store-error: OperationalError:
database is locked` a few times an
hour across `system`/`llm`/`ups`; the poller handles it exactly as invariant 4 requires — hour across `system`/`llm`/`ups`; the poller handles it exactly as invariant 4 requires —
the source goes red with the real reason, ages, and writes `recording` when the next poll the source goes red with the real reason, ages, and writes `recording` when the next poll
lands — so nothing freezes, but those polls' samples are lost. The one cause this repo can lands — so nothing freezes, but those polls' samples are lost. The one cause this repo can
prove is two servers on one file, which is why `scripts/dev-server.sh stop` waits for the prove is two servers on one file, which is why `scripts/dev-server.sh stop` waits for the
old pid to die (its comment records the incident). A controlled 25 s external read old pid to die (its comment records the incident). A controlled 25 s external read
transaction during this pass produced none, so checkpoint starvation is not yet shown. transaction during this pass produced none, and neither did the dev-server log window open on
2026-10-19 — the absence of a sample, not the absence of the pressure, since the file only
keeps growing.
Retention/`wal_checkpoint` tuning for a long-running store is its own phase, not a Retention/`wal_checkpoint` tuning for a long-running store is its own phase, not a
phase-09 follow-up. phase-09 follow-up.
5. **The login throttle keys on the socket peer address** (phase 11), and the one
`LoginThrottle` instance now holds two keys per address: the bare one for
`POST /api/auth/login` and `password:<address>` for `POST /api/auth/password`. Behind nginx
every login shares one bucket, so anyone who can reach the login form can lock the operator out
of the *dashboard* — not the UPS, not the data, not a host — for at most
`LoginThrottle.max_seconds` (1 h by default) at a time, and a process restart clears the
counters. The two keys are separate on purpose, so neither form can be hammered at the other's
rate and a stolen cookie plus the change form is not an unthrottled oracle for the old
password; the cost of that choice is that one address can be blocked twice over. Accepted for a
single-admin box rather than becoming an `X-Forwarded-For` knob:
that alternative is spoofable by anyone the moment somebody forgets to configure which proxy
to trust, which would hand the attacker the throttle key instead of leaving it on the
network layer. PBKDF2-HMAC-SHA256 at `PBKDF2_ITERATIONS` (600 000) stays the real cost of
every guess; the throttle only makes hammering the form slower than useless.
6. **One admin, one factor — no second factor and no per-user accounts, by design** (phase 11).
The operator asked for a single admin, and that is what the model is: one verifier row, one
username in every signed session, one name in every `password-changed` event, no read-only
role and no per-viewer view. The consequence is worth stating out loud, because it is the
whole security budget of a public domain: the PBKDF2 cost plus the backoff is the only thing
between a stranger and the fleet's hostnames and the UPS command buttons, so there is no
defence in depth behind it — no TOTP, no WebAuthn, no per-IP allow-list, no "this login is
from somewhere new" notice. Adding a second human means more than a second row: the gate has
no notion of scope, so a colleague would be able to cut power unless somebody designs a
read-only session first. That is a phase, not an edit.
7. **The throttle is per-process, so a second uvicorn worker multiplies it** (phase 11).
`LoginThrottle` is a dict in one process's memory: `uvicorn --workers 4` hands anyone who
reaches the form four independent buckets and four times the attempts before a `429`, and a
restart clears all of them (the gap-5 note above). Nothing else in the auth design has that
problem — the session is an HMAC-signed cookie and the CSRF token derives from it, so both
are worker-safe by construction with no store to share and nothing to prune. The reason this
stays unfixed is that the app is single-process by nature anyway: every worker would run its
own poller loops against the same SQLite file (AGENTS.md invariant 4, and the `database is
locked` incident in gap 4 is what two processes on one store already looks like). If this is
ever revisited, the fix is a shared counter (the `meta` table would do, at login rates), not
a bigger number in one process.
8. **A logout ends this browser's cookie, not a copy of it** (phase 11). The session is an
HMAC-signed cookie with no server-side record — that is the design invariant 12 and gap 7 rest
on — so `POST /api/auth/logout` can only tell *this* browser to drop the cookie (verified
against a running server: a replayed token from a logged-out jar still authenticates until it
expires). The same is true of an `Authorization: Bearer` token, which was never in a cookie to
begin with. The only revocation this app has is rotating `auth.session_secret`, which
`POST /api/auth/password` does on purpose, and the reset `DELETE FROM meta …` does as the
lockout escape hatch. Accepted for a single-admin box on a domain nobody else signs in to —
the operator's own machine is the threat model here, not a shared kiosk — and the reason a
lost laptop is a password change and not a sign-out. A real revocation list would be a row per
session with a prune rule, which is the thing this design traded away.
## Locked decisions (and why) ## Locked decisions (and why)
+284 -11
View File
@@ -13,6 +13,10 @@ Four 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.
Two more pages are not dashboards: `/login` (the one thing an anonymous visitor sees) and
`/settings` — `⚙ Account` in the masthead of every dashboard — which shows this browser's
session, signs it out, and changes the admin password.
Working on this repo? **[`AGENTS.md`](AGENTS.md)** holds the invariants (read-only by 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 construction, never invent a number, unconfigured ≠ broken), the locked decisions, the real
fleet facts, and the known gaps. fleet facts, and the known gaps.
@@ -21,12 +25,19 @@ fleet facts, and the known gaps.
```bash ```bash
uv sync --python 3.14 # deps + the package, editable uv sync --python 3.14 # deps + the package, editable
cp .env.example .env # then edit cp .env.example .env # then edit — including DASH_ADMIN_PASSWORD
uv run dashboards # http://127.0.0.1:8090 uv run dashboards # http://127.0.0.1:8090
uv run uvicorn dashboards.app:app --port 8090 # equivalent uv run uvicorn dashboards.app:app --port 8090 # equivalent
uv run pytest # unit tests, no network needed uv run pytest # unit tests, no network needed
``` ```
The first start needs a password to seed: with no `DASH_ADMIN_PASSWORD` and no verifier stored
in `DASH_DB` yet, the process prints the reason and exits non-zero rather than serving the fleet
open (see *Signing in*). The dev password this project uses locally is
`testpass`; production gets a real one. The first thing a browser meets is `/login` — every page
and every `/api` route except the four listed under *What the session covers* asks for it, while
`/api/health` stays open for the container healthcheck and `./scripts/dev-server.sh status`.
Developing against the real fleet from a laptop — dev dials exactly the addresses the deployed Developing against the real fleet from a laptop — dev dials exactly the addresses the deployed
dashboard dials, so there is nothing to tunnel and no port to forward: dashboard dials, so there is nothing to tunnel and no port to forward:
@@ -40,6 +51,11 @@ uv run dashboards # foreground
./scripts/dev-server.sh restart # or backgrounded, with a pidfile + logs ./scripts/dev-server.sh restart # or backgrounded, with a pidfile + logs
``` ```
Local dev signs in with the `DASH_ADMIN_PASSWORD` in that file — the word the template's comment
names (`testpass`, what the hermetic suite uses) — and without it a fresh database exits non-zero
for the same reason production does. `.env.dev` is gitignored and `0600`: it holds the agent
tokens and the upsd password too.
Each `HOST_TARGETS` entry carries that token as URL userinfo Each `HOST_TARGETS` entry carries that token as URL userinfo
(`driveripper=http://<token>@<addr>:9120`); `parse_targets` moves it into an `Authorization` (`driveripper=http://<token>@<addr>:9120`); `parse_targets` moves it into an `Authorization`
header, so `Target.url`, `/api/config` and every error message stay credential-free. No root header, so `Target.url`, `/api/config` and every error message stay credential-free. No root
@@ -99,6 +115,207 @@ and runs the agent under `ProtectSystem=strict`, `DevicePolicy=strict` with only
nodes and the `/dev/null` family reopened (SMART needs the former; without `/dev/null` in nodes and the `/dev/null` family reopened (SMART needs the former; without `/dev/null` in
the allow list systemd cannot start the service at all), with no capabilities. the allow list systemd cannot start the service at all), with no capabilities.
## Signing in
One admin, one password, and the server **refuses to start** when it has neither a stored
verifier nor `DASH_ADMIN_PASSWORD` — the domain is public and the fleet's hostnames and UPS
controls sit behind this login, so there is no way to run it open by accident. There is no
second account and no per-user view: the five pages (`/`, `/system`, `/disks`, `/llm`, `/ups`),
the `/settings` page and every data route belong to that one operator.
A session is an **HMAC-signed cookie** (`dash_session`), not a row in a table: it carries the
signed-in name and its own expiry, is valid for `DASH_SESSION_HOURS` (default `720` = 30 days),
and is verified with a signing key that also lives in `DASH_DB`. That is why a password change
signs out every other browser at once — it rotates the key, so every cookie minted under the old
one stops verifying on its next request — and why nothing has to be pruned.
`DASH_ADMIN_PASSWORD` is a *seed*, not the credential. The first start hashes it
(PBKDF2-HMAC-SHA256, 600 000 rounds) into the `meta` table of `DASH_DB` as `auth.verifier`, and
from then on the stored verifier wins: remove the variable once the server is up and the same
password still works, change it with `POST /api/auth/password` — or in a browser, on the
`/settings` page (*The account page* below) — with no redeploy, and know that a container env can
never roll a changed password back. If `DASH_ADMIN_PASSWORD` is still set on a box that already has a
verifier, it loses — and the server says so once at startup, in words, without quoting the value.
Forgotten password, or a database you want to hand over? Delete the rows and seed again:
```bash
sqlite3 "$DASH_DB" "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')"
```
Deleting `auth.session_secret` — which that command does — signs out every browser already
signed in, which is also exactly what changing the password does on purpose. Only the hash and
the signing key are ever written: `grep -c "your password" "$DASH_DB"` comes back `0`, and no log
line, event row, or API response carries the password, the verifier, or a session token.
Nothing that leaves the app carries a credential. A `HOST_TARGETS` userinfo token is parsed
into an `Authorization` header, and every URL the browser can see — `/api/config`, a source's
error string, the event feed — goes through `config.strip_userinfo` first, because an
unreachable host writes its failure message into the event table and `/api/events` serves it.
### What the session covers
The gate is default-deny: **every page and every `/api` route** requires a signed-in admin
except four entries, named next to the middleware they guard in `app.py`:
| Public | Why it has to be |
| -------------------------- | ------------------------------------------------------------------ |
| `/login` | the form itself |
| `POST /api/auth/login` | you cannot be signed in to sign in |
| `/api/health` | the Containerfile `HEALTHCHECK` and `./scripts/dev-server.sh status` poll it unsigned, and it publishes no fleet data |
| `/static/…` | the login page is styled by the same stylesheet as the dashboards |
Adding a path to that list is a security decision, and a route a future phase forgets fails
`tests/test_auth_api.py::test_every_route_that_is_not_public_requires_a_session`, which walks
the served route table rather than a list written by hand.
What a refusal looks like depends on who asked, so that neither caller has to guess:
```bash
curl -si http://127.0.0.1:8090/system | head -3
# HTTP/1.1 303 See Other → Location: /login?next=/system (a browser ends up back here)
curl -s http://127.0.0.1:8090/api/summary
# {"ok":false,"detail":"sign in required","login":"/login"} (a script never gets HTML)
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/api/health
# 200
```
The `?next=` value keeps the query string too (`/llm?range=1h` survives the round trip), and it
is judged before it is ever followed: a `next` that leaves the site (`//evil.example`,
`http://evil.example`, the percent-encoded and control-character spellings alike) is dropped to
`/`. `auth.safe_next` is the one function that decides both halves. And every answer the gate
produces — `303`, `401`, `403`, `429`, the route's own `404` — carries `no-store`, because a
cached redirect would park a browser on the login page after it had already signed in.
### The sign-in page
`/login` is one card with one password field — no username, because there is one admin and a field
that is always the same word teaches you that half the form is theatre. It carries the masthead's
brand link and the theme toggle and nothing else from it: no tabs (each would bounce straight back
here) and no status dots, because there is nothing signed in for them to report.
What it does with an answer is the part worth knowing:
| Response | On screen |
| -------- | -------------------------------------------------------------------- |
| `200` | straight to `?next=`, or to `/` |
| `401` | `Wrong password.` under the field, and the text you typed is selected |
| `429` | the refusal plus the lockout counting down, button disabled to zero |
| other | the server's own `detail`, printed verbatim |
The countdown is the server's `Retry-After`, not a guess, and a refusal is written as text rather
than HTML — this is the one page a stranger reaches first. A browser that already holds a session
never sees the form: the server `303`s it to the dashboard, and the script checks
`/api/auth/status` first so the form does not even flash. `?next=` is followed only when it is an
in-app path (`/ups`, not `//evil.example`), judged on the server and again in the page.
### The account page
`/settings` — reached by `⚙ Account` in the masthead of every dashboard — is the session facts and
the one form that changes the password.
| Panel | What it says, and what the button does |
| ----- | ------------------------------------------------------------------------ |
| Session | the signed-in name, the expiry as a local timestamp **and** as time left, and **Sign out** (`POST /api/auth/logout`, then `/login`) |
| Change password | current + new + confirm, then `POST /api/auth/password`; the outcome is one line under the form |
The form checks what it can check itself (a current password typed, at least 8 new characters,
the two new boxes agreeing) so a typo is answered without a round trip, and then reports whatever
the server said: `403` is *the current password is wrong*, `422` names the rule the new password
broke, `429` is the lockout with its own seconds. The page also states the precedence rule in its
own words — the env var seeded the password once, a change here wins, resetting from the
environment means deleting `auth.verifier` and restarting — because that is the sentence that
prevents a support call about an edited `.env` that did nothing.
One detail makes the page more than a form. A change rotates the session-signing secret, which is
what signs out every *other* browser, and it invalidates the CSRF token this browser cached — so
`Dash.resetAuth()` clears that cache after the change (and after any `401` bounce). Without it the
operator changes the password, then every UPS button 403s until they reload.
**Sign out deletes this browser's cookie; it cannot revoke a copy of the token.** A session is
signed rather than stored, so nothing is left to mark as dead — the one thing that reaches every
browser at once is the signing key, and that is what a password change rotates. If a machine is
lost or a session is suspected, change the password here: that is the revocation, and it is why
the same page carries both buttons.
### Writes need the CSRF token
A write authorised by the **cookie** must carry `X-CSRF-Token`, validated against that same
session (`GET /api/auth/status` is where a page that is already signed in reads it — the login and
change-password responses carry it too, minted for the session they just created — and it sits
behind the session because a token is worthless to anyone who cannot read that response). `Dash.fetchJSON`
fetches it once per browser and attaches it to every non-`GET`/`HEAD` request, so page code does
not think about it — and `POST /api/auth/logout` is gated like any other write, because an
`<a href="/logout">` planted on a page you read mid-outage is a CSRF that signs you out.
Mismatched `Origin` or a `Sec-Fetch-Site` that is not `same-origin`/`none` is refused before the
token is even looked at, so a cross-site login post cannot spend your throttle either.
### Changing the password
`POST /api/auth/password` with `{"current": "…", "new": "…"}` — a protected write, so it needs the
session and the CSRF token like everything else:
```bash
curl -s -X POST -H 'content-type: application/json' -H "Cookie: dash_session=$SESSION" \
-H "X-CSRF-Token: $CSRF" -d '{"current":"…","new":"…"}' \
http://127.0.0.1:8090/api/auth/password
# {"ok":true,"changed":true,"hint":"other browsers are now signed out",
# "expires_at":1794982214.0,"csrf":"…"}
```
Nothing forces a change: no expiry, no first-login interstitial, and `DASH_ADMIN_PASSWORD` is never
re-applied after the first boot of a database. The only composition rule is length — at least
`api.MIN_PASSWORD_LENGTH` (8) characters, not empty once stripped, and different from the current
one — each refused with a `422` that names the rule. A symbol/digit mandate produces sticky notes
rather than passwords, and the cost of a guess here is PBKDF2 plus the backoff, not the character
classes.
A wrong `current` is `403 {"ok":false,"detail":"current password is wrong"}` and a
`password-denied` event, and guesses at that field are throttled on their **own** key
(`password:<address>`) — the same arithmetic as the login form, a separate bucket, so this form is
never an unthrottled oracle for the old password once a cookie is stolen and one form's lockout
does not lock out the other.
A right one rewrites `auth.verifier` **and** `auth.session_secret`, which is the whole revocation
story: a session is a signed cookie with no server-side record, so rotating the signing key is the
only way a password change reaches the browsers that are not in front of the form (without it a
stolen cookie would stay valid for another `DASH_SESSION_HOURS`). The response carries a fresh
cookie and a fresh CSRF token minted from the **new** secret, so the caller who just changed the
password is not signed out by their own form, while every other browser gets a `401` on its next
request and signs back in with the new password. `password-changed` lands in the event feed — an
event, not an overview alert, and not a field on `/api/health` (which is public) or `/api/config`.
### Scripting the API
`Authorization: Bearer <session token>` is the scripted caller, and it needs no CSRF token —
another site can make your browser send its cookie but cannot read a token out of your shell.
The session token is in the login response's `Set-Cookie`, not in its body:
```bash
SESSION=$(curl -si -X POST -H 'content-type: application/json' \
-d '{"password":"…"}' http://127.0.0.1:8090/api/auth/login \
| tr -d '\r' | sed -n 's/^set-cookie: dash_session=\([^;]*\).*/\1/p' | head -1)
curl -s -H "Authorization: Bearer $SESSION" http://127.0.0.1:8090/api/summary # 200
curl -s -H "Authorization: Bearer $SESSION" -X POST -H 'content-type: application/json' \
-d '{"ups":"apc","command":"test.panel.start"}' http://127.0.0.1:8090/api/ups/command
```
That is the app's one write endpoint **that dials anything** (the `/api/auth/*` writes change only
this process and its SQLite `meta` table — invariant 1 is about the fleet), and the bearer header
changes nothing about its gates: a command that can cut output still needs `"confirm":"DANGER"`,
and is still refused while the UPS is on battery.
### Lockout
Five wrong passwords from one address starts a backoff (`429` with `Retry-After`, exponential
from 60 s, capped at an hour; a good sign-in clears it, and a restart resets the counters). The
change-password form keeps its **own** counter under `password:<address>`, so hammering one form
cannot lock you out of the other and neither form is an unthrottled oracle for the same secret.
The refusal is the same sentence for every kind of failure — one account, so the response must
not rank reasons — and each refused attempt writes a `login-denied` or `password-denied` event you
can read at `/api/events?source=auth`. Neither ever becomes an overview alert: a password guess is
not an outage, and a page that blinks for both trains you to ignore the ones that matter.
## Configuration ## Configuration
Everything comes from the environment (see `.env.example` for the annotated list). At Everything comes from the environment (see `.env.example` for the annotated list). At
@@ -125,7 +342,12 @@ documentation and is never loaded, and `scripts/dev-server.sh` exports `.env.dev
| `NUT_SIMULATE` | `1` replaces upsd with a **labelled** simulator (development only) | | `NUT_SIMULATE` | `1` replaces upsd with a **labelled** simulator (development only) |
| `DASH_READ_ONLY` | `1` disables every mutating endpoint regardless of other flags | | `DASH_READ_ONLY` | `1` disables every mutating endpoint regardless of other flags |
| `DASH_DB` | SQLite path (default `data/dashboards.sqlite3`) | | `DASH_DB` | SQLite path (default `data/dashboards.sqlite3`) |
| `DASH_ADMIN_PASSWORD` | the admin password **seed**, needed only on the first boot of a database — see *Signing in* above |
| `DASH_SESSION_HOURS` | session lifetime (default `720` = 30 days) |
| `DASH_COOKIE_SECURE` | `1` marks the session cookie `Secure`; off because dev is `http://127.0.0.1`, and a request that arrived over `X-Forwarded-Proto: https` is treated as secure either way |
| `DASH_PBKDF2_ITERATIONS` | verifier cost (default `600000`). The knob exists for the test suite; leave it alone in a deployment. Raising it on a real box does take effect: the next successful sign-in re-hashes a cheaper stored verifier and keeps the signing key |
| `POLL_MS_SYSTEM` / `POLL_MS_LLM` / `POLL_MS_UPS` | how often the *browser* refetches each page (10 s / 2 s / 5 s) — shown in the page footer. `/disks` has no `POLL_MS_*` of its own and polls at a fixed 30 s: its payload only moves when the `disks` job re-reads the agents | | `POLL_MS_SYSTEM` / `POLL_MS_LLM` / `POLL_MS_UPS` | how often the *browser* refetches each page (10 s / 2 s / 5 s) — shown in the page footer. `/disks` has no `POLL_MS_*` of its own and polls at a fixed 30 s: its payload only moves when the `disks` job re-reads the agents |
| `DASH_RELOAD` | `1` makes `uv run dashboards` watch the source and restart on a change — development only, and every poll loop restarts with it |
| `DEBUGPY` / `DEBUGPY_PORT` | `DEBUGPY=1` opens a debugpy listener (default 5678) without blocking startup | | `DEBUGPY` / `DEBUGPY_PORT` | `DEBUGPY=1` opens a debugpy listener (default 5678) without blocking startup |
| `RETAIN_RAW_HOURS` / `RETAIN_MINUTE_DAYS` / `RETAIN_HOUR_DAYS` | history rollup and pruning | | `RETAIN_RAW_HOURS` / `RETAIN_MINUTE_DAYS` / `RETAIN_HOUR_DAYS` | history rollup and pruning |
@@ -133,11 +355,6 @@ A source that is not configured answers `404 {"configured": false}` **without di
and the page says "not configured" instead of showing a red panel for something you never and the page says "not configured" instead of showing a red panel for something you never
installed. installed.
Nothing that leaves the app carries a credential. A `HOST_TARGETS` userinfo token is parsed
into an `Authorization` header, and every URL the browser can see — `/api/config`, a source's
error string, the event feed — goes through `config.strip_userinfo` first, because an
unreachable host writes its failure message into the event table and `/api/events` serves it.
## Architecture ## Architecture
``` ```
@@ -151,7 +368,9 @@ src/dashboards/collectors.py ─────────────────
sources/host.py · disks.py · llm.py · nut.py poller.py (one task per source) sources/host.py · disks.py · llm.py · nut.py poller.py (one task per source)
│ samples events │ │ samples events │
▼ ▼ ▼ ▼
store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ── api.py → web/ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event + meta ── api.py → web/
▲
auth.py ── verifier + session key, loaded once at startup (no row and no seed → no server)
``` ```
* `poller.py` — every source gets its own asyncio task, its own health record, and its own * `poller.py` — every source gets its own asyncio task, its own health record, and its own
@@ -164,7 +383,15 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ──
poller, so a SQLite write that gets refused is reported (`ok: false`, the real reason, the poller, so a SQLite write that gets refused is reported (`ok: false`, the real reason, the
numbers aging, one line in the event log) instead of freezing the source under a green dot. numbers aging, one line in the event log) instead of freezing the source under a green dot.
* `store.py` — raw samples roll up to 1-minute and 1-hour averages and the raw rows are * `store.py` — raw samples roll up to 1-minute and 1-hour averages and the raw rows are
pruned, so a year of history is a few hundred MB rather than a disk emergency. pruned, so a year of history is a few hundred MB rather than a disk emergency. Its `meta`
table is the one place state that must outlive a restart goes (`get_meta`/`set_meta`/
`delete_meta`), and its first user is the admin credential below.
* `auth.py` — the admin login's decisions, in stdlib and without HTTP: a self-describing
PBKDF2-HMAC-SHA256 verifier, an HMAC-signed stateless session cookie (no session table, and
rotating the stored key is what evicts every other browser), a session-bound CSRF token, and
an in-process login backoff. `load_auth_state()` is the startup half: the environment seeds a
password **once**, the stored verifier wins from then on, and a server with neither refuses to
start (see *Signing in*).
* `severity.py` — the single green/amber/red table (`THRESHOLDS`, plus `INVERTED` for the * `severity.py` — the single green/amber/red table (`THRESHOLDS`, plus `INVERTED` for the
metrics where lower is worse). Rated values in the API payloads travel with a sibling metrics where lower is worse). Rated values in the API payloads travel with a sibling
`severity` (`cpu.severity.usage_pct`, `memory.severity`, `disks.severity`, a UPS card's `severity` (`cpu.severity.usage_pct`, `memory.severity`, `disks.severity`, a UPS card's
@@ -343,6 +570,40 @@ quietly, which reads exactly like a pass), they drive the production code paths
(`collectors.*`, `NutMonitor.collect`) against whatever hardware is reachable, and they never (`collectors.*`, `NutMonitor.collect`) against whatever hardware is reachable, and they never
write — a battery test on live hardware is not a unit test. write — a battery test on live hardware is not a unit test.
`tests/auth_contract.py` is two constants and the reason for them: every test that starts a
server seeds the admin password `testpass` (a server with no stored verifier and no seed
refuses to start, so a test that wants a running app has to give it a credential the way
`.env.dev` does) and hashes it at 1 000 rounds through that same `pbkdf2_iterations` setting
instead of the real 600 000 — ~0.3 s per hash times a hundred-odd servers is not a slower suite,
it is a suite nobody runs. The real cost is pinned where it belongs
(`test_the_default_cost_is_the_owasp_figure` and the config default test), so lowering it here
cannot lower it there. Its partner is `tests/auth_client.py`: `sign_in(client)` posts that
password once, asserts the sign-in worked, and puts the returned CSRF token on the client's
default headers — which is what keeps the ~200 pre-existing HTTP assertions asserting the
behaviour they were written for instead of the new gate.
`tests/test_auth_api.py` is the perimeter, and it is deliberately awkward in one place: every
anonymous request runs with `follow_redirects=False`, because a client that follows them would
fetch the login page and report `200` for a route that refused it — the way a gated suite ends
up passing while testing nothing. It also walks the *served* route table
(`test_every_route_that_is_not_public_requires_a_session`), so a route a later phase forgets to
protect fails there rather than in a browser, and it proves the one refusal that matters most on
the wire rather than on a status code: a `POST /api/ups/command` without the CSRF token answers
`403` **and the scripted upsd records nothing**, while the same request with the token still
reaches it — and `driver.killpower` still needs the word `DANGER` behind the session, because a
fifth gate that quietly replaced the fourth would be the worst kind of regression.
`tests/test_auth_docs.py` tests the paperwork, because the paperwork is what an operator holds
when the dashboard will not come up. It reads `.env.example` and `.env.dev.example` and demands
that every `DASH_*` auth setting is declared there and that no secret-shaped **assignment** carries
anything but a placeholder (the same allow-list `bash .agents/validate.sh` enforces through
`git ls-files`, restated so a plain `uv run pytest` catches it on the working tree) — while the
local-testing word stays legal in prose, which is the only place it can be useful. It demands that
`scripts/dev-server.sh` still gets its readiness probe and its `status` from the public
`/api/health` and has grown no session, token or password of its own, and that both docs still name
the four public paths, the reset command, the four variables and the `DASH_COOKIE_SECURE=1`
deployment note. Nothing starts a server there; a sentence that quietly disappears is the bug.
One more file is worth knowing about: `tests/card_contract.py` is the table of One more file is worth knowing about: `tests/card_contract.py` is the table of
`/api/system/snapshot` paths the system page's per-host cards are allowed to read. It is `/api/system/snapshot` paths the system page's per-host cards are allowed to read. It is
checked against all three captured host shapes (discrete GPU, APU, no GPU), over HTTP, and checked against all three captured host shapes (discrete GPU, APU, no GPU), over HTTP, and
@@ -429,6 +690,18 @@ podman run --rm -p 8090:8090 --env-file dashboards.env \
``` ```
It listens on `0.0.0.0:8090` as an unprivileged user, and the SQLite history lives in It listens on `0.0.0.0:8090` as an unprivileged user, and the SQLite history lives in
`/var/lib/dashboards` — put a volume there or every restart forgets the charts. With no `/var/lib/dashboards` — put a volume there or every restart forgets the charts **and** the admin
sources configured `/api/health` answers `200 {"status": "empty"}`, so the container passes password, because the verifier and the session key are rows in that database. `dashboards.env`
its health probe before its env file is right instead of crash-looping. therefore needs `DASH_ADMIN_PASSWORD` for the first start of a new volume; with neither the seed
nor a stored verifier the container exits non-zero with the reason instead of coming up open, and
the same seed is ignored once the row exists, so it can be dropped from the env file afterwards.
With no sources configured `/api/health` answers `200 {"status": "empty"}`, so the container
passes its health probe before its env file is right instead of crash-looping.
Two more lines belong in that quadlet env template because this service faces the public domain:
`DASH_COOKIE_SECURE=1`, so the session cookie never rides plain HTTP (the app also treats a
request that arrived with `X-Forwarded-Proto: https` as secure, so a misconfigured flag degrades
rather than leaks), and the password itself — set once, then removable, since the stored verifier
wins. The nginx server block, its `X-Forwarded-*` headers and HSTS live in the deployment repo
like every other service here, so a change to how the edge forwards TLS is a change in that repo,
not in this one; the only thing this app reads off the proxy is that one scheme header.
-5
View File
@@ -1,6 +1 @@
# TODO # TODO
- [ ] "Last self-test: Done and passed" still shows up as a warning in the overview and UPS section
- [ ] In the LLM engines page the "fleet" section has no spacing between it and the engine summaries
- [ ] Decode tok/s sometimes jumps unrealistically high, could be a calculation error
- [ ] For privileged actions (UPS commands) the exporter should be installed with password protection and that password must be used to register it with the collector. Effectively the exporters become protected web servers. Additionally, the collector should generate and manage a root certificate that needs to be installed with each exporter. In fact, just use the certs as auth rather than a password.
+3
View File
@@ -28,8 +28,11 @@ start() {
nohup .venv/bin/dashboards >"$LOG" 2>&1 & nohup .venv/bin/dashboards >"$LOG" 2>&1 &
echo $! >"$PIDFILE" echo $! >"$PIDFILE"
sleep 3 sleep 3
# The readiness probe is /api/health, which stayed public in phase 11: this script needs no
# credential and must never grow one. What it can say is where the credential comes from.
if running && curl -sf -o /dev/null "http://127.0.0.1:${PORT}/api/health"; then if running && curl -sf -o /dev/null "http://127.0.0.1:${PORT}/api/health"; then
echo "started pid $(cat "$PIDFILE") — http://127.0.0.1:${PORT}" echo "started pid $(cat "$PIDFILE") — http://127.0.0.1:${PORT}"
echo " sign in with DASH_ADMIN_PASSWORD from .env.dev"
else else
echo "FAILED to start; last lines of $LOG:" echo "FAILED to start; last lines of $LOG:"
tail -20 "$LOG" tail -20 "$LOG"
+6 -2
View File
@@ -20,8 +20,12 @@ def main() -> None:
if reload: if reload:
uvicorn.run("dashboards.app:app", host=SETTINGS.host, port=SETTINGS.port, reload=True) uvicorn.run("dashboards.app:app", host=SETTINGS.host, port=SETTINGS.port, reload=True)
else: else:
# pass the object: create_app() is cheap and avoids a second import of config # pass the object: create_app() is cheap and avoids a second import of config.
uvicorn.run(create_app(), host=SETTINGS.host, port=SETTINGS.port) # `SETTINGS` goes in explicitly rather than being picked up as the module default, so
# the app that gets served is configured by the same object the bind address came from
# — otherwise a replaced `SETTINGS` would move the port but leave the DB path and the
# admin credential pointing at whatever the environment said at import time.
uvicorn.run(create_app(SETTINGS), host=SETTINGS.host, port=SETTINGS.port)
if __name__ == "__main__": if __name__ == "__main__":
+361 -2
View File
@@ -13,13 +13,18 @@ are worth restating here because they are easy to break by accident:
from __future__ import annotations from __future__ import annotations
import logging
import math
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field, replace
from typing import Any from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from . import alerts as alert_rules from . import alerts as alert_rules
from . import auth
from .auth import ADMIN_USER, LOGIN_PATH, AuthState, LoginThrottle, Session
from .collectors import NutMonitor from .collectors import NutMonitor
from .config import Settings, strip_userinfo from .config import Settings, strip_userinfo
from .poller import Hub, SourceState from .poller import Hub, SourceState
@@ -33,6 +38,28 @@ from .sources.llm import fleet_totals
from .store import Event, SampleStore from .store import Event, SampleStore
NO_STORE = {"cache-control": "no-store, max-age=0"} NO_STORE = {"cache-control": "no-store, max-age=0"}
def refusal(status: int, detail: str, *, extra: dict[str, Any] | None = None,
headers: dict[str, str] | None = None) -> JSONResponse:
"""One JSON shape for every refusal the app answers *directly*.
Routes normally raise `HTTPException` and let the app's handler build the body; the
session gate and the login route build theirs here instead, because they answer before or
after that handler and must be certain of `no-store` either way — and one of them carries
`Retry-After`. The body is the same `{ok: false, detail}` a raised exception produces, so
no client has to know which of the two paths wrote it.
"""
return JSONResponse({"ok": False, "detail": detail} | (extra or {}), status_code=status,
headers={**NO_STORE, **(headers or {})})
#: The only composition rule a new password has: length. No symbol/digit/case mandates — those
#: produce sticky notes under a keyboard, and the cost of a guess here is PBKDF2 plus the
#: login backoff, not the character classes. Eight is where a memorable passphrase stops being
#: guessable by hand, and it is the part that does work.
MIN_PASSWORD_LENGTH = 8
_log = logging.getLogger("dashboards.api")
RANGE_SECONDS = {"5m": 300, "15m": 900, "30m": 1800, "1h": 3600, "3h": 10_800, RANGE_SECONDS = {"5m": 300, "15m": 900, "30m": 1800, "1h": 3600, "3h": 10_800,
"6h": 21_600, "12h": 43_200, "24h": 86_400, "7d": 604_800, "6h": 21_600, "12h": 43_200, "24h": 86_400, "7d": 604_800,
"30d": 2_592_000, "90d": 7_776_000} "30d": 2_592_000, "90d": 7_776_000}
@@ -40,13 +67,27 @@ RANGE_SECONDS = {"5m": 300, "15m": 900, "30m": 1800, "1h": 3600, "3h": 10_800,
@dataclass @dataclass
class AppContext: class AppContext:
"""Everything the routes need, assembled once at startup and passed around.""" """Everything the routes need, assembled once at startup and passed around.
`auth` is built during the lifespan before any poll starts (`app.py`), and is `None` only
on a context assembled outside the lifespan — which is nothing that serves a request, so
the session gate (task 03) may treat it as always present and still has to answer a
rejection rather than a 500 if it somehow is not.
`login_throttle` is a field default rather than a lifespan assignment for one reason: the
context is constructed exactly once per app in both assembly branches, so the default
*is* "one throttle per process" — and `None` would be a live branch in the login route
for a state nothing can reach. A throttle created per request would be a throttle that
never trips, which is the failure this object must not have.
"""
settings: Settings settings: Settings
store: SampleStore store: SampleStore
hub: Hub hub: Hub
nut_monitor: NutMonitor | None = None nut_monitor: NutMonitor | None = None
started_at: float = 0.0 started_at: float = 0.0
auth: AuthState | None = None
login_throttle: LoginThrottle = field(default_factory=LoginThrottle)
def get_context(request: Request) -> AppContext: def get_context(request: Request) -> AppContext:
@@ -118,6 +159,324 @@ def _ups_severity(card: dict[str, Any]) -> dict[str, str | None]:
} }
# --------------------------------------------------------------------------- #
# The admin session: login, status, logout
# --------------------------------------------------------------------------- #
def _forwarded_https(request: Request) -> bool:
"""Did this request reach us over TLS, as its proxy says?
The first `X-Forwarded-Proto` value is the client-facing one; anything after it was added
by a hop between there and here. Marking the cookie `Secure` on a lying header only ever
tightens it (a plain-HTTP caller that lied simply gets a cookie it cannot send back), so
this is safe to believe in the one direction that matters.
"""
forward = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
return forward == "https"
async def _login_body(request: Request) -> tuple[str, str | None]:
"""`(password, next)` from the login POST body, or a 422.
Parsed here rather than by a FastAPI model so a bad body gets this app's one error shape
instead of a validation report — and so an absent password is refused before any hashing
happens. A hash comparison on `""` would be a free oracle on the empty-verifier case, and
a 422 is the honest answer to a request that never asked to authenticate. `next` may ride
in the body or the query string; the caller cannot make it a redirect, only a hint —
`auth.safe_next` decides.
"""
try:
payload = await request.json()
except Exception as exc: # not JSON at all — the same refusal as a missing field
raise HTTPException(422, "send a JSON body with `password`") from exc
if not isinstance(payload, dict):
raise HTTPException(422, "send a JSON body with `password`")
password = payload.get("password")
if not isinstance(password, str) or not password:
raise HTTPException(422, "`password` is required")
raw_next = payload.get(auth.LOGIN_NEXT) or request.query_params.get(auth.LOGIN_NEXT)
return password, raw_next if isinstance(raw_next, str) else None
def _session_answer(body: dict[str, Any], *, secret: str, hours: float, request: Request,
settings: Settings) -> JSONResponse:
"""Mint a session, answer with its expiry and CSRF token, and set the cookie.
Two routes hand out a session — `POST /api/auth/login` (task 03) and
`POST /api/auth/password` (task 04) — and they must not disagree about one cookie
attribute: a `Secure` flag on one path and not the other is a dashboard that half-works
behind TLS, and a `max_age` that drifts from `session_hours` is a session that dies in the
middle of an outage. So there is one function, and the callers only say what else goes in
the body.
The body gains `expires_at` and `csrf` and nothing that identifies the credential: the
session token itself travels **only** in the `Set-Cookie`, never in a field a page could
render or a proxy could log.
"""
token = auth.mint_session(secret, hours=hours)
session = auth.read_session(secret, token)
if session is None: # pragma: no cover - a token minted seconds ago with this very secret
return refusal(500, "session could not be started")
response = JSONResponse(body | {"expires_at": session.expires_at,
"csrf": auth.mint_csrf(secret, token)}, headers=NO_STORE)
response.set_cookie(auth.SESSION_COOKIE, token, path="/", max_age=int(hours * 3600),
httponly=True, samesite="lax",
secure=settings.cookie_secure or _forwarded_https(request))
return response
def _upgrade_verifier(ctx: AppContext, password: str, state: AuthState) -> AuthState:
"""Re-hash a cheaper stored verifier at the configured cost, on a login that proved it.
Without this, raising `DASH_PBKDF2_ITERATIONS` never actually raises anything: the old row
keeps verifying forever, so the operator gets a bigger number in the env and the same cost
on disk. A successful login is the only moment the plaintext is in hand, and
:func:`auth.needs_rehash` is the only judge of whether the row is behind.
The signing secret is deliberately **not** rotated: this is not a logout, and rotating it
here would sign every other browser out over a change to a CPU constant. A refused write is
swallowed for the same reason — a `database is locked` on a bookkeeping row must not turn a
correct password into a 500 and no login at all (the next sign-in tries again), and the
message is fixed text because a credential never goes in a log line (invariant 6).
"""
configured = ctx.settings.pbkdf2_iterations
if not auth.needs_rehash(state.verifier, iterations=configured):
return state
upgraded = auth.hash_password(password, iterations=configured)
try:
ctx.store.set_meta(auth.META_VERIFIER, upgraded)
except Exception: # noqa: BLE001 - any store failure is the same non-event: keep the login
_log.warning("could not store the upgraded admin password verifier; "
"the next sign-in will try again")
return state
return replace(state, verifier=upgraded)
async def _password_body(request: Request) -> tuple[str, str]:
"""`(current, new)` from the change-password POST body, or a 422.
The same reasoning as :func:`_login_body`: a body that never named both fields is a
malformed request, not a wrong password, so it is refused before `verify_password`, before
the throttle, and before anything is written. A hash comparison on an absent `current` would
also charge a throttle slot to a caller who never guessed anything.
"""
try:
payload = await request.json()
except Exception as exc: # not JSON at all — the same refusal as a missing field
raise HTTPException(422, "send a JSON body with `current` and `new`") from exc
if not isinstance(payload, dict):
raise HTTPException(422, "send a JSON body with `current` and `new`")
current = payload.get("current")
new = payload.get("new")
if not isinstance(current, str) or not current:
raise HTTPException(422, "`current` is required")
if not isinstance(new, str):
raise HTTPException(422, "`new` is required")
return current, new
def _new_password_problem(new: str, current: str) -> str | None:
"""Why this new password may not be stored, in words a form can show, or `None`.
Every answer here is a `422` raised **before** anything is written or the current password
is looked at, so a rejected composition costs a caller nothing — no throttle slot, no event
row, no rotated secret. Length is the whole policy (see :data:`MIN_PASSWORD_LENGTH`); an
empty-after-strip value is called out separately from a short one because the two need
different fixes, and a "new" password identical to the current one is refused because it
would rotate the signing secret and sign every browser out to change nothing.
"""
if not new.strip():
return "a new password must not be empty or whitespace"
if len(new) < MIN_PASSWORD_LENGTH:
return f"a new password must be at least {MIN_PASSWORD_LENGTH} characters"
if new == current:
return "the new password must differ from the current one"
return None
def get_session(request: Request) -> Session:
"""The session the gate proved for this request.
The gate is the only way into a protected route and it stores what it verified on
`request.state`, so a missing session here means a route was reached that the gate does not
know about. That is a bug in this file, not in the caller — but the answer is still the
caller's 401, never a 500 with a stack trace, and `tests/test_auth_api.py` drives this
function directly so the defensive branch is a checked branch rather than a rumour.
"""
session = getattr(request.state, "session", None)
if not isinstance(session, Session):
raise HTTPException(401, detail={"ok": False, "detail": "sign in required",
"login": LOGIN_PATH})
return session
def get_auth_state(ctx: AppContext = Depends(get_context)) -> AuthState:
"""The startup credential bundle, or the same 503 the gate answers without one."""
state = ctx.auth
if state is None:
raise HTTPException(503, detail={"ok": False, "detail": "startup did not complete"})
return state
def auth_router() -> APIRouter:
"""`/api/auth/*` — the only routes that create, describe, re-credential, and end a session.
`/api/auth/login` is on the public list (`app.py`); the other three are ordinary protected
routes, so logout and the password change inherit the CSRF requirement the gate enforces on
every cookie write — a plain `<a href>` link that signs the operator out is a CSRF, and one
planted on a page they read during an outage is the version that actually hurts.
These are the app's only writes besides `POST /api/ups/command`, and they are writes to
**this process** and its SQLite `meta` table, never to an upstream: invariant 1 is about the
fleet, and `tests/test_api.py` pins the two lists separately rather than widening the first.
"""
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login")
async def login(request: Request, ctx: AppContext = Depends(get_context)) -> JSONResponse:
"""One admin, one password, no username field.
Every refusal is the same sentence. There is exactly one account, so "no such user",
"locked out", and "wrong password" would each tell a caller something the response
must not: the throttle answer is the exception because it is a fact the caller has to
act on (`Retry-After`), and it names no account.
"""
password, raw_next = await _login_body(request)
key = request.client.host if request.client else "unknown"
throttle = ctx.login_throttle
blocked = throttle.retry_after(key)
if blocked > 0:
seconds = math.ceil(blocked)
return refusal(429, f"too many failed sign-ins — try again in {seconds}s",
headers={"Retry-After": str(seconds)})
state = ctx.auth
if state is None:
# Same answer the gate gives with no context: a half-started app has no credential
# to check against, and "sign in" would be a lie. Reachable only through the public
# login route, which is why it is a branch here and not a `Depends`.
return refusal(503, "startup did not complete")
if not auth.verify_password(password, state.verifier):
throttle.failure(key)
# An event row is written *because* it cannot blink the overview: `alerts.py`
# turns only `kind: "unreachable"` events into rows and every page feed filters
# by `source`, so `auth` events are visible in `/api/events?source=auth` and
# nowhere else. Do not "fix" this by hiding the event — a refused sign-in against
# a public domain is exactly what an operator wants in the log the morning after.
# The message names no account and no reason: there is one account, and the
# reason is a secret about the credential (invariant 6).
ctx.store.log_event(Event(time.time(), "auth", ADMIN_USER, "login-denied", "warn",
f"sign-in refused (attempt {throttle.attempts(key)})"))
return refusal(401, "wrong password")
# A stored verifier minted at an older cost is upgraded here and only here: this is the
# one request that has the password in hand and has proved it. The secret is untouched,
# so nobody else is signed out by a bookkeeping write.
state = _upgrade_verifier(ctx, password, state)
ctx.auth = state
throttle.success(key)
return _session_answer({"ok": True, "username": auth.ADMIN_USER,
"next": auth.safe_next(raw_next) or "/"},
secret=state.secret, hours=state.session_hours,
request=request, settings=ctx.settings)
@router.post("/password")
async def change_password(request: Request,
ctx: AppContext = Depends(get_context),
state: AuthState = Depends(get_auth_state)) -> JSONResponse:
"""Prove the current password, replace the verifier, rotate the signing secret.
Optional in every sense: nothing forces a change, no expiry, no first-login
interstitial. What it does when used is the revocation this app has — sessions are a
signed cookie with no server-side record, so **rotating the secret is the only way a
password change reaches the browsers that are not in front of the form**. Without it a
stolen cookie would outlive the password that supposedly revoked it, which is why
:func:`auth.update_verifier` writes `auth.session_secret` alongside `auth.verifier` and
why the response is built from the **new** state: a cookie minted from the old secret
dies on its next request, and the operator who just typed a new password would be told
to type it again.
The current password is throttled on its own key (`password:<address>`) rather than the
login one. A caller holding a stolen cookie is already in, so this is not the front
door — but it is a live oracle for the old password (right answer: the change lands and
is visible; wrong answer: 403), and the point of a password change is often exactly that
the old one may be compromised. Separate buckets, so neither form can be hammered at the
other's rate and locking one does not lock the other.
"""
current, new = await _password_body(request)
problem = _new_password_problem(new, current)
if problem is not None:
raise HTTPException(422, problem)
key = f"password:{request.client.host if request.client else 'unknown'}"
throttle = ctx.login_throttle
blocked = throttle.retry_after(key)
if blocked > 0:
seconds = math.ceil(blocked)
return refusal(429, f"too many failed password changes — try again in {seconds}s",
headers={"Retry-After": str(seconds)})
if not auth.verify_password(current, state.verifier):
throttle.failure(key)
# Same bargain as `login-denied`: an `auth` event is readable at
# `/api/events?source=auth` and cannot blink the overview, because `alerts.py`
# promotes only `kind: "unreachable"`. The message names no reason beyond "refused"
# — the reason is the credential (invariant 6).
ctx.store.log_event(Event(time.time(), "auth", ADMIN_USER, "password-denied",
"warn",
"password change refused "
f"(attempt {throttle.attempts(key)})"))
return refusal(403, "current password is wrong")
new_state = auth.update_verifier(ctx.store, state, new)
ctx.auth = new_state # a plain mutable dataclass: this is the hand-off, request by request
throttle.success(key)
# `warn` and not `info`: a credential changed on a public domain is something to read
# the morning after, whatever the reason it was changed.
ctx.store.log_event(Event(time.time(), "auth", ADMIN_USER, "password-changed", "warn",
"admin password changed"))
return _session_answer({"ok": True, "changed": True,
"hint": "other browsers are now signed out"},
secret=new_state.secret, hours=new_state.session_hours,
request=request, settings=ctx.settings)
@router.get("/status")
async def status(session: Session = Depends(get_session),
state: AuthState = Depends(get_auth_state)) -> dict[str, Any]:
"""Who the caller is signed in as, how long for, and their CSRF token.
Where a page that is already signed in reads the CSRF token, and it sits behind the
session for a reason: a token bound to one session is worthless to anyone who cannot
already read this response. (`/api/auth/login` and `/api/auth/password` mint one too,
for the session each has just created — a caller cannot fetch this route first, because
it has no session until that response gives it one.) Pages call it once (`Dash.ensureAuth`) and reuse the value, so this is
also the one request that has to succeed before a page can write anything.
"""
return {
"ok": True,
"authenticated": True,
"username": session.username,
"expires_at": session.expires_at,
"seconds_left": max(0, int(session.expires_at - time.time())),
"csrf": auth.mint_csrf(state.secret, session.token),
"session_hours": state.session_hours,
}
@router.post("/logout")
async def logout() -> JSONResponse:
"""Clear the cookie. The session itself needs no revoking — that is what stateless means.
A caller holding a bearer token cannot be logged out of it (nothing stores it), which
is the documented trade of `Authorization: Bearer`: revocation is the signing secret,
and changing the password (task 04) is the button that pulls it.
"""
response = JSONResponse({"ok": True}, headers=NO_STORE)
response.delete_cookie(auth.SESSION_COOKIE, path="/")
return response
return router
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Common routes # Common routes
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+241 -4
View File
@@ -2,6 +2,12 @@
`create_app()` takes injectable settings and store so tests can build an app with an `create_app()` takes injectable settings and store so tests can build an app with an
in-memory DB and no targets — the same code path production uses, without the network. in-memory DB and no targets — the same code path production uses, without the network.
Startup is fail-closed: the lifespan builds the admin :class:`~dashboards.auth.AuthState`
before the first poll, and a server with neither a stored verifier nor `DASH_ADMIN_PASSWORD`
raises out of the lifespan and exits non-zero instead of serving the fleet to whoever finds the
domain. The `RuntimeError` is deliberately not caught here — uvicorn prints it and leaves, and
the message is the operator's fix.
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,14 +18,28 @@ from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote
import httpx import httpx
from fastapi import FastAPI, Request from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.routing import Match
from .api import NO_STORE, AppContext, common_router, disks_router, llm_router, system_router, ups_router from . import auth
from .api import (
NO_STORE,
AppContext,
auth_router,
common_router,
disks_router,
llm_router,
refusal,
system_router,
ups_router,
)
from .auth import load_auth_state
from .collectors import ( from .collectors import (
NutMonitor, NutMonitor,
UpstreamError, UpstreamError,
@@ -38,10 +58,36 @@ from .sources.nut_sim import from_env as simulator_from_env
from .store import SampleStore from .store import SampleStore
WEB = Path(__file__).resolve().parent / "web" WEB = Path(__file__).resolve().parent / "web"
# `/settings` is a page and not a dashboard: it is in `PAGES` (so it is a route) and *not* in
# `PUBLIC_PATHS` (so the gate guards it with no extra code — the route-enumeration test in
# `tests/test_auth_api.py` proves that, and `tests/test_api.py` proves the anonymous refusal for
# this page specifically). It carries no `aria-current` tab because it is not one of the four.
PAGES = {"/": "index.html", "/system": "system.html", "/disks": "disks.html", PAGES = {"/": "index.html", "/system": "system.html", "/disks": "disks.html",
"/llm": "llm.html", "/ups": "ups.html"} "/llm": "llm.html", "/ups": "ups.html", "/login": "login.html",
"/settings": "settings.html"}
VERSION = "0.1.0" VERSION = "0.1.0"
# Everything else requires a signed-in admin. This list is the whole attack surface, so
# adding a path here is a security decision, not a convenience: /api/health is here
# because the Containerfile HEALTHCHECK and ./scripts/dev-server.sh status poll it
# unauthenticated, and it is the only data route that stays public.
PUBLIC_PATHS = frozenset({auth.LOGIN_PATH, "/api/auth/login", "/api/health"})
PUBLIC_PREFIXES = ("/static/",)
def is_public(path: str) -> bool:
"""Is this path outside the session gate? `PUBLIC_PATHS` says what that costs.
A `Mount` is asked about its own root (`/static`) as often as about its children, and
StaticFiles answers that with a redirect to `/static/` — the same asset tree either way,
so the bare prefix counts as public too. Nothing about the fleet is reachable there:
the mount serves only this app's own CSS and JavaScript, which carry no data.
"""
if path in PUBLIC_PATHS:
return True
rooted = path if path.endswith("/") else f"{path}/"
return any(rooted.startswith(prefix) for prefix in PUBLIC_PREFIXES)
def build_hub(settings: Settings, store: SampleStore, def build_hub(settings: Settings, store: SampleStore,
client: httpx.AsyncClient) -> tuple[Hub, NutMonitor | None]: client: httpx.AsyncClient) -> tuple[Hub, NutMonitor | None]:
@@ -89,6 +135,123 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
return ctx, store, hub.start return ctx, store, hub.start
def _session_of(request: Request, state: auth.AuthState) -> tuple[auth.Session | None, bool]:
"""The session this request proves, and whether it arrived in the cookie.
Two carriers, deliberately: the cookie (a browser, which is why CSRF applies) and
`Authorization: Bearer <token>` (a script — `curl -H "Authorization: Bearer …"` can drive
the API, which is what the bearer header buys). The `via_cookie` answer is what decides
the CSRF requirement: a cross-site page can make the browser send its cookie but cannot
read a bearer token out of the operator's shell, so a bearer caller has nothing to forge
with. An invalid token is `None` rather than an exception for the same reason every
`auth.py` primitive answers `False`/`None` — the reason is a credential.
"""
token = request.cookies.get(auth.SESSION_COOKIE, "")
via_cookie = bool(token)
if not via_cookie:
scheme, _, raw = request.headers.get("authorization", "").partition(" ")
token = raw.strip() if scheme.lower() == "bearer" else ""
if not token:
return None, via_cookie
return auth.read_session(state.secret, token), via_cookie
def _own_origin(request: Request) -> str:
"""This app's origin, as the browser that is calling would have written it.
Behind nginx (the documented deployment) uvicorn sees plain `http` and a `Host` the proxy
forwarded, so a naive `request.url.scheme` would answer `http://…` to a browser that sent
`Origin: https://…` and refuse every write as cross-origin. `X-Forwarded-Proto`'s first
value fixes that, the same header that marks the session cookie `Secure` — and trusting it
here opens nothing, because a missing `Origin` is already accepted (it is a non-browser
client, not a cross-site one), so a caller who could spoof the header could simply omit
the origin instead.
"""
forwarded = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
scheme = forwarded or request.url.scheme
return f"{scheme}://{request.url.netloc}"
def _origin_denied(request: Request) -> bool:
"""Does this write come from somewhere that is not this app?
`Origin` is checked when present (a same-origin form post always sends it; a curl has no
origin and is not a cross-site request), and `Sec-Fetch-Site` is checked when present —
where an unrecognised value is a rejection, so a new browser token defaults to the safe
answer. No allow-list is passed: same-origin is the only policy this app needs, and a
`DASH_ALLOWED_ORIGINS` knob would be a way to open the gate by typo.
"""
origin = request.headers.get("origin")
if origin and not auth.origin_matches(origin, _own_origin(request), ()):
return True
return not auth.sec_fetch_site_allowed(request.headers.get("sec-fetch-site"))
def _route_is_missing(request: Request) -> bool:
"""Would the router answer 404 for this request?
The gate runs before routing, so without this check an unauthenticated `curl` at a typo'd
URL is told to sign in and never learns the one thing it was asking — whether the route
exists. `Match.NONE` is handed to the router instead, whose 404 arrives in this app's JSON
shape, so "no such route" stays available to whoever types it.
A path that exists but not for this verb (`Match.PARTIAL`, which the router answers 405)
stays behind the gate: that route is real, and "sign in first" is the honest answer about
it. Leaking which routes exist is not a leak here — every one of them is in the shipped
JavaScript, and the OpenAPI document is switched off.
"""
scope = request.scope
return not any(route.matches(scope)[0] is not Match.NONE for route in request.app.routes)
def _wants_page(request: Request, path: str) -> bool:
"""Is this a request for a *page*, so a redirect to the login form is the honest answer?
`GET`/`HEAD` on a non-`/api` path, unless the caller asked for JSON specifically. The
`Accept` test is negative rather than positive on purpose: a browser sends `text/html`,
and `curl` — the first probe of any new deployment, and what the phase's own acceptance
check runs — sends `*/*`. Sending `curl` a 401 body for `/system` while a browser gets a
redirect would make the same question have two answers depending on the tool.
"""
if request.method not in ("GET", "HEAD") or path.startswith("/api"):
return False
return "application/json" not in request.headers.get("accept", "")
def _next_target(request: Request) -> str:
"""What `?next=` is set to: the path **and** the query string the operator asked for.
The range chips and the per-engine filters all live in the query string, so a `next` with
only `/system` in it would land them on the default window of the page they came back for.
"""
query = request.url.query
return f"{request.url.path}?{query}" if query else request.url.path
def _to_login(request: Request) -> RedirectResponse:
"""303 to the login form, carrying the page the operator actually wanted.
Quoted with `quote`'s default safe set, which is what makes the `Location` unambiguous: an
encoded `?` cannot be read back as a second query separator, and a crafted path cannot
split the header. `auth.safe_next` judges the value on the way in, so carrying the query
costs nothing in safety — `?` is not what makes a URL leave the site.
"""
target = quote(_next_target(request), safe="/")
return RedirectResponse(f"{auth.LOGIN_PATH}?{auth.LOGIN_NEXT}={target}",
status_code=303, headers=NO_STORE)
def _to_dashboard(request: Request) -> RedirectResponse:
"""303 away from `/login` for someone already signed in — to `?next=`, or to `/`.
`auth.safe_next` is the open-redirect check: a `next` that leaves the site is dropped
rather than followed, because a login page that will forward you anywhere is a phishing
kit with a homelab theme.
"""
target = auth.safe_next(request.query_params.get(auth.LOGIN_NEXT)) or "/"
return RedirectResponse(target, status_code=303, headers=NO_STORE)
def create_app(settings: Settings | None = None, store: SampleStore | None = None) -> FastAPI: def create_app(settings: Settings | None = None, store: SampleStore | None = None) -> FastAPI:
"""`store` is for tests: when supplied it is not created and not closed here.""" """`store` is for tests: when supplied it is not created and not closed here."""
settings = settings or DEFAULT_SETTINGS settings = settings or DEFAULT_SETTINGS
@@ -111,6 +274,13 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
start = hub.start start = hub.start
else: else:
ctx, owned_store, start = build_context(settings, client) ctx, owned_store, start = build_context(settings, client)
# The credential is ready before anything can answer a request *or* poll: a server
# that cannot say who its admin is must not start the loops either.
ctx.auth = load_auth_state(ctx.store,
admin_password=settings.admin_password,
iterations=settings.pbkdf2_iterations,
session_hours=settings.session_hours,
db_path=settings.db_path)
app.state.ctx = ctx app.state.ctx = ctx
app.state.hub = ctx.hub app.state.hub = ctx.hub
start() start()
@@ -130,6 +300,7 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
for path, filename in PAGES.items(): for path, filename in PAGES.items():
app.add_api_route(path, _page_handler(filename), methods=["GET"], name=f"page:{path}") app.add_api_route(path, _page_handler(filename), methods=["GET"], name=f"page:{path}")
app.include_router(auth_router())
app.include_router(common_router()) app.include_router(common_router())
app.include_router(system_router()) app.include_router(system_router())
app.include_router(disks_router()) app.include_router(disks_router())
@@ -141,6 +312,72 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
return JSONResponse({"ok": False, "detail": exc.message}, status_code=exc.status, return JSONResponse({"ok": False, "detail": exc.message}, status_code=exc.status,
headers=NO_STORE) headers=NO_STORE)
@app.middleware("http")
async def session_gate(request: Request, call_next: Callable[[Request], Awaitable[Response]]):
"""Default-deny: every route needs a signed-in admin except `PUBLIC_PATHS`.
The whole perimeter in one function, because a gate you have to read three files to
find is a gate a later route skips. Order of decisions, and why:
1. **No auth state → 503.** The lifespan builds `ctx.auth` before anything can be
served (`app.py`), so getting here without it means startup failed halfway. Serving
pages with `ctx.auth = None` would be the open dashboard this phase exists to
prevent; so would letting `/api/auth/login` reach a context with no credential.
2. **No session and no such route → the router's 404.** A typo is not a sign-in
problem, and "sign in required" would be the only answer anyone outside ever gets
for it (`_route_is_missing`).
3. **Public → through**, with one exception: `GET /login` for someone holding a valid
session goes to the dashboard instead of offering the form again.
4. **No session** → `303` to `/login?next=…` for a page, `401` for anything else.
A data route answers JSON to a script and never a redirect it cannot render.
5. **A write** additionally passes the origin check, and — when it was authorised by
the *cookie* — must carry this session's CSRF token. A bearer caller is exempt from
the token because no other site can make it produce one.
`OPTIONS` is a safe method (this app sends no CORS headers, so a preflight is not part
of any same-origin request), and every answer here carries `no-store`.
"""
ctx = getattr(request.app.state, "ctx", None)
state = getattr(ctx, "auth", None) if ctx is not None else None
if state is None:
return refusal(503, "startup did not complete")
path = request.url.path
session, via_cookie = _session_of(request, state)
writing = request.method not in auth.SAFE_METHODS
if session is None and _route_is_missing(request):
# A typo is not a sign-in problem, and "401 sign in required" would be the only
# answer anyone outside gets. Handed to the router, whose 404 is this app's JSON.
return await call_next(request)
if is_public(path):
# Only the origin gate applies to a public write (`POST /api/auth/login`): there
# is no session yet, so there is no CSRF token to demand — and a cross-site login
# post is still refused, because it spends the throttle of whoever is typing.
if writing and _origin_denied(request):
return refusal(403, "cross-origin request refused")
if session is not None:
request.state.session = session
if path == auth.LOGIN_PATH and not writing:
return _to_dashboard(request)
return await call_next(request)
if session is None:
if _wants_page(request, path):
return _to_login(request)
return refusal(401, "sign in required", extra={"login": auth.LOGIN_PATH})
if writing:
if _origin_denied(request):
return refusal(403, "cross-origin request refused")
if via_cookie and not auth.verify_csrf(state.secret, session.token,
request.headers.get(auth.CSRF_HEADER)):
return refusal(403, "missing or invalid CSRF token")
request.state.session = session
return await call_next(request)
@app.middleware("http") @app.middleware("http")
async def no_store_for_api(request: Request, call_next): async def no_store_for_api(request: Request, call_next):
"""Every /api response is `no-store`, success or failure. """Every /api response is `no-store`, success or failure.
+752
View File
@@ -0,0 +1,752 @@
"""Authentication primitives: password verifier, session token, CSRF token, login throttle.
Every authentication *decision* in the app lives here as a pure function or an in-memory
object — no FastAPI, no `Request`, no SQLite driver, no clock it cannot be handed. The one
part that persists (the verifier bootstrap at the end) does it through a three-method
`MetaStore` protocol instead of importing the store, which is what keeps this file testable
with a dict. The middleware,
the `/login` route and `/api/auth/*` (phase 11 tasks 03-04) are thin: they read headers, call
one function here, and turn its answer into a status code. That split is what makes the
security-relevant branch testable at all, because there is no HTTP transport between a test
and the decision.
**Why hand-rolled, in stdlib only.** The same reasoning as `prom.py`, `sources/nut.py`, and
the host agent: the alternative was `itsdangerous` + Starlette `SessionMiddleware` +
`bcrypt`, which buys a session store to operate and a native wheel to build in the
Containerfile, and costs the one property that matters here — every rule readable in one file
with no magic inside the cookie. `hmac`/`hashlib`/`secrets` are the primitives those
libraries wrap. No dependency was added to write this and none is coming.
**Credentials never leave the process.** A password, a verifier, a salt, or a session token
must never appear in a log line, an exception message, or a value a caller could render. The
primitives therefore record nothing and write nothing to stdout, and their failure modes
return `False`/`None` instead of raising with the offending input in the message — a
`ValueError: bad salt '3f9a…'` in a traceback is a credential on `/api/health` and in the
event table. The one exception is deliberate and checked by `tests/test_auth.py`: the
*bootstrap* below raises twice — once when a server has no password anywhere (fail closed is
worth stopping the process for) and once when somebody submits an empty new password — and
raises with fixed operator-facing text, never with an input in it. The file's one `logging`
call is likewise a fixed sentence with no interpolation, so there is no path from a credential
to a log line here. `AGENTS.md` invariant 6 ("never leak a credential") therefore covers the
admin's own credential, not only the agent tokens and the NUT password it was written for.
Nothing above the bootstrap is a *policy*: which routes require a session and what a rejection
answers belong to the caller. Keeping that line straight is why a corrupt `meta` row has to
read as "wrong password" (so the login page shows an error) rather than a 500 on the page
whose whole job is to say "wrong password".
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import logging
import math
import secrets
import threading
import time
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol
from urllib.parse import unquote
# --- the vocabulary the middleware and the pages share --------------------------------- #
#: There is one admin. No user table, no roles, and none is coming: this dashboard watches
#: one homelab, and a second account would mean a permission model nobody asked for.
ADMIN_USER = "admin"
#: Cookie name. Deliberately not `session` — a name that collides with another app on the
#: same host is a session that gets set by somebody else.
SESSION_COOKIE = "dash_session"
#: Header a write must carry. A custom header cannot be set by a cross-origin form post,
#: which is the second half of the CSRF defence (the first is that a browser will not read
#: it cross-origin either).
CSRF_HEADER = "X-CSRF-Token"
#: The sign-in page and its endpoint's neighbour. Named here because both halves of the gate
#: need the same string: the middleware redirects a page request to it, and `app.py` lists it
#: as the one HTML route outside the session. One constant is what keeps the redirect target
#: and the public list from ever naming different places.
LOGIN_PATH = "/login"
#: The query parameter that carries "where to land after signing in". Named because the gate
#: writes it and the login page reads it back: a typo in either place is an operator silently
#: dropped on `/` instead of the chart they came back for.
LOGIN_NEXT = "next"
#: Methods that change nothing, so the CSRF/origin gate does not apply to them. `HEAD`
#: because it is GET, `OPTIONS` because a CORS preflight never carries credentials of ours.
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
# --- password verifier ------------------------------------------------------------------ #
#: PBKDF2-HMAC-SHA256 rounds. OWASP's current figure for PBKDF2 with SHA-256; ~60 ms per
#: verification on this hardware, which is nothing for a login form and enough to make an
#: offline crack of a stolen `meta` row expensive. `DASH_PBKDF2_ITERATIONS` overrides it
#: (task 02), and :func:`needs_rehash` is how a hash minted under an older cost is upgraded
#: on the next successful login.
PBKDF2_ITERATIONS = 600_000
#: Algorithm tag inside the verifier string. Self-describing so the cost — or the algorithm —
#: can change later without a migration: an old row still verifies, and reports itself as
#: needing a rehash.
PBKDF2_ALGO = "pbkdf2_sha256"
PBKDF2_SALT_BYTES = 16
PBKDF2_HASH_BYTES = 32 # the sha256 digest size; anything else is a corrupt row
#: Upper bound on a *stored* iteration count. `verify_password` must never raise, and an
#: absurd cost read out of a damaged row would otherwise turn the login page into a CPU
#: outage. 10M is an order of magnitude above anything this project would configure.
PBKDF2_MAX_ITERATIONS = 10_000_000
# --- sessions --------------------------------------------------------------------------- #
#: Default session lifetime: 30 days. This is a dashboard an operator opens from a phone,
#: not a banking app; a shorter life means the login page in front of the number that
#: matters, and the password is changeable (and rotates the secret) from `/settings`.
SESSION_HOURS = 720.0
#: Values of `Sec-Fetch-Site` a write may come with. `same-origin` is the normal case;
#: `none` is a non-browser client (curl, the Containerfile HEALTHCHECK, a script) — which
#: still needs the cookie *and* the CSRF header, so it is not a bypass. Anything else,
#: including a value this module has not heard of, is rejected: an unrecognised token is a
#: browser doing something we did not reason about. `same-site` is rejected on purpose —
#: same-site-but-cross-origin is still a page we do not control.
ALLOWED_SEC_FETCH_SITE = frozenset({"same-origin", "none"})
#: Hard ceiling on how many source addresses :class:`LoginThrottle` remembers. Public so the
#: test can drive it past the limit instead of guessing at a private name.
THROTTLE_MAX_KEYS = 1024
def _password_bytes(password: str) -> bytes:
"""The password as UTF-8, tolerating a lone surrogate.
`surrogatepass` is not a formatting preference: a form field or JSON body decoded with
`errors="surrogateescape"` can carry a surrogate, and `.encode("utf-8")` would raise on
the login path. Same encoding on both sides of the comparison, so it is still a
deterministic function of the password.
"""
return password.encode("utf-8", "surrogatepass")
def _digest(password: str, salt: bytes, iterations: int) -> bytes:
return hashlib.pbkdf2_hmac("sha256", _password_bytes(password), salt, iterations)
def hash_password(password: str, *, iterations: int = PBKDF2_ITERATIONS) -> str:
"""`pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>`.
Self-describing on purpose: the stored string carries its own cost and algorithm, so
raising :data:`PBKDF2_ITERATIONS` later needs no migration — old rows keep verifying and
:func:`needs_rehash` says which ones to replace. The salt is 16 fresh random bytes per
hash, so two accounts (or one password set twice) never share a digest.
"""
salt = secrets.token_bytes(PBKDF2_SALT_BYTES)
digest = _digest(password, salt, max(1, iterations))
return f"{PBKDF2_ALGO}${max(1, iterations)}${salt.hex()}${digest.hex()}"
def verify_password(password: str, encoded: str) -> bool:
"""Does `password` match the stored verifier? **Never raises.**
Every malformed input — empty string, the wrong number of `$`-separated fields, a
non-numeric or absurd iteration count, an algorithm this build does not know, odd-length
or non-hex salt/hash, a digest of the wrong length — is `False`, the same answer a wrong
password gets. A damaged `auth.verifier` row must produce a login error the operator can
act on, not a 500 on the page whose whole job is to say "wrong password", and the reason
cannot be put in an exception message anyway (module docstring: no credential in a
message).
The comparison is `hmac.compare_digest` over the raw digests, so a timing difference
cannot tell an attacker how many leading bytes of the hash they guessed.
"""
parts = (encoded or "").split("$")
if len(parts) != 4:
return False
algo, raw_iterations, raw_salt, raw_hash = parts
if algo != PBKDF2_ALGO:
return False
try:
iterations = int(raw_iterations)
except ValueError:
return False
if not 1 <= iterations <= PBKDF2_MAX_ITERATIONS:
return False
try:
salt = bytes.fromhex(raw_salt)
expected = bytes.fromhex(raw_hash)
except ValueError:
return False
if len(expected) != PBKDF2_HASH_BYTES:
return False
return hmac.compare_digest(_digest(password, salt, iterations), expected)
def needs_rehash(encoded: str, *, iterations: int) -> bool:
"""Is the stored verifier weaker than `iterations` (or not our current shape)?
`True` means "replace this row on the next successful login", which is how the cost rises
without a migration step. An unparseable verifier or an unknown algorithm is also `True`:
it needs replacing, even though a login cannot succeed against it to do the replacing —
the operator sets a new password in that case, and `verify_password` keeps answering
`False` until then.
"""
parts = (encoded or "").split("$")
if len(parts) != 4 or parts[0] != PBKDF2_ALGO:
return True
try:
stored = int(parts[1])
except ValueError:
return True
return stored < iterations
# --- sessions --------------------------------------------------------------------------- #
def new_secret() -> str:
"""A fresh signing secret (256 bits, hex).
Stored by task 02 under the meta key `auth.session_secret`. Rotating it invalidates every
cookie ever issued — which is exactly the semantics changing the password wants: the
browser in front of the change survives (it gets a re-minted cookie), every *other* one is
out, with no session table to walk.
"""
return secrets.token_hex(32)
@dataclass(frozen=True)
class Session:
"""A session the caller has just been proved to hold.
`token` is carried so the caller can derive the session's CSRF token
(:func:`mint_csrf`) without threading the cookie value around separately; it is a
credential and must not be rendered or logged.
"""
username: str
expires_at: float
token: str
def _b64e(raw: bytes) -> str:
"""urlsafe base64 without padding — a cookie value has no reason to carry `=`."""
return base64.b64encode(raw, altchars=b"-_").decode("ascii").rstrip("=")
def _b64d(text: str) -> bytes | None:
"""The inverse of :func:`_b64e`, or `None` for anything that is not that.
`validate=True` so a token of garbage does not silently decode to a shorter byte string:
the caller's answer for garbage is `None`, same as for a bad signature.
"""
if not text:
return None
try:
return base64.b64decode(text + "=" * (-len(text) % 4), altchars=b"-_", validate=True)
except (binascii.Error, ValueError):
return None
def _session_signature(secret: str, payload: str) -> bytes:
return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest()
def mint_session(secret: str, *, username: str = ADMIN_USER, hours: float = SESSION_HOURS,
now: float | None = None) -> str:
"""A signed, stateless session token: `b64url(payload) + '.' + b64url(hmac)`.
The payload is `"<username>|<expires_at epoch seconds>"`. There is no session table and
nothing to prune: the expiry is inside the signed payload, so "is this session valid" is
one HMAC and one float comparison, and a restart cannot orphan rows.
`now` is injectable (the caller passes `time.time()`; a test passes a fixed value) so the
expiry is testable without sleeping, and the minted token is stable for a given `now`.
"""
base = time.time() if now is None else now
expires_at = base + hours * 3600.0
payload = f"{username}|{expires_at:.0f}"
signature = _session_signature(secret, payload)
return f"{_b64e(payload.encode('utf-8'))}.{_b64e(signature)}"
def read_session(secret: str, token: str, *, now: float | None = None) -> Session | None:
"""The session this token proves, or `None`.
Order matters and is the whole function: **signature first**, in constant time, then the
expiry. Checking the expiry before the signature would let an unauthenticated caller
learn whether a forged payload parses, and would grade an attacker's guesses about the
format for free.
`None` for: an empty token, no dot, an empty payload or signature, a payload that is not
base64 or not UTF-8, a bad signature (including one from a rotated secret), a payload
without `|` or without a username, a non-numeric/non-finite expiry, an expired session,
and a tampered username — the username lives inside the signed payload, so changing it
changes the HMAC.
"""
if not token:
return None
payload_b64, dot, signature_b64 = token.partition(".")
if not dot:
return None
raw_payload = _b64d(payload_b64)
signature = _b64d(signature_b64)
if raw_payload is None or signature is None:
return None
try:
payload = raw_payload.decode("utf-8")
except UnicodeDecodeError:
return None
if not hmac.compare_digest(signature, _session_signature(secret, payload)):
return None
username, bar, raw_expiry = payload.partition("|")
if not bar or not username:
return None
try:
expires_at = float(raw_expiry)
except ValueError:
return None
if not math.isfinite(expires_at):
return None
base = time.time() if now is None else now
if expires_at <= base:
return None
return Session(username=username, expires_at=expires_at, token=token)
# --- CSRF ------------------------------------------------------------------------------- #
def mint_csrf(secret: str, session_token: str) -> str:
"""The CSRF token for one session: `hmac_sha256(secret, "csrf|" + session token)`.
Bound to the session rather than stored, so it needs no expiry of its own (the session
has one) and no server-side state. A token stolen from session A cannot authorise a write
in session B, and a leaked CSRF token is not a session token — the direction that matters,
because a CSRF token is the one credential this app hands to page JavaScript.
"""
message = f"csrf|{session_token}"
return hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
def verify_csrf(secret: str, session_token: str, provided: str | None) -> bool:
"""Is `provided` the CSRF token of *this* session? `False` for missing or empty.
`surrogatepass` keeps a non-ASCII header value from raising inside `compare_digest`
(which rejects non-ASCII `str`); it simply cannot match the hex digest.
"""
if not provided or not session_token:
return False
expected = mint_csrf(secret, session_token).encode("ascii")
return hmac.compare_digest(expected, provided.encode("utf-8", "surrogatepass"))
# --- login throttle ---------------------------------------------------------------------- #
@dataclass
class _Attempts:
"""One key's state. Kept tiny because there can be :data:`THROTTLE_MAX_KEYS` of them."""
failures: int = 0
blocked_until: float = 0.0
last_failure: float = 0.0
class LoginThrottle:
"""Per-key exponential backoff for failed logins — in process memory.
In memory because there is **one admin and one uvicorn process**: a throttle that lived in
SQLite would be a table to prune for a single user, and a distributed one is a service.
The trade is that a restart clears it, which is why the verifier's PBKDF2 cost — not this
class — is the real defence against an offline-style guessing campaign; the throttle exists
so that hammering the login form is *slower* than it is useless.
`max_attempts` failures are allowed before a key is blocked (5 by default); the block
starts at `base_seconds` and doubles with each further failure up to `max_seconds`.
A successful login clears the key, so the operator who typed it wrong twice is back to a
clean slate when they get it right.
ASSUMPTION (see `AGENTS.md` → Known gaps): the key is the socket peer address. Behind
nginx every login shares one bucket, so an attacker who can spray the form can lock the
operator out of the *dashboard* — not the UPS, not the data — for at most
:attr:`max_seconds` at a time. That is accepted for a single-admin box rather than
becoming an `X-Forwarded-For` knob, which is trivially spoofed the day someone forgets to
trust the proxy, and would make the lockout attackable by header instead of by address.
Thread-safe with a plain lock: the login route is `async` (one event loop) but nothing in
this class may assume that forever.
"""
def __init__(self, *, max_attempts: int = 5, base_seconds: float = 60.0,
max_seconds: float = 3600.0) -> None:
self.max_attempts = max(1, max_attempts)
self.base_seconds = max(0.0, base_seconds)
# A cap below the base step would make "capped" and "first block" disagree.
self.max_seconds = max(self.base_seconds, max_seconds)
self._entries: dict[str, _Attempts] = {}
self._lock = threading.Lock()
@property
def tracked(self) -> int:
"""How many keys are being watched — bounded by :data:`THROTTLE_MAX_KEYS`."""
with self._lock:
return len(self._entries)
def retry_after(self, key: str, *, now: float | None = None) -> float:
"""Seconds this key must still wait; `0.0` means the attempt is allowed.
Read-only: a request that is refused must not extend its own block by asking.
The caller answers `429` with this value in `Retry-After`.
"""
base = time.monotonic() if now is None else now
with self._lock:
entry = self._entries.get(key)
return 0.0 if entry is None else max(0.0, entry.blocked_until - base)
def failure(self, key: str, *, now: float | None = None) -> float:
"""Record a failed attempt; return the backoff just applied (`0.0` if still allowed)."""
base = time.monotonic() if now is None else now
with self._lock:
self._bound_size_locked(base)
entry = self._entries.get(key)
if entry is None:
entry = _Attempts()
self._entries[key] = entry
entry.failures += 1
entry.last_failure = base
if entry.failures < self.max_attempts:
return 0.0
steps = entry.failures - self.max_attempts
delay = min(self.max_seconds, self.base_seconds * float(2 ** steps))
# Never *shorten* a block that is still running: a failure during a lockout adds
# the new step on top of what is left of it.
entry.blocked_until = max(base, entry.blocked_until) + delay
return delay
def attempts(self, key: str) -> int:
"""How many failures this key has recorded — the number the denial event quotes.
Read-only, like :meth:`retry_after`, and it reads 0 after :meth:`success` or a prune.
The event row says "sign-in refused (attempt N)" so an operator reading the feed can
tell one confused person from a campaign, which is a fact about the log, not about the
response: the HTTP answer stays the same single sentence whatever N is.
"""
with self._lock:
entry = self._entries.get(key)
return 0 if entry is None else entry.failures
def success(self, key: str) -> None:
"""Clear a key — a good login forgets every earlier mistake for that address."""
with self._lock:
self._entries.pop(key, None)
def prune(self, now: float | None = None) -> int:
"""Forget keys that can no longer affect a decision. Returns how many went.
A key is dropped once it is not blocked **and** has been quiet for
:attr:`max_seconds` — as long as the longest block it could ever be given, so an
attacker cannot reset their own backoff by idling (they would lose the doubling
advantage anyway). Called on a timer by the poll loop and inline by
:meth:`failure` as the length guard.
"""
base = time.monotonic() if now is None else now
with self._lock:
return self._prune_locked(base)
def _prune_locked(self, now: float) -> int:
dead = [
key for key, entry in self._entries.items()
if entry.blocked_until <= now and now - entry.last_failure >= self.max_seconds
]
for key in dead:
del self._entries[key]
return len(dead)
def _bound_size_locked(self, now: float) -> None:
"""Keep the dict bounded so a spoofed swarm of source addresses is not a memory leak.
First prune what is already expired; if the dict is still at the cap, drop the
least-recently-active quarter. Losing a block is the cheaper failure: the alternative
is the dashboard's process growing until it is the outage, and the attacker already
has to beat PBKDF2 on every guess regardless of what this dict remembers.
"""
if len(self._entries) < THROTTLE_MAX_KEYS:
return
self._prune_locked(now)
# +1: the caller is about to record this key, so pruning must leave room for it.
overflow = len(self._entries) + 1 - THROTTLE_MAX_KEYS
if overflow <= 0:
return
by_age = sorted(self._entries.items(), key=lambda item: item[1].last_failure)
for key, _entry in by_age[: max(1, len(by_age) // 4)]:
del self._entries[key]
# --- origin / Sec-Fetch ------------------------------------------------------------------ #
def _normalise_origin(value: str) -> str:
"""Trim, lower-case, and drop trailing slashes: `https://Host:8090/` == `https://host:8090`."""
return value.strip().rstrip("/").lower()
def origin_matches(origin: str, own_origin: str, allowed: Sequence[str] = ()) -> bool:
"""Is a request's `Origin` header this app (or one of `allowed`)?
`False` for an empty/absent origin — the empty string is not this app's origin. The
caller must gate on *presence* (`if origin and not origin_matches(...)`: reject), because
a missing Origin is a non-browser client, not a cross-site one, and the same-origin form
post a browser makes always sends it. Treating absent as a match would be the bug where
the gate protects nothing.
Scheme, host and port are all part of the comparison: `http://evil.example` fails against
`https://dash.example`, and so does `https://dash.example:9999`.
"""
if not origin.strip():
return False
candidate = _normalise_origin(origin)
if candidate == _normalise_origin(own_origin):
return True
return candidate in {_normalise_origin(item) for item in allowed}
def safe_next(raw: str | None) -> str | None:
"""The in-app location a `?next=` may point at, or `None` to drop it.
This is the open-redirect check, and it is the whole reason the login page can be
trusted with the `next` it was handed: keep a value only when it is a path on *this*
app. Rejected — because every one of them leaves the site the login form belongs to:
* anything not starting with `/` (`http://evil.example`, `javascript:alert(1)`);
* `//evil.example` and `/\\evil.example`, which browsers read as a scheme-relative
host, backslash being the old IE trick and still honoured by some;
* anything with a `://` anywhere, so a smuggled `/%2F/evil.example`-style value that a
library might re-encode into an absolute URL cannot survive here;
* anything containing whitespace or a control character — this string goes into a
`Location` header and a `Set-Cookie`-adjacent redirect, and a `\\r\\n` in a query
parameter is header injection, not a path.
Returning `None` means "no opinion", and the caller falls back to `/`. That is the safe
default: a `?next=` this function refuses costs the operator one extra click, while one
it accepts by mistake sends them to a page that looks exactly like the login they just
used — a phishing kit with a homelab theme.
"""
if not raw or not raw.startswith("/"):
return None
if raw.startswith(("//", "/\\")) or "://" in raw:
return None
if any(ord(char) <= 0x20 or ord(char) == 0x7f for char in raw):
return None
# The authority and scheme rules are checked a second time on the decoded value, because
# the two are not the same string and only one of them is what a browser resolves:
# `/%2F%2Fevil.example` is a path here, while anything that decodes before it resolves the
# authority sees `//evil.example`. Control characters stay a raw-value rule — that is the
# string going into `Location`, and a legitimate `%20` in a query value must survive.
decoded = unquote(raw)
if decoded.startswith(("//", "/\\")) or "://" in decoded:
return None
return raw
def sec_fetch_site_allowed(value: str | None) -> bool:
"""May a write with this `Sec-Fetch-Site` value through? See :data:`ALLOWED_SEC_FETCH_SITE`.
`None` (header absent) is allowed — a client that did not send it is not a browser that
sent a cross-site request — while a value that is present and unrecognised is rejected,
so a future spec value defaults to the safe answer rather than the open one.
"""
if value is None:
return True
return value.strip().lower() in ALLOWED_SEC_FETCH_SITE
# --- verifier bootstrap ------------------------------------------------------------------ #
#: Meta keys this module owns, named here because the operator has to type them into a
#: `sqlite3` command when the password is forgotten, and :func:`update_verifier` rotates the
#: second one. `auth.verifier` holds the encoded PBKDF2 string — never a password — and
#: `auth.session_secret` the key every issued cookie is signed with.
META_VERIFIER = "auth.verifier"
META_SESSION_SECRET = "auth.session_secret"
#: A stored secret has to be at least what :func:`new_secret` writes (256 bits, hex-encoded).
#: Something shorter or not-hex in that row is corruption rather than a smaller key, and the
#: answer to corruption is a fresh secret: every browser is asked to sign in again, which is a
#: login prompt, not a lockout — the safe direction for a damaged row.
SECRET_MIN_BYTES = 32
#: The one sentence this module logs. A fixed string with no interpolation is what lets
#: `tests/test_auth.py` prove that no log line here can carry a credential, a hash, or even a
#: prefix of one.
ENV_IGNORED_MESSAGE = (
"DASH_ADMIN_PASSWORD is ignored — the password set in /settings wins; delete the "
"'auth.verifier' row to reset from the environment"
)
_log = logging.getLogger("dashboards.auth")
class MetaStore(Protocol):
"""The slice of :class:`dashboards.store.SampleStore` the bootstrap needs.
Declared here rather than imported so this module still depends on nothing in the package
(and nothing in the package can reach a credential through it). `delete_meta` is part of
the shape because the reset procedure — the one an operator runs over `sqlite3`, and the
one task 04's tests use — has to be able to prove it removed exactly one key.
"""
def get_meta(self, key: str) -> str | None: ...
def set_meta(self, key: str, value: str) -> None: ...
def delete_meta(self, key: str) -> int: ...
@dataclass(frozen=True)
class AuthState:
"""What one process start decided about authentication, read on every request.
`verifier` is the encoded PBKDF2 string and `secret` the session-signing key, so this
object is a bundle of credentials: it is never rendered, serialised, or logged, and a
caller that wants to show something shows `session_hours`, not a field of it.
`seeded` says this start wrote the verifier row from the environment (the first boot of a
deployment), and `env_ignored` that a stored verifier beat `DASH_ADMIN_PASSWORD` — the two
facts that let the operator tell the difference between "the env var set my password" and
"the env var is doing nothing on this box".
"""
verifier: str
secret: str
session_hours: float
iterations: int
seeded: bool
env_ignored: bool
def _no_admin_message(db_path: str | None) -> str:
"""Why the process is stopping, in words an operator can act on.
Names the variable, names the row, and gives the command — in that order, because the
common case is a first start with no seed (set the variable), and the rare one is a
forgotten password (delete the rows). The reset command keeps a `<db>` placeholder when the
caller did not pass a path; a path is not a credential, so the real one is printed when it
is known. Nothing here quotes a value.
"""
target = db_path or "<db>"
reset = (f'sqlite3 "{target}" "DELETE FROM meta WHERE key IN '
f"('{META_VERIFIER}', '{META_SESSION_SECRET}')\"")
return (
"no admin password configured — refusing to start a public dashboard open.\n"
"\n"
" Set DASH_ADMIN_PASSWORD to the password you want and start again:\n"
"\n"
" DASH_ADMIN_PASSWORD='…' dashboards\n"
"\n"
f" It is hashed (PBKDF2-HMAC-SHA256) into the '{META_VERIFIER}' row of the SQLite\n"
" database on this first boot, never stored as text, and does not have to be changed:\n"
" remove the variable once the server is up and the password still works, or change\n"
" it later in /settings without a redeploy.\n"
"\n"
" If a password is already set and forgotten, delete the stored rows and start again:\n"
"\n"
f" {reset}\n"
"\n"
" Deleting the secret signs out every browser that is already in."
)
def _secret_is_usable(secret: str) -> bool:
"""Is this stored value a key we would sign a cookie with? See :data:`SECRET_MIN_BYTES`."""
if len(secret) < SECRET_MIN_BYTES * 2:
return False
try:
bytes.fromhex(secret)
except ValueError:
return False
return True
def load_auth_state(store: MetaStore, *, admin_password: str,
iterations: int = PBKDF2_ITERATIONS,
session_hours: float = SESSION_HOURS,
db_path: str | None = None) -> AuthState:
"""Turn settings + the `meta` table into the state every request is checked against.
The rules, in the order they are applied:
* A stored :data:`META_VERIFIER` **wins**. The environment seeds a password once; after
that the row is the credential, so `/settings` can change it with no redeploy and no
deployment can silently roll the password back to whatever the container env says. When
a seed is present *and* a row already exists, `env_ignored` is set and
:data:`ENV_IGNORED_MESSAGE` is logged once — this function runs once per process start,
so "once" needs no state of its own.
* No row and a seed → hash it, store it, report `seeded=True`.
* No row and no seed → :class:`RuntimeError`. **Fail closed**: the domain is public and the
fleet's hostnames and UPS controls sit behind this login, so "start open and warn" is
the failure mode the whole phase exists to prevent. The message is the operator's fix,
not a stack trace of a secret.
* The session secret is loaded or minted and **persisted**, because a random secret per
start would sign every browser out on every restart (and on every worker) for no gain:
rotation is what :func:`update_verifier` does on purpose. A stored value that is not a
usable key is replaced the same way — the effect is one login prompt, never a lockout.
The password is hashed with `iterations`; nothing here reads a clock, so the state has no
expiry of its own (a session's expiry lives inside the signed payload and is checked per
request by :func:`read_session`).
"""
verifier = (store.get_meta(META_VERIFIER) or "").strip()
seeded = False
if not verifier:
if not admin_password:
raise RuntimeError(_no_admin_message(db_path))
verifier = hash_password(admin_password, iterations=iterations)
store.set_meta(META_VERIFIER, verifier)
seeded = True
secret = (store.get_meta(META_SESSION_SECRET) or "").strip()
if not _secret_is_usable(secret):
secret = new_secret()
store.set_meta(META_SESSION_SECRET, secret)
env_ignored = bool(admin_password) and not seeded
if env_ignored:
_log.warning(ENV_IGNORED_MESSAGE)
return AuthState(verifier=verifier, secret=secret, session_hours=session_hours,
iterations=iterations, seeded=seeded, env_ignored=env_ignored)
def update_verifier(store: MetaStore, state: AuthState, new_password: str) -> AuthState:
"""Set a new password and rotate the signing secret; return the state that replaces this.
Both rows change, and that is the mechanism behind "changing the password signs out every
other browser": the session cookie is an HMAC over the secret, so a new secret invalidates
every cookie ever minted — there is no session table to walk. The caller re-mints the
current browser's cookie from the state it gets back (task 04), so the one session in front
of the form survives.
The secret is written **first** on purpose. If the process died between the two writes, the
worst case would be a stale secret beside a new verifier — sessions that outlive the
password they were issued under, for as long as `session_hours`. With this order the worst
case is everyone signing in again with the new password.
An empty or whitespace-only password raises: a blank verifier is a dashboard whose admin
page accepts nothing but the login form accepts everything, and an operator who cannot say
what the password is cannot reset it.
"""
if not new_password.strip():
raise ValueError("a new password must not be empty or whitespace")
secret = new_secret()
verifier = hash_password(new_password, iterations=state.iterations)
store.set_meta(META_SESSION_SECRET, secret)
store.set_meta(META_VERIFIER, verifier)
return AuthState(verifier=verifier, secret=secret, session_hours=state.session_hours,
iterations=state.iterations, seeded=False, env_ignored=False)
+24
View File
@@ -15,6 +15,10 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
# `auth.py` imports nothing from this package (it is stdlib-only on purpose), so this is a
# leaf dependency: no cycle, and the default cost below is the same number the primitives use.
from .auth import PBKDF2_ITERATIONS
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -150,6 +154,26 @@ class Settings:
db_path: str = _env("DASH_DB", str(ROOT / "data" / "dashboards.sqlite3")) db_path: str = _env("DASH_DB", str(ROOT / "data" / "dashboards.sqlite3"))
read_only: bool = _flag("DASH_READ_ONLY") # kills every mutating endpoint read_only: bool = _flag("DASH_READ_ONLY") # kills every mutating endpoint
# --- authentication (phase 11) ------------------------------------------------
# The password here is a **seed**, not the credential: the first start hashes it into the
# SQLite `meta` table (`auth.verifier`) and from then on the stored verifier wins, so the
# operator can drop the variable after first boot and `/settings` can change the password
# with no redeploy. An empty value means "no seed", and a server with no seed *and* no
# stored verifier refuses to start (`auth.load_auth_state`) — the domain is public, so the
# default has to be locked, not open.
admin_password: str = _env("DASH_ADMIN_PASSWORD") # `_env` strips: `VAR=` means unset
#: 30 days by default. A homelab dashboard that logs the operator out at shift change gets
#: bypassed (or ignored during an outage), which is worse than a long-lived cookie; the
#: signing secret rotates whenever the password changes, so a leak is one form away.
session_hours: float = _num("DASH_SESSION_HOURS", 720)
#: Mark the session cookie `Secure`. Off by default because dev is `http://127.0.0.1`; a
#: request that arrived over `X-Forwarded-Proto: https` is treated as secure too (task 03).
cookie_secure: bool = _flag("DASH_COOKIE_SECURE")
#: PBKDF2 rounds for the verifier. The knob exists so the hermetic test suite can hash in
#: microseconds instead of ~0.3 s per client — **do not lower it in production**, it is the
#: real cost of every offline guess against a stolen `meta` row (600 000 = OWASP's figure).
pbkdf2_iterations: int = _int("DASH_PBKDF2_ITERATIONS", PBKDF2_ITERATIONS)
# --- system dashboard --------------------------------------------------------- # --- system dashboard ---------------------------------------------------------
host_targets: tuple[Target, ...] = () host_targets: tuple[Target, ...] = ()
host_target_problems: tuple[str, ...] = () host_target_problems: tuple[str, ...] = ()
+40 -2
View File
@@ -21,6 +21,11 @@ sample in it rolls up that sample's average, so the "latest wins" shape a fast s
gets is the same arithmetic. `history()` then falls back to `raw` while the rollups lag, gets is the same arithmetic. `history()` then falls back to `raw` while the rollups lag,
which is what keeps a five-minute series drawable in its first hour. which is what keeps a five-minute series drawable in its first hour.
The `meta` table is the app's durable key/value state (`get_meta`/`set_meta`/`delete_meta`):
small values that must survive a restart and belong to the deployment rather than to the
environment — today the admin verifier and the session-signing secret, both written once at
first start (phase 11). Retention never touches it; it holds a handful of rows.
The connection is guarded by a lock and every method is synchronous: async callers wrap The connection is guarded by a lock and every method is synchronous: async callers wrap
them in `anyio.to_thread.run_sync`, sync FastAPI handlers run in the threadpool already. them in `anyio.to_thread.run_sync`, sync FastAPI handlers run in the threadpool already.
""" """
@@ -267,6 +272,37 @@ class SampleStore:
with self._lock: with self._lock:
return int(self._db.execute(sql, args).fetchone()[0]) return int(self._db.execute(sql, args).fetchone()[0])
# -- meta: small durable key/value state ------------------------------------ #
def get_meta(self, key: str) -> str | None:
"""The value stored under `key`, or `None` when nothing is stored there.
The `meta` table is where state that must outlive a restart lives and is the only
place that state goes: no new table per feature, no config file (config is env, and
anything the *operator* sets in the app belongs to the DB). Its first user is the
admin credential (phase 11) — `auth.verifier` and `auth.session_secret`, both read
once at startup by `auth.load_auth_state`.
"""
with self._lock:
row = self._db.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
return None if row is None else row[0]
def set_meta(self, key: str, value: str) -> None:
"""Store `value` under `key`, replacing whatever was there."""
with self._lock, self._db:
self._db.execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)",
(key, value))
def delete_meta(self, key: str) -> int:
"""Remove one key; returns the number of rows deleted (0 or 1).
The rowcount is the whole point: the password-reset procedure has to be able to say
whether a row was actually there, and the caller must never guess "deleted" from the
absence of an exception. One key per call, because a reset that deletes more than the
key it names is a worse bug than the one it fixes.
"""
with self._lock, self._db:
return self._db.execute("DELETE FROM meta WHERE key=?", (key,)).rowcount
# -- maintenance ----------------------------------------------------------- # # -- maintenance ----------------------------------------------------------- #
def maintain(self, now: float | None = None) -> dict[str, int]: def maintain(self, now: float | None = None) -> dict[str, int]:
"""Roll up and prune. Safe to call every minute; each step is idempotent. """Roll up and prune. Safe to call every minute; each step is idempotent.
@@ -303,11 +339,13 @@ class SampleStore:
return counts return counts
def stats(self) -> dict[str, object]: def stats(self) -> dict[str, object]:
# Read through `get_meta` so there is one path to the table — but *outside* the lock
# below, which `get_meta` takes itself (`threading.Lock` is not reentrant).
last_raw = self.get_meta("last_maintain")
with self._lock: with self._lock:
by_res = {row[0]: row[1] for row in self._db.execute( by_res = {row[0]: row[1] for row in self._db.execute(
"SELECT res, COUNT(*) FROM sample GROUP BY res")} "SELECT res, COUNT(*) FROM sample GROUP BY res")}
(events,) = self._db.execute("SELECT COUNT(*) FROM event").fetchone() (events,) = self._db.execute("SELECT COUNT(*) FROM event").fetchone()
last = self._db.execute("SELECT value FROM meta WHERE key='last_maintain'").fetchone()
size = 0 size = 0
if str(self.path) != ":memory:" and self.path.exists(): if str(self.path) != ":memory:" and self.path.exists():
size = self.path.stat().st_size size = self.path.stat().st_size
@@ -317,7 +355,7 @@ class SampleStore:
"samples_by_res": by_res, "samples_by_res": by_res,
"samples_total": sum(by_res.values()), "samples_total": sum(by_res.values()),
"events": events, "events": events,
"last_maintain": float(last[0]) if last else None, "last_maintain": float(last_raw) if last_raw is not None else None,
} }
def delete_series(self, source: str, host: str, metric: str | None = None) -> int: def delete_series(self, source: str, host: str, metric: str | None = None) -> int:
+1
View File
@@ -21,6 +21,7 @@
<div id="nav-status" class="gap" aria-live="polite"></div> <div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span> <span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button> <button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button> <button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div> </div>
</header> </header>
+1
View File
@@ -20,6 +20,7 @@
<div class="masthead-right"> <div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div> <div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span> <span class="clock" id="clock">--:--:--</span>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button> <button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div> </div>
</header> </header>
+1
View File
@@ -21,6 +21,7 @@
<div id="nav-status" class="gap" aria-live="polite"></div> <div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span> <span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button> <button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button> <button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div> </div>
</header> </header>
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab · sign in</title>
<link rel="stylesheet" href="/static/dash.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23ff8a3d'/></svg>">
</head>
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<div class="masthead-right">
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
</header>
<!-- One card, one field. No username: there is one admin, and a field that is always the
same word teaches the operator that half the form is theatre. No tabs either — every
tab would bounce straight back here, and a nav of links that all redirect is a lie
about what is available. -->
<main>
<section class="card login-card">
<div class="body">
<h1>Sign in</h1>
<p class="sub">the fleet's system, engine and battery dashboards — including the UPS
controls</p>
<form id="login-form" autocomplete="on">
<label for="admin-password">Admin password</label>
<input id="admin-password" name="password" type="password"
autocomplete="current-password" autofocus required>
<button class="action" id="sign-in" type="submit">Sign in</button>
</form>
<!-- The refusal is announced; the countdown is not. A tick per second is noise, and the
alert line already carries the whole sentence the operator needs. -->
<p id="login-error" class="login-error" role="alert" hidden></p>
<p id="login-note" class="login-note faint mono" hidden></p>
</div>
</section>
</main>
<script src="/static/app.js" defer></script>
<script src="/static/page-login.js" defer></script>
</body>
</html>
+97
View File
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Homelab · account</title>
<link rel="stylesheet" href="/static/dash.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23ff8a3d'/></svg>">
</head>
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<!-- The five dashboards, and no `aria-current`: this page is not one of them, and a tab
marked current that is not in the list would claim a location the page does not have.
It is reached from the `⚙ Account` link in the masthead of every dashboard. -->
<nav class="tabs" aria-label="Dashboards">
<a href="/">Overview</a>
<a href="/system">System</a>
<a href="/disks">Disks</a>
<a href="/llm">LLM</a>
<a href="/ups">UPS</a>
</nav>
<div class="masthead-right">
<!-- No `#nav-status`: a session page has no fleet reachability to report, and
`Dash.initChrome()` builds the dots only when the container exists. -->
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div>
</header>
<main>
<div class="page-head">
<div>
<h1>Account</h1>
<div class="sub">this browser's session and the admin password</div>
</div>
<div class="spacer"></div>
</div>
<div class="grid auto items-start">
<section class="card" aria-labelledby="session-title">
<header><h2 id="session-title">Session</h2></header>
<div class="body">
<dl class="kv">
<dt>Signed in as</dt><dd id="session-user">…</dd>
<dt>Expires</dt><dd id="session-expires">…</dd>
<dt>Time left</dt><dd id="session-left">…</dd>
</dl>
<div class="gap" style="margin-top:.85rem">
<button class="action" id="sign-out" type="button">Sign out</button>
</div>
</div>
</section>
<section class="card" aria-labelledby="password-title">
<header><h2 id="password-title">Change password</h2></header>
<div class="body">
<form class="settings-form" id="password-form" autocomplete="on">
<label for="current-password">Current password</label>
<input id="current-password" name="current" type="password"
autocomplete="current-password" aria-describedby="password-hint" required>
<label for="new-password">New password</label>
<input id="new-password" name="new" type="password"
autocomplete="new-password" aria-describedby="password-hint" required>
<label for="confirm-password">Confirm new password</label>
<input id="confirm-password" name="confirm" type="password"
autocomplete="new-password" aria-describedby="password-hint" required>
<!-- The hint sits with the fields it describes, not under the button: a screen reader
reaches it from each input via `aria-describedby`, and an eye scanning the form
reads the rule before it types. -->
<p class="faint" id="password-hint">At least 8 characters, and different from the
current one — length is the only rule.</p>
<button class="action" id="change-password" type="submit">Change password</button>
</form>
<!-- The outcome of the one write this page can make, announced because the operator
asked for it. Every word in it arrives through `textContent`. -->
<p class="settings-result" id="settings-result" role="status" aria-live="polite"></p>
</div>
</section>
</div>
<!-- The precedence rule, in the page's own voice. Without it the next operator edits the
env file, restarts, and files a bug that the password did not change. -->
<p class="sub" style="margin-top:1rem">
The password was seeded from <code>DASH_ADMIN_PASSWORD</code> the first time this database
started. Changing it here wins from then on: the environment variable never overwrites a
password set on this page, and a container env can never roll one back. To hand the database
over or start again from the environment, delete the stored rows
(<code>auth.verifier</code> and <code>auth.session_secret</code>) and restart — which signs
out every browser that is signed in today.
</p>
</main>
<script src="/static/app.js" defer></script>
<script src="/static/page-settings.js" defer></script>
</body>
</html>
+1
View File
@@ -21,6 +21,7 @@
<div id="nav-status" class="gap" aria-live="polite"></div> <div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span> <span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button> <button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button> <button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div> </div>
</header> </header>
+1
View File
@@ -21,6 +21,7 @@
<div id="nav-status" class="gap" aria-live="polite"></div> <div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span> <span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button> <button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button> <button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
</div> </div>
</header> </header>
+145 -3
View File
@@ -426,11 +426,146 @@ const Dash = (() => {
} }
// ---- networking --------------------------------------------------------- // ---- networking ---------------------------------------------------------
/**
* Header name from `auth.py`'s `CSRF_HEADER`. Duplicated here because there is no build
* step to generate one from the other (a locked anchor), so this line *is* the contract:
* `tests/test_auth_api.py` asserts the literal matches what the server demands.
*/
const CSRF_HEADER = 'X-CSRF-Token';
/**
* `auth.LOGIN_PATH`, for the same reason and with the same contract: the login page is
* where a refusal belongs instead of a redirect, so `bounceToLogin()` has to know it is
* already there. `tests/test_auth_api.py` asserts the literal matches the server's.
*/
const LOGIN_PATH = '/login';
/** Cached `/api/auth/status` answer — `{ csrf, expires_at, username, … }`.
*
* Exported as the live object (mutated in place, never replaced) so a page can print the
* session's expiry without a second request. Nothing on a dashboard prints a credential, and
* this object holds none: the CSRF token inside it is not a session token and cannot open a
* session by itself — that is the whole shape of the token (`auth.mint_csrf`). */
const authStatus = {
loaded: false, authenticated: false, csrf: null,
username: null, expires_at: null, seconds_left: null, session_hours: null,
};
let authPromise = null;
let bouncedToLogin = false;
/**
* Generation counter for the memoised status call.
*
* `resetAuth()` clears `authPromise`, but a request already in flight still resolves, and its
* handler is what writes into `authStatus` — so without a generation check a status call
* started **before** a password change would land **after** it and quietly repopulate the cache
* with a CSRF token minted by the secret that just stopped existing. Same failure,
* intermittently: a write carrying a token the rotated server refuses.
*/
let authEpoch = 0;
/**
* Fetch this browser's auth status once, and hand everyone the same answer.
*
* The **promise** is memoised, not the result: five page scripts loading at once would
* otherwise be five `/api/auth/status` requests before the first one answered, which is the
* cheapest way to make a dashboard look slow on the login that fixes that.
*
* A failed call is not cached. A status request answers 401 when the session has expired,
* and caching that forever would leave the page unable to write anything even after the
* operator signs in again in another tab; the retry costs one request, and the caller still
* gets `null` so it can send the write unauthenticated and let the server refuse it in words
* the page already knows how to show.
*/
function ensureAuth() {
if (authStatus.loaded && authStatus.authenticated) return Promise.resolve(authStatus);
if (!authPromise) {
const epoch = authEpoch;
authPromise = fetch('/api/auth/status', { headers: { accept: 'application/json' } })
.then((response) => (response.ok ? response.json() : null))
.catch(() => null)
.then((body) => {
// A `resetAuth()` during the flight: this answer describes the session that was just
// forgotten, so it writes nothing and clears nothing (a newer call may own the promise).
if (epoch !== authEpoch) return null;
authPromise = null;
if (!body || !body.ok) return null;
Object.assign(authStatus, body, { loaded: true });
return authStatus;
});
}
return authPromise;
}
/**
* Forget what this browser knows about its session, so the next request re-reads it.
*
* Two callers, and both are the same event seen from either side of a **secret rotation**:
*
* - after `POST /api/auth/password` — that route rewrites `auth.session_secret` on purpose
* (it is this app's only revocation), which invalidates the `csrf` sitting in `authStatus`
* together with every cookie that is not in front of the form. Without this call the
* operator changes the password, clicks *beeper disable* on `/ups`, and gets `403 missing
* or invalid CSRF token` until they reload — a dashboard that appears to have broken;
* - when a `401` sends the browser to `/login`, and after `POST /api/auth/logout` — the
* session is gone, so caching its token (or its expiry) is describing a session that does
* not exist.
*
* `authStatus` is mutated in place rather than replaced: it is exported as the live object.
*/
function resetAuth() {
authEpoch += 1;
Object.assign(authStatus, {
loaded: false, authenticated: false, csrf: null,
username: null, expires_at: null, seconds_left: null, session_hours: null,
});
authPromise = null;
}
/**
* Send the browser to the login page, once.
*
* The guard is the point: a 2-second poll loop whose session expired would otherwise call
* `location.assign` on every tick, stacking history entries and hammering a page that is
* already loading. One bounce, then the loop waits for the navigation it started.
*
* The second guard is why `/login` may use `fetchJSON` at all: on the login page a bounce is
* a reload, and a reload deletes the inline "Wrong password." the form exists to show. A
* refusal the page is already standing in front of is answered by that page, not by the
* gate — so `page-login.js` gets the thrown `Error` and prints it.
*/
function bounceToLogin() {
if (bouncedToLogin) return;
if (window.location.pathname === LOGIN_PATH) return;
bouncedToLogin = true;
// Whatever was cached about the session that just failed is now false information.
resetAuth();
window.location.assign(`${LOGIN_PATH}?next=${encodeURIComponent(window.location.pathname)}`);
}
/**
* `GET`/`HEAD` need nothing but the cookie; everything else is a write.
*
* Mirrors `auth.SAFE_METHODS`. `OPTIONS` is not here because no page script sends one and a
* preflight never carries our credential anyway.
*/
const READ_METHODS = ['GET', 'HEAD'];
async function fetchJSON(url, options) { async function fetchJSON(url, options) {
const response = await fetch(url, Object.assign({ headers: { accept: 'application/json' } }, const opts = Object.assign({}, options || {});
options || {})); const headers = Object.assign({ accept: 'application/json' }, opts.headers || {});
const method = String(opts.method || 'GET').toUpperCase();
if (READ_METHODS.indexOf(method) < 0) {
// Awaited only on writes: a page that only reads never pays for the status call, and a
// read with an expired session is answered by the 401 below just the same.
const state = await ensureAuth();
if (state && state.csrf) headers[CSRF_HEADER] = state.csrf;
}
opts.method = method;
opts.headers = headers;
const response = await fetch(url, opts);
let body = null; let body = null;
try { body = await response.json(); } catch (err) { body = null; } try { body = await response.json(); } catch (err) { body = null; }
if (response.status === 401) bounceToLogin();
if (!response.ok) { if (!response.ok) {
const detail = body && body.detail const detail = body && body.detail
? (typeof body.detail === 'string' ? body.detail : body.detail.detail || JSON.stringify(body.detail)) ? (typeof body.detail === 'string' ? body.detail : body.detail.detail || JSON.stringify(body.detail))
@@ -438,6 +573,10 @@ const Dash = (() => {
const error = new Error(detail); const error = new Error(detail);
error.status = response.status; error.status = response.status;
error.body = body; error.body = body;
// The headers travel with the refusal because one of them is the answer: `Retry-After` is
// the only number that says how long a lockout lasts, and the login form counts it down
// rather than guessing. A page that never looks at it pays nothing for it.
error.headers = response.headers;
throw error; throw error;
} }
return body; return body;
@@ -638,7 +777,10 @@ const Dash = (() => {
return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt, return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt,
setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause, setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause,
staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText, staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText,
eventText, eventsPager, EVENT_PAGE_SIZE, EVENT_FEED_LIMIT }; eventText, eventsPager, EVENT_PAGE_SIZE, EVENT_FEED_LIMIT,
// phase 11: the session this browser holds, the one call that fills it in, and the
// one that forgets it when the signing secret rotates under us
authStatus, ensureAuth, resetAuth };
})(); })();
document.addEventListener('DOMContentLoaded', () => Dash.initChrome()); document.addEventListener('DOMContentLoaded', () => Dash.initChrome());
+57 -6
View File
@@ -146,7 +146,10 @@ nav.tabs a[aria-current="page"] { background: var(--accent-soft); color: var(--a
.clock { font-family: var(--mono); font-size: 12.5px; color: var(--text-muted); } .clock { font-family: var(--mono); font-size: 12.5px; color: var(--text-muted); }
@media (max-width: 900px) { .clock { display: none; } } @media (max-width: 900px) { .clock { display: none; } }
button.ghost { /* One control, two tags: the pager's arrows and the masthead's `⚙ Account` link are the same
* quiet outlined thing, and an `<a>` styled by a second near-copy is the version that stops
* matching the theme. `text-decoration` is here because the global `a:hover` underlines. */
button.ghost, a.ghost {
background: transparent; background: transparent;
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text-muted); color: var(--text-muted);
@@ -157,8 +160,12 @@ button.ghost {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: .35rem; gap: .35rem;
text-decoration: none;
}
button.ghost:hover, a.ghost:hover {
border-color: var(--border-strong); color: var(--text); background: var(--bg-sunken);
text-decoration: none;
} }
button.ghost:hover { border-color: var(--border-strong); color: var(--text); background: var(--bg-sunken); }
button.ghost[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } button.ghost[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
/* The pager's ends: still legible, still unclickable (an arrow that does nothing at page 1 /* The pager's ends: still legible, still unclickable (an arrow that does nothing at page 1
or the last page is a control that lies about there being more history). */ or the last page is a control that lies about there being more history). */
@@ -812,6 +819,48 @@ input[type="text"].confirm {
border: 1px solid var(--fail); border-radius: var(--radius-sm); padding: .35rem .5rem; width: 11ch; border: 1px solid var(--fail); border-radius: var(--radius-sm); padding: .35rem .5rem; width: 11ch;
} }
/* ---- login ---------------------------------------------------------------
* The one page with no tabs, no status dots and no data: it is what stands between the public
* domain and the fleet, so it renders the same tokens with nothing to do but type a password.
* Nothing here is a verdict colour — a refused sign-in is `--fail` text, not a graded value,
* and there is no `.sev-*` family for it to join. The password field is the app's one text
* input rule (`input[type="text"], input[type="password"]` in the buttons section): the same
* control `/ups`'s `DANGER` box is, styled once. `label` is the `.hero .label` caption
* vocabulary, so the field is labelled the way every number on the other four pages is. */
.login-card { max-width: 26rem; margin: 3rem auto; }
.login-card h1 { margin: 0; font-size: 1.15rem; font-weight: 650; letter-spacing: .01em; }
.login-card .sub { margin: .3rem 0 0; color: var(--text-muted); font-size: 12.5px; }
.login-card form { display: grid; gap: .6rem; margin-top: .9rem; }
.login-card label {
font-size: 10.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
color: var(--text-faint);
}
.login-card button { justify-self: start; }
/* The refusal is the sentence; the countdown under it is the clock. Both are text, and the
* countdown is not a live region — a tick per second is noise, not news. */
.login-card .login-error { margin: .8rem 0 0; font-size: 12.5px; color: var(--fail); }
.login-card .login-note { margin: .35rem 0 0; }
/* ---- account ------------------------------------------------------------
* `/settings`: the session this browser holds and the one form that changes the password. It
* borrows the sheet's vocabulary rather than growing a second one — `.kv` for the session rows,
* the `.hero .label` caption for the fields, `input[type="password"]` for the controls, and the
* shared `ok|warn|fail` words for the outcome of the write. A flavour of its own is what makes a
* page look like it belongs to another app. */
.settings-form { display: grid; gap: .45rem; max-width: 22rem; }
.settings-form label {
font-size: 10.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
color: var(--text-faint);
}
.settings-form button { justify-self: start; margin-top: .35rem; }
/* The outcome line: no space until it has something to say, and its colour is the shared word
* for what happened (`ok` landed, `warn` was refused before the wire, `fail` was refused by the
* credential), not a third palette. */
.settings-result { margin: .8rem 0 0; font-size: 12.5px; }
.settings-result.ok { color: var(--ok); }
.settings-result.warn { color: var(--warn); }
.settings-result.fail { color: var(--fail); }
/* ---- dialog ------------------------------------------------------------- */ /* ---- dialog ------------------------------------------------------------- */
dialog { dialog {
border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--bg-raised);
@@ -845,10 +894,12 @@ dialog footer { padding: .7rem .9rem; border-top: 1px solid var(--border); displ
.stale { opacity: .62; } .stale { opacity: .62; }
/* Only the UPS confirmation dialog takes text now — `/system`'s host/metric pickers were the /* One rule for the app's two text fields — `/ups`'s `DANGER` box and `/login`'s password —
last `select` on the app and left with the shared charts, along with the `label.field` caption * which are the same control, and a second near-copy is how one of them stops matching the
that sat above them. */ * theme. `/system`'s host/metric pickers were the last `select` on the app and left with the
input[type="text"] { * shared charts, along with the `label.field` caption that sat above them. Focus needs nothing
* here: `:focus-visible` at the top of this file already draws the accent outline. */
input[type="text"], input[type="password"] {
background: var(--bg-sunken); color: var(--text); border: 1px solid var(--border); background: var(--bg-sunken); color: var(--text); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: .3rem .45rem; font: 500 12.5px var(--sans); border-radius: var(--radius-sm); padding: .3rem .45rem; font: 500 12.5px var(--sans);
} }
+145
View File
@@ -0,0 +1,145 @@
/* page-login.js — the sign-in form.
*
* The one page with no data to poll and no session to assume. It is also the only page that
* has to treat a refusal as its *expected* answer: the request is a public one, so there is no
* session to attach a CSRF token to, and the answer is either a redirect the server chose or a
* sentence in the operator's own words. `Dash.fetchJSON` is still the caller — it adds the
* `accept` header and turns every non-2xx into an `Error` carrying `status`, `body` and the
* response headers — and it is safe here only because `bounceToLogin()` answers "you are
* already on the page you are asking for" by doing nothing. Without that guard a wrong
* password would reload the form instead of saying so.
*
* Nothing here is markup: the server's `detail` goes out as text, because a refusal that
* renders as HTML is an injection point on the one page a stranger sees first.
*/
'use strict';
(function () {
/**
* The client's copy of `auth.safe_next`: an in-app path, never a site.
*
* The server judges `next` on every login whatever this does — this exists so the browser
* never *tries* `//evil.example`, and because the already-signed-in redirect is followed
* here rather than by the server. Same rule, in the same order, for the same reasons:
* scheme-prefixed and scheme-relative are other origins, `/\` resolves to a host in some
* browsers, and whitespace is not a path.
*/
function target(next) {
if (typeof next !== 'string') return '/';
if (!next.startsWith('/')) return '/';
if (next.startsWith('//')) return '/';
if (next.startsWith('/\\')) return '/';
if (/\s/.test(next)) return '/';
return next;
}
const form = document.getElementById('login-form');
const field = document.getElementById('admin-password');
const button = document.getElementById('sign-in');
const errorLine = document.getElementById('login-error');
const noteLine = document.getElementById('login-note');
let timer = null;
/** The `?next=` this page was handed, already judged. */
function requested() {
return target(new URLSearchParams(window.location.search).get('next'));
}
/** Write one line, and show it only while it has words in it. Text only, always. */
function write(el, message) {
if (!el) return;
const text = message == null ? '' : String(message);
if (el.textContent !== text) el.textContent = text;
el.hidden = !text;
}
function stopCountdown() {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
}
/**
* Tick the lockout down, and keep the button out until the wait is over.
*
* `Retry-After` is the server's own number, so this is a countdown and not a guess: when it
* reaches 0 the form is pressable again and the note is gone. A `429` that named no wait is
* not counted down at all — inventing a wait would be inventing a number.
*/
function countDown(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) return;
stopCountdown();
if (button) button.disabled = true;
let left = Math.ceil(seconds);
const tick = () => {
if (left <= 0) {
stopCountdown();
write(noteLine, '');
// The sentence this replaces is now false: the wait it named is over and the button is
// pressable again, so leaving "try again in 59s" up would print a number that no longer
// describes anything.
write(errorLine, '');
if (button) button.disabled = false;
return;
}
write(noteLine, `try again in ${left}s`);
left -= 1;
};
tick();
timer = setInterval(tick, 1000);
}
/** The seconds from `Retry-After`, or `null` when the refusal did not name a wait. */
function retryAfter(err) {
const raw = err && err.headers ? err.headers.get('retry-after') : null;
const seconds = Number(raw);
return raw !== null && Number.isFinite(seconds) && seconds > 0 ? seconds : null;
}
async function submit(event) {
if (event) event.preventDefault();
const password = field ? field.value : '';
if (!password) {
write(errorLine, 'Enter the admin password.');
if (field) field.focus();
return;
}
write(errorLine, '');
try {
// The `next` this page was handed goes back to the server to be judged, not followed
// here on the strength of the query string alone.
const body = await Dash.fetchJSON('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password, next: requested() }),
});
window.location.assign(target(body && body.next));
} catch (err) {
if (err.status === 401) {
write(errorLine, 'Wrong password.');
// Selecting what is already typed is the whole UX of a rejected password: the fix is
// usually one character, and making the operator retype it invites a second typo.
if (field) {
field.select();
field.focus();
}
} else if (err.status === 429) {
write(errorLine, err.message || 'Too many failed sign-ins.');
countDown(retryAfter(err));
} else {
write(errorLine, err.message || 'The dashboard could not be reached.');
}
}
}
if (form) form.addEventListener('submit', submit);
// Already signed in (another tab, or a link held open across a sign-in): the form is a
// question that has no answer to give, so ask nothing. `app.py` answers `GET /login` with a
// 303 in this case; this is what stops the form flashing first when it did not.
Dash.ensureAuth().then((state) => {
if (state) window.location.replace(requested());
});
}());
+175
View File
@@ -0,0 +1,175 @@
/* page-settings.js — the account page: the session this browser holds, signing out, and the
* one form that changes the admin password.
*
* Two things make this page more than a form, and both come from what a password change does
* to a signed cookie:
*
* - the change rotates the session-signing secret (`auth.update_verifier`), so the CSRF token
* this browser cached from `/api/auth/status` is **invalid the moment the change lands**.
* `Dash.resetAuth()` is what forgets it; without that call every later write — the UPS
* buttons included — 403s on `missing or invalid CSRF token` until the operator reloads,
* which is a bug that looks like the password change broke the dashboard;
* - the same rotation signs out every *other* browser on purpose. So after a successful
* change the panel re-reads `/api/auth/status` rather than trusting what it printed before:
* this page must never show a session the server no longer honours.
*
* Nothing here is markup from the network: the outcome line is written with `textContent`, so
* a `detail` from the server stays a sentence.
*/
'use strict';
(function () {
/**
* `api.MIN_PASSWORD_LENGTH`, duplicated because there is no build step to share it.
*
* The server is the authority and still answers `422` for anything shorter; this number only
* decides whether the form asks before spending a request. `tests/test_api.py` pins the two
* literals together, so a floor raised on one side fails a test instead of making the local
* check a lie.
*/
const MIN_PASSWORD_LENGTH = 8;
const userCell = document.getElementById('session-user');
const expiresCell = document.getElementById('session-expires');
const leftCell = document.getElementById('session-left');
const signOutButton = document.getElementById('sign-out');
const form = document.getElementById('password-form');
const changeButton = document.getElementById('change-password');
const result = document.getElementById('settings-result');
const currentField = document.getElementById('current-password');
const newPasswordField = document.getElementById('new-password');
const confirmField = document.getElementById('confirm-password');
const fields = [currentField, newPasswordField, confirmField].filter((el) => el);
/** Write the outcome line. Text only, and never markup. */
function say(text, kind) {
if (!result) return;
result.textContent = text;
result.classList.remove('ok', 'warn', 'fail');
if (kind) result.classList.add(kind);
}
/**
* Fill the session panel from `/api/auth/status`.
*
* The expiry goes out twice on purpose: a local timestamp for "which moment" and a
* `Dash.duration` for "how long do I have", because "in 29d" is the sentence an operator
* reading it during an outage acts on and `03 Nov 2026, 04:32` is the one they cross-check.
*/
function paint(status) {
Dash.setTxt(userCell, (status && status.username) || Dash.naText);
const expiresAt = status && status.expires_at;
Dash.setTxt(expiresCell, typeof expiresAt === 'number'
? new Date(expiresAt * 1000).toLocaleString(undefined,
{ dateStyle: 'medium', timeStyle: 'short' })
: Dash.naText);
const left = status && status.seconds_left;
Dash.setTxt(leftCell, typeof left === 'number' ? `in ${Dash.duration(left)}` : Dash.naText);
}
/**
* Re-read the session and repaint.
*
* `Dash.fetchJSON` bounces a `401` to `/login` by itself, so a session that died between the
* page load and a poll ends at the form rather than in a panel of stale dates.
*/
async function readSession() {
try {
paint(await Dash.fetchJSON('/api/auth/status'));
} catch (err) {
// A 401 is already navigating; anything else is a server that cannot answer about
// itself, which is `n/a` rather than last poll's date kept on screen.
paint(null);
}
}
async function signOut() {
if (signOutButton) signOutButton.disabled = true;
try {
// A write, so it needs the CSRF token like every other one — which is the point: an
// `<a href="/logout">` planted on another page must not be able to sign the operator out.
await Dash.fetchJSON('/api/auth/logout', { method: 'POST' });
} catch (err) {
// The cookie is cleared by the response, so a refusal here means the session is already
// gone or the gate refused the write. Either way the next stop is the login form.
}
Dash.resetAuth();
window.location.assign('/login');
}
/** Empty every field the operator typed into, twice.
*
* `form.reset()` returns the controls to their markup values, and password managers can
* refill them from the same page's history; blanking them explicitly is what keeps the old
* password sitting in a box after it stopped being the password.
*/
function clearFields() {
if (form && typeof form.reset === 'function') form.reset();
fields.forEach((field) => { field.value = ''; });
}
/**
* What the form can tell without asking the server.
*
* Each of these is also refused by `POST /api/auth/password` — a client-side check buys the
* operator an instant answer and buys the server nothing. The order is the useful one: the
* current password first (the one field the browser cannot judge), then length, then the two
* new-password boxes agreeing.
*/
function localProblem(current, next, confirm) {
if (!current) return 'Type the current password first.';
if (next.length < MIN_PASSWORD_LENGTH) {
return `A new password needs at least ${MIN_PASSWORD_LENGTH} characters.`;
}
if (next !== confirm) return 'The new password and its confirmation do not match.';
return null;
}
async function changePassword(event) {
if (event) event.preventDefault();
const current = (currentField && currentField.value) || '';
const next = (newPasswordField && newPasswordField.value) || '';
const confirm = (confirmField && confirmField.value) || '';
const problem = localProblem(current, next, confirm);
if (problem) {
say(problem, 'warn');
// Put the caret where the fix is, rather than making the operator hunt for the box the
// sentence names.
const offender = !current ? currentField
: (next.length < MIN_PASSWORD_LENGTH ? newPasswordField : confirmField);
if (offender) offender.focus();
return;
}
if (changeButton) changeButton.disabled = true;
try {
await Dash.fetchJSON('/api/auth/password', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ current, new: next }),
});
} catch (err) {
if (err.status === 403) say('The current password is wrong.', 'fail');
else if (err.status === 429) say(err.message || 'Too many failed attempts — wait and try again.', 'warn');
else say(err.message || 'The dashboard could not change the password.', 'fail');
if (changeButton) changeButton.disabled = false;
return;
}
// The secret just rotated. Drop the memoised status — including its CSRF token — before
// anything else in this page tries to write, then hand the fresh one back on the next call.
Dash.resetAuth();
clearFields();
if (changeButton) changeButton.disabled = false;
say('Password changed. Other browsers are signed out.', 'ok');
// And prove it: the panel shows the session this browser now holds, not the one from
// before the rotation. The response already set the new cookie, so this is a real read.
await readSession();
}
if (signOutButton) signOutButton.addEventListener('click', signOut);
if (form) form.addEventListener('submit', changePassword);
readSession();
}());
+48
View File
@@ -0,0 +1,48 @@
"""How a test gets a signed-in client, and the credential it signs in with.
Phase 11 task 03 put every route except `PUBLIC_PATHS` behind a session, so a TestClient that
used to `GET /api/config` now gets a 401 unless it signs in first. This module is the one place
that does it, for the same reason `tests/card_contract.py` is the one place the card paths are
written down: if signing in is a sequence twelve test files have to remember, one of them will
remember it wrong and assert the 401.
`sign_in` also copies the CSRF token onto the client's default headers, which is what keeps the
twelve existing `client.post(`/`ups_client.post(` assertions in `tests/test_api.py` unedited:
they get the session cookie *and* the write header for free, so the assertions still assert the
UPS behaviour they were written for instead of the new gate.
The password itself lives in `auth_contract.py` (with the cheap PBKDF2 cost the suite hashes it
at) and is re-exported here, so `from auth_client import ADMIN_PASSWORD, sign_in` works and
there is still exactly one file that says what the testing password is.
"""
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2 # re-exported: one constant, two doors
from dashboards import auth
__all__ = ["ADMIN_PASSWORD", "FAST_PBKDF2", "sign_in"]
def sign_in(client: TestClient, password: str = ADMIN_PASSWORD,
**payload: Any) -> dict[str, Any]:
"""Log the client in and make its writes carry the CSRF token.
Returns the login body (`{ok, username, expires_at, csrf, next}`) so a test can assert on it
without repeating the request. The assertion that the sign-in *worked* is part of the helper:
a test that silently started from a 401 would otherwise fail three lines later on something
that looks like an unrelated bug.
Pass extra keyword arguments to reach the parts of the endpoint the pages do not use (a
`next`, or a body the login form would never send).
"""
response = client.post("/api/auth/login", json={"password": password, **payload})
assert response.status_code == 200, f"sign-in failed: {response.status_code} {response.text}"
body: dict[str, Any] = response.json()
# On the client, not per request: the ~200 existing HTTP assertions in test_api.py post to
# the one write endpoint, and a token the helper did not attach would 403 every one of them.
client.headers[auth.CSRF_HEADER] = str(body.get("csrf", ""))
return body
+38
View File
@@ -0,0 +1,38 @@
"""The admin credential the HTTP suite boots with, and the cost it hashes it at.
Phase 11 made every app start need a password: with no `auth.verifier` row and no
`DASH_ADMIN_PASSWORD` the lifespan raises instead of serving the fleet (see
`auth.load_auth_state`). So every test that runs a lifespan passes one, the same way the
operator's `.env.dev` does, and `testpass` is the phase's local-testing password — never a
production fallback.
The cost is the other half. The real 600 000 rounds cost ~0.3 s per hash, and the HTTP suite
starts a server a hundred-odd times; hashing at 1 000 rounds is the documented purpose of
`DASH_PBKDF2_ITERATIONS` and keeps that overhead at microseconds. The real figure is pinned
where it belongs — `tests/test_auth.py::test_the_default_cost_is_the_owasp_figure` and
`tests/test_config.py::test_the_auth_defaults_are_a_locked_door_and_a_long_session` — so
lowering it here cannot lower it there.
Deliberately not a fixture: `Settings(...)` is built inside helper functions and module-level
setup as well as inside tests, and a constant imports everywhere without dragging a request
scope along.
"""
from __future__ import annotations
#: The password every test app is seeded with (and, from task 03 on, signs in with).
ADMIN_PASSWORD = "testpass"
#: PBKDF2 rounds for the suite. See the module docstring; do not use this in a deployment.
FAST_PBKDF2 = 1_000
def auth_settings() -> dict[str, object]:
"""The `Settings` kwargs a test app needs in order to boot at all.
A server with neither a stored verifier nor an `admin_password` **refuses to start**
(task 02, invariant 11), and every `create_app()` in the suite runs a lifespan. Spreading
this into the `Settings(...)` calls keeps that a one-line change in this file instead of an
edit in every fixture that builds its own settings object.
"""
return {"admin_password": ADMIN_PASSWORD, "pbkdf2_iterations": FAST_PBKDF2}
+290 -19
View File
@@ -19,8 +19,10 @@ import pytest
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from dashboards import alerts from auth_client import sign_in
from dashboards.api import RANGE_SECONDS, parse_range from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
from dashboards import alerts, auth
from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, parse_range
from dashboards.app import create_app from dashboards.app import create_app
from dashboards.collectors import UpstreamError from dashboards.collectors import UpstreamError
from dashboards.config import Settings, Target from dashboards.config import Settings, Target
@@ -52,6 +54,14 @@ DEAD_URL = "http://127.0.0.1:1"
#: fifth page cannot join the masthead and skip the checks the others had to pass. #: fifth page cannot join the masthead and skip the checks the others had to pass.
PAGES = ("/", "/system", "/disks", "/llm", "/ups") PAGES = ("/", "/system", "/disks", "/llm", "/ups")
#: `/settings` is a **page** and not a dashboard (phase 11): it carries the same masthead with
#: no `aria-current` tab, and the account link in every dashboard's masthead is how you reach
#: it. Kept out of `PAGES` because the pins over that tuple assert tab structure — one current
#: tab, a fixed tab order — which this page deliberately does not have. `MARKUP_PAGES` is the
#: list for the checks that are about the bytes rather than the nav.
SETTINGS_PAGE = "/settings"
MARKUP_PAGES = (*PAGES, SETTINGS_PAGE)
@contextmanager @contextmanager
def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: bool = False, def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: bool = False,
@@ -67,11 +77,13 @@ def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: b
else: else:
monkeypatch.delenv("NUT_SIMULATE", raising=False) monkeypatch.delenv("NUT_SIMULATE", raising=False)
db = str(tmp_path / "api.sqlite3") db = str(tmp_path / "api.sqlite3")
settings = Settings(db_path=db, **overrides) settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2, **overrides)
store = SampleStore(db) store = SampleStore(db)
client = TestClient(create_app(settings, store)) client = TestClient(create_app(settings, store))
client.__enter__() client.__enter__()
try: try:
sign_in(client)
yield client yield client
finally: finally:
client.__exit__(None, None, None) client.__exit__(None, None, None)
@@ -128,10 +140,12 @@ def data_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
db = str(tmp_path / "summary.sqlite3") db = str(tmp_path / "summary.sqlite3")
store = SampleStore(db) store = SampleStore(db)
app = create_app(Settings(db_path=db, nut_host="127.0.0.1", app = create_app(Settings(db_path=db, nut_host="127.0.0.1",
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2,
host_targets=(Target("driveripper", DEAD_URL),)), store) host_targets=(Target("driveripper", DEAD_URL),)), store)
test_client = TestClient(app) test_client = TestClient(app)
test_client.__enter__() test_client.__enter__()
try: try:
sign_in(test_client)
app.state.ctx.hub.jobs["host:driveripper"].state.payload = failing_host_payload( app.state.ctx.hub.jobs["host:driveripper"].state.payload = failing_host_payload(
host_scrape, agent_snapshot) host_scrape, agent_snapshot)
wait_for_payload(test_client, "/api/ups/snapshot") wait_for_payload(test_client, "/api/ups/snapshot")
@@ -449,13 +463,15 @@ def test_an_upstream_failure_escaping_a_route_keeps_its_own_status(tmp_path: Pat
""" """
db = str(tmp_path / "upstream-error.sqlite3") db = str(tmp_path / "upstream-error.sqlite3")
store = SampleStore(db) store = SampleStore(db)
app = create_app(Settings(db_path=db), store) app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2), store)
@app.get("/api/_boom") @app.get("/api/_boom")
async def boom() -> None: async def boom() -> None:
raise UpstreamError("driveripper: http://127.0.0.1:9120/snapshot timed out", status=504) raise UpstreamError("driveripper: http://127.0.0.1:9120/snapshot timed out", status=504)
with TestClient(app) as test_client: with TestClient(app) as test_client:
sign_in(test_client)
response = test_client.get("/api/_boom") response = test_client.get("/api/_boom")
assert response.status_code == 504, "a timeout stays a timeout, not a 500" assert response.status_code == 504, "a timeout stays a timeout, not a 500"
assert response.json() == {"ok": False, assert response.json() == {"ok": False,
@@ -473,8 +489,11 @@ def test_agent_tokens_never_reach_the_browser(tmp_path: Path, monkeypatch: pytes
headers={"Authorization": "Bearer SEKRIT-TOKEN"})] headers={"Authorization": "Bearer SEKRIT-TOKEN"})]
db = str(tmp_path / "tokens.sqlite3") db = str(tmp_path / "tokens.sqlite3")
store = SampleStore(db) store = SampleStore(db)
app = create_app(Settings(db_path=db, host_targets=tuple(targets)), store) app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2,
host_targets=tuple(targets)), store)
with TestClient(app) as client: with TestClient(app) as client:
sign_in(client)
body = client.get("/api/config") body = client.get("/api/config")
assert "SEKRIT-TOKEN" not in body.text assert "SEKRIT-TOKEN" not in body.text
target = body.json()["system"]["targets"][0] target = body.json()["system"]["targets"][0]
@@ -505,10 +524,12 @@ def test_a_dead_host_never_writes_its_token_into_the_feed(tmp_path: Path) -> Non
store = SampleStore(db) store = SampleStore(db)
settings = Settings( settings = Settings(
db_path=db, db_path=db,
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2,
host_interval=0.05, host_interval=0.05,
host_targets=(Target(name="driveripper", url="http://SEKRIT-AGENT-TOKEN@127.0.0.1:1"),), host_targets=(Target(name="driveripper", url="http://SEKRIT-AGENT-TOKEN@127.0.0.1:1"),),
) )
with TestClient(create_app(settings, store)) as client: with TestClient(create_app(settings, store)) as client:
sign_in(client)
deadline = time.time() + 10 deadline = time.time() + 10
while time.time() < deadline: while time.time() < deadline:
health = client.get("/api/health").json() health = client.get("/api/health").json()
@@ -540,10 +561,13 @@ def test_engine_urls_are_redacted_the_same_way_hosts_are(tmp_path: Path) -> None
happens to carry credentials has to be redacted where it is served (invariant 6). happens to carry credentials has to be redacted where it is served (invariant 6).
""" """
db = str(tmp_path / "engine-redact.sqlite3") db = str(tmp_path / "engine-redact.sqlite3")
settings = Settings(db_path=db, gufo_url="http://SEKRIT-ENGINE-TOKEN@deskwork:8731", settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2,
gufo_url="http://SEKRIT-ENGINE-TOKEN@deskwork:8731",
vllm_url="http://driveripper:8000") vllm_url="http://driveripper:8000")
store = SampleStore(db) store = SampleStore(db)
with TestClient(create_app(settings, store)) as client: with TestClient(create_app(settings, store)) as client:
sign_in(client)
body = client.get("/api/config") body = client.get("/api/config")
assert "SEKRIT-ENGINE-TOKEN" not in body.text assert "SEKRIT-ENGINE-TOKEN" not in body.text
llm = body.json()["llm"] llm = body.json()["llm"]
@@ -567,6 +591,60 @@ def test_target_problems_are_surfaced_not_swallowed(client) -> None:
assert body["system"]["target_problems"] == [] assert body["system"]["target_problems"] == []
# ---- the credential the server refuses to start without (phase 11) --------- #
def test_no_verifier_and_no_seed_means_the_server_does_not_come_up(tmp_path: Path) -> None:
"""Fail closed, at the one place a mistake is unmissable: startup.
Task 03 is what demands a session on every route; this is the half before it — a deployment
with neither a stored `auth.verifier` row nor `DASH_ADMIN_PASSWORD` must not run open, so
the lifespan raises instead of publishing a context, starting the pollers, or serving a
page. Asserting that nothing was written is part of it: "refused" that seeded a row on the
way out would leave the next start half-configured.
"""
db = str(tmp_path / "no-credential.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db, admin_password=""), store)
try:
with pytest.raises(RuntimeError) as caught, TestClient(app):
pytest.fail("the lifespan yielded with no admin credential")
message = str(caught.value)
assert "DASH_ADMIN_PASSWORD" in message, "the operator's fix is in the exit output"
assert "auth.verifier" in message and "DELETE FROM meta" in message
assert not hasattr(app.state, "ctx"), "nothing is published, so nothing can serve"
assert store.get_meta("auth.verifier") is None
assert store.get_meta("auth.session_secret") is None
finally:
store.close()
def test_the_same_server_starts_once_the_environment_seeds_a_password(tmp_path: Path) -> None:
"""The other side of the gate, so the failure above cannot be satisfied by a broken app.
With a seed the process comes up, the state hangs off the context (task 03 reads it on
every request), and both rows are durable — which is also what lets the operator delete
`DASH_ADMIN_PASSWORD` from the deployment after the first boot.
"""
db = str(tmp_path / "seeded.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2), store)
try:
with TestClient(app) as client:
assert client.get("/api/health").status_code == 200 # public: the healthcheck
state = app.state.ctx.auth
assert state is not None
assert state.seeded is True and state.env_ignored is False
assert state.iterations == FAST_PBKDF2
assert auth.verify_password(ADMIN_PASSWORD, state.verifier) is True
assert store.get_meta("auth.verifier") == state.verifier
assert store.get_meta("auth.session_secret") == state.secret
assert ADMIN_PASSWORD not in state.verifier + state.secret
finally:
store.close()
# ---- pages ---------------------------------------------------------------- # # ---- pages ---------------------------------------------------------------- #
@pytest.mark.parametrize("path,title", [ @pytest.mark.parametrize("path,title", [
@@ -581,8 +659,14 @@ def test_pages_serve_and_declare_themselves(client, path, title) -> None:
def test_no_page_loads_anything_from_the_internet(client) -> None: def test_no_page_loads_anything_from_the_internet(client) -> None:
"""No CDN, no webfonts: this dashboard has to work with no route to the internet.""" """No CDN, no webfonts: this dashboard has to work with no route to the internet.
for path in PAGES:
`/login` is not in `MARKUP_PAGES` and cannot be: this `client` is signed in, a signed-in
`GET /login` is a `303`, and following it would audit `/`'s bytes under the wrong name. The
same fragments are checked against the real login page, unsigned, by
`tests/test_auth_api.py::test_the_login_page_loads_nothing_from_the_internet`.
"""
for path in MARKUP_PAGES:
html = client.get(path).text html = client.get(path).text
for fragment in ("//cdn.", "https://fonts.", "//unpkg.com", "//cdnjs"): for fragment in ("//cdn.", "https://fonts.", "//unpkg.com", "//cdnjs"):
assert fragment not in html, f"{path} references {fragment}" assert fragment not in html, f"{path} references {fragment}"
@@ -593,6 +677,152 @@ def test_static_assets_are_served(client) -> None:
assert client.get(asset).status_code == 200, asset assert client.get(asset).status_code == 200, asset
# ---- /settings: the account page (phase 11 task 06) ----------------------- #
#
# `/settings` is the operator's own surface: the session this browser holds, a sign-out, and the
# one form that changes the admin password. Its HTTP surface is tested twice on purpose — the
# route-enumeration test in `tests/test_auth_api.py` proves nothing outside the public list
# answers anonymously (which is how `/settings` is gated for free by being in `PAGES` in
# `app.py`), and the assertions here pin the **page**: the form fields a password manager needs,
# the outcome line that is a live region, the precedence sentence that stops the "I edited the
# env file and nothing happened" support call, and the masthead link that is the only way in.
def test_the_settings_page_carries_the_session_and_the_form(client) -> None:
response = client.get(SETTINGS_PAGE)
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
html = response.text
# the session panel and its way out
for fragment in ('id="sign-out"', 'id="session-user"', 'id="session-expires"',
'id="session-left"'):
assert fragment in html, f"/settings has no {fragment}"
# the change-password form: three fields, each labelled, each with the floor spelled out,
# and the autocomplete values a password manager needs to fill the right one
for fragment in ('id="current-password"', 'autocomplete="current-password"',
'id="new-password"', 'autocomplete="new-password"',
'id="confirm-password"', 'id="change-password"',
'<label for="current-password">', '<label for="new-password">',
'<label for="confirm-password">', 'aria-describedby="password-hint"'):
assert fragment in html, f"/settings' form is missing {fragment}"
assert "8 characters" in html, "the form stopped naming the length floor it enforces"
# the outcome line is announced, because the operator asked for it and nothing else will
assert 'id="settings-result"' in html and 'role="status"' in html
def test_the_settings_page_states_the_precedence_rule(client) -> None:
"""The sentence that makes the env file's losing half legible on the page that wins.
`README.md` documents the same rule, but the operator reading it there is deciding what to
write in a file; the operator reading *this* page just changed a password and needs to know
why a container env will not undo it.
"""
html = client.get(SETTINGS_PAGE).text
assert "DASH_ADMIN_PASSWORD" in html
assert "auth.verifier" in html
assert "never overwrites" in html
def test_the_settings_page_is_not_one_of_the_dashboards(client) -> None:
"""The tabs are there to leave with; nothing marks itself current, because nothing is.
A tab marked current on a page that is not in the tab list claims a location the app does not
have, and the four dashboards' own masthead pin (`aria-current` exactly once) relies on that
marker meaning something.
"""
html = client.get(SETTINGS_PAGE).text
assert '<nav class="tabs"' in html
assert 'aria-current="page"' not in html
for path in PAGES:
assert f'href="{path}"' in html, f"/settings cannot navigate to {path}"
assert 'id="nav-status"' not in html, "/settings grew fleet dots it has nothing to fill"
def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> None:
"""One page-level assertion of the gate, not a second enumeration of it.
`tests/test_auth_api.py::test_every_route_that_is_not_public_requires_a_session` is the
general proof; this one exists so a regression in `/settings` names `/settings`.
"""
db = str(tmp_path / "settings-gate.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2), store)
try:
with TestClient(app, follow_redirects=False) as anon:
page = anon.get(SETTINGS_PAGE, headers={"accept": "text/html"})
assert page.status_code == 303
assert page.headers["location"] == f"/login?next={SETTINGS_PAGE}"
data = anon.get(SETTINGS_PAGE, headers={"accept": "application/json"})
assert data.status_code == 401
assert data.json() == {"ok": False, "detail": "sign in required",
"login": "/login"}
finally:
store.close()
def test_every_dashboard_page_links_to_settings(client) -> None:
"""The `⚙ Account` link is the only way to the session page, so it is on every dashboard.
It sits in `.masthead-right` before the theme toggle and **outside** `.tabs`, because the tab
strip's structure — five dashboards, one of them current — is pinned by
`test_the_disks_tab_is_in_the_masthead_of_every_page` and a sixth entry with no
`aria-current` would break what that marker means.
"""
link = '<a class="ghost account" href="/settings"'
for path in PAGES:
html = client.get(path).text
assert link in html, f"{path} has no route to the account page"
tabs = html[html.index('<nav class="tabs"'):html.index("</nav>")]
assert "/settings" not in tabs, f"{path} made Account a dashboard tab"
assert html.index(link) < html.index('id="theme-toggle"'), \
f"{path} put the account link after the theme toggle"
# the stylesheet styles the link with the button's own rule, not a copy of it
css = client.get("/static/dash.css").text
assert "a.ghost" in css
def test_the_password_floor_the_form_checks_is_the_one_the_api_enforces(client) -> None:
"""The client-side length check is a courtesy; the server's is the rule.
There is no build step to share the constant (a locked anchor), so the two literals are
pinned together here: a floor raised in `api.py` alone would leave the form promising eight
characters to a request the server answers `422`.
"""
js = client.get("/static/page-settings.js").text
found = re.search(r"const MIN_PASSWORD_LENGTH = (\d+);", js)
assert found, "page-settings.js no longer names its length floor"
assert int(found.group(1)) == MIN_PASSWORD_LENGTH
def test_a_rotated_secret_clears_the_cached_session_every_page_uses(client) -> None:
"""`Dash.resetAuth()` is the difference between a password change and a 403 dashboard.
`POST /api/auth/password` rotates the signing secret on purpose (`auth.update_verifier`),
which invalidates the CSRF token `ensureAuth()` memoised. Every page writes through
`fetchJSON`, which reads that cache — so unless the change clears it, the operator changes
the password, clicks *beeper disable* on `/ups`, and gets `missing or invalid CSRF token`
until they reload the page. A bounce on a `401` clears it for the same reason: the session it
described no longer exists.
"""
app_js = client.get("/static/app.js").text
assert "function resetAuth(" in app_js
exports = app_js.rsplit("return {", 1)[1].split("}", 1)[0]
assert re.search(r"\bresetAuth\b", exports), "Dash.resetAuth is not exported"
reset = app_js[app_js.index("function resetAuth("):app_js.index("function bounceToLogin(")]
for field in ("loaded: false", "authenticated: false", "csrf: null"):
assert field in reset, f"resetAuth() left `{field}` cached"
assert "authPromise = null" in reset, \
"resetAuth() left an in-flight status call memoised — the fresh write would reuse it"
bounce = app_js[app_js.index("function bounceToLogin("):app_js.index("const READ_METHODS")]
assert "resetAuth()" in bounce, "a 401 bounce kept the dead session's token cached"
js = client.get("/static/page-settings.js").text
assert js.count("Dash.resetAuth()") >= 2, \
"the settings page must reset the cached session after both a change and a sign-out"
assert "textContent" in js and "innerHTML" not in js, \
"/settings renders a server sentence as markup"
# ---- the severity language in the static assets --------------------------- # # ---- the severity language in the static assets --------------------------- #
# #
# There is no JS test runner (a locked anchor: adding one would mean a build step). # There is no JS test runner (a locked anchor: adding one would mean a build step).
@@ -603,7 +833,8 @@ def test_static_assets_are_served(client) -> None:
ASSETS = ("/static/dash.css", "/static/app.js", "/static/charts.js", ASSETS = ("/static/dash.css", "/static/app.js", "/static/charts.js",
"/static/page-index.js", "/static/page-disks.js", "/static/page-system.js", "/static/page-index.js", "/static/page-disks.js", "/static/page-system.js",
"/static/page-llm.js", "/static/page-ups.js") "/static/page-llm.js", "/static/page-ups.js", "/static/page-login.js",
"/static/page-settings.js")
def _css(client: TestClient) -> str: def _css(client: TestClient) -> str:
@@ -702,7 +933,7 @@ def _fleet_field_metrics(js: str) -> list[str]:
def test_no_parallel_severity_vocabulary_exists_anywhere(client) -> None: def test_no_parallel_severity_vocabulary_exists_anywhere(client) -> None:
"""One word per colour: `ok|warn|fail`, as in severity.py and the existing components.""" """One word per colour: `ok|warn|fail`, as in severity.py and the existing components."""
for asset in (*ASSETS, *PAGES): for asset in (*ASSETS, *MARKUP_PAGES):
body = _code(client.get(asset).text) body = _code(client.get(asset).text)
assert ".sev-" not in body, f"{asset} invented a .sev-* class" assert ".sev-" not in body, f"{asset} invented a .sev-* class"
assert not re.search(r"classList\.(?:add|toggle)\(\s*[\"'](?:danger|good|warning)[\"']", assert not re.search(r"classList\.(?:add|toggle)\(\s*[\"'](?:danger|good|warning)[\"']",
@@ -2404,7 +2635,16 @@ PAGE_SCRIPTS = {
"/disks": "page-disks.js", "/disks": "page-disks.js",
"/llm": "page-llm.js", "/llm": "page-llm.js",
"/ups": "page-ups.js", "/ups": "page-ups.js",
# `/settings` is audited like a dashboard even though it is not one: an id nothing reads is
# dead markup whatever page it sits on, and this page has five new ones.
SETTINGS_PAGE: "page-settings.js",
} }
# `/login` is deliberately **not** in this table even though it is audited (phase 11): a
# signed-in client asking for it gets a 303 to the dashboard, so this helper's own
# redirect-following `get` would audit `/`'s markup and call it `/login`. The login page's
# orphan-`id`, CDN and masthead checks live in `tests/test_auth_api.py`, fetched unsigned, and
# `/static/page-login.js` is in `ASSETS` above.
SHARED_SCRIPTS = ("app.js", "charts.js") SHARED_SCRIPTS = ("app.js", "charts.js")
@@ -2472,7 +2712,7 @@ def test_no_page_narrates_its_own_pipeline(client, fragment: str) -> None:
the same sentence migrating into another page's card is the same slop, and a pin that the same sentence migrating into another page's card is the same slop, and a pin that
only guards its original file would let that through. only guards its original file would let that through.
""" """
for path in (*PAGES, *ASSETS): for path in (*MARKUP_PAGES, *ASSETS):
body = client.get(path).text body = client.get(path).text
assert fragment not in body, f"{path} narrates the app again: {fragment!r}" assert fragment not in body, f"{path} narrates the app again: {fragment!r}"
@@ -2484,7 +2724,7 @@ def test_pages_leave_no_empty_narration_container(client) -> None:
renders a gap where the sentence used to be. Elements the scripts fill (`#sub`, renders a gap where the sentence used to be. Elements the scripts fill (`#sub`,
`#alerts-count`) carry an `id` and are not in this category. `#alerts-count`) carry an `id` and are not in this category.
""" """
for path in PAGES: for path in MARKUP_PAGES:
html = _code(client.get(path).text) html = _code(client.get(path).text)
for cls in ("sub", "faint", "mono"): for cls in ("sub", "faint", "mono"):
assert f'<span class="{cls}"></span>' not in html, \ assert f'<span class="{cls}"></span>' not in html, \
@@ -2543,8 +2783,11 @@ def test_history_returns_seeded_samples(tmp_path: Path, monkeypatch: pytest.Monk
now = time.time() now = time.time()
for i in range(30): for i in range(30):
store.add("host", "driveripper", "cpu.usage_pct", 10.0 + i, ts=now - 300 + i) store.add("host", "driveripper", "cpu.usage_pct", 10.0 + i, ts=now - 300 + i)
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL),)), store) app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2,
host_targets=(Target("driveripper", DEAD_URL),)), store)
with TestClient(app) as client: with TestClient(app) as client:
sign_in(client)
body = client.get("/api/history", params={"source": "host", "host": "driveripper", body = client.get("/api/history", params={"source": "host", "host": "driveripper",
"metric": "cpu.usage_pct", "range": "15m"}) "metric": "cpu.usage_pct", "range": "15m"})
assert response_ok(body) assert response_ok(body)
@@ -2743,9 +2986,12 @@ def disks_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
""" """
db = str(tmp_path / "disks.sqlite3") db = str(tmp_path / "disks.sqlite3")
store = SampleStore(db) store = SampleStore(db)
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL), app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2,
host_targets=(Target("driveripper", DEAD_URL),
Target("deskwork", DEAD_URL))), store) Target("deskwork", DEAD_URL))), store)
with TestClient(app) as test_client: with TestClient(app) as test_client:
sign_in(test_client)
now = time.time() now = time.time()
snapshot = disk_snapshot("driveripper") snapshot = disk_snapshot("driveripper")
snapshot["ts"] = now # "the agent answered just now", so charts are in range snapshot["ts"] = now # "the agent answered just now", so charts are in range
@@ -2867,7 +3113,7 @@ def test_the_disks_fleet_header_is_summed_by_the_api_not_the_page(disks_client)
def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one( def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one(
disks_client) -> None: disks_client) -> None:
"""Invariant 1: the app has exactly one POST, and a fourth dashboard does not add one.""" """Invariant 1: the app writes to an **upstream** from exactly one route."""
routes = flat_routes(disks_client.app) routes = flat_routes(disks_client.app)
disks = [route for route in routes disks = [route for route in routes
if str(getattr(route, "path", "")).startswith("/api/disks")] if str(getattr(route, "path", "")).startswith("/api/disks")]
@@ -2879,7 +3125,26 @@ def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one(
assert response.status_code == 405, f"{verb} /api/disks/snapshot was accepted" assert response.status_code == 405, f"{verb} /api/disks/snapshot was accepted"
writes = sorted(str(route.path) for route in routes writes = sorted(str(route.path) for route in routes
if "POST" in (getattr(route, "methods", None) or set())) if "POST" in (getattr(route, "methods", None) or set()))
assert writes == ["/api/ups/command"], "one write endpoint, four gates, unchanged" # Invariant 1 is about the fleet, and phase 11 did not touch the fleet: the three `/auth/`
# writes create, re-credential, and clear a session in this process and dial nothing. They
# are named here instead of filtered out of sight, because the failure this assertion catches
# is a POST arriving quietly — a list the reader recognises is worth more than a short one.
upstream = [path for path in writes if not path.startswith("/api/auth/")]
session = [path for path in writes if path.startswith("/api/auth/")]
assert upstream == ["/api/ups/command"], "one write endpoint, four gates, unchanged"
assert session == ["/api/auth/login", "/api/auth/logout", "/api/auth/password"]
# …and nothing anywhere grew the verbs this app has no use for, session included.
assert not sorted(path for route in routes
for path in [str(route.path)]
if set(getattr(route, "methods", None) or ()) & {"PUT", "PATCH", "DELETE"})
# The session writes are not upstream writes, but they are still the only ones: `/login`
# is the page, not a POST target, and `/api/auth/status` stays a read.
pages = sorted(str(route.path) for route in routes
if "GET" in (getattr(route, "methods", None) or set())
and not str(route.path).startswith("/api/"))
# `/settings` (phase 11) is a fifth dashboard-shaped **page** and it grew no verb: a page
# route is a GET, so the write surface this test guards stays the one UPS command.
assert pages == ["/", "/disks", "/llm", "/login", "/settings", "/system", "/ups"]
def test_config_prints_the_disks_cadence_the_page_explains_itself_with( def test_config_prints_the_disks_cadence_the_page_explains_itself_with(
@@ -3239,9 +3504,11 @@ def test_summary_names_the_drive_the_health_verdict_called_failed(
""" """
db = str(tmp_path / "summary-failed.sqlite3") db = str(tmp_path / "summary-failed.sqlite3")
store = SampleStore(db) store = SampleStore(db)
app = create_app(Settings(db_path=db, app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2,
host_targets=(Target("driveripper", DEAD_URL),)), store) host_targets=(Target("driveripper", DEAD_URL),)), store)
with TestClient(app) as test_client: with TestClient(app) as test_client:
sign_in(test_client)
now = time.time() now = time.time()
snapshot = disk_snapshot("driveripper", mode="failed") snapshot = disk_snapshot("driveripper", mode="failed")
snapshot["ts"] = now snapshot["ts"] = now
@@ -3261,8 +3528,10 @@ def test_events_are_returned_in_json(tmp_path: Path, monkeypatch: pytest.MonkeyP
store = SampleStore(db) store = SampleStore(db)
store.log_event(Event(ts=time.time(), source="host", host="deskwork", kind="down", store.log_event(Event(ts=time.time(), source="host", host="deskwork", kind="down",
severity="error", message="deskwork is not answering")) severity="error", message="deskwork is not answering"))
app = create_app(Settings(db_path=db), store) app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2), store)
with TestClient(app) as client: with TestClient(app) as client:
sign_in(client)
events = client.get("/api/events").json()["events"] events = client.get("/api/events").json()["events"]
assert events[0]["host"] == "deskwork" assert events[0]["host"] == "deskwork"
assert events[0]["severity"] == "error" assert events[0]["severity"] == "error"
@@ -3544,8 +3813,10 @@ def test_samples_written_by_the_poller_are_queryable(tmp_path: Path,
store = SampleStore(db) store = SampleStore(db)
store.add_many([Sample("llm", "gufo", "llm.decode_tokens_per_second", 40.0, time.time() - 60), store.add_many([Sample("llm", "gufo", "llm.decode_tokens_per_second", 40.0, time.time() - 60),
Sample("llm", "gufo", "llm.decode_tokens_per_second", 45.0, time.time() - 30)]) Sample("llm", "gufo", "llm.decode_tokens_per_second", 45.0, time.time() - 30)])
app = create_app(Settings(db_path=db), store) app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2), store)
with TestClient(app) as client: with TestClient(app) as client:
sign_in(client)
metrics = client.get("/api/metrics", params={"source": "llm"}).json()["metrics"] metrics = client.get("/api/metrics", params={"source": "llm"}).json()["metrics"]
assert "llm.decode_tokens_per_second" in metrics assert "llm.decode_tokens_per_second" in metrics
points = client.get("/api/history", params={"source": "llm", "host": "gufo", points = client.get("/api/history", params={"source": "llm", "host": "gufo",
+629
View File
@@ -0,0 +1,629 @@
"""`src/dashboards/auth.py` — every authentication decision, tested as a pure function.
This is the security boundary of the app, so the negative table is the point of the file:
a wrong password, a tampered cookie, a cookie signed with a rotated secret, a CSRF token
from another session, a spoofed swarm of source addresses. A branch here that no test reaches
is a security decision nobody checked, which is why `auth.py` is covered to ~100%.
Nothing sleeps. `now` is an argument on every function that reads a clock, so expiry and
backoff are asserted with numbers instead of with `time.sleep(60)`.
"""
from __future__ import annotations
import ast
import base64
import hashlib
import hmac
import sys
from pathlib import Path
import pytest
from dashboards import auth
AUTH_SOURCE = Path(__file__).resolve().parents[1] / "src" / "dashboards" / "auth.py"
#: Cheap cost for tests that are not about the cost. The default (600k) is pinned once, in
#: `test_the_default_cost_is_the_owasp_figure`, so the rest of the suite stays fast.
CHEAP = 1_000
SECRET = "0" * 64
NOW = 1_700_000_000.0
SALT = "00" * 16
DIGEST = "a" * 64
def _b64(raw: bytes) -> str:
"""The wire form of a base64url payload with the padding stripped.
Reimplemented here rather than calling the module's own encoder on purpose: a test that
builds a tampered token with `_b64e` cannot notice that the format changed.
"""
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _token(secret: str, payload: str) -> str:
"""A correctly-signed token for an arbitrary payload string."""
signature = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest()
return f"{_b64(payload.encode())}.{_b64(signature)}"
def _tree() -> ast.Module:
"""`auth.py` parsed, for the structural pins below."""
return ast.parse(AUTH_SOURCE.read_text())
def _imported_modules() -> set[str]:
"""Every top-level module `auth.py` imports."""
names: set[str] = set()
for node in ast.walk(_tree()):
if isinstance(node, ast.Import):
names.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
names.add(node.module.split(".")[0])
return names
# --- the vocabulary ---------------------------------------------------------------------- #
def test_the_public_names_are_the_ones_the_middleware_and_pages_share() -> None:
"""Pinned because task 03 sets the cookie and task 04 reads the header by these names."""
assert auth.ADMIN_USER == "admin"
assert auth.SESSION_COOKIE == "dash_session"
assert auth.CSRF_HEADER == "X-CSRF-Token"
assert frozenset({"GET", "HEAD", "OPTIONS"}) == auth.SAFE_METHODS
assert "POST" not in auth.SAFE_METHODS
assert auth.PBKDF2_ALGO == "pbkdf2_sha256"
assert auth.SESSION_HOURS == 720.0
# The two rows the operator has to be able to name — in the reset command, in `/settings`,
# and in every test that seeds a database by hand.
assert auth.META_VERIFIER == "auth.verifier"
assert auth.META_SESSION_SECRET == "auth.session_secret"
def test_the_primitives_import_nothing_third_party() -> None:
"""No `itsdangerous`, no `bcrypt`, no session store (PLAN.md's locked toolchain).
A dependency would not fail a test by itself — it would simply become the way this
project authenticates. This fails the day someone adds one.
"""
outside = {name for name in _imported_modules() if name not in sys.stdlib_module_names}
assert outside == set()
def test_the_module_cannot_echo_a_credential() -> None:
"""`AGENTS.md` invariant 6, applied to the admin's own password.
The docstring says nothing here can echo a secret; this makes it a fact instead of an
intention. `logging` is allowed (task 02 added exactly one startup warning), but the shape
of that call is pinned: it must pass a bare module-level **string constant**, never an
f-string, a concatenation, or a local — so there is no expression in this file that could
put a verifier, a password, or a prefix of either on a log line.
"""
imported = _imported_modules()
assert imported & {"sys", "warnings", "traceback"} == set()
called: set[str] = set()
for node in ast.walk(_tree()):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
called.add(node.func.id)
assert called & {"print", "pprint", "input", "vars", "locals"} == set()
tree = _tree()
constants = {
node.id for stmt in tree.body
if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Constant)
for node in stmt.targets if isinstance(node, ast.Name)
}
for node in ast.walk(tree):
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
continue
if not (isinstance(node.func.value, ast.Name) and node.func.value.id == "_log"):
continue
assert len(node.args) == 1, "the one log call takes one message"
message = node.args[0]
assert isinstance(message, ast.Name) and message.id in constants, (
"the logged message must be a module-level literal, not an interpolation")
assert isinstance(ast.literal_eval(_constant_value(tree, message.id)), str)
def _constant_value(tree: ast.Module, name: str) -> ast.Constant:
"""The literal behind a module-level constant name."""
for stmt in tree.body:
if isinstance(stmt, ast.Assign) and any(
isinstance(t, ast.Name) and t.id == name for t in stmt.targets
):
assert isinstance(stmt.value, ast.Constant)
return stmt.value
raise AssertionError(f"{name} is not a module-level constant")
def test_the_primitives_never_raise_and_the_bootstrap_raises_exactly_twice() -> None:
"""Every primitive's failure mode is a `False`/`None` the caller turns into a status code.
An exception would carry the reason, and the reason is a credential (`bad salt '3f9a…'`)
in a traceback the health record and the event feed both surface. The negative tables
below are the behavioural half of this structural pin.
The two documented exceptions are the bootstrap (task 02), and they are enumerated rather
than merely tolerated: `load_auth_state` refuses to start a dashboard with no password
anywhere, and `update_verifier` refuses to store an empty one. Both raise fixed text, and
neither may interpolate a password, a verifier, a secret, a salt or a digest into it.
"""
tree = _tree()
allowed = {"load_auth_state": "RuntimeError", "update_verifier": "ValueError"}
credential_names = {"password", "verifier", "secret", "token", "salt", "digest", "provided"}
per_function: dict[str, list[ast.Raise]] = {}
for func in (n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)):
found = [n for n in ast.walk(func) if isinstance(n, ast.Raise)]
if found:
per_function[func.name] = found
assert set(per_function) == set(allowed)
for name, expected in allowed.items():
for node in per_function[name]:
exc = node.exc
assert isinstance(exc, ast.Call) and isinstance(exc.func, ast.Name)
assert exc.func.id == expected, f"{name} must raise {expected}"
assert node.cause is None
for arg in exc.args:
assert not _mentions(arg, credential_names), f"{name} raises with a credential"
# nothing outside those two functions raises at all
module_raises = [n for n in ast.walk(tree) if isinstance(n, ast.Raise)]
assert len(module_raises) == sum(len(v) for v in per_function.values())
def _mentions(node: ast.AST, names: set[str]) -> bool:
"""Does this expression name one of `names` anywhere inside it?"""
return any(isinstance(child, ast.Name) and child.id in names
for child in ast.walk(node))
# --- password verifier ------------------------------------------------------------------- #
def test_a_hash_round_trips_and_describes_itself() -> None:
encoded = auth.hash_password("testpass", iterations=CHEAP)
algo, iterations, salt, digest = encoded.split("$")
assert algo == auth.PBKDF2_ALGO
assert iterations == str(CHEAP)
assert len(salt) == auth.PBKDF2_SALT_BYTES * 2
assert len(digest) == auth.PBKDF2_HASH_BYTES * 2
assert auth.verify_password("testpass", encoded) is True
def test_the_default_cost_is_the_owasp_figure() -> None:
"""The completion criterion for this task, end to end, at the real cost."""
assert auth.PBKDF2_ITERATIONS == 600_000
encoded = auth.hash_password("testpass")
assert encoded.startswith(f"{auth.PBKDF2_ALGO}${auth.PBKDF2_ITERATIONS}$")
assert auth.verify_password("testpass", encoded) is True
assert auth.verify_password("wrong", encoded) is False
assert auth.verify_password("testpass", "junk") is False
def test_a_wrong_password_is_false_and_nothing_else() -> None:
encoded = auth.hash_password("correct horse", iterations=CHEAP)
assert auth.verify_password("", encoded) is False
assert auth.verify_password("Correct horse", encoded) is False
assert auth.verify_password("correct horse ", encoded) is False
def test_the_password_is_encoded_as_utf8() -> None:
"""`pässwörd` round-trips — proof the encode is utf-8 and not a lossy 8-bit one.
A latin-1 or ascii encode would either raise or make two distinct passwords verify
against one digest; non-ASCII input is the only way to tell them apart.
"""
for password in ("pässwörd", "пароль", "パスワード", "🔥🔥"):
encoded = auth.hash_password(password, iterations=CHEAP)
assert auth.verify_password(password, encoded) is True
assert auth.verify_password("password", encoded) is False
def test_a_lone_surrogate_verifies_instead_of_raising() -> None:
"""`.encode("utf-8")` raises on a surrogate; the login path must not."""
password = "\ud800"
encoded = auth.hash_password(password, iterations=CHEAP)
assert auth.verify_password(password, encoded) is True
assert auth.verify_password("\ud801", encoded) is False
def test_the_salt_is_fresh_per_hash() -> None:
"""Two hashes of one password share no byte of digest — or one table cracks both."""
first = auth.hash_password("testpass", iterations=CHEAP)
second = auth.hash_password("testpass", iterations=CHEAP)
assert first != second
assert first.split("$")[2] != second.split("$")[2]
assert first.split("$")[3] != second.split("$")[3]
assert auth.verify_password("testpass", first) is True
assert auth.verify_password("testpass", second) is True
def test_verification_uses_the_stored_iteration_count() -> None:
"""Bumping the cost field must not silently accept the password.
The iteration count feeds the digest, so rewriting it is corruption, not an upgrade:
`needs_rehash` is what asks for a replacement, never `verify_password`.
"""
encoded = auth.hash_password("testpass", iterations=CHEAP)
algo, _iterations, salt, digest = encoded.split("$")
assert auth.verify_password("testpass", f"{algo}${CHEAP * 2}${salt}${digest}") is False
#: Every malformed verifier shape. `verify_password` answers `False` to all of them — the same
#: answer a wrong password gets — because a damaged `auth.verifier` row has to read as a login
#: error the operator can act on, not a 500 on the page whose job is to say "wrong password".
MALFORMED: list[tuple[str, str]] = [
("empty", ""),
("junk", "junk"),
("one field", auth.PBKDF2_ALGO),
("three fields", f"{auth.PBKDF2_ALGO}${CHEAP}$00112233"),
("five fields", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}${DIGEST}$extra"),
("leading dollar", f"${auth.PBKDF2_ALGO}${CHEAP}${SALT}${DIGEST}"),
("unknown algo", f"md5${CHEAP}${SALT}${DIGEST}"),
("plaintext row", "testpass"),
("non-numeric iterations", f"{auth.PBKDF2_ALGO}$six$00112233${DIGEST}"),
("float iterations", f"{auth.PBKDF2_ALGO}$1000.5${SALT}${DIGEST}"),
("zero iterations", f"{auth.PBKDF2_ALGO}$0${SALT}${DIGEST}"),
("negative iterations", f"{auth.PBKDF2_ALGO}$-5${SALT}${DIGEST}"),
("absurd iterations", f"{auth.PBKDF2_ALGO}$999999999999${SALT}${DIGEST}"),
("odd-length salt", f"{auth.PBKDF2_ALGO}${CHEAP}$00112233a${DIGEST}"),
("non-hex salt", f"{auth.PBKDF2_ALGO}${CHEAP}$zzzzzzzz${DIGEST}"),
("empty salt", f"{auth.PBKDF2_ALGO}${CHEAP}$$0123456789abcdef"),
("odd-length hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}${DIGEST}a"),
("non-hex hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$" + "z" * 64),
("short hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$" + "a" * 32),
("long hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$" + "a" * 96),
("empty hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$"),
]
@pytest.mark.parametrize(("label", "encoded"), MALFORMED, ids=[label for label, _ in MALFORMED])
def test_a_malformed_verifier_is_neither_true_nor_an_exception(label: str, encoded: str) -> None:
assert auth.verify_password("testpass", encoded) is False, label
assert auth.verify_password("", encoded) is False, label
def test_a_corrupt_row_does_not_make_the_absurd_cost_free() -> None:
"""The iteration ceiling bounds the CPU a damaged row can demand of the login page."""
assert auth.PBKDF2_MAX_ITERATIONS > auth.PBKDF2_ITERATIONS
absurd = "9" * 30
assert auth.verify_password("testpass", f"{auth.PBKDF2_ALGO}${absurd}${SALT}${DIGEST}") is False
def test_needs_rehash_tracks_the_configured_cost() -> None:
weak = auth.hash_password("testpass", iterations=1_000)
strong = auth.hash_password("testpass", iterations=100_000)
# the case from the task: a 1_000-iteration hash against the default cost
assert auth.needs_rehash(weak, iterations=auth.PBKDF2_ITERATIONS) is True
assert auth.needs_rehash(strong, iterations=100_000) is False # equal is not below
assert auth.needs_rehash(strong, iterations=1_000) is False # above is not below
assert auth.needs_rehash(weak, iterations=1_000) is False
assert auth.needs_rehash(weak, iterations=999) is False
assert auth.needs_rehash(auth.hash_password("x"), iterations=auth.PBKDF2_ITERATIONS) is False
@pytest.mark.parametrize("encoded", [
"",
"junk",
f"md5${CHEAP}$00112233${DIGEST}",
f"{auth.PBKDF2_ALGO}$many$00112233${DIGEST}",
])
def test_needs_rehash_wants_a_replacement_for_anything_off_shape(encoded: str) -> None:
"""Unparseable or another algorithm → `True`: that is not the shape we want stored."""
assert auth.needs_rehash(encoded, iterations=auth.PBKDF2_ITERATIONS) is True
# --- sessions ---------------------------------------------------------------------------- #
def test_a_minted_session_round_trips() -> None:
token = auth.mint_session(SECRET, now=NOW)
session = auth.read_session(SECRET, token, now=NOW + 60)
assert session is not None
assert session.username == auth.ADMIN_USER
assert session.expires_at == pytest.approx(NOW + auth.SESSION_HOURS * 3600.0, abs=1.0)
assert session.token == token
def test_the_token_is_two_urlsafe_base64_parts_with_no_padding() -> None:
token = auth.mint_session(SECRET, now=NOW)
payload_b64, signature_b64 = token.split(".")
assert "=" not in token
assert set(token) <= set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.")
assert token.count(".") == 1 # the separator only: nothing inside the payload or signature
assert base64.urlsafe_b64decode(payload_b64 + "==").decode().startswith("admin|")
assert len(base64.urlsafe_b64decode(signature_b64 + "==")) == 32
def test_a_session_can_be_minted_for_another_lifetime() -> None:
token = auth.mint_session(SECRET, hours=1.0, now=NOW)
assert auth.read_session(SECRET, token, now=NOW + 3_599) is not None
assert auth.read_session(SECRET, token, now=NOW + 3_600) is None # the expiry is exclusive
def test_a_new_secret_is_256_bits_of_hex_and_unique() -> None:
minted = {auth.new_secret() for _ in range(8)}
assert len(minted) == 8
for secret in minted:
assert len(secret) == 64
int(secret, 16) # raises if it is not hex
def test_an_expired_session_is_none() -> None:
token = auth.mint_session(SECRET, now=NOW)
assert auth.read_session(SECRET, token, now=NOW + 90 * 24 * 3600) is None
@pytest.mark.parametrize("token", [
"",
"no-dot-here",
".YWRtaW58OTk5OTk5OTk5OQ",
"YWRtaW58OTk5OTk5OTk5OQ.",
"!!!.!!!",
"YWRtaW58OTk5OTk5OTk5OQ.!!!",
"....",
])
def test_a_token_that_is_not_a_token_is_none(token: str) -> None:
assert auth.read_session(SECRET, token, now=NOW) is None
def test_a_flipped_signature_byte_is_none() -> None:
token = auth.mint_session(SECRET, now=NOW)
payload_b64, signature_b64 = token.split(".")
first = signature_b64[0]
flipped = ("B" if first != "B" else "C") + signature_b64[1:]
assert auth.read_session(SECRET, f"{payload_b64}.{flipped}", now=NOW) is None
def test_a_truncated_token_is_none() -> None:
token = auth.mint_session(SECRET, now=NOW)
assert auth.read_session(SECRET, token[:-4], now=NOW) is None
assert auth.read_session(SECRET, token[4:], now=NOW) is None
def test_a_cookie_signed_by_a_rotated_secret_is_none() -> None:
"""Rotating `auth.session_secret` is how signing every other browser out works."""
token = auth.mint_session(SECRET, now=NOW)
assert auth.read_session("f" * 64, token, now=NOW) is None
def test_a_tampered_username_is_none() -> None:
token = auth.mint_session(SECRET, now=NOW)
payload_b64, signature_b64 = token.split(".")
payload = base64.urlsafe_b64decode(payload_b64 + "==").decode()
forged = _b64(payload.replace("admin", "root").encode())
assert forged != payload_b64
assert auth.read_session(SECRET, f"{forged}.{signature_b64}", now=NOW) is None
def test_a_payload_that_is_not_utf8_is_none() -> None:
token = auth.mint_session(SECRET, now=NOW)
_signed_payload, signature_b64 = token.split(".")
assert auth.read_session(SECRET, f"{_b64(b'\xff\xff')}.{signature_b64}", now=NOW) is None
@pytest.mark.parametrize(("label", "payload"), [
("no bar", "admin"),
("no username", f"|{NOW + 3600:.0f}"),
("non-numeric expiry", "admin|soon"),
("empty expiry", "admin|"),
("nan expiry", "admin|nan"),
("inf expiry", "admin|inf"),
("-inf expiry", "admin|-inf"),
("empty payload", ""),
])
def test_a_signed_payload_that_is_not_a_session_is_none(label: str, payload: str) -> None:
"""Correctly signed, still rejected: the signature is necessary and not sufficient."""
assert auth.read_session(SECRET, _token(SECRET, payload), now=NOW) is None, label
def test_the_signature_covers_the_expiry_too() -> None:
"""Pushing a session's expiry out is the same class of forgery as renaming the user."""
token = auth.mint_session(SECRET, hours=1.0, now=NOW)
session = auth.read_session(SECRET, token, now=NOW)
assert session is not None
later = f"admin|{session.expires_at + 10 * 3600:.0f}"
# without the secret, an extended payload is just an unsigned string
assert auth.read_session(SECRET, _token("f" * 64, later), now=NOW) is None
# and the real token does not outlive the expiry it was minted with
assert auth.read_session(SECRET, token, now=NOW + 2 * 3600) is None
# --- CSRF -------------------------------------------------------------------------------- #
def test_the_csrf_token_is_bound_to_its_session() -> None:
token = auth.mint_session(SECRET, now=NOW)
other = auth.mint_session(SECRET, hours=1.0, now=NOW)
csrf = auth.mint_csrf(SECRET, token)
assert len(csrf) == 64
assert csrf == auth.mint_csrf(SECRET, token) # stateless: no rotation, no storage
assert csrf != auth.mint_csrf(SECRET, other)
assert auth.verify_csrf(SECRET, token, csrf) is True
# a token stolen from one session cannot authorise another's write
assert auth.verify_csrf(SECRET, other, csrf) is False
@pytest.mark.parametrize("provided", [None, "", " ", "deadbeef", " " * 64])
def test_a_missing_or_wrong_csrf_token_is_false(provided: str | None) -> None:
token = auth.mint_session(SECRET, now=NOW)
assert auth.verify_csrf(SECRET, token, provided) is False
def test_csrf_verification_survives_an_empty_session_and_a_non_ascii_header() -> None:
token = auth.mint_session(SECRET, now=NOW)
csrf = auth.mint_csrf(SECRET, token)
assert auth.verify_csrf(SECRET, "", csrf) is False
assert auth.verify_csrf(SECRET, "", "") is False
assert auth.verify_csrf(SECRET, token, f"{csrf}päss") is False
assert auth.verify_csrf(SECRET, token, "\ud800" + csrf) is False
assert auth.verify_csrf(SECRET, token, csrf) is True # the good case still works
# --- login throttle ---------------------------------------------------------------------- #
def test_the_defaults_are_five_attempts_then_a_minute() -> None:
throttle = auth.LoginThrottle()
assert throttle.max_attempts == 5
assert throttle.base_seconds == 60.0
assert throttle.max_seconds == 3600.0
def test_five_failures_are_allowed_and_the_sixth_is_blocked() -> None:
throttle = auth.LoginThrottle()
for _ in range(5):
assert throttle.retry_after("10.0.0.1", now=100.0) == 0.0
throttle.failure("10.0.0.1", now=100.0)
assert throttle.retry_after("10.0.0.1", now=100.0) == pytest.approx(60.0)
assert throttle.retry_after("10.0.0.9", now=100.0) == 0.0 # another address, another bucket
def test_an_unknown_key_is_never_blocked() -> None:
assert auth.LoginThrottle().retry_after("nobody", now=1.0) == 0.0
def test_the_backoff_doubles_and_caps_at_max_seconds() -> None:
throttle = auth.LoginThrottle(max_attempts=3, base_seconds=10.0, max_seconds=100.0)
now = 1_000.0
assert throttle.failure("k", now=now) == 0.0 # 1st
assert throttle.failure("k", now=now) == 0.0 # 2nd
assert throttle.failure("k", now=now) == 10.0 # 3rd trips the block
assert throttle.retry_after("k", now=now) == 10.0
now += 10
assert throttle.failure("k", now=now) == 20.0
now += 20
assert throttle.failure("k", now=now) == 40.0
now += 40
assert throttle.failure("k", now=now) == 80.0
now += 80
assert throttle.failure("k", now=now) == 100.0 # capped
now += 100
assert throttle.failure("k", now=now) == 100.0 # and stays capped
def test_the_block_lapses_on_its_own() -> None:
throttle = auth.LoginThrottle(max_attempts=1, base_seconds=30.0)
throttle.failure("k", now=500.0)
assert throttle.retry_after("k", now=500.0) == 30.0
assert throttle.retry_after("k", now=515.0) == pytest.approx(15.0)
assert throttle.retry_after("k", now=530.0) == 0.0
def test_a_failure_during_a_block_never_shortens_it() -> None:
throttle = auth.LoginThrottle(max_attempts=2, base_seconds=60.0)
throttle.failure("k", now=1_000.0)
throttle.failure("k", now=1_000.0) # blocked until 1_060
assert throttle.retry_after("k", now=1_000.0) == 60.0
throttle.failure("k", now=1_010.0) # hammering during the lockout
assert throttle.retry_after("k", now=1_010.0) > 60.0
def test_a_successful_login_clears_the_key() -> None:
throttle = auth.LoginThrottle(max_attempts=3, base_seconds=60.0)
for _ in range(2):
throttle.failure("k", now=10.0)
throttle.success("k")
assert throttle.retry_after("k", now=10.0) == 0.0
# the count is gone too: the next block starts at the base step, not a doubled one
throttle.failure("k", now=10.0)
throttle.failure("k", now=10.0)
assert throttle.failure("k", now=10.0) == 60.0
throttle.success("k")
assert throttle.retry_after("k", now=10.0) == 0.0
throttle.success("k") # clearing an unknown key is not an error
def test_prune_forgets_only_keys_that_can_no_longer_affect_a_decision() -> None:
throttle = auth.LoginThrottle(max_attempts=2, base_seconds=60.0, max_seconds=600.0)
throttle.failure("quiet", now=0.0) # under the block threshold, then idle
throttle.failure("blocked", now=0.0)
throttle.failure("blocked", now=0.0) # blocked until 60
assert throttle.tracked == 2
assert throttle.prune(now=100.0) == 0 # 100 - 0 < max_seconds: too soon to forget
assert throttle.retry_after("blocked", now=100.0) == 0.0
assert throttle.prune(now=700.0) == 2
assert throttle.tracked == 0
assert throttle.prune(now=700.0) == 0
def test_the_dict_is_bounded_against_a_swarm_of_source_addresses() -> None:
"""A spoofed spray must not turn the dashboard's own memory into the outage."""
throttle = auth.LoginThrottle()
for _ in range(5):
throttle.failure("attacker", now=5_000.0)
assert throttle.retry_after("attacker", now=5_000.0) == pytest.approx(60.0)
for index in range(auth.THROTTLE_MAX_KEYS * 2):
throttle.failure(f"swarm-{index}", now=float(index))
assert throttle.tracked <= auth.THROTTLE_MAX_KEYS
# what pressure drops is the least-recently-active key, never the one still attacking
assert throttle.retry_after("attacker", now=5_000.0) == pytest.approx(60.0)
def test_the_length_guard_prunes_before_it_evicts() -> None:
throttle = auth.LoginThrottle()
for index in range(auth.THROTTLE_MAX_KEYS):
throttle.failure(f"k{index}", now=0.0)
assert throttle.tracked == auth.THROTTLE_MAX_KEYS
# every entry has been idle past max_seconds, so pruning alone makes the room
throttle.failure("newest", now=10_000.0)
assert throttle.tracked == 1
def test_silly_limits_are_clamped_instead_of_disabling_the_throttle() -> None:
throttle = auth.LoginThrottle(max_attempts=0, base_seconds=30.0, max_seconds=5.0)
assert throttle.max_attempts == 1
assert throttle.base_seconds == 30.0
assert throttle.max_seconds == 30.0
assert throttle.failure("k", now=0.0) == 30.0
# --- origin / Sec-Fetch ------------------------------------------------------------------ #
def test_the_origin_gate_accepts_this_app_and_the_allow_list() -> None:
own = "https://dash.example"
assert auth.origin_matches(own, own) is True
assert auth.origin_matches("https://dash.example/", own) is True
assert auth.origin_matches("HTTPS://DASH.EXAMPLE", own) is True
assert auth.origin_matches(" https://dash.example ", own) is True
extra = ["https://lab.internal:8090"]
assert auth.origin_matches("https://lab.internal:8090", own, extra) is True
@pytest.mark.parametrize("origin", [
"http://evil.example",
"https://evil.example",
"https://dash.example:9999",
"http://dash.example", # a different scheme is not the same origin
"https://dash.example.evil.net",
"null",
])
def test_the_origin_gate_rejects_everything_else(origin: str) -> None:
assert auth.origin_matches(origin, "https://dash.example") is False
def test_an_empty_origin_is_not_a_match_and_the_caller_gates_on_presence() -> None:
"""The empty string is not this app's origin; `if origin and not origin_matches(…)` is the
caller's check, so curl (no Origin header at all) is not rejected here."""
assert auth.origin_matches("", "https://dash.example") is False
assert auth.origin_matches(" ", "https://dash.example") is False
assert auth.origin_matches("", "") is False
def test_the_sec_fetch_gate_allows_same_origin_and_non_browsers_only() -> None:
assert auth.sec_fetch_site_allowed(None) is True
assert auth.sec_fetch_site_allowed("same-origin") is True
assert auth.sec_fetch_site_allowed("none") is True
assert auth.sec_fetch_site_allowed(" Same-Origin ") is True
assert frozenset({"same-origin", "none"}) == auth.ALLOWED_SEC_FETCH_SITE
@pytest.mark.parametrize("value", [
"cross-site", "cross-origin", "same-site", "SAME-SITE", "", " ", "sourcerer", "same-site ",
])
def test_an_unrecognised_sec_fetch_site_is_rejected(value: str) -> None:
"""Unknown → reject: a browser inventing a value is a browser doing something we did not
reason about, so the safe answer is the one this returns."""
assert auth.sec_fetch_site_allowed(value) is False
File diff suppressed because it is too large Load Diff
+306
View File
@@ -0,0 +1,306 @@
"""Where the admin credential lives, and what happens when it does not (phase 11 task 02).
`auth.load_auth_state` is the only code that turns a deployment's environment plus the durable
`meta` rows into the state every request is checked against, and it owns three decisions that
are easy to get subtly wrong and impossible to see in a screenshot:
* the environment **seeds** a password once — after the first boot the stored verifier wins, so
`/settings` can change it without a redeploy and a restart cannot roll it back;
* a server with neither a stored verifier nor `DASH_ADMIN_PASSWORD` **refuses to start**
(fail closed) rather than serving the fleet open behind a public domain;
* changing the password rotates the signing secret, which is what signs out every *other*
browser when there is no session table to walk.
The store here is a dict. That is the point of the `MetaStore` protocol: the security-relevant
branch has no SQLite in it, so these tests assert the decisions rather than the schema — and
one test at the end puts the real `SampleStore` back underneath to prove the durability the
protocol is written against.
"""
from __future__ import annotations
import logging
from dataclasses import FrozenInstanceError
from pathlib import Path
from typing import Any
import pytest
from dashboards import auth
from dashboards.store import SampleStore
#: Nothing in this file is about the cost; `tests/test_auth.py` pins the 600 000 default.
CHEAP = 1_000
PASSWORD = "testpass"
class FakeMeta:
"""The three-method shape `load_auth_state` asks for, with no database in it."""
def __init__(self, rows: dict[str, str] | None = None) -> None:
self.rows: dict[str, str] = dict(rows or {})
def get_meta(self, key: str) -> str | None:
return self.rows.get(key)
def set_meta(self, key: str, value: str) -> None:
self.rows[key] = value
def delete_meta(self, key: str) -> int:
return 1 if self.rows.pop(key, None) is not None else 0
def load(store: FakeMeta, *, admin_password: str = PASSWORD, **kwargs: Any) -> auth.AuthState:
return auth.load_auth_state(store, admin_password=admin_password, iterations=CHEAP,
**kwargs)
def seed(*, admin_password: str = PASSWORD) -> tuple[FakeMeta, auth.AuthState]:
"""A store that has already booted once, and the state that boot produced."""
store = FakeMeta()
return store, load(store, admin_password=admin_password)
# --- first boot: the environment seeds ---------------------------------------------------- #
def test_first_boot_seeds_the_verifier_and_persists_the_secret() -> None:
store = FakeMeta()
state = load(store)
assert state.seeded is True, "this start wrote the verifier row"
assert state.env_ignored is False, "there was nothing stored to ignore the env with"
assert auth.verify_password(PASSWORD, store.rows[auth.META_VERIFIER]) is True
assert store.rows[auth.META_SESSION_SECRET] == state.secret
assert state.iterations == CHEAP
assert state.session_hours == auth.SESSION_HOURS
def test_the_seed_is_stored_as_a_verifier_and_never_as_a_password() -> None:
"""`AGENTS.md` invariant 6 on the way *in*: the row is PBKDF2 text, not the secret.
The completion criterion for the task is that `grep -c testpass` on a real database is 0;
this is the same claim one layer up, where a regression would show.
"""
store = FakeMeta()
state = load(store)
for value in [*store.rows.values(), state.verifier, state.secret]:
assert PASSWORD not in value
assert store.rows[auth.META_VERIFIER].startswith(f"{auth.PBKDF2_ALGO}${CHEAP}$")
def test_the_stored_rows_survive_a_reopen_of_the_real_database(tmp_path: Path) -> None:
"""The whole reason this is in SQLite and not in process memory.
A restart must not re-seed, must not mint a new signing secret (that would sign every
browser out on every deploy), and must not need `DASH_ADMIN_PASSWORD` any more.
"""
path = tmp_path / "dashboards.sqlite3"
first_store = SampleStore(path)
try:
first = auth.load_auth_state(first_store, admin_password=PASSWORD, iterations=CHEAP)
assert first.seeded is True
finally:
first_store.close()
reopened = SampleStore(path)
try:
second = auth.load_auth_state(reopened, admin_password="", iterations=CHEAP)
finally:
reopened.close()
assert second.seeded is False
assert second.verifier == first.verifier
assert second.secret == first.secret, "a new secret on every restart would log everyone out"
assert auth.verify_password(PASSWORD, second.verifier) is True
# --- the stored verifier wins ------------------------------------------------------------- #
def test_a_stored_verifier_beats_a_conflicting_env_password(caplog: pytest.LogCaptureFixture) -> None:
store, _first = seed()
original = store.rows[auth.META_VERIFIER]
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
second = load(store, admin_password="some-other-password")
assert second.env_ignored is True
assert second.seeded is False
assert store.rows[auth.META_VERIFIER] == original, "the env must not overwrite the row"
assert second.verifier == original
assert auth.verify_password(PASSWORD, second.verifier) is True
assert auth.verify_password("some-other-password", second.verifier) is False
def test_the_ignored_env_var_is_reported_once_in_fixed_words(caplog: pytest.LogCaptureFixture) -> None:
"""The operator who set `DASH_ADMIN_PASSWORD` and saw nothing happen gets one sentence.
The sentence must name the variable and the row; it must not name the value, the hash, or
any prefix of either — that line ends up in a container log, which is exactly where
invariant 6 says a credential must not go.
"""
store, _first = seed()
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
state = load(store, admin_password="ignored-now")
messages = [rec.getMessage() for rec in caplog.records if rec.name == "dashboards.auth"]
assert messages == [auth.ENV_IGNORED_MESSAGE], "exactly one line, at startup"
line = messages[0]
assert "DASH_ADMIN_PASSWORD is ignored" in line
assert auth.META_VERIFIER in line
for secret in (PASSWORD, "ignored-now", state.verifier, state.secret):
assert secret not in line
def test_the_warning_is_not_written_when_the_env_actually_seeded(caplog: pytest.LogCaptureFixture) -> None:
"""`seeded` and `env_ignored` are mutually exclusive, and only the second one speaks."""
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
state = load(FakeMeta())
assert state.env_ignored is False
assert [r for r in caplog.records if r.name == "dashboards.auth"] == []
def test_the_environment_variable_can_be_dropped_after_first_boot() -> None:
"""The operator's wording: "configured initially with an env var, not required to change".
Second boot with no seed at all reuses the row, reports neither `seeded` nor
`env_ignored`, and stays quiet.
"""
store, first = seed()
second = load(store, admin_password="")
assert second.seeded is False
assert second.env_ignored is False
assert second.verifier == first.verifier
def test_a_blank_stored_verifier_counts_as_no_verifier_at_all() -> None:
"""A row of whitespace is not a password — reseeding it is the only way back in."""
store = FakeMeta({auth.META_VERIFIER: " \n "})
state = load(store)
assert state.seeded is True
assert auth.verify_password(PASSWORD, store.rows[auth.META_VERIFIER]) is True
# --- fail closed -------------------------------------------------------------------------- #
def test_no_verifier_and_no_seed_refuses_to_start_instead_of_running_open() -> None:
store = FakeMeta()
with pytest.raises(RuntimeError) as caught:
load(store, admin_password="")
message = str(caught.value)
assert "DASH_ADMIN_PASSWORD" in message, "names the variable to set"
assert auth.META_VERIFIER in message, "names the row that would have been used"
assert auth.META_SESSION_SECRET in message
assert "DELETE FROM meta WHERE key IN" in message, "gives the reset command"
assert "sqlite3" in message
assert store.rows == {}, "refusing means writing nothing, not writing and then complaining"
def test_the_refusal_names_the_database_the_operator_has_to_reset() -> None:
"""`<db>` is a placeholder nobody can run; the real path is what makes the fix copy-paste."""
with pytest.raises(RuntimeError) as caught:
auth.load_auth_state(FakeMeta(), admin_password="", db_path="/var/lib/dashboards/d.db")
assert "/var/lib/dashboards/d.db" in str(caught.value)
with pytest.raises(RuntimeError) as bare:
auth.load_auth_state(FakeMeta(), admin_password="")
assert "<db>" in str(bare.value), "no path known, and no invented one either"
def test_the_refusal_carries_no_password_and_no_verifier() -> None:
"""The message reaches a terminal and a container log; it may carry instructions only."""
store = FakeMeta()
with pytest.raises(RuntimeError) as caught:
load(store, admin_password="")
assert PASSWORD not in str(caught.value)
# --- the signing secret -------------------------------------------------------------------- #
def test_a_usable_session_secret_is_reused_as_is() -> None:
"""A restart must not rotate the key, or every browser is signed out for nothing."""
existing = auth.new_secret()
# a second boot, not a first: the verifier row exists and the secret is what is under test
store = FakeMeta({auth.META_VERIFIER: auth.hash_password(PASSWORD, iterations=CHEAP),
auth.META_SESSION_SECRET: existing})
state = load(store, admin_password="")
assert state.secret == existing
@pytest.mark.parametrize("corrupt", [
"",
" ",
"deadbeef", # hex, but a 16-bit key is not what we sign a 30-day cookie with
"z" * 64, # long enough, and not hex
"0" * 63, # one character short of 256 bits
])
def test_a_corrupt_session_secret_is_regenerated_and_persisted(corrupt: str) -> None:
"""The chosen behaviour, stated: replace it and write the replacement.
The effect on the operator is "every browser is asked to sign in again" — a login prompt,
not a lockout — which is the safe direction for a damaged row. Refusing to start instead
would turn a corrupt cell into an outage of a dashboard that still has a working password.
"""
store, first = seed()
store.rows[auth.META_SESSION_SECRET] = corrupt
state = load(store, admin_password="")
assert state.secret != corrupt
assert state.secret == store.rows[auth.META_SESSION_SECRET], "the replacement is durable"
assert len(state.secret) == auth.SECRET_MIN_BYTES * 2
bytes.fromhex(state.secret) # raises if the replacement is not the format we mint
assert state.verifier == first.verifier, "the password is untouched"
# and the state is usable: a session minted from it reads back
token = auth.mint_session(state.secret, now=1_700_000_000.0)
assert auth.read_session(state.secret, token, now=1_700_000_000.0) is not None
# --- changing the password ----------------------------------------------------------------- #
def test_update_verifier_replaces_both_rows_and_returns_the_new_state() -> None:
store, before = seed()
old_token = auth.mint_session(before.secret, now=1_700_000_000.0)
after = auth.update_verifier(store, before, "horse battery staple")
assert after.secret != before.secret
assert store.rows[auth.META_SESSION_SECRET] == after.secret
assert store.rows[auth.META_VERIFIER] == after.verifier
assert auth.verify_password("horse battery staple", after.verifier) is True
assert auth.verify_password(PASSWORD, after.verifier) is False
assert after.session_hours == before.session_hours
assert after.iterations == before.iterations
assert after.seeded is False and after.env_ignored is False
# the browser that was not in front of the form is out: its cookie is signed by the old key
assert auth.read_session(after.secret, old_token, now=1_700_000_000.0) is None
# and the new state can still mint one that reads
fresh = auth.mint_session(after.secret, now=1_700_000_000.0)
assert auth.read_session(after.secret, fresh, now=1_700_000_000.0) is not None
@pytest.mark.parametrize("blank", ["", " ", "\t", "\n \r"])
def test_update_verifier_refuses_to_store_an_empty_password(blank: str) -> None:
store, before = seed()
with pytest.raises(ValueError) as caught:
auth.update_verifier(store, before, blank)
assert store.rows[auth.META_VERIFIER] == before.verifier
assert store.rows[auth.META_SESSION_SECRET] == before.secret
# fixed text: nothing the caller typed is echoed back into a log or a traceback
assert str(caught.value) == "a new password must not be empty or whitespace"
def test_the_state_is_a_frozen_bundle_of_credentials() -> None:
"""Nothing may mutate a live `AppContext.auth`, and nothing may treat it as display data."""
_store, state = seed()
with pytest.raises(FrozenInstanceError):
state.verifier = "swapped" # type: ignore[misc]
assert {"verifier", "secret", "session_hours", "iterations", "seeded", "env_ignored"} == {
f.name for f in state.__dataclass_fields__.values()}
+265
View File
@@ -0,0 +1,265 @@
"""The operator-facing setup surface of phase 11: the two templates, the dev script, the docs.
Nothing here runs a server. What it does is pin the four artefacts an operator actually reads
when a dashboard will not come up, because every one of them is prose that can go stale without
breaking a single assertion in the rest of the suite:
* the tracked env templates must **declare** every auth setting (a setting nobody has to write a
line for is a setting nobody knows exists) and must carry **no real secret** — the same rule
`.agents/validate.sh` 2b enforces through `git ls-files`, restated here so `uv run pytest`
catches it too and catches it on the working tree, not only on what was committed;
* `scripts/dev-server.sh` must keep working **unsigned**: its readiness probe and its `status`
both ride `/api/health`, the one data route the gate leaves open (AGENTS.md invariant 12). The
day someone "fixes" the script by signing in first, the script grows a password and the
Containerfile healthcheck is the next thing that needs one;
* `README.md`'s *Signing in* section and `AGENTS.md`'s invariant/gap prose must still name the
things an operator has to act on — the four public paths, the reset command, the four
variables, and the deployment flags. A doc that stops naming the reset command is a support
call about a locked-out admin.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
ENV_EXAMPLE = ROOT / ".env.example"
ENV_DEV_EXAMPLE = ROOT / ".env.dev.example"
DEV_SERVER = ROOT / "scripts" / "dev-server.sh"
README = ROOT / "README.md"
AGENTS = ROOT / "AGENTS.md"
#: The four settings phase 11 added. `tests/test_config.py` pins what they *mean*; these tests
#: pin that a human setting up a box is told they exist.
AUTH_VARS = (
"DASH_ADMIN_PASSWORD",
"DASH_SESSION_HOURS",
"DASH_COOKIE_SECURE",
"DASH_PBKDF2_ITERATIONS",
)
#: The public surface, as `app.py`'s `PUBLIC_PATHS`/`PUBLIC_PREFIXES` and AGENTS.md invariant 12
#: name it. A README that lists a different set is the doc drifting from the gate.
PUBLIC_ENTRIES = ("/login", "/api/auth/login", "/api/health", "/static/")
#: `.agents/validate.sh` 2b's placeholder allow-list, copied deliberately: the gate and the test
#: are two readers of one rule, and a test that invents a laxer list would pass while the gate
#: fails (or the reverse). The empty alternative is the "no secret here, unset it" spelling.
PLACEHOLDER = re.compile(
r"^(|changeme|change-me|replace-me[-a-z0-9]*|secret|placeholder|dummy|example|example\.net|x+)$",
re.IGNORECASE,
)
#: A secret-shaped *assignment* (never a comment: the pattern is anchored at `^` exactly like the
#: gate's, which is what makes prose about the local testing password safe to write).
SECRET_ASSIGNMENT = re.compile(
r"^[ \t]*(?:export[ \t]+)?([A-Za-z0-9_]*(?:PASSWORD|TOKEN|SECRET|KEY)[A-Za-z0-9_]*)=(.*)$"
)
#: The local-testing word (`tests/auth_contract.ADMIN_PASSWORD`). It is documentation, not a
#: credential — AGENTS.md invariant 11 states it in prose too — but it must never become the
#: *value* of a tracked line, which is the exact trap the phase task calls out.
LOCAL_TESTING_PASSWORD = "testpass"
TEMPLATES = (ENV_EXAMPLE, ENV_DEV_EXAMPLE)
def _assignments(path: Path) -> dict[str, str]:
"""Every secret-shaped assignment in a file, comment lines excluded by the anchor."""
out: dict[str, str] = {}
for line in path.read_text().splitlines():
match = SECRET_ASSIGNMENT.match(line)
if match is None:
continue
name, value = match.group(1), match.group(2)
# The gate's own value munging: strip a trailing comment, whitespace, and quotes.
value = value.split("#", 1)[0].replace('"', "").replace("'", "").strip()
out[name] = value
return out
# --- the two tracked env templates ---------------------------------------------------------
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
def test_every_auth_setting_is_declared_in_the_template(template: Path) -> None:
"""A setting that appears nowhere in the template is a setting nobody knows to set."""
text = template.read_text()
declared = {
name for name in AUTH_VARS if re.search(rf"^[ \t]*(?:export[ \t]+)?{name}=", text, re.MULTILINE)
}
assert declared == set(AUTH_VARS), f"{template.name} declares {sorted(declared)}, wants all of {AUTH_VARS}"
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
def test_no_secret_shaped_assignment_carries_anything_but_a_placeholder(template: Path) -> None:
"""The hermetic twin of `.agents/validate.sh` 2b, run on the working tree.
`testpass` is not on the allow-list and must not be: the fix for a template that needs a
dev password is a placeholder plus prose, with the real word in the gitignored file.
"""
offenders = {
name: value for name, value in _assignments(template).items() if not PLACEHOLDER.match(value)
}
assert not offenders, f"{template.name} carries a real-looking secret: {offenders}"
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
def test_the_local_testing_password_is_prose_and_never_an_assignment(template: Path) -> None:
"""The word is allowed in a comment (it is in AGENTS.md too) and forbidden as a value."""
text = template.read_text()
values = _assignments(template)
assert LOCAL_TESTING_PASSWORD not in values.values(), (
f"{template.name} assigns the local-testing password; it belongs in .env.dev"
)
commented = [ln for ln in text.splitlines() if ln.lstrip().startswith("#")]
if LOCAL_TESTING_PASSWORD in text:
assert any(LOCAL_TESTING_PASSWORD in ln for ln in commented), (
f"{template.name} names the local-testing password outside a comment"
)
def test_the_dev_template_points_at_where_the_local_password_lives() -> None:
"""A dev copying the template needs to know what to type at the first login prompt."""
text = ENV_DEV_EXAMPLE.read_text().lower()
assert LOCAL_TESTING_PASSWORD in text, "the dev template stopped naming the local-testing word"
assert "auth_contract" in text or "auth_client" in text, (
"the dev template stopped pointing at the file that defines that word"
)
# …and the seed-vs-override rule, which is the sentence that prevents the "I edited the env
# file and nothing happened" call, has to be in the file people actually edit.
assert "seed" in text
assert "refuses to start" in text
def test_the_tracked_templates_are_the_only_tracked_env_files() -> None:
"""`.gitignore` un-ignores exactly the two `.example` files; a third one is a leak waiting."""
tracked = sorted(p.name for p in ROOT.glob(".env*"))
allowed = {".env", ".env.dev", ".env.example", ".env.dev.example"}
assert set(tracked) <= allowed, f"unexpected env file in the checkout: {set(tracked) - allowed}"
for name in (".env.example", ".env.dev.example"):
assert (ROOT / name).is_file(), f"{name} must stay tracked — it is the setup documentation"
# --- scripts/dev-server.sh ----------------------------------------------------------------
def test_the_dev_server_probes_the_public_health_route() -> None:
"""`start`'s readiness probe and `status` both ride `/api/health`, the one open data route."""
text = DEV_SERVER.read_text()
assert text.count("/api/health") >= 2, "both start()'s probe and status() must use /api/health"
assert "/api/summary" not in text and "/api/config" not in text, (
"the dev script started polling a route that needs a session"
)
def test_the_dev_server_grew_no_auth_logic_and_no_password() -> None:
"""Invariant 12's corollary: the script needs no credential, so it must never hold one."""
text = DEV_SERVER.read_text()
for forbidden in ("dash_session", "/api/auth/", "X-CSRF-Token", "Authorization:"):
assert forbidden not in text, f"dev-server.sh grew auth ({forbidden!r})"
assert LOCAL_TESTING_PASSWORD not in text, "the local-testing password does not belong in a script"
# What it *does* say: where the credential comes from, without quoting it. Naming the
# variable is fine; an assignment is a value, and a value here is a committed password.
assert "DASH_ADMIN_PASSWORD" in text, "the script stopped telling the operator where to sign in with"
assert not re.search(r"DASH_ADMIN_PASSWORD\s*=", text), (
"dev-server.sh assigns the admin password; it may only name the variable"
)
def test_the_dev_server_still_parses() -> None:
"""`bash -n` is the only syntax gate a shell script here gets outside a real run."""
bash = shutil.which("bash") or "/bin/bash"
result = subprocess.run([bash, "-n", str(DEV_SERVER)], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
# --- README.md ----------------------------------------------------------------------------
def test_readme_has_a_signing_in_section_before_configuration() -> None:
"""The first thing an operator meets is a login prompt; the doc says so before the table."""
text = README.read_text()
signing = text.index("## Signing in")
configuration = text.index("## Configuration")
assert signing < configuration, "*Signing in* must come before *Configuration*"
assert "## Admin login" not in text, "the section was renamed; a stale heading is a broken anchor"
def test_readme_documents_the_public_surface_and_why_health_is_one_of_it() -> None:
text = README.read_text()
section = text[text.index("## Signing in"):text.index("## Configuration")]
for entry in PUBLIC_ENTRIES:
assert entry in section, f"*Signing in* stopped naming the public entry {entry}"
# The reason is the part an operator decides with, not the list.
assert "HEALTHCHECK" in section and "dev-server.sh" in section, (
"*Signing in* must say why /api/health stays open"
)
def test_readme_carries_the_reset_command_verbatim() -> None:
"""Copy-pasteable: someone locked out of a public dashboard reads this line and runs it."""
text = README.read_text()
assert "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')" in text, (
"README's password-reset command changed shape; make it match the RuntimeError in auth.py"
)
def test_readme_documents_every_auth_variable_and_the_precedence_rule() -> None:
text = README.read_text()
table = text[text.index("## Configuration"):text.index("## Architecture")]
for name in AUTH_VARS:
assert f"`{name}`" in table, f"the configuration table lost {name}"
# seed → stored verifier wins, and the bearer path for scripted callers
assert "seed" in table
signing = text[text.index("## Signing in"):text.index("## Configuration")]
assert "Authorization: Bearer" in signing, "the scripted-caller path is undocumented"
assert "30 days" in signing or "720" in signing, "the session lifetime is undocumented"
def test_readme_tells_the_deployment_repo_what_to_set() -> None:
"""Two lines belong in the quadlet env template; a note about nginx lives there, not here."""
text = README.read_text()
deployment = text[text.index("## Deployment"):]
assert "DASH_ADMIN_PASSWORD" in deployment
assert "DASH_COOKIE_SECURE=1" in deployment, "the deployment note stopped asking for Secure cookies"
assert "HSTS" in deployment and "deployment repo" in deployment, (
"README must say nginx config/HSTS live in the deployment repo like every other service"
)
# --- AGENTS.md ----------------------------------------------------------------------------
def test_agents_md_carries_the_login_invariants() -> None:
"""One rule for the credential, one for the gate, and the public list named in full."""
text = AGENTS.read_text()
rules = text[text.index("## Non-negotiable rules"):text.index("## Architecture")]
for entry in PUBLIC_ENTRIES:
assert entry in rules, f"the invariants stopped naming the public entry {entry}"
assert "seed" in rules and "refuses to start" in rules
assert "auth.verifier" in rules and "auth.session_secret" in rules
# /api/health being open is a decision, and the invariant has to say what that costs.
assert "nothing may be added to it without re-deciding" in rules
def test_agents_md_documents_the_session_and_the_pages_that_need_it() -> None:
text = AGENTS.read_text()
intro = text[:text.index("## Non-negotiable rules")]
assert "/settings" in intro and "/login" in intro, "the page list stopped naming the auth pages"
assert "invariant 12" in intro, "the page list must point at the gate rule, not restate it"
assert "- `auth.py` —" in text, "the module-responsibility list lost auth.py"
def test_agents_md_names_the_three_auth_gaps() -> None:
"""Peer-address lockout, single factor by design, per-process throttle: all three stay known."""
text = AGENTS.read_text()
gaps = text[text.index("## Known gaps"):text.index("## Locked decisions")]
assert "socket peer address" in gaps, "the lockout-is-global gap went missing"
assert "no second factor and no per-user accounts" in gaps, "the single-factor gap went missing"
assert "The throttle is per-process" in gaps, "the multi-worker gap went missing"
+74
View File
@@ -9,6 +9,7 @@ from typing import Any
import pytest import pytest
from dashboards import config from dashboards import config
from dashboards.auth import PBKDF2_ITERATIONS
from dashboards.config import Settings, parse_targets from dashboards.config import Settings, parse_targets
@@ -216,3 +217,76 @@ def test_the_example_file_is_never_loaded(monkeypatch: pytest.MonkeyPatch, tmp_p
reloaded = importlib.reload(config) reloaded = importlib.reload(config)
assert reloaded.load_env_file() is None assert reloaded.load_env_file() is None
assert reloaded.SETTINGS.nut_host == "" assert reloaded.SETTINGS.nut_host == ""
# ---- authentication settings (phase 11) ------------------------------------ #
def test_the_auth_defaults_are_a_locked_door_and_a_long_session() -> None:
"""No default password, a 30-day session, no `Secure` flag, OWASP's cost.
Each default is a decision, so each is asserted rather than inherited:
* `admin_password == ""` is what makes "no password configured" detectable at all — a
default of `testpass` would ship a known credential on every box that forgot the env.
* 720 h (30 days) because a dashboard that logs the operator out mid-outage gets bypassed.
* `cookie_secure` off: dev is `http://127.0.0.1`, and a default on would make the cookie
unsendable there (the task adds the `X-Forwarded-Proto` case for the real proxy).
* the cost is `auth.PBKDF2_ITERATIONS`, imported rather than repeated, so the primitive and
the setting cannot drift.
"""
settings = Settings()
assert settings.admin_password == ""
assert settings.session_hours == 720.0
assert settings.cookie_secure is False
assert settings.pbkdf2_iterations == PBKDF2_ITERATIONS == 600_000
def test_the_auth_settings_come_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DASH_ADMIN_PASSWORD", " spaced-but-real ")
monkeypatch.setenv("DASH_SESSION_HOURS", "12")
monkeypatch.setenv("DASH_COOKIE_SECURE", "1")
monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "4096")
reloaded = importlib.reload(config)
try:
assert reloaded.SETTINGS.admin_password == "spaced-but-real", "_env() strips"
assert reloaded.SETTINGS.session_hours == 12.0
assert reloaded.SETTINGS.cookie_secure is True
assert reloaded.SETTINGS.pbkdf2_iterations == 4096
finally:
for name in ("DASH_ADMIN_PASSWORD", "DASH_SESSION_HOURS", "DASH_COOKIE_SECURE",
"DASH_PBKDF2_ITERATIONS"):
monkeypatch.delenv(name)
importlib.reload(config)
def test_an_empty_DASH_ADMIN_PASSWORD_means_unset_not_blank(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`DASH_ADMIN_PASSWORD=` is the operator saying "no seed", not "the password is empty".
The distinction is load-bearing: `load_auth_state` treats `""` as no seed and fails closed,
where a value of `""` that survived as a password would be the one credential shape an
attacker tries first.
"""
monkeypatch.setenv("DASH_ADMIN_PASSWORD", "")
reloaded = importlib.reload(config)
try:
assert reloaded.SETTINGS.admin_password == ""
finally:
monkeypatch.delenv("DASH_ADMIN_PASSWORD")
importlib.reload(config)
def test_the_pbkdf2_knob_is_for_tests_and_not_a_performance_tune(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A garbage value falls back to the OWASP figure rather than to 0 or 1.
`_int`/`_num` answer a non-number with the default, so a typo in
`DASH_PBKDF2_ITERATIONS` cannot produce a verifier hashed once.
"""
monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "not-a-cost")
assert config._int("DASH_PBKDF2_ITERATIONS", 600_000) == 600_000
monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "1000")
assert config._int("DASH_PBKDF2_ITERATIONS", 600_000) == 1000
+8 -1
View File
@@ -21,6 +21,8 @@ from typing import Any
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from auth_client import sign_in
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
from dashboards.app import create_app from dashboards.app import create_app
from dashboards.config import Settings from dashboards.config import Settings
from dashboards.store import SampleStore from dashboards.store import SampleStore
@@ -46,10 +48,15 @@ def client(tmp_path: Path) -> Iterator[TestClient]:
"""A running app, so the deletion assertions run on the bytes a browser would get.""" """A running app, so the deletion assertions run on the bytes a browser would get."""
db = str(tmp_path / "slots-contract.sqlite3") db = str(tmp_path / "slots-contract.sqlite3")
store = SampleStore(db) store = SampleStore(db)
app = create_app(Settings(db_path=db), store) app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2), store)
test_client = TestClient(app) test_client = TestClient(app)
test_client.__enter__() test_client.__enter__()
try: try:
# Signed in because this fixture reads `/llm` as served bytes, and `TestClient` follows
# the 303 to /login: an unsigned client would check the deleted slot table against the
# *login page* and pass without ever looking at /llm.
sign_in(test_client)
yield test_client yield test_client
finally: finally:
test_client.__exit__(None, None, None) test_client.__exit__(None, None, None)
+9 -1
View File
@@ -13,6 +13,8 @@ import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from auth_client import sign_in
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
from dashboards import __main__ as entry from dashboards import __main__ as entry
from dashboards import debug as debug_module from dashboards import debug as debug_module
from dashboards.config import Settings from dashboards.config import Settings
@@ -39,7 +41,8 @@ def test_main_serves_the_assembled_app(fake_uvicorn: FakeUvicorn, tmp_path: Any,
# Settings is frozen on purpose, so the module attribute is replaced, not poked. The DB # Settings is frozen on purpose, so the module attribute is replaced, not poked. The DB
# path is private: a test must not open the developer's running database. # path is private: a test must not open the developer's running database.
monkeypatch.setattr(entry, "SETTINGS", monkeypatch.setattr(entry, "SETTINGS",
Settings(host="0.0.0.0", port=8090, db_path=str(tmp_path / "main.sqlite3"))) Settings(host="0.0.0.0", port=8090, db_path=str(tmp_path / "main.sqlite3"),
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2))
monkeypatch.delenv("DASH_RELOAD", raising=False) monkeypatch.delenv("DASH_RELOAD", raising=False)
entry.main() entry.main()
(target, kwargs), = fake_uvicorn.calls (target, kwargs), = fake_uvicorn.calls
@@ -47,7 +50,12 @@ def test_main_serves_the_assembled_app(fake_uvicorn: FakeUvicorn, tmp_path: Any,
# FastAPI keeps included routers in a private wrapper, so the honest check is a request # FastAPI keeps included routers in a private wrapper, so the honest check is a request
assert isinstance(target, FastAPI) assert isinstance(target, FastAPI)
with TestClient(target) as client: with TestClient(target) as client:
# `/api/health` is public and must stay so — the Containerfile HEALTHCHECK has no
# session and no way to get one. `/ups` is not: signing in here is what keeps this
# test about startup wiring instead of about which routes are gated (that is
# tests/test_auth_api.py's job).
assert client.get("/api/health").status_code == 200 assert client.get("/api/health").status_code == 200
sign_in(client)
assert client.get("/ups").status_code == 200 assert client.get("/ups").status_code == 200
+74
View File
@@ -326,3 +326,77 @@ def test_a_300s_source_is_pruned_by_the_existing_retention(db) -> None:
assert counts["pruned"] >= 4 assert counts["pruned"] >= 4
assert db.query("disks", "h", "disk.temp_c@sda", start=0) == [] assert db.query("disks", "h", "disk.temp_c@sda", start=0) == []
assert len(db.query("disks", "h", "disk.temp_c@sdb", start=0)) == 1 assert len(db.query("disks", "h", "disk.temp_c@sdb", start=0)) == 1
# ---- meta: the durable key/value state (phase 11) --------------------------- #
def test_get_meta_answers_none_for_a_key_nobody_wrote(db) -> None:
"""`None` and `""` are different sentences here.
`auth.verifier` missing means "this box has never been seeded" (fail closed at startup),
while a value means "the credential lives here". A store that returned `""` for an absent
row would read as a corrupt one instead.
"""
assert db.get_meta("no.such.key") is None
db.set_meta("empty.on.purpose", "")
assert db.get_meta("empty.on.purpose") == ""
def test_meta_round_trips_through_a_reopen(db) -> None:
db.set_meta("auth.session_secret", "ab" * 32)
assert db.get_meta("auth.session_secret") == "ab" * 32
db.close()
reopened = SampleStore(db.path)
try:
assert reopened.get_meta("auth.session_secret") == "ab" * 32
finally:
reopened.close()
def test_set_meta_replaces_instead_of_adding(db) -> None:
"""One row per key (`INSERT OR REPLACE`), because `key` is the primary key.
An `INSERT` alone would raise on the second write, which is the change-password path
(`auth.update_verifier`) failing after the user pressed Save.
"""
db.set_meta("auth.verifier", "first")
db.set_meta("auth.verifier", "second")
assert db.get_meta("auth.verifier") == "second"
(rows,) = db._db.execute( # the private handle is the point: exactly one row, not two
"SELECT COUNT(*) FROM meta WHERE key='auth.verifier'").fetchone()
assert rows == 1
def test_delete_meta_reports_whether_a_row_was_there(db) -> None:
"""The rowcount is the whole point: the password-reset procedure has to say what it did."""
db.set_meta("auth.verifier", "x")
assert db.delete_meta("auth.verifier") == 1
assert db.delete_meta("auth.verifier") == 0
assert db.get_meta("auth.verifier") is None
def test_delete_meta_touches_only_the_key_it_names(db) -> None:
"""A reset that deletes one row too many deletes the operator's only credential."""
db.set_meta("auth.verifier", "the-verifier")
db.set_meta("auth.session_secret", "the-secret")
db.set_meta("last_maintain", "1700000000.0")
assert db.delete_meta("auth.session_secret") == 1
assert db.get_meta("auth.session_secret") is None
assert db.get_meta("auth.verifier") == "the-verifier"
assert db.get_meta("last_maintain") == "1700000000.0"
def test_stats_reads_last_maintain_through_get_meta(db) -> None:
"""One path to the table.
`stats()` reported `None` before any maintenance ran and the timestamp afterwards; that
behaviour is pinned here while the read goes through the same helper everything else uses,
and it is why the helper is called outside `stats()`'s own lock (`threading.Lock` is not
reentrant — call it inside and the dashboard deadlocks on the first `/api/health`).
"""
assert db.stats()["last_maintain"] is None
db.maintain(now=1_700_000_000.0)
assert db.stats()["last_maintain"] == 1_700_000_000.0
+9 -2
View File
@@ -36,6 +36,8 @@ from typing import Any
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from auth_client import sign_in
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
from card_contract import GPU_METRICS, HOST_SHAPES, assert_card_contract, dig from card_contract import GPU_METRICS, HOST_SHAPES, assert_card_contract, dig
from dashboards.app import create_app from dashboards.app import create_app
from dashboards.config import Settings, Target from dashboards.config import Settings, Target
@@ -91,11 +93,16 @@ def api_client(tmp_path: Any, agent_snapshot: dict[str, Any],
"""A running app serving one host payload, so the contract is checked over HTTP too.""" """A running app serving one host payload, so the contract is checked over HTTP too."""
db = str(tmp_path / "contract.sqlite3") db = str(tmp_path / "contract.sqlite3")
store = SampleStore(db) store = SampleStore(db)
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL),)), app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
store) pbkdf2_iterations=FAST_PBKDF2,
host_targets=(Target("driveripper", DEAD_URL),)), store)
client = TestClient(app) client = TestClient(app)
client.__enter__() client.__enter__()
try: try:
# phase 11: the snapshot is behind the session, and `TestClient` follows the 303 to
# /login — a contract checked against the login page would pass on every path check
# below it, which is worse than the test failing.
sign_in(client)
# the job's port refuses, so a poll can never overwrite what is injected here # the job's port refuses, so a poll can never overwrite what is injected here
app.state.ctx.hub.jobs["host:driveripper"].state.payload = host_scrape( app.state.ctx.hub.jobs["host:driveripper"].state.payload = host_scrape(
agent_snapshot, cpu_busy=10.0) agent_snapshot, cpu_busy=10.0)
+3 -1
View File
@@ -46,6 +46,7 @@ from typing import Any
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
from dashboards.app import create_app from dashboards.app import create_app
from dashboards.config import Settings from dashboards.config import Settings
from dashboards.severity import classify from dashboards.severity import classify
@@ -87,7 +88,8 @@ def client(tmp_path: Path) -> Iterator[TestClient]:
""" """
db = str(tmp_path / "ups-contract.sqlite3") db = str(tmp_path / "ups-contract.sqlite3")
store = SampleStore(db) store = SampleStore(db)
app = create_app(Settings(db_path=db), store) app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2), store)
test_client = TestClient(app) test_client = TestClient(app)
test_client.__enter__() test_client.__enter__()
try: try: