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,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**
|
||||
@@ -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$<iterations>$<salt_hex>$<hash_hex>`, 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
|
||||
@@ -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 <db> "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
|
||||
@@ -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 <token>`
|
||||
(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=<safe 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": "<string>"}` — 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: <ceil>` 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": <token>, "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
|
||||
@@ -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": <new token>,
|
||||
"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
|
||||
@@ -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' `<head>`
|
||||
(`/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`: `<h1>Sign in</h1>`, a `.sub` line naming what this protects ("the
|
||||
fleet's system, engine and battery dashboards — including the UPS controls"), then a form:
|
||||
`<label for="admin-password">Admin password</label>` +
|
||||
`<input id="admin-password" type="password" autocomplete="current-password" required>` +
|
||||
`<button class="action" id="sign-in" type="submit">Sign in</button>`.
|
||||
- a `<p id="login-error" class="login-error" role="alert" hidden>` for refusals and
|
||||
`<p id="login-note" class="faint mono" hidden>` for the lockout countdown.
|
||||
- scripts: `/static/app.js` then `/static/page-login.js`, `defer`.
|
||||
- **no** username field: one admin, and a field that is always the same word teaches the
|
||||
operator that half the form is theatre (`auth.ADMIN_USER` is the server's business).
|
||||
2. `src/dashboards/web/static/page-login.js` — IIFE in the house style (see
|
||||
`page-index.js`), no framework:
|
||||
- `submit` handler → `POST /api/auth/login` with
|
||||
`Dash.fetchJSON` (it adds the JSON accept header and turns a non-2xx into a thrown
|
||||
`Error` with `status`/`body`); body `{password}`;
|
||||
- on success `location.assign(body.next || '/')`;
|
||||
- on `401` show `Wrong password.` and select the field's contents (fixing a typo without
|
||||
retyping is the whole UX of a login form);
|
||||
- on `429` show the `detail` plus the `Retry-After` seconds ticking down, and disable the
|
||||
button until it reaches 0 — a submit that will be refused should not look pressable;
|
||||
- on any other failure print the server's `detail` verbatim through the escape path the app
|
||||
already uses for text (`textContent`, never `innerHTML`);
|
||||
- honour `?next=` **only** if it starts with `/` and not `//` (the same rule the server
|
||||
enforces; a client-side copy is defence in depth, and the server's check is the one that
|
||||
counts);
|
||||
- if the browser already holds a valid session, `GET /api/auth/status` succeeds → redirect
|
||||
to `/` immediately (task 03 also does this server-side; this is what stops a flash of the
|
||||
form).
|
||||
3. `src/dashboards/web/static/dash.css`:
|
||||
- extend the existing `input[type="text"]` rule (line ~642) to
|
||||
`input[type="text"], input[type="password"]` — do not duplicate it;
|
||||
- add `.login-card { max-width: 26rem; margin: 3rem auto; }` and `.login-error { color:
|
||||
var(--fail); }`, `.login-card form { display: grid; gap: .6rem; }`, plus
|
||||
`.login-card label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: var(--text-faint); }` (the same caption vocabulary as `.hero .label`);
|
||||
- only existing `:root` tokens (`--fail`, `--text-faint`, `--accent`, …) —
|
||||
`tests/test_api.py` fails if a rule reaches for a token `:root` does not declare, and if a
|
||||
`.sev-*` family appears.
|
||||
4. Keep the invariant: nothing external. `tests/test_api.py` already asserts the pages load no
|
||||
foreign asset — extend that assertion to `/login` (same test, one more path).
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `tests/test_api.py` (the served-HTML section) — `/login` is served anonymously with
|
||||
`HTTP/1.1 200`, contains `id="admin-password"`, `type="password"`,
|
||||
`autocomplete="current-password"`, `role="alert"`, and **no** `/system`-style `.tabs`; no
|
||||
`http://` or `https://` asset reference in the served bytes.
|
||||
- Orphan-`id` pin: every new `id` (`admin-password`, `sign-in`, `login-error`, `login-note`)
|
||||
must be referenced by `page-login.js` (or the CSS) — that test fails on a dead `id`, so name
|
||||
what you use and use what you name.
|
||||
- `node --check src/dashboards/web/static/page-login.js` (part of `validate.sh`).
|
||||
- Coverage: no new Python; the served-bytes assertions are the tests. Do not add a JS test
|
||||
runner (a locked anchor).
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] anonymous `GET /login` → 200 with the form; anonymous `GET /` → 303 to `/login?next=/`
|
||||
- [ ] in the dev server: wrong password shows `Wrong password.` inline with no page reload;
|
||||
`testpass` lands on `/` and the header status dots go green
|
||||
- [ ] five wrong passwords then a correct one shows the lockout with a counting-down
|
||||
`Retry-After` and a disabled button; it clears and the next attempt works
|
||||
- [ ] `/login?next=/ups` after a successful sign-in lands on `/ups`
|
||||
- [ ] dark and light both readable (screenshot both, or one plus a note in `NOTES-*.md`)
|
||||
- [ ] `uv run pytest` green; `ruff` + `pyright` clean; `bash .agents/validate.sh` green
|
||||
@@ -0,0 +1,89 @@
|
||||
# Task 06 — `/settings`: change the password, sign out, see when the session ends
|
||||
|
||||
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "the password should be changeable once logged in"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
A fifth page the operator reaches from the masthead: the session facts, a working
|
||||
change-password form, and a sign-out. It has to survive the secret rotation from task 04
|
||||
without leaving the browser holding a stale CSRF token.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/app.py` — add `"/settings": "settings.html"` to `PAGES`. It lands behind the
|
||||
gate with no extra code (task 03's route-enumeration test proves it); do not add it to the
|
||||
public list.
|
||||
2. `src/dashboards/web/pages/settings.html` — same `<head>`/masthead as the other pages, `.tabs`
|
||||
nav **without** `aria-current` (Settings is not one of the four dashboards), and in `main`:
|
||||
- **Session** panel: `<dl>` with the signed-in name, the expiry as a local timestamp *and*
|
||||
"in 29d 14h" via `Dash.duration(seconds_left)`, and `<button class="action"
|
||||
id="sign-out" type="button">Sign out</button>`.
|
||||
- **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 `<label
|
||||
for=…>` and an `aria-describedby` hint naming the 8-character floor, a `<button
|
||||
class="action" id="change-password">Change password</button>`, and one
|
||||
`<p id="settings-result" role="status" aria-live="polite">` for the outcome.
|
||||
- A static `.sub` paragraph stating the precedence rule in the app's own voice: the password
|
||||
was seeded from `DASH_ADMIN_PASSWORD` at first start, changing it here takes precedence,
|
||||
the env var never overwrites a password set here, and resetting from the environment means
|
||||
deleting the `auth.verifier` row and restarting. (This is the sentence that stops the
|
||||
"I edited the env file and nothing happened" support call.)
|
||||
- No `#nav-status` — `Dash.initChrome()` already guards its absence.
|
||||
3. `src/dashboards/web/static/app.js`:
|
||||
- `resetAuth()` — clears the memoised `authState`/promise added in task 03, exported in the
|
||||
`return { … }` list. Needed twice: after a password change (the secret rotated, so the
|
||||
cached `csrf` is now invalid) and after logout. Forgetting this is the bug where the
|
||||
operator changes the password and then every UPS button 403s until they reload.
|
||||
- also call it when a `401` bounces to `/login` (the flag from task 03 already stops the
|
||||
redirect storm).
|
||||
4. `src/dashboards/web/static/page-settings.js`:
|
||||
- on load `Dash.fetchJSON('/api/auth/status')` → fill the session panel;
|
||||
- sign-out → `POST /api/auth/logout` → `Dash.resetAuth()` → `location.assign('/login')`;
|
||||
- change password → local checks first (new ≥ 8 chars, `new === confirm`, current non-empty),
|
||||
reported inline without a round-trip; then
|
||||
`POST /api/auth/password` with `{current, new}`; on success write
|
||||
"Password changed. Other browsers are signed out." into `#settings-result`,
|
||||
`Dash.resetAuth()` (so the next write re-reads the fresh `csrf`), clear all three fields
|
||||
(`form.reset()` plus explicit blanking — password managers autofill them), and keep the
|
||||
session cookie the response set; on `403` print "current password is wrong", on `422`
|
||||
print the server's `detail`, on `429` the lockout text — every message via `textContent`;
|
||||
- after a successful change, verify once by calling `/api/auth/status` again so the panel
|
||||
cannot show a session the server no longer honours.
|
||||
5. Masthead on all four dashboard pages (`index.html`, `system.html`, `llm.html`, `ups.html`) —
|
||||
one link in `.masthead-right`, before the theme toggle:
|
||||
`<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>`.
|
||||
**No `id`** (the orphan-`id` test in `tests/test_api.py` flags ids nothing references — an
|
||||
`<a href>` needs none), and it must sit outside `.tabs` so the pinned tab structure of the
|
||||
four dashboards is untouched. Add `.ghost` styling for `a` alongside the existing
|
||||
`button.ghost` rule (extend its selector to `a.ghost` rather than duplicating; existing
|
||||
tokens only).
|
||||
6. `src/dashboards/web/static/page-login.js` — a sign-in that carries `?next=/settings` is a
|
||||
normal path once the session dies on the settings page; task 05's `next` handling covers it,
|
||||
just verify it in the browser.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `tests/test_api.py` (served-bytes section, signed-in client) —
|
||||
`GET /settings` is `200` and contains `id="new-password"`, `autocomplete="new-password"`,
|
||||
`id="sign-out"`, `role="status"`; anonymous `GET /settings` is `401`/`303` (already covered by
|
||||
the enumeration test — do not duplicate it there, but assert it once here for the page);
|
||||
`test_every_dashboard_page_links_to_settings` over the four page paths; `/settings` carries no
|
||||
external asset; the orphan-`id` test still passes with the four new ids.
|
||||
- `node --check` for `page-settings.js` and `app.js` (in `validate.sh`).
|
||||
- No new Python except the one `PAGES` line, so coverage moves only through `app.py`'s existing
|
||||
route tests; keep the overall gate (94%) where it is.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] in a browser: the `⚙ Account` link appears on all four dashboards and opens `/settings`
|
||||
- [ ] signing out from `/settings` returns to `/login`, and `/system` afterwards bounces to
|
||||
`/login` rather than rendering an empty page of `n/a`s
|
||||
- [ ] changing the password on `/settings` shows the success line, keeps the operator signed
|
||||
in, and a UPS button on `/ups` (e.g. beeper disable → confirm) still works **without a
|
||||
page reload** — that is `resetAuth()` doing its job
|
||||
- [ ] the same change in a second browser tab signed in earlier shows the 401 → `/login` path
|
||||
- [ ] `/settings` renders correctly in dark and light; screenshots
|
||||
`data/logs/auth-settings.png` and `data/logs/auth-account-link.png`
|
||||
- [ ] `uv run pytest` green; `ruff` + `pyright` clean; `bash .agents/validate.sh` green
|
||||
@@ -0,0 +1,125 @@
|
||||
# Task 07 — Dev config, the two docs, and proof in a browser against live data
|
||||
|
||||
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "for local testing the default admin password should be `testpass`"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Make `testpass` the password a developer types locally without ever putting it in a tracked
|
||||
template, document the whole thing for the operator, and prove the gate in a real browser on
|
||||
live fleet data (AGENTS.md invariant 10).
|
||||
|
||||
## Work
|
||||
|
||||
1. `.env.example` — new `Server` block lines, production-shaped and placeholder-only:
|
||||
```
|
||||
# Admin password. Seeds the SQLite verifier on first start; after that the password set
|
||||
# in /settings wins and this line is ignored (delete the 'auth.verifier' row to reset).
|
||||
# With neither this variable nor a stored verifier the app REFUSES to start.
|
||||
DASH_ADMIN_PASSWORD=change-me
|
||||
DASH_SESSION_HOURS=720
|
||||
# 1 marks the session cookie Secure. Set it in deployment (TLS/nginx); the app also sets
|
||||
# it automatically for requests that arrive with X-Forwarded-Proto: https.
|
||||
DASH_COOKIE_SECURE=0
|
||||
# Cost of the PBKDF2 hash. Lowering it weakens the one thing standing between the network
|
||||
# and your fleet; it exists so the test suite can hash fast.
|
||||
DASH_PBKDF2_ITERATIONS=600000
|
||||
```
|
||||
2. `.env.dev.example` — `export DASH_ADMIN_PASSWORD=change-me`, and the local-testing value in
|
||||
prose **on a comment line**, e.g. `# Local throwaway value: the hermetic suite and every
|
||||
dev box here use the word the tests sign in with (see tests/auth_client.py).`
|
||||
- **The trap to avoid:** `.agents/validate.sh` scans every tracked env *template* and fails
|
||||
the phase if a `*_PASSWORD=` / `*_TOKEN=` / `*_SECRET=` / `*_KEY=` line carries anything
|
||||
but an allow-listed placeholder (`changeme`, `change-me`, `replace-me*`, `secret`,
|
||||
`placeholder`, `dummy`, `example`, `x+`, empty). **`testpass` is not on that list**, so
|
||||
`DASH_ADMIN_PASSWORD=testpass` in a tracked template fails the gate — correctly. Do not
|
||||
edit `validate.sh` to let it through; the fix is a placeholder in the template and the
|
||||
real word in the gitignored file. Comment lines are not matched (the pattern is anchored
|
||||
at `^` and cannot start at `#`), which is why prose is safe and an assignment is not.
|
||||
3. The developer's own `.env.dev` (gitignored, mode `0600`) — append
|
||||
`export DASH_ADMIN_PASSWORD=testpass` if no `DASH_ADMIN_PASSWORD` line exists. Append with
|
||||
`printf '…\n' >> .env.dev` (`tee` truncates in place; the mode is 0600 for a reason) and do
|
||||
not reorder or reformat the rest of that file — it carries real upsd credentials.
|
||||
Never print its contents into a log, a commit message, or a screenshot.
|
||||
4. Optional one-line ergonomics in `scripts/dev-server.sh`'s `start()` — after the success
|
||||
`echo`, add `echo " sign in with DASH_ADMIN_PASSWORD from .env.dev"`. `start`'s readiness
|
||||
probe and `status` both use `/api/health`, which stayed public, so **no auth logic belongs in
|
||||
this script** and no password belongs in it either.
|
||||
5. `README.md`:
|
||||
- **Quickstart** — after `cp .env.dev.example .env.dev`, one line: local dev signs in with
|
||||
the dev file's password; the app refuses to start without one.
|
||||
- New **Signing in** section (before *Configuration*): one admin; the four public paths and
|
||||
why `/api/health` is one of them (container `HEALTHCHECK`, `dev-server.sh status`);
|
||||
password seeded from `DASH_ADMIN_PASSWORD`, changed in `/settings`, never forced;
|
||||
sessions are HMAC-signed cookies (`DASH_SESSION_HOURS`, default 30 days) invalidated
|
||||
together by a password change; the reset procedure, verbatim:
|
||||
`sqlite3 data/dashboards.sqlite3 "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')"`;
|
||||
scripting the API with `Authorization: Bearer <session token>`;
|
||||
deployment note — set the password in the container env file (the deployment repo's quadlet
|
||||
env template), set `DASH_COOKIE_SECURE=1` behind the TLS/nginx front, and note that nginx
|
||||
config/HSTS live in the deployment repo like every other service here.
|
||||
- **Configuration table** — the four new variables with their defaults, in the same
|
||||
two-column style as the existing rows.
|
||||
6. `AGENTS.md`:
|
||||
- a new numbered rule in **Non-negotiable rules** — *"One admin, one password, four public
|
||||
paths."* Everything else is behind the session; the public list is
|
||||
`/login`, `/api/auth/login`, `/api/health`, `/static/*`; the verifier is PBKDF2 in the
|
||||
SQLite `meta` table and the env var is a **seed, never an override**; no stored verifier
|
||||
and no env var means the process refuses to start; a password change rotates the signing
|
||||
secret so every browser is out; the login POST and every write are origin/CSRF-checked;
|
||||
and `/api/health` is public by decision, which means nothing sensitive may be added to it
|
||||
without re-deciding.
|
||||
- `auth.py` in the one-line-per-responsibility architecture list (pure primitives: PBKDF2
|
||||
verifier, signed sessions, CSRF tokens, login throttle — stdlib only, no new dependency).
|
||||
- Architecture/API prose: the four dashboards and `/settings` now require a session.
|
||||
- **Known gaps** — three entries: the login throttle keys the socket peer address, so behind
|
||||
nginx a lockout is global (at worst `max_seconds`; accepted for a single admin);
|
||||
no second factor and no per-user accounts by design; the throttle is per-process, so
|
||||
running more than one uvicorn worker multiplies the buckets (and the session/CSRF design
|
||||
itself is worker-safe because it is stateless).
|
||||
- `PLAN.md`'s Architectural Anchors table has no auth row. This phase adds no dependency, so
|
||||
it stays inside the existing anchors — but note in the phase `NOTES-*.md` that the operator
|
||||
may want to ratify "in-house stdlib auth (PBKDF2 + HMAC cookie)" as an anchor; this skill
|
||||
and this phase must not edit `PLAN.md`.
|
||||
7. Live browser verification (dev server on `http://127.0.0.1:8090`, tunnels up, live data):
|
||||
- `./scripts/dev-server.sh restart`, then confirm `./scripts/dev-server.sh status` still
|
||||
prints per-source `ok`/`DOWN` **without** credentials;
|
||||
- screenshots into `data/logs/`: `auth-login.png`, `auth-login-rejected.png`,
|
||||
`auth-locked-out.png`, `auth-signed-in-overview.png`, `auth-settings.png`,
|
||||
`auth-account-link.png` (the last two from task 06 — retake if a pixel moved);
|
||||
- leak checks, recorded in `NOTES-verify.md` with the actual status codes:
|
||||
`curl -si localhost:8090/system` (303 → `/login?next=/system`),
|
||||
`curl -s localhost:8090/api/summary` (401), `/api/config` (401), `/api/events` (401),
|
||||
`/api/system/history?...` (401), `/api/health` (200),
|
||||
`curl -s localhost:8090/login | grep -c 'http'` (0 external refs);
|
||||
- once signed in, walk all five pages and confirm the five completed phases look exactly as
|
||||
they did before: the `/system` hero rows, the `/llm` sparklines and latency feet, the `/ups`
|
||||
glyph and heroes, the overview's two panels, the `Δ<window>` footnotes;
|
||||
- `uv run pytest -m live --live` — the live suite talks to the agents, engines and upsd
|
||||
directly rather than to the dashboard, so it must be unaffected; run it and say so.
|
||||
8. `NOTES-auth.md` in the phase directory — what was verified how (which screenshot shows
|
||||
which behaviour), the `curl` outputs, the exact `meta` keys written, and anything the operator
|
||||
must do in the deployment repo (set `DASH_ADMIN_PASSWORD`, set `DASH_COOKIE_SECURE=1`).
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `bash .agents/validate.sh` green — this is the task where the tracked-template secret gate
|
||||
bites, so run it before declaring the docs done.
|
||||
- `git status --porcelain` must show no `.env` / `.env.dev` change staged (both are ignored) and
|
||||
no screenshot except the `data/logs/` drops the repo already ignores.
|
||||
- Coverage: no code change expected; the gate (94%) must not move.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `.env.example` and `.env.dev.example` are placeholder-clean and `bash .agents/validate.sh`
|
||||
passes the template scan (quote the passing line in the commit message)
|
||||
- [ ] `.env.dev` has the dev password, still mode `0600`, still containing its real NUT
|
||||
credentials untouched
|
||||
- [ ] `./scripts/dev-server.sh restart && ./scripts/dev-server.sh status` works unauthenticated
|
||||
- [ ] all six screenshots exist in `data/logs/` and show live data, not the simulator
|
||||
- [ ] the leak-check list above returns exactly the codes listed, recorded in `NOTES-auth.md`
|
||||
- [ ] README's *Signing in* section and four config rows exist; `AGENTS.md` has the new
|
||||
invariant, the `auth.py` line, and the three known gaps
|
||||
- [ ] `uv run pytest` and `uv run pytest -m live --live` both green;
|
||||
`uv run ruff check src tests agent && uv run pyright` clean; `bash .agents/validate.sh`
|
||||
green
|
||||
Reference in New Issue
Block a user