diff --git a/.agents/phases/todo/11_admin_login/00_phase.md b/.agents/phases/todo/11_admin_login/00_phase.md new file mode 100644 index 0000000..b522472 --- /dev/null +++ b/.agents/phases/todo/11_admin_login/00_phase.md @@ -0,0 +1,110 @@ +# Phase 11 — One admin, one password: the dashboards stop being public + +**Source:** operator request in chat, 2026-10-17 — "password protect this dashboard for a single admin user… changeable once logged in, configured initially with an env var, not required to change… for local testing the default admin password should be `testpass`." +**Story:** n/a +**Context:** `src/dashboards/app.py` (route table, middleware, `PAGES`), `src/dashboards/api.py` (`AppContext`, `/api/config`, the one `POST /api/ups/command`), `src/dashboards/config.py` (env-only settings), `src/dashboards/store.py` (the existing `meta` table), `src/dashboards/web/static/app.js` (`fetchJSON`, `initChrome`), `.env.example` / `.env.dev.example`, `README.md` → *Configuration*, `AGENTS.md` → *Non-negotiable rules* + +## Objective + +Every page and every `/api` route except a small, explicit public list requires a signed-in +admin session; the admin password is seeded from `DASH_ADMIN_PASSWORD` on first start, is +changeable from a new `/settings` page, and does **not** have to be changed. The server refuses +to start when it has neither a stored password nor the env var — there is no way to run this +thing open by accident. + +## Derived scope (from the operator, in chat) + +- **Fail closed.** `DASH_ADMIN_PASSWORD` unset **and** no stored verifier → the process exits + with an operator-readable reason. `testpass` is the local-testing password (dev config + + the hermetic suite), never a production fallback. +- **`/api/health` stays public** on purpose — the Containerfile `HEALTHCHECK` and + `./scripts/dev-server.sh status` depend on it. Its shape must not change, and nothing new + may be added to it without treating it as a public endpoint. +- **Most secure, least likely to break** was the instruction for sessions/CSRF → stdlib + `hashlib`/`hmac` only: PBKDF2-HMAC-SHA256 verifier, HMAC-signed stateless session cookie, + a session-bound CSRF token on every write, and a default-deny gate. **No new dependency**, + so no `PLAN.md` anchor moves (`itsdangerous` + Starlette `SessionMiddleware` was the + alternative the operator declined by asking for "least likely to break"). + +## Dependencies + +- `01_severity_language` … `05_ups_hero_battery_glyph` (complete) — the pages, the shared + `.masthead` chrome and `Dash.fetchJSON` that this phase wraps; nothing here may change what + they render once a session exists. +- `06_system_page_gpu_verdicts`, `07_llm_monitor_style_pages`, `08_events_paging_and_copy`, + `09_disks_smartctl` (todo) — **no** dependency in either direction. This phase touches + `app.py`, `config.py`, `store.py`, `app.js` and new files; those phases touch + `severity.py`, `sources/*`, page scripts. If they run first, re-read the `create_app(` call + sites in `tests/` before task 03 — that is the only overlap that can conflict. +- `10_dev_direct_dial` (todo) — **no functional dependency, four shared files**: it rewrites + `.env.dev.example`, edits `.env.example`'s NUT section, rewrites the local `.env.dev`, and + rewrites the dev sections of `README.md`/`AGENTS.md`. Run either order, with two rules: apply + this phase's `.env*` additions by **appending to whatever the file says at the time** (never + restore a `DEV_TUNNELS`/`DEV_FORWARDS` line that 10 deleted), and never delete a + `DASH_ADMIN_PASSWORD` line 10 touches. Whichever phase runs second re-checks the diff of the + other's doc edits rather than overwriting them. + +## Tasks + +1. `01_auth_primitives.md` — `src/dashboards/auth.py`: password hash/verify (self-describing PBKDF2), session mint/read, CSRF token, login throttle, origin check. Pure logic, no HTTP. +2. `02_verifier_config_fail_closed.md` — `store.get_meta`/`set_meta`/`delete_meta`, the four `DASH_*` auth settings, and `ensure_admin_verifier()` wired into the lifespan so a dashboard with no password anywhere refuses to start. +3. `03_session_gate_and_public_surface.md` — the default-deny middleware, `/login` route, `/api/auth/login|logout|status`, CSRF enforcement on every write, `fetchJSON` attaching the token and bouncing to `/login` on 401, plus the test that enumerates **every** route and demands a session. +4. `04_change_password.md` — `POST /api/auth/password`: current-password check, verifier replace, session-secret rotation (every other browser is out), event row, throttle reset. +5. `05_login_page.md` — `pages/login.html` + `static/page-login.js`: password field, inline accessible errors, lockout message, no CDN, theme parity with the other pages. +6. `06_settings_page.md` — `/settings`: change-password form, sign-out, session age; a `⚙` link in the masthead of all four dashboards. +7. `07_dev_config_docs_browser_verification.md` — env templates, the operator's gitignored `.env.dev`, README + AGENTS.md, then live browser proof with screenshots and the leak checks (`/api/summary` 401 anonymous, `/system` 303, `/api/health` 200). + +## Testing & Quality + +- Unit: `tests/test_auth.py` for every primitive — wrong password, tampered cookie, expired + cookie, cookie signed with a rotated secret, CSRF token from another session, throttle + backoff and reset, `needs_rehash` when the configured cost rises. +- Integration: `tests/test_auth_api.py` — anonymous 401 on every `/api/*` data route, 303 → + `/login?next=…` for HTML, login success/malfailure/lockout over HTTP, logout kills the + cookie, change-password invalidates the *old* session and the *other* browser's, + `POST /api/ups/command` refuses without the CSRF header even **with** a valid session. +- The default-deny guard: `test_every_route_that_is_not_public_requires_a_session` walks + `create_app().routes` and asserts nothing outside the public list answers anonymously. A + route added by a future phase that forgets the gate fails here, not in production. +- `tests/test_api.py` keeps passing unmodified in intent: `build_client()` logs in once with + `testpass`, so the ~200 existing HTTP assertions still assert what they assert. The nine + direct `create_app(Settings(...))` call sites in `tests/` get the same password. +- Coverage: **>90%** on new/modified code (`auth.py` should be near 100%); the overall gate + (94%, currently ~98%) must hold. +- The phase is "Complete" only when the **entire** project test suite passes 100% (every test + in the project, not just this phase's), including `uv run pytest -m live --live`. + +## Documentation & Consistency + +- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands + after this phase — fixing every inconsistency found, **including pre-existing ones not + caused by this phase**. +- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible + (setup, commands, configuration, features, conventions). This phase is user-visible in the + strongest sense: the first thing an operator meets is a login prompt. + +## Completion Criteria + +- [ ] `curl -si http://127.0.0.1:8090/system` → `303` to `/login?next=/system`; `curl -s + http://127.0.0.1:8090/api/summary` → `401 {"ok": false, …}`; `curl -s + http://127.0.0.1:8090/api/config` → `401`; `curl -s http://127.0.0.1:8090/api/health` → + `200` (the one deliberate public data route) +- [ ] login with `testpass` in a browser reaches `/`; a wrong password shows an inline error + and, after 5 failures, the lockout message with `Retry-After` honoured +- [ ] `/settings` changes the password; the same browser is signed out and gets back in with + the new one; a second browser (or a saved cookie) is rejected with 401 +- [ ] with `DASH_ADMIN_PASSWORD` unset **and** no `auth.verifier` row, `uv run dashboards` + exits non-zero with a message naming the variable and the reset command +- [ ] `./scripts/dev-server.sh status` and the Containerfile `HEALTHCHECK` still work + unauthenticated (`/api/health` unchanged) +- [ ] `uv run pytest` green, `uv run pytest --cov --cov-fail-under=94` green, + `uv run ruff check src tests agent && uv run pyright` clean, `bash .agents/validate.sh` + green +- [ ] screenshots in `data/logs/`: `auth-login.png`, `auth-login-rejected.png`, + `auth-locked-out.png`, `auth-signed-in-overview.png`, `auth-settings.png` (AGENTS.md + invariant 10 — a UI change is screenshotted, not claimed) +- [ ] `README.md` documents the four new `DASH_*` variables, the reset procedure, and the fact + that a UI change wins over the env var; `AGENTS.md` carries the new invariant naming the + public surface; both are accurate and consistent with the whole project (final pass) +- [ ] no behavior change in completed phases: every payload, hero card, chart and gate the + five completed phases shipped still renders identically **once signed in** diff --git a/.agents/phases/todo/11_admin_login/01_auth_primitives.md b/.agents/phases/todo/11_admin_login/01_auth_primitives.md new file mode 100644 index 0000000..6596372 --- /dev/null +++ b/.agents/phases/todo/11_admin_login/01_auth_primitives.md @@ -0,0 +1,112 @@ +# Task 01 — Auth primitives: hashes, session tokens, CSRF tokens, throttle + +**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "password protect this dashboard for a single admin user" +**Story:** n/a + +## Objective + +`src/dashboards/auth.py`: every authentication decision as a pure, unit-testable function with +no HTTP and no I/O — password hash/verify, session token mint/read, CSRF token mint/verify, +login throttle, origin check. Nothing here knows about FastAPI; task 03 wires it. + +## Work + +1. New `src/dashboards/auth.py`, module docstring stating the design and **why** it is + hand-rolled (stdlib only — the same reasoning as `prom.py` and the NUT wire client; no + `itsdangerous`, no `bcrypt`, no session store to operate). Constants at the top: + + ```python + ADMIN_USER = "admin" # single admin; there is no user table and none is coming + SESSION_COOKIE = "dash_session" + CSRF_HEADER = "X-CSRF-Token" + SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + PBKDF2_ITERATIONS = 600_000 # OWASP's PBKDF2-HMAC-SHA256 figure; DASH_PBKDF2_ITERATIONS overrides + PBKDF2_ALGO = "pbkdf2_sha256" + ``` + +2. Password verifier — self-describing so the cost can rise later without a migration: + - `hash_password(password: str, *, iterations: int = PBKDF2_ITERATIONS) -> str` → + `pbkdf2_sha256$$$`, salt `secrets.token_bytes(16)`, + `hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)`. + - `verify_password(password: str, encoded: str) -> bool` — parses defensively and returns + `False` for `""`, a wrong field count, a non-numeric iteration count, an unknown algo + name, odd-length hex, or a length mismatch. **It must never raise**: a corrupt `meta` + row has to read as "wrong password", not a 500 on the login page. Compare with + `hmac.compare_digest`. + - `needs_rehash(encoded: str, *, iterations: int) -> bool` — true when the stored cost (or + algo) is below the configured one, so a login can transparently upgrade the hash. + - No password, hash, salt or session token may ever appear in a log line, an exception + message, or a return value used for display. Name this in the docstring: AGENTS.md + invariant 6 now covers the admin's own credential. + +3. Sessions — stateless and signed, so there is no session table and no cleanup job: + - `new_secret() -> str` → `secrets.token_hex(32)`. Stored by task 02 under the meta key + `auth.session_secret`; rotating it invalidates every issued cookie, which is how changing + the password signs everyone else out. + - `@dataclass(frozen=True) Session: username: str; expires_at: float; token: str` + - `mint_session(secret: str, *, username: str = ADMIN_USER, hours: float = 720.0, + now: float | None = None) -> str` → `b64url(f"{username}|{expires_at:.0f}")` + `"."` + + `b64url(hmac_sha256(secret, payload))`. Use `base64.urlsafe_b64encode` and strip padding; + `hmac.new(secret.encode(), payload.encode(), hashlib.sha256)`. + - `read_session(secret: str, token: str, *, now: float | None = None) -> Session | None` — + signature first (constant-time), then `expires_at > now`. Returns `None` for: `""`, + no dot, non-base64 payload, payload without `|`, non-numeric expiry, an expired session, + a token signed by a rotated secret, and a payload whose *username* was tampered with. + - `mint_csrf(secret: str, session_token: str) -> str` → + `hmac_sha256(secret, "csrf|" + session_token).hexdigest()` — bound to the session, so a + token stolen from one session cannot authorise another's write, and it needs no expiry + of its own because the session has one. + - `verify_csrf(secret: str, session_token: str, provided: str | None) -> bool` — + `compare_digest`, `False` on `None`/empty. + +4. Login throttle — in-process memory, because there is one admin and one uvicorn process: + - `class LoginThrottle: def __init__(self, *, max_attempts: int = 5, base_seconds: float = + 60.0, max_seconds: float = 3600.0)` + - `retry_after(key: str, *, now: float | None = None) -> float` → seconds still blocked, + `0.0` when allowed. + - `failure(key: str, *, now: float | None = None) -> float` → the backoff just applied. + Exponential from `base_seconds` per key, capped at `max_seconds`; a key that gets a + successful login is cleared. + - `success(key: str) -> None`, and `prune(now)` (or an inline length guard) so a spoofed + swarm of source addresses cannot grow the dict without bound. + - Take `now` as an injectable seconds value on every method (`time.monotonic` at the call + site in task 03) — a test must not sleep 60 s to prove the backoff works. + - ASSUMPTION: the key is the socket peer address. Behind nginx every login shares one + bucket, so a determined attacker can lock the operator out of the dashboard (not of the + UPS, not of the data) for at most `max_seconds`. Accepted for a single-admin box; it goes + in `AGENTS.md` → Known gaps rather than becoming an `X-Forwarded-For` knob that is + trivially spoofed when someone forgets to trust the proxy. + +5. Origin / Sec-Fetch gate as pure string functions (unit-testable without a `Request`): + - `origin_matches(origin: str, own_origin: str, allowed: Sequence[str] = ()) -> bool` + - `sec_fetch_site_allowed(value: str | None) -> bool` — allow `None`, `"same-origin"`, + `"none"`; reject `"cross-site"`, `"cross-origin"`, `"same-site"`(unknown → reject). + Anything unrecognised is rejected: a browser that invents a new value is a browser doing + something we did not reason about. + +## Testing & Quality + +- `tests/test_auth.py` (new) — the exhaustive negative table above. Minimum: + round-trip hash/verify, wrong password, unicode password (`"pässwörd"` — proves the utf-8 + encode), five malformed encodings all `False` with no exception, `needs_rehash` on a + 1_000-iteration hash against the default cost, session round-trip, tampered username, + flipped signature byte, truncated token, expired session (via `now`), cookie from a rotated + secret, CSRF ok/wrong-session/missing, throttle allows 5, blocks the 6th with + `retry_after > 0`, backoff doubles and caps at `max_seconds`, `success()` clears, + `origin_matches` accepts its own origin and the allow-list and rejects + `http://evil.example`, `sec_fetch_site_allowed("cross-site") is False`. +- Cover `auth.py` to ~100% — every branch here is a security decision, and a branch no test + reaches is a decision nobody checked. +- Coverage: **>90%** on this task's new code; overall gate (94%) maintained. + +## Completion Criteria + +- [ ] `uv run pytest tests/test_auth.py -v` green +- [ ] `python -c "from dashboards import auth; h=auth.hash_password('testpass'); print(h)"` + prints a `pbkdf2_sha256$600000$…` string and `auth.verify_password('testpass', h)` is + `True` while `verify_password('wrong', h)` and `verify_password('testpass', 'junk')` are + `False` +- [ ] `grep -n "print(\|logger\|logging" src/dashboards/auth.py` shows nothing that could + echo a credential +- [ ] no new dependency in `pyproject.toml` / `uv.lock` (`uv pip list --outdated` stays empty) +- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean diff --git a/.agents/phases/todo/11_admin_login/02_verifier_config_fail_closed.md b/.agents/phases/todo/11_admin_login/02_verifier_config_fail_closed.md new file mode 100644 index 0000000..5f478d2 --- /dev/null +++ b/.agents/phases/todo/11_admin_login/02_verifier_config_fail_closed.md @@ -0,0 +1,119 @@ +# Task 02 — Where the password lives: `meta`, the four settings, and failing closed + +**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "configured initially with an env var (but should not be required to change)" +**Story:** n/a + +## Objective + +Make the credential durable and the startup honest: the verifier and the signing secret live in +the SQLite `meta` table, the environment seeds them once, and a server with neither a stored +verifier nor `DASH_ADMIN_PASSWORD` **refuses to start** instead of serving the fleet open. + +## Work + +1. `src/dashboards/store.py` — three small public helpers over the `meta` table that already + exists (`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)`), each under + `self._lock`: + - `get_meta(key: str) -> str | None` + - `set_meta(key: str, value: str) -> None` (`INSERT OR REPLACE`) + - `delete_meta(key: str) -> int` (returns rowcount; the reset procedure needs it and a + test should prove it removes only the named key) + Route `stats()`'s `last_maintain` read through `get_meta()` so there is one path to the + table. Leave `maintain()`'s write inline — it is inside that method's transaction. +2. `src/dashboards/config.py` — four new `Settings` fields beside the server block, using the + existing `_env` / `_num` / `_flag` / `_int` helpers (field defaults are read once at import; + that is the established pattern, see the module docstring): + - `admin_password: str = _env("DASH_ADMIN_PASSWORD")` — the **seed**. `_env()` strips, so + `DASH_ADMIN_PASSWORD=` means *unset*, never "the password is empty". + - `session_hours: float = _num("DASH_SESSION_HOURS", 720)` — 30 days; a homelab dashboard + that logs the operator out every shift change gets bypassed, which is worse. + - `cookie_secure: bool = _flag("DASH_COOKIE_SECURE")` — off by default because dev is + `http://127.0.0.1`; task 03 also marks the cookie `Secure` when the request arrived over + `X-Forwarded-Proto: https`. + - `pbkdf2_iterations: int = _int("DASH_PBKDF2_ITERATIONS", PBKDF2_ITERATIONS)` — import the + constant from `.auth` (`auth.py` imports nothing from this package, so there is no cycle). + The knob exists so the hermetic suite can hash in microseconds instead of 0.3 s; the + comment must say that, and must not suggest lowering it in production. +3. `src/dashboards/auth.py` — the loader that turns settings + `meta` into request-time state. + Declare a `MetaStore` `Protocol` (`get_meta` / `set_meta` / `delete_meta`) so this module + still imports nothing from `config`/`store`: + + ```python + @dataclass(frozen=True) + class AuthState: + verifier: str # encoded PBKDF2, never the password + secret: str # session-signing key + session_hours: float + iterations: int + seeded: bool # True when this start wrote the verifier row + env_ignored: bool # True when a stored verifier beat DASH_ADMIN_PASSWORD + ``` + + - `load_auth_state(store: MetaStore, *, admin_password: str, iterations: int = + PBKDF2_ITERATIONS, session_hours: float = 720.0, now: float | None = None) -> AuthState` + - `verifier = store.get_meta("auth.verifier")`. + - If absent and `admin_password` → `hash_password(...)`, `set_meta("auth.verifier", …)`, + `seeded=True`. + - If absent and no `admin_password` → raise `RuntimeError` with the exact operator text: + name `DASH_ADMIN_PASSWORD`, name the meta key, and give the reset command + (`sqlite3 "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')"`). + Fail closed here — not "start open with a warning": the domain is public and the fleet's + hostnames and UPS controls are behind it. + - `secret = store.get_meta("auth.session_secret")` or generate with `new_secret()` and + persist it. Persisting it is what makes a restart not invalidate every browser. + - `env_ignored = bool(admin_password) and not seeded`. When true, + `logging.getLogger("dashboards.auth").warning()` **once**, at startup, with the words: + `DASH_ADMIN_PASSWORD is ignored — the password set in /settings wins; delete the + 'auth.verifier' row to reset from the environment`. Never log the value, the hash, or + any prefix of either. + - `update_verifier(store: MetaStore, state: AuthState, new_password: str) -> AuthState` — + hashes, writes both `auth.verifier` and a **fresh** `auth.session_secret`, returns the new + state (task 04 uses it; the rotation is what signs out every other browser). Empty / + whitespace-only `new_password` raises `ValueError` — an empty password must never be + storable. +4. `src/dashboards/app.py` — build the state during lifespan startup, before `start()`, for + both the owned-store and injected-store branches, and hang it off the context: + `ctx.auth = load_auth_state(store, admin_password=settings.admin_password, + iterations=settings.pbkdf2_iterations, session_hours=settings.session_hours)`. + `AppContext` (in `api.py`) gains `auth: Any = None` (typed as `AuthState | None`; import the + dataclass — that is how it stays honest under pyright). Task 03 is what *enforces* it; this + task only guarantees it exists or the process does not come up. + Startup failure must be loud and non-zero: a `RuntimeError` raised in the lifespan does that + for `uvicorn dashboards.app:app` and `uv run dashboards` alike. Do **not** swallow it. +5. Tests: + - `tests/test_store.py` — `get_meta` `None` for a missing key, round-trip, `set_meta` + replaces, `delete_meta` returns 1 for an existing key / 0 for a missing one and leaves the + other keys alone. + - `tests/test_config.py` — the four new fields pick up their env vars and their defaults; + `DASH_ADMIN_PASSWORD=` is `""`. + - `tests/test_auth.py` (or a new `tests/test_auth_bootstrap.py` if 01's file is getting + long) — seeds on first call and writes both rows; second call with `admin_password=""` + reuses the stored verifier (so a deployment can drop the env var after first boot); + `admin_password=""` **and** no row raises `RuntimeError` naming both the variable and the + key; a stored verifier makes a conflicting env password `env_ignored=True` and does **not** + overwrite the row; a corrupt `auth.session_secret`… (state the behaviour you choose: + regenerate and persist is fine, since the effect is "everyone signs in again", which is a + login prompt, not a lockout); `update_verifier` changes both rows and the new state's + secret, and refuses an empty password. + - `tests/test_api.py` — one test that `create_app(Settings(db_path=db), store)` (no + password anywhere) fails to start, and that with `admin_password="testpass"` it starts. + +## Testing & Quality + +- Coverage **>90%** on `auth.py`, `config.py`, `store.py`, `app.py` changes; overall gate + (94%) maintained. +- Prove the fail-closed branch and the seeded branch each run — a security default that no test + exercises is a rumour. + +## Completion Criteria + +- [ ] `uv run pytest tests/test_store.py tests/test_config.py tests/test_auth.py -v` green +- [ ] starting the real dev server with `DASH_ADMIN_PASSWORD` unset and a scratch + `DASH_DB=/tmp/auth-probe.sqlite3` exits non-zero and prints the message naming the + variable and the reset command (paste the line into the commit message) +- [ ] the same command with `DASH_ADMIN_PASSWORD=testpass` starts, and + `sqlite3 /tmp/auth-probe.sqlite3 "SELECT key FROM meta"` lists `auth.verifier` and + `auth.session_secret` +- [ ] the stored hash in `meta` is a `pbkdf2_sha256$…` string; `grep -c testpass` in that DB + is 0 +- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean diff --git a/.agents/phases/todo/11_admin_login/03_session_gate_and_public_surface.md b/.agents/phases/todo/11_admin_login/03_session_gate_and_public_surface.md new file mode 100644 index 0000000..4c21a80 --- /dev/null +++ b/.agents/phases/todo/11_admin_login/03_session_gate_and_public_surface.md @@ -0,0 +1,163 @@ +# Task 03 — The gate: default-deny middleware, `/login` route, `/api/auth/*` + +**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "password protect this dashboard" +**Story:** n/a + +## Objective + +Put every route behind the session except four deliberately public ones, with the login, +logout and status endpoints that create and end it, and make the browser scripts carry the +credentials so no page has to think about auth. + +## Work + +1. `src/dashboards/app.py` — state the public surface **next to the route table** it guards: + + ```python + # 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({"/login", "/api/auth/login", "/api/health"}) + PUBLIC_PREFIXES = ("/static/",) + ``` + + Add `"/login": "login.html"` to `PAGES`. Task 05 supplies the file; until then serve the + existing shape it will get (a minimal page is fine to create here and flesh out in 05 — do + **not** leave `/login` 404, or the gate redirects to a dead end). +2. Add the gate as an `@app.middleware("http")` in `create_app()`, beside the existing + `no_store_for_api`: + - `ctx = getattr(request.app.state, "ctx", None)`; no `ctx` → `503 {"ok": false, "detail": + "startup did not complete"}` for non-public paths (the app that failed to build its + context must not serve pages with `ctx.auth = None`). + - public path → straight through. + - session: `request.cookies.get(auth.SESSION_COOKIE)`, or `Authorization: Bearer ` + (so `curl -H "Authorization: Bearer …"` can script the API — document it in README). + Read with `auth.read_session(ctx.auth.secret, token)`. + - no/invalid session → + - `GET`/`HEAD` with `text/html` in `Accept` and a path not starting with `/api` → + `303` to `/login?next=`; + - everything else → `401 {"ok": false, "detail": "sign in required", "login": "/login"}`. + - `safe_next(raw)` — keep it only if it starts with `/`, does not start with `//`, and + contains no `://`; else drop it. That is the open-redirect check; a `?next=` that sends + the operator to someone else's site would be a phishing kit with a homelab theme. + - `GET`/`HEAD` with a valid session → set `request.state.session = session` and continue. + - non-`GET`/`HEAD` (everything that can write, `POST` included) additionally: + - origin check with `auth.origin_matches(origin, own_origin, allowed)` where `own_origin` + is `f"{request.url.scheme}://{request.url.netloc}"` and `allowed` comes from + `DASH_ALLOWED_ORIGINS` — **no such setting is added**: same-origin is the only policy + this app needs, so pass `()`. Reject when `Origin` is present and mismatched, or when + `Sec-Fetch-Site` is present and `auth.sec_fetch_site_allowed()` rejects it; + - CSRF: required when the caller authenticated with the **cookie** (a bearer caller + cannot be forged by another site — that is what the header buys), validated with + `auth.verify_csrf(ctx.auth.secret, session.token, request.headers.get(auth.CSRF_HEADER))`; + - failures → `403 {"ok": false, "detail": "cross-origin request refused"}` / + `403 {"ok": false, "detail": "missing or invalid CSRF token"}`. + - **every response the gate produces carries `NO_STORE`** (the gate may short-circuit + before `no_store_for_api` runs, depending on middleware order — do not depend on the + order, pin the header in a test). +3. `POST /api/auth/login` (public; put it in a new `auth_router()` in + `src/dashboards/api.py`, included in `app.py` alongside the other routers, prefix `/api/auth`): + - body `{"password": ""}` — there is no username field (single admin; + `auth.ADMIN_USER`). An absent/empty password is `422`, not a hash comparison. + - throttle first: `key = request.client.host if request.client else "unknown"`; + `retry_after = ctx.login_throttle.retry_after(key)`; blocked → `429` with + `Retry-After: ` and `detail` naming the lockout seconds — no "wrong password" + lie, and no hint about which account. + - `auth.verify_password(password, ctx.auth.verifier)`: + - no → `throttle.failure(key)`, log an event + (`Event(now, "auth", "admin", "login-denied", "warn", "sign-in refused (attempt N)")`) + and `401 {"ok": false, "detail": "wrong password"}` — one message for every failure + kind, because there is one account and the response must not rank failures. + `alerts.py` only turns `kind == "unreachable"` events into overview rows and the page + feeds filter by `source`, so an `auth` event cannot blink the overview — say so in the + code comment so nobody "fixes" it by hiding the event. + - yes → `throttle.success(key)`, `auth.mint_session(...)`, set the cookie + (`httponly=True`, `samesite="lax"`, `path="/"`, `max_age=int(hours*3600)`, + `secure=True` when `settings.cookie_secure` **or** the request's first + `X-Forwarded-Proto` value is `https` — marking it `Secure` for an https visit is safe + even if the header lies, because it only ever tightens), and answer + `{"ok": true, "username": "admin", "expires_at": ts, "csrf": , "next": "/safe"}`. + Never echo the password, the verifier, or the session token in any other field. + - `GET /login` while already holding a valid session → `303` to `/` (or to a safe `next`). +4. Protected auth routes on the same router: + - `GET /api/auth/status` → `{"ok": true, "authenticated": true, "username": "admin", + "expires_at": ts, "seconds_left": n, "csrf": auth.mint_csrf(secret, session.token), + "session_hours": …}`. This is the only place the CSRF token is published; it is inside + the session for a reason. + - `POST /api/auth/logout` (session + CSRF required — a plain link would be a CSRF that + signs the operator out) → clears the cookie (`delete_cookie(path="/")`) and answers + `{"ok": true}`. +5. `src/dashboards/api.py` — `AppContext` gains `login_throttle: Any = None` next to the + `auth` field from task 02; `app.py` constructs one `auth.LoginThrottle()` per app (per + process, which is the documented model). Do not create it per request: a new throttle per + request is a throttle that never trips. +6. `src/dashboards/web/static/app.js` — one place learns the token, every request uses it: + - module-level `authState = null` + `async function ensureAuth()` that fetches + `/api/auth/status` once (memoise the promise, not just the result, or 4 page scripts race + four status calls), caching `{csrf}`; + - `fetchJSON(url, options)` attaches `X-CSRF-Token` for every method outside + `GET`/`HEAD`, awaiting `ensureAuth()` first; + - on `401`, `window.location.assign('/login?next=' + encodeURIComponent(location.pathname))` + and throw once — guard with a module flag so a 2 s poll loop whose session expired does + not spam the login page with redirects; + - keep the exported surface additive (`app.js`'s `return { … }` list): export `authStatus` + (the cached object) because task 06's `/settings` page prints the session expiry. +7. Tests — `tests/test_auth_api.py` (new) plus the plumbing this task forces on the suite: + - `tests/auth_client.py` (new helper module, imported the way `tests/card_contract.py` + is): `ADMIN_PASSWORD = "testpass"`, `sign_in(client, password=ADMIN_PASSWORD)` that posts + the login, asserts 200, and stashes the token on the client + (`client.headers[auth.CSRF_HEADER] = body["csrf"]`). Setting it on the TestClient is what + keeps the 12 existing `client.post(` assertions in `tests/test_api.py` unedited — they get + cookies *and* the CSRF header for free. + - `tests/test_api.py::build_client` logs in through that helper. The seven other + `create_app(` sites in `tests/test_api.py` (search `grep -n "create_app(" tests/`; there + are also two in `tests/test_system_contract.py` and `tests/test_ups_contract.py`) each get + `admin_password=ADMIN_PASSWORD` **and** a `sign_in()`, or their assertions read 401s. + - gate behaviour: every `/api/*` data route (at least `/api/config`, `/api/summary`, + `/api/events`, `/api/metrics`, `/api/system/snapshot`, `/api/system/history`, + `/api/llm/snapshot`, `/api/ups/snapshot`, `/api/ups/commands`) answers `401` with that + exact body shape anonymously; `GET /`, `/system`, `/llm`, `/ups` with `Accept: + text/html` → `303` with `Location` starting `/login?next=`; `/api/health`, `/login`, + `/static/dash.css` stay reachable anonymously; every gate response has + `cache-control: no-store`. + - `test_every_route_that_is_not_public_requires_a_session` — build the app, walk + `app.routes`, derive each path (`getattr(r, "path", "")`; a `Mount` counts as its prefix), + and assert: anything outside `PUBLIC_PATHS`/`PUBLIC_PREFIXES` is `401`/`303` for anonymous + `GET` **and** `POST`. This is the test that protects the next phase that adds a route. + - `?next=` handling: `/nope` passes through, `//evil.example` and + `http://evil.example` are dropped to `/` (assert the `Location` has no `evil` in it). + - login: wrong password 401 `wrong password`; empty 422; success sets a `Set-Cookie` + containing `HttpOnly`, `SameSite=lax` and `Max-Age`, and returns a usable `csrf`; + `X-Forwarded-Proto: https` adds `Secure`; `DASH_COOKIE_SECURE=1` adds it without the + header; 6th bad attempt from the same client → 429 with `Retry-After` and the event row + exists in `/api/events?source=auth` once signed in. + - CSRF: `POST /api/ups/command` with a valid session and **no** `X-CSRF-Token` → 403, and + prove it never reached the wire (the scripted `ScriptedNutClient` in `tests/test_api.py` + records nothing); with the header it behaves exactly as it did before this phase + (`DANGER` still required for `driver.killpower`); `Origin: http://evil.example` → 403 on + login and on a write; `Sec-Fetch-Site: cross-site` → 403; a bearer-token caller can write + without the CSRF header but not without the token. + - logout kills the cookie (subsequent `/api/summary` → 401). + - the served-pages tests in `tests/test_api.py` that scrape page scripts still pass — the + orphan-`id` test and the two-panel/hero pins must stay green without weakening them. + +## Testing & Quality + +- Coverage **>90%** on `app.py`, `api.py`, `auth.py` changes; every branch of the gate + (public, sessionless HTML, sessionless JSON, bad-session write, CSRF-missing, + origin-mismatch, no-ctx) needs a test — this module is the entire perimeter. +- `node --check src/dashboards/web/static/app.js` is part of `.agents/validate.sh`; run it. + +## Completion Criteria + +- [ ] `uv run pytest tests/test_auth_api.py -v` green, including the route-enumeration test +- [ ] `curl -si http://127.0.0.1:8090/system | head -5` shows `303` + `Location: /login?next=/system` +- [ ] `curl -s http://127.0.0.1:8090/api/summary` → the 401 JSON; `curl -s + http://127.0.0.1:8090/api/health` → 200; `curl -s -o /dev/null -w '%{http_code}' + http://127.0.0.1:8090/static/dash.css` → 200 +- [ ] `curl -s -X POST -H 'content-type: application/json' -d '{"password":"testpass"}' + http://127.0.0.1:8090/api/auth/login` returns `ok: true` with a `csrf`, and the returned + token used as `X-CSRF-Token` + the cookie makes `POST /api/ups/command` behave as before +- [ ] `uv run pytest` fully green (all ~550 tests, not just the auth ones); `ruff` + `pyright` + clean; `bash .agents/validate.sh` green diff --git a/.agents/phases/todo/11_admin_login/04_change_password.md b/.agents/phases/todo/11_admin_login/04_change_password.md new file mode 100644 index 0000000..7cea43d --- /dev/null +++ b/.agents/phases/todo/11_admin_login/04_change_password.md @@ -0,0 +1,87 @@ +# Task 04 — Change the password from the API, and mean it for every session + +**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "the password should be changeable once logged in" +**Story:** n/a + +## Objective + +`POST /api/auth/password`: prove the current password, replace the verifier, rotate the signing +secret so the change takes effect everywhere at once. Optional in every sense — nothing forces +a change, no expiry, no first-login interstitial. + +## Work + +1. `src/dashboards/api.py` — add to `auth_router()`: + + `POST /api/auth/password`, body `{"current": str, "new": str}` (session **and** + `X-CSRF-Token` required by task 03's gate): + - Validate `new` before touching anything: non-empty after `.strip()`, at least + `MIN_PASSWORD_LENGTH = 8`, and different from `current` → otherwise `422` with a `detail` + that says which. No composition rules (symbols/digits requirements produce sticky notes, + not passwords); length is the part that does work. + - Throttle the *current* password guesses on a **separate key** — + `f"password:{request.client.host}"` on the same `LoginThrottle` instance — so the + change-password form is not an unthrottled oracle for the old password once a cookie is + stolen. Blocked → `429` with `Retry-After`, same as login. + - `auth.verify_password(current, ctx.auth.verifier)`; wrong → `throttle.failure(that key)` + and `403 {"ok": false, "detail": "current password is wrong"}`. Nothing else changes and + an event is logged (`source="auth"`, `kind="password-denied"`, `severity="warn"`). + - Right → `auth.update_verifier(store, ctx.auth, new)` (task 02: writes `auth.verifier` and + a fresh `auth.session_secret`), assign `ctx.auth = new_state` (`AppContext` is a plain + mutable dataclass), `throttle.success()`, log + `Event(now, "auth", "admin", "password-changed", "warn", "admin password changed")`, and + respond with a **fresh cookie minted from the new secret** plus + `{"ok": true, "changed": true, "expires_at": ts, "csrf": , + "hint": "other browsers are now signed out"}`. + Cookie attributes identical to login (including the `X-Forwarded-Proto` / + `DASH_COOKIE_SECURE` rule). + - Rotating the secret is the whole point: sessions are stateless, so without it a stolen + cookie would outlive the password that "revoked" it. Say that in a comment — the next + reader will wonder why a password change touches the signing key. +2. Also in `auth_router()`: make login upgrade a cheap hash transparently. After a successful + `verify_password`, if `auth.needs_rehash(ctx.auth.verifier, + iterations=settings.pbkdf2_iterations)` → re-hash with the configured cost and write it + (keep the secret — this is not a logout). Without this, an operator who raises + `DASH_PBKDF2_ITERATIONS` never actually gets the new cost. +3. Order-of-operations guard: the response must be built from `new_state`, never from the old + `ctx.auth`. A response signed with the old secret is a browser that dies on its next + request — the exact bug this task can introduce. +4. Tests (extend `tests/test_auth_api.py`): + - wrong `current` → 403, and the `auth.verifier` string in the DB is byte-identical to + before; five wrong tries then a **correct** one is still `429` (proves the separate + throttle key actually throttles); + - `new` empty / whitespace / 7 chars / equal to `current` → 422 and both `meta` rows + unchanged; + - success → `auth.verifier` and `auth.session_secret` both changed; `verify_password` + confirms new-yes/old-no against the stored string; the **old** cookie now gets `401` from + `/api/summary`; the cookie in the response works and its returned `csrf` authorises + `POST /api/ups/command`; a second browser's cookie (captured earlier in the test) gets + `401`; + - restart semantics: after a change, rebuild the app from the same DB with + `admin_password="testpass"` in settings → login with the **new** password works and + `testpass` does not (the env var is a seed, not an override); + - rehash: seed a verifier at `iterations=1000`, start with `pbkdf2_iterations=2000`, log in + → 200 and the stored string now says `pbkdf2_sha256$2000$…`, and the same password still + works afterwards; + - the `auth` events exist and are visible via `/api/events?source=auth`, and `alerts.build` + does **not** turn them into overview alerts (assert the summary's `alerts` list is + unchanged by them — invariant: a login failure is a fact for the feed, not a red panel). +5. `/api/config` and `/api/health` must not gain any auth detail: `/api/health` is public, and + "the admin password was changed at T" is not something to serve to an anonymous probe. The + fact lives in the event feed (authenticated) and in `/settings`. + +## Testing & Quality + +- Coverage **>90%** on the new route and `auth.update_verifier`; every rejection path above is + a test, not a comment. +- The suite's existing `/api/ups/command` tests (the `ScriptedNutClient` ones) must stay green + unchanged in behaviour. + +## Completion Criteria + +- [ ] `uv run pytest tests/test_auth_api.py -v` green for the eight scenarios above +- [ ] by hand on the dev server: sign in with `testpass`, change it to something else with + `curl`, and confirm the old cookie now gets `401` while the returned one works +- [ ] `sqlite3 data/dashboards.sqlite3 "SELECT value FROM meta WHERE key='auth.verifier'"` + still prints only an encoded PBKDF2 string — no plaintext, anywhere +- [ ] `uv run pytest` green; `ruff` + `pyright` clean; `bash .agents/validate.sh` green diff --git a/.agents/phases/todo/11_admin_login/05_login_page.md b/.agents/phases/todo/11_admin_login/05_login_page.md new file mode 100644 index 0000000..ab04f1d --- /dev/null +++ b/.agents/phases/todo/11_admin_login/05_login_page.md @@ -0,0 +1,83 @@ +# Task 05 — The login page + +**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "password protect this dashboard" +**Story:** n/a + +## Objective + +A single-purpose page at `/login` that asks for the admin password, says honestly why it +refused, and lands the operator on `/` (or the page they were heading to). Hand-rolled HTML + +one small script, no CDN, dark/light parity with the dashboards. + +## Work + +1. `src/dashboards/web/pages/login.html` — modelled on the other four pages' `` + (`/static/dash.css`, the same inline SVG favicon, `data-theme="dark"` so `app.js` can flip + it from `localStorage` before paint): + - title `Homelab · sign in`; the `.masthead` brand link, but **no** `.tabs` nav — the tabs + lead to pages that would bounce straight back here, and a nav of links that all redirect + is a lie about what is available. + - one `.card` in `main`: `

Sign in

`, a `.sub` line naming what this protects ("the + fleet's system, engine and battery dashboards — including the UPS controls"), then a form: + `` + + `` + + ``. + - a `
` with the signed-in name, the expiry as a local timestamp *and* + "in 29d 14h" via `Dash.duration(seconds_left)`, and ``. + - **Change password** panel: three fields — current (`autocomplete="current-password"`), + new (`autocomplete="new-password"`, `id="new-password"`), confirm + (`autocomplete="new-password"`, `id="confirm-password"`) — each with a real `