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`.
This commit is contained in:
2026-10-19 07:45:44 -04:00
parent eb4788cba1
commit 1734d81e48
75 changed files with 6941 additions and 121 deletions
@@ -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.
# export NUT_SIMULATE=1
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=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
# 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
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
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
command, and a UI confirmation — `DANGER` for anything that can cut power) and the server
re-checks every gate on every request. Hiding a button is not a safety control.
Commands that would cut output are refused outright while the UPS is already on battery.
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
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
@@ -75,7 +83,14 @@ page still renders.
`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
`/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
`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
@@ -91,6 +106,49 @@ page still renders.
10. **The dev server stays up while work happens** (`./scripts/dev-server.sh restart`,
http://127.0.0.1:8090), and UI changes are verified in a real browser with a screenshot
before they are called done. `data/logs/*.png` is where those land.
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
@@ -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
expiry instead of hammering the disk.
- `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.
`/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
@@ -208,6 +273,44 @@ Responsibilities, one line each:
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).
- `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,
`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
@@ -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
`/disks`, so the one alert a disk job *can* raise — SMART failing while its host answers —
links to the page that names the drive.
- `store.py` — `sample(source,host,metric,res,ts)` + `event` + `meta`, rollups, pruning.
- `api.py` — GETs everywhere plus the one POST. `/api/config` is redaction boundary.
- `store.py` — `sample(source,host,metric,res,ts)` + `event` + `meta`, rollups, pruning. The
`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
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
@@ -275,10 +415,61 @@ Responsibilities, one line each:
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`
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
with an in-memory DB and no targets.
- `web/` — five static pages (`/`, `/system`, `/disks`, `/llm`, `/ups`), one stylesheet,
`charts.js`. No framework. `app.js`'s masthead status dots are one per dashboard job —
- `app.py` — assembly, and the **session gate** (`session_gate` middleware, invariant 12) whose
`PUBLIC_PATHS`/`PUBLIC_PREFIXES` are the whole anonymous surface. It answers in this order and
the order is the design: no `AuthState` → `503` for everything; no session and no such route →
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
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
@@ -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
addresses the deployed dashboard does, and `scripts/capture-fixtures.sh` reads
`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
`gateway.reeselink.com` resolves to, and answers `/health` + `/snapshot` **on the host**
(re-verified 2026-10-18, and the token in `.env.dev` matches `/etc/dashboards-agent.token`),
but the laptop cannot reach its `:9120` — and that is now proven to be nobody's firewall here:
opening `9120/tcp` in that host's own firewalld (a runtime rule, added and reverted in the
phase-10 final pass; runtime and permanent port lists are back to their original set) changed
nothing, because the SYN never arrives — `ss -tan sport = :9120` on the host shows only the
`LISTEN` — while the same laptop reaches `:22` and `:80` on the same address in milliseconds.
The drop is the port-selective inter-VLAN filter on the lab gateway `10.2.0.1`, the same one
that eats 3051/3493. On `driveripper` and `deskwork` the ad-hoc `~/lab-agent/host_agent.py` agents
are gone (stopped with the old tunnel sessions, nothing replaced them, `:9120` is empty on
both) because their `deploy/agent-sudoers` grant is not installed yet, so the installer
cannot run there. Every `system`/`disks` source is therefore `DOWN` with its real reason
and renders as an `unreachable` stub row — the designed degradation, recorded rather than
hidden — which is also why 6 of the `live` tests fail on transport while both engines pass.
The per-host record, the exact root commands and the one firewall line each host still
needs are in `data/logs/NOTES-agent-install.md`, how far the NUT wire reaches from each
(`tests/test_deploy_scripts.py` fails the day any tunnel name or port returns).
**Fleet transport, re-verified from the laptop on 2026-10-19.** The port-selective inter-VLAN
drop phase 10 recorded on the lab gateway `10.2.0.1` is **gone**: `gateway` (`10.1.1.27:9120`)
and `deskwork` (`10.1.0.51:9120`) both take a plain TCP connect and answer `/snapshot`, and
`driveripper`'s upsd answers `LIST UPS` on `10.1.0.126:3051` from this same laptop. Two hosts
run the **installed** service — `systemctl is-active dashboards-agent` is `active` on both,
with `/usr/local/lib/dashboards-agent/host_agent.py` and a `--token-file` unit bound to the
address the dashboard dials. `driveripper` is the one host with no agent at all (unit
`inactive`, no installed file, nothing listening on `:9120` → `ConnectionRefused`), and it
renders as an `unreachable` stub row: the designed degradation, recorded rather than hidden.
`/api/health` today reads `host:deskwork ok`, `host:gateway ok`, `disks:deskwork ok`,
`disks:gateway ok`, `nut ok`, `llm:gufo ok`, `llm:vllm ok` with the two `driveripper` jobs
`DOWN`, and the `live` suite is 8 passed / 3 failed with every failure that one transport.
Phase 10's proof that the old drop was not this repo's firewall stands as history in
`data/logs/NOTES-verify.md` — do not re-add a tunnel on the strength of it, and re-run the
probe rather than trusting either note.
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
(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
`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`.
- 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
@@ -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` +
`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
range `10.1.0.0/21` does reach it today (verified from `deskwork`); the laptop's
`10.2.0.0/22` does **not** — the lab gateway `10.2.0.1` forwards 22/1234/8000-8004 and
silently drops 3051/3493 in both families, which no sudo on a fleet host can change, so the
laptop cannot yet dial `NUT_PORT=3051` (probe evidence and the operator's one rule:
`data/logs/NOTES-upsd-acl.md`).
Two UPSes are exported over USB and dialled directly as `NUT_HOST` + `NUT_PORT=3051` (from the
laptop that is still the blocked path above; from the host itself, and from the deployed
dashboard, it reads live):
range `10.1.0.0/21` reaches it, and **since 2026-10-19 so does the laptop**: a raw
`LIST UPS` to `10.1.0.126:3051` from the dev box answers `BEGIN LIST UPS`, which is why
`nut` reads `ok` in `./scripts/dev-server.sh status` with two real UPSes and no
`NUT_SIMULATE`. Phase 10 recorded `10.2.0.1` dropping 3051/3493 for this laptop; that is
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`, and that
reads live from every vantage the dashboard has ever used — the host itself, the deployed
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
`ups.description`, the operator's label — the driver's own `identity.model` answers
`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` +
`systemctl restart|reload nut-server` pair documented in `deploy/nut-sudoers`); `deskwork`
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
— and the `firewall-cmd` line each still needs — stay with the operator.
- **SMART is captured from real hardware; the live agents still cannot read it.** `smartctl`
7.5 is installed on deskwork and driveripper. The `ops` account (which runs the dev agents)
is in no `disk` group; driveripper grants it `(root) NOPASSWD: /usr/sbin/smartctl*` and
deskwork grants nothing, so the agent's own unprivileged `smartctl -i -A -H` — it shells the
binary and never calls sudo — answers *Permission denied* (exit 2) on every host and `smart`
stays `null` end to end: the designed degradation, not a bug. The agents deployed on
driveripper and deskwork also predate the SMART pass, so the `disks` source today yields one row
per physical disk (16 on driveripper, 1 on deskwork, `vda` on gateway) with `smart: null`,
`smart_errors: []` and `smartctl_available: null` — "nobody told us either way", which is why
that flag is three-way and not a boolean. `gateway`'s agent is the one exception as of phase 10:
it is the installed systemd service, which does know about the SMART pass and answers
`smartctl_available: false` there because smartmontools is not installed — the *install it*
half of the three-way flag, from a host the dashboard cannot yet reach (its firewall).
Neither host grants `ops` the installer or `visudo` (re-checked 2026-10-19), so every install
that has happened on those two went through root by the operator — including the one still
outstanding, `driveripper`'s.
- **SMART: real captures, and one live host finally reads them.** `smartctl` 7.5 is installed on
deskwork and driveripper. The `ops` account (which is who runs a hand-started dev agent) is in
no `disk` group, driveripper grants it `(root) NOPASSWD: /usr/sbin/smartctl*` and deskwork
grants nothing, and `host_agent.py` shells the binary **as itself** — it never calls sudo — so
an agent run as `ops` still answers *Permission denied* (exit 2) and reports `smart: null`.
What changed by 2026-10-19 is that `deskwork`'s **installed** service runs as
`dashboards-agent`, which is in the `disk` group (`getent group disk` → `disk:dashboards-agent`)
on an agent installed after the SMART pass: `/disks` shows real numbers there —
`smartctl_available: true`, `nvme0n1` healthy at 52 °C, 19 158 power-on hours, 11 % of rated
wear — so the wear bar and the alarm chips are drawn from live hardware, not only fixtures.
`gateway` answers `smartctl_available: false` (no smartmontools in its VM: the *install it*
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
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
@@ -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
`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.
**Lighting up the live page is two operator actions that travel together:** give the agent
user device read access — the `disk` group (`sudo usermod -aG disk <agent-user>`, which is
what `deploy/install-agent.sh` does on install), *not* a sudo rule, because the agent must
read the device itself — and redeploy `agent/host_agent.py` (never redeploy without the
grant: 16 permission-denied lines would age nothing but are pure noise, while with the grant
they are 16 drives' worth of temperature, hours and sector counters). Until then `/disks`
**Lighting up the rest of it is the same two operator actions that travel together:** install
the agent on `driveripper`, and give its agent user device read access — the `disk` group
(`sudo usermod -aG disk <agent-user>`, which is what `deploy/install-agent.sh` does on install),
*not* a sudo rule, because the agent must read the device itself — on an `agent/host_agent.py`
from phase 09 or later (never redeploy without the grant: 16 permission-denied lines would age
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
the one-line why — which is also what gateway shows permanently, being a VM with no physical
disk to read and no smartmontools at all.
the one-line why.
`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
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
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
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 status # per-source ok/DOWN
./scripts/dev-server.sh restart # keep http://127.0.0.1:8090 up; sign in with the
# DASH_ADMIN_PASSWORD from .env.dev (`testpass`)
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
./scripts/dev-server.sh logs
uv run pytest # hermetic: 793 passed + 11 live-skipped, no network
uv run pytest --cov # coverage gate (98.33%; floor 94%)
uv run pytest # hermetic: 1140 passed + 11 live-skipped, no network
uv run pytest --cov # coverage gate (98.6%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
uv run ruff check src tests agent && uv run pyright
./scripts/capture-fixtures.sh # re-record ground truth (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.
- 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`.
- `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
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,
@@ -878,16 +1117,65 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
field map is written.
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.
4. **The dev DB is big enough to refuse a write now and then.** The dev store (~610 MB, WAL
grown past 140 MB) logs `store-error: OperationalError: database is locked` a few times an
4. **The dev DB is big enough to refuse a write now and then.** The dev store (~640 MB on
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 —
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
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
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
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)
+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
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
construction, never invent a number, unconfigured ≠ broken), the locked decisions, the real
fleet facts, and the known gaps.
@@ -21,12 +25,19 @@ fleet facts, and the known gaps.
```bash
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 uvicorn dashboards.app:app --port 8090 # equivalent
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
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
```
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
(`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
@@ -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
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
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) |
| `DASH_READ_ONLY` | `1` disables every mutating endpoint regardless of other flags |
| `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 |
| `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 |
| `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
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
```
@@ -151,7 +368,9 @@ src/dashboards/collectors.py ─────────────────
sources/host.py · disks.py · llm.py · nut.py poller.py (one task per source)
│ 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
@@ -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
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
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
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
@@ -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
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
`/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
@@ -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
`/var/lib/dashboards` — put a volume there or every restart forgets the charts. 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.
`/var/lib/dashboards` — put a volume there or every restart forgets the charts **and** the admin
password, because the verifier and the session key are rows in that database. `dashboards.env`
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.
+3
View File
@@ -28,8 +28,11 @@ start() {
nohup .venv/bin/dashboards >"$LOG" 2>&1 &
echo $! >"$PIDFILE"
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
echo "started pid $(cat "$PIDFILE") — http://127.0.0.1:${PORT}"
echo " sign in with DASH_ADMIN_PASSWORD from .env.dev"
else
echo "FAILED to start; last lines of $LOG:"
tail -20 "$LOG"
+6 -2
View File
@@ -20,8 +20,12 @@ def main() -> None:
if reload:
uvicorn.run("dashboards.app:app", host=SETTINGS.host, port=SETTINGS.port, reload=True)
else:
# pass the object: create_app() is cheap and avoids a second import of config
uvicorn.run(create_app(), host=SETTINGS.host, port=SETTINGS.port)
# pass the object: create_app() is cheap and avoids a second import of config.
# `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__":
+361 -2
View File
@@ -13,13 +13,18 @@ are worth restating here because they are easy to break by accident:
from __future__ import annotations
import logging
import math
import time
from dataclasses import dataclass
from dataclasses import dataclass, field, replace
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from . import alerts as alert_rules
from . import auth
from .auth import ADMIN_USER, LOGIN_PATH, AuthState, LoginThrottle, Session
from .collectors import NutMonitor
from .config import Settings, strip_userinfo
from .poller import Hub, SourceState
@@ -33,6 +38,28 @@ from .sources.llm import fleet_totals
from .store import Event, SampleStore
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,
"6h": 21_600, "12h": 43_200, "24h": 86_400, "7d": 604_800,
"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
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
store: SampleStore
hub: Hub
nut_monitor: NutMonitor | None = None
started_at: float = 0.0
auth: AuthState | None = None
login_throttle: LoginThrottle = field(default_factory=LoginThrottle)
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
# --------------------------------------------------------------------------- #
+241 -4
View File
@@ -2,6 +2,12 @@
`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.
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
@@ -12,14 +18,28 @@ from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from urllib.parse import quote
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
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 (
NutMonitor,
UpstreamError,
@@ -38,10 +58,36 @@ from .sources.nut_sim import from_env as simulator_from_env
from .store import SampleStore
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",
"/llm": "llm.html", "/ups": "ups.html"}
"/llm": "llm.html", "/ups": "ups.html", "/login": "login.html",
"/settings": "settings.html"}
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,
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
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:
"""`store` is for tests: when supplied it is not created and not closed here."""
settings = settings or DEFAULT_SETTINGS
@@ -111,6 +274,13 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
start = hub.start
else:
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.hub = ctx.hub
start()
@@ -130,6 +300,7 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
for path, filename in PAGES.items():
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(system_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,
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")
async def no_store_for_api(request: Request, call_next):
"""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 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]
@@ -150,6 +154,26 @@ class Settings:
db_path: str = _env("DASH_DB", str(ROOT / "data" / "dashboards.sqlite3"))
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 ---------------------------------------------------------
host_targets: tuple[Target, ...] = ()
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,
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
them in `anyio.to_thread.run_sync`, sync FastAPI handlers run in the threadpool already.
"""
@@ -267,6 +272,37 @@ class SampleStore:
with self._lock:
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 ----------------------------------------------------------- #
def maintain(self, now: float | None = None) -> dict[str, int]:
"""Roll up and prune. Safe to call every minute; each step is idempotent.
@@ -303,11 +339,13 @@ class SampleStore:
return counts
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:
by_res = {row[0]: row[1] for row in self._db.execute(
"SELECT res, COUNT(*) FROM sample GROUP BY res")}
(events,) = self._db.execute("SELECT COUNT(*) FROM event").fetchone()
last = self._db.execute("SELECT value FROM meta WHERE key='last_maintain'").fetchone()
size = 0
if str(self.path) != ":memory:" and self.path.exists():
size = self.path.stat().st_size
@@ -317,7 +355,7 @@ class SampleStore:
"samples_by_res": by_res,
"samples_total": sum(by_res.values()),
"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:
+1
View File
@@ -21,6 +21,7 @@
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<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>
</div>
</header>
+1
View File
@@ -20,6 +20,7 @@
<div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div>
<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>
</div>
</header>
+1
View File
@@ -21,6 +21,7 @@
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<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>
</div>
</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>
<span class="clock" id="clock">--:--:--</span>
<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>
</div>
</header>
+1
View File
@@ -21,6 +21,7 @@
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<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>
</div>
</header>
+145 -3
View File
@@ -426,11 +426,146 @@ const Dash = (() => {
}
// ---- 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) {
const response = await fetch(url, Object.assign({ headers: { accept: 'application/json' } },
options || {}));
const opts = Object.assign({}, 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;
try { body = await response.json(); } catch (err) { body = null; }
if (response.status === 401) bounceToLogin();
if (!response.ok) {
const detail = body && 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);
error.status = response.status;
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;
}
return body;
@@ -638,7 +777,10 @@ const Dash = (() => {
return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt,
setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause,
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());
+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); }
@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;
border: 1px solid var(--border);
color: var(--text-muted);
@@ -157,8 +160,12 @@ button.ghost {
display: inline-flex;
align-items: center;
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); }
/* 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). */
@@ -812,6 +819,48 @@ input[type="text"].confirm {
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 {
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; }
/* Only the UPS confirmation dialog takes text now — `/system`'s host/metric pickers were the
last `select` on the app and left with the shared charts, along with the `label.field` caption
that sat above them. */
input[type="text"] {
/* One rule for the app's two text fields — `/ups`'s `DANGER` box and `/login`'s password —
* which are the same control, and a second near-copy is how one of them stops matching the
* theme. `/system`'s host/metric pickers were the last `select` on the app and left with the
* 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);
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.testclient import TestClient
from dashboards import alerts
from dashboards.api import RANGE_SECONDS, parse_range
from auth_client import sign_in
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.collectors import UpstreamError
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.
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
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:
monkeypatch.delenv("NUT_SIMULATE", raising=False)
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)
client = TestClient(create_app(settings, store))
client.__enter__()
try:
sign_in(client)
yield client
finally:
client.__exit__(None, None, None)
@@ -128,10 +140,12 @@ def data_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
db = str(tmp_path / "summary.sqlite3")
store = SampleStore(db)
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)
test_client = TestClient(app)
test_client.__enter__()
try:
sign_in(test_client)
app.state.ctx.hub.jobs["host:driveripper"].state.payload = failing_host_payload(
host_scrape, agent_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")
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")
async def boom() -> None:
raise UpstreamError("driveripper: http://127.0.0.1:9120/snapshot timed out", status=504)
with TestClient(app) as test_client:
sign_in(test_client)
response = test_client.get("/api/_boom")
assert response.status_code == 504, "a timeout stays a timeout, not a 500"
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"})]
db = str(tmp_path / "tokens.sqlite3")
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:
sign_in(client)
body = client.get("/api/config")
assert "SEKRIT-TOKEN" not in body.text
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)
settings = Settings(
db_path=db,
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2,
host_interval=0.05,
host_targets=(Target(name="driveripper", url="http://SEKRIT-AGENT-TOKEN@127.0.0.1:1"),),
)
with TestClient(create_app(settings, store)) as client:
sign_in(client)
deadline = time.time() + 10
while time.time() < deadline:
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).
"""
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")
store = SampleStore(db)
with TestClient(create_app(settings, store)) as client:
sign_in(client)
body = client.get("/api/config")
assert "SEKRIT-ENGINE-TOKEN" not in body.text
llm = body.json()["llm"]
@@ -567,6 +591,60 @@ def test_target_problems_are_surfaced_not_swallowed(client) -> None:
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 ---------------------------------------------------------------- #
@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:
"""No CDN, no webfonts: this dashboard has to work with no route to the internet."""
for path in PAGES:
"""No CDN, no webfonts: this dashboard has to work with no route to the internet.
`/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
for fragment in ("//cdn.", "https://fonts.", "//unpkg.com", "//cdnjs"):
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
# ---- /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 --------------------------- #
#
# 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",
"/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:
@@ -702,7 +933,7 @@ def _fleet_field_metrics(js: str) -> list[str]:
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."""
for asset in (*ASSETS, *PAGES):
for asset in (*ASSETS, *MARKUP_PAGES):
body = _code(client.get(asset).text)
assert ".sev-" not in body, f"{asset} invented a .sev-* class"
assert not re.search(r"classList\.(?:add|toggle)\(\s*[\"'](?:danger|good|warning)[\"']",
@@ -2404,7 +2635,16 @@ PAGE_SCRIPTS = {
"/disks": "page-disks.js",
"/llm": "page-llm.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")
@@ -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
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
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`,
`#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)
for cls in ("sub", "faint", "mono"):
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()
for i in range(30):
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:
sign_in(client)
body = client.get("/api/history", params={"source": "host", "host": "driveripper",
"metric": "cpu.usage_pct", "range": "15m"})
assert response_ok(body)
@@ -2743,9 +2986,12 @@ def disks_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
"""
db = str(tmp_path / "disks.sqlite3")
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)
with TestClient(app) as test_client:
sign_in(test_client)
now = time.time()
snapshot = disk_snapshot("driveripper")
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(
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)
disks = [route for route in routes
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"
writes = sorted(str(route.path) for route in routes
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(
@@ -3239,9 +3504,11 @@ def test_summary_names_the_drive_the_health_verdict_called_failed(
"""
db = str(tmp_path / "summary-failed.sqlite3")
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)
with TestClient(app) as test_client:
sign_in(test_client)
now = time.time()
snapshot = disk_snapshot("driveripper", mode="failed")
snapshot["ts"] = now
@@ -3261,8 +3528,10 @@ def test_events_are_returned_in_json(tmp_path: Path, monkeypatch: pytest.MonkeyP
store = SampleStore(db)
store.log_event(Event(ts=time.time(), source="host", host="deskwork", kind="down",
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:
sign_in(client)
events = client.get("/api/events").json()["events"]
assert events[0]["host"] == "deskwork"
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.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)])
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:
sign_in(client)
metrics = client.get("/api/metrics", params={"source": "llm"}).json()["metrics"]
assert "llm.decode_tokens_per_second" in metrics
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
from dashboards import config
from dashboards.auth import PBKDF2_ITERATIONS
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)
assert reloaded.load_env_file() is None
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
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.config import Settings
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."""
db = str(tmp_path / "slots-contract.sqlite3")
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.__enter__()
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
finally:
test_client.__exit__(None, None, None)
+9 -1
View File
@@ -13,6 +13,8 @@ import pytest
from fastapi import FastAPI
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 debug as debug_module
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
# path is private: a test must not open the developer's running database.
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)
entry.main()
(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
assert isinstance(target, FastAPI)
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
sign_in(client)
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 db.query("disks", "h", "disk.temp_c@sda", start=0) == []
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
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 dashboards.app import create_app
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."""
db = str(tmp_path / "contract.sqlite3")
store = SampleStore(db)
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)
client = TestClient(app)
client.__enter__()
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
app.state.ctx.hub.jobs["host:driveripper"].state.payload = host_scrape(
agent_snapshot, cpu_busy=10.0)
+3 -1
View File
@@ -46,6 +46,7 @@ from typing import Any
import pytest
from fastapi.testclient import TestClient
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
from dashboards.app import create_app
from dashboards.config import Settings
from dashboards.severity import classify
@@ -87,7 +88,8 @@ def client(tmp_path: Path) -> Iterator[TestClient]:
"""
db = str(tmp_path / "ups-contract.sqlite3")
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.__enter__()
try: