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:
@@ -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 @@
|
||||
ok
|
||||
@@ -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 @@
|
||||
ok
|
||||
@@ -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`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+55
@@ -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 ==
|
||||
+19
@@ -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`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+61
@@ -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 @@
|
||||
ok
|
||||
@@ -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 @@
|
||||
ok
|
||||
@@ -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 @@
|
||||
ok
|
||||
@@ -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
|
||||
+12
@@ -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).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+61
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}());
|
||||
@@ -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();
|
||||
}());
|
||||
@@ -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
|
||||
@@ -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}
|
||||
+291
-20
@@ -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),
|
||||
Target("deskwork", DEAD_URL))), store)
|
||||
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",
|
||||
|
||||
@@ -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
@@ -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()}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user