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:
@@ -13,6 +13,10 @@ Four dashboards, one domain, one process, one SQLite file:
|
||||
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
|
||||
runtime; every asset is served by this app.
|
||||
|
||||
Two more pages are not dashboards: `/login` (the one thing an anonymous visitor sees) and
|
||||
`/settings` — `⚙ Account` in the masthead of every dashboard — which shows this browser's
|
||||
session, signs it out, and changes the admin password.
|
||||
|
||||
Working on this repo? **[`AGENTS.md`](AGENTS.md)** holds the invariants (read-only by
|
||||
construction, never invent a number, unconfigured ≠ broken), the locked decisions, the real
|
||||
fleet facts, and the known gaps.
|
||||
@@ -21,12 +25,19 @@ fleet facts, and the known gaps.
|
||||
|
||||
```bash
|
||||
uv sync --python 3.14 # deps + the package, editable
|
||||
cp .env.example .env # then edit
|
||||
cp .env.example .env # then edit — including DASH_ADMIN_PASSWORD
|
||||
uv run dashboards # http://127.0.0.1:8090
|
||||
uv run uvicorn dashboards.app:app --port 8090 # equivalent
|
||||
uv run pytest # unit tests, no network needed
|
||||
```
|
||||
|
||||
The first start needs a password to seed: with no `DASH_ADMIN_PASSWORD` and no verifier stored
|
||||
in `DASH_DB` yet, the process prints the reason and exits non-zero rather than serving the fleet
|
||||
open (see *Signing in*). The dev password this project uses locally is
|
||||
`testpass`; production gets a real one. The first thing a browser meets is `/login` — every page
|
||||
and every `/api` route except the four listed under *What the session covers* asks for it, while
|
||||
`/api/health` stays open for the container healthcheck and `./scripts/dev-server.sh status`.
|
||||
|
||||
Developing against the real fleet from a laptop — dev dials exactly the addresses the deployed
|
||||
dashboard dials, so there is nothing to tunnel and no port to forward:
|
||||
|
||||
@@ -40,6 +51,11 @@ uv run dashboards # foreground
|
||||
./scripts/dev-server.sh restart # or backgrounded, with a pidfile + logs
|
||||
```
|
||||
|
||||
Local dev signs in with the `DASH_ADMIN_PASSWORD` in that file — the word the template's comment
|
||||
names (`testpass`, what the hermetic suite uses) — and without it a fresh database exits non-zero
|
||||
for the same reason production does. `.env.dev` is gitignored and `0600`: it holds the agent
|
||||
tokens and the upsd password too.
|
||||
|
||||
Each `HOST_TARGETS` entry carries that token as URL userinfo
|
||||
(`driveripper=http://<token>@<addr>:9120`); `parse_targets` moves it into an `Authorization`
|
||||
header, so `Target.url`, `/api/config` and every error message stay credential-free. No root
|
||||
@@ -99,6 +115,207 @@ and runs the agent under `ProtectSystem=strict`, `DevicePolicy=strict` with only
|
||||
nodes and the `/dev/null` family reopened (SMART needs the former; without `/dev/null` in
|
||||
the allow list systemd cannot start the service at all), with no capabilities.
|
||||
|
||||
## Signing in
|
||||
|
||||
One admin, one password, and the server **refuses to start** when it has neither a stored
|
||||
verifier nor `DASH_ADMIN_PASSWORD` — the domain is public and the fleet's hostnames and UPS
|
||||
controls sit behind this login, so there is no way to run it open by accident. There is no
|
||||
second account and no per-user view: the five pages (`/`, `/system`, `/disks`, `/llm`, `/ups`),
|
||||
the `/settings` page and every data route belong to that one operator.
|
||||
|
||||
A session is an **HMAC-signed cookie** (`dash_session`), not a row in a table: it carries the
|
||||
signed-in name and its own expiry, is valid for `DASH_SESSION_HOURS` (default `720` = 30 days),
|
||||
and is verified with a signing key that also lives in `DASH_DB`. That is why a password change
|
||||
signs out every other browser at once — it rotates the key, so every cookie minted under the old
|
||||
one stops verifying on its next request — and why nothing has to be pruned.
|
||||
|
||||
`DASH_ADMIN_PASSWORD` is a *seed*, not the credential. The first start hashes it
|
||||
(PBKDF2-HMAC-SHA256, 600 000 rounds) into the `meta` table of `DASH_DB` as `auth.verifier`, and
|
||||
from then on the stored verifier wins: remove the variable once the server is up and the same
|
||||
password still works, change it with `POST /api/auth/password` — or in a browser, on the
|
||||
`/settings` page (*The account page* below) — with no redeploy, and know that a container env can
|
||||
never roll a changed password back. If `DASH_ADMIN_PASSWORD` is still set on a box that already has a
|
||||
verifier, it loses — and the server says so once at startup, in words, without quoting the value.
|
||||
|
||||
Forgotten password, or a database you want to hand over? Delete the rows and seed again:
|
||||
|
||||
```bash
|
||||
sqlite3 "$DASH_DB" "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')"
|
||||
```
|
||||
|
||||
Deleting `auth.session_secret` — which that command does — signs out every browser already
|
||||
signed in, which is also exactly what changing the password does on purpose. Only the hash and
|
||||
the signing key are ever written: `grep -c "your password" "$DASH_DB"` comes back `0`, and no log
|
||||
line, event row, or API response carries the password, the verifier, or a session token.
|
||||
|
||||
Nothing that leaves the app carries a credential. A `HOST_TARGETS` userinfo token is parsed
|
||||
into an `Authorization` header, and every URL the browser can see — `/api/config`, a source's
|
||||
error string, the event feed — goes through `config.strip_userinfo` first, because an
|
||||
unreachable host writes its failure message into the event table and `/api/events` serves it.
|
||||
|
||||
### What the session covers
|
||||
|
||||
The gate is default-deny: **every page and every `/api` route** requires a signed-in admin
|
||||
except four entries, named next to the middleware they guard in `app.py`:
|
||||
|
||||
| Public | Why it has to be |
|
||||
| -------------------------- | ------------------------------------------------------------------ |
|
||||
| `/login` | the form itself |
|
||||
| `POST /api/auth/login` | you cannot be signed in to sign in |
|
||||
| `/api/health` | the Containerfile `HEALTHCHECK` and `./scripts/dev-server.sh status` poll it unsigned, and it publishes no fleet data |
|
||||
| `/static/…` | the login page is styled by the same stylesheet as the dashboards |
|
||||
|
||||
Adding a path to that list is a security decision, and a route a future phase forgets fails
|
||||
`tests/test_auth_api.py::test_every_route_that_is_not_public_requires_a_session`, which walks
|
||||
the served route table rather than a list written by hand.
|
||||
|
||||
What a refusal looks like depends on who asked, so that neither caller has to guess:
|
||||
|
||||
```bash
|
||||
curl -si http://127.0.0.1:8090/system | head -3
|
||||
# HTTP/1.1 303 See Other → Location: /login?next=/system (a browser ends up back here)
|
||||
curl -s http://127.0.0.1:8090/api/summary
|
||||
# {"ok":false,"detail":"sign in required","login":"/login"} (a script never gets HTML)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/api/health
|
||||
# 200
|
||||
```
|
||||
|
||||
The `?next=` value keeps the query string too (`/llm?range=1h` survives the round trip), and it
|
||||
is judged before it is ever followed: a `next` that leaves the site (`//evil.example`,
|
||||
`http://evil.example`, the percent-encoded and control-character spellings alike) is dropped to
|
||||
`/`. `auth.safe_next` is the one function that decides both halves. And every answer the gate
|
||||
produces — `303`, `401`, `403`, `429`, the route's own `404` — carries `no-store`, because a
|
||||
cached redirect would park a browser on the login page after it had already signed in.
|
||||
|
||||
### The sign-in page
|
||||
|
||||
`/login` is one card with one password field — no username, because there is one admin and a field
|
||||
that is always the same word teaches you that half the form is theatre. It carries the masthead's
|
||||
brand link and the theme toggle and nothing else from it: no tabs (each would bounce straight back
|
||||
here) and no status dots, because there is nothing signed in for them to report.
|
||||
|
||||
What it does with an answer is the part worth knowing:
|
||||
|
||||
| Response | On screen |
|
||||
| -------- | -------------------------------------------------------------------- |
|
||||
| `200` | straight to `?next=`, or to `/` |
|
||||
| `401` | `Wrong password.` under the field, and the text you typed is selected |
|
||||
| `429` | the refusal plus the lockout counting down, button disabled to zero |
|
||||
| other | the server's own `detail`, printed verbatim |
|
||||
|
||||
The countdown is the server's `Retry-After`, not a guess, and a refusal is written as text rather
|
||||
than HTML — this is the one page a stranger reaches first. A browser that already holds a session
|
||||
never sees the form: the server `303`s it to the dashboard, and the script checks
|
||||
`/api/auth/status` first so the form does not even flash. `?next=` is followed only when it is an
|
||||
in-app path (`/ups`, not `//evil.example`), judged on the server and again in the page.
|
||||
|
||||
### The account page
|
||||
|
||||
`/settings` — reached by `⚙ Account` in the masthead of every dashboard — is the session facts and
|
||||
the one form that changes the password.
|
||||
|
||||
| Panel | What it says, and what the button does |
|
||||
| ----- | ------------------------------------------------------------------------ |
|
||||
| Session | the signed-in name, the expiry as a local timestamp **and** as time left, and **Sign out** (`POST /api/auth/logout`, then `/login`) |
|
||||
| Change password | current + new + confirm, then `POST /api/auth/password`; the outcome is one line under the form |
|
||||
|
||||
The form checks what it can check itself (a current password typed, at least 8 new characters,
|
||||
the two new boxes agreeing) so a typo is answered without a round trip, and then reports whatever
|
||||
the server said: `403` is *the current password is wrong*, `422` names the rule the new password
|
||||
broke, `429` is the lockout with its own seconds. The page also states the precedence rule in its
|
||||
own words — the env var seeded the password once, a change here wins, resetting from the
|
||||
environment means deleting `auth.verifier` and restarting — because that is the sentence that
|
||||
prevents a support call about an edited `.env` that did nothing.
|
||||
|
||||
One detail makes the page more than a form. A change rotates the session-signing secret, which is
|
||||
what signs out every *other* browser, and it invalidates the CSRF token this browser cached — so
|
||||
`Dash.resetAuth()` clears that cache after the change (and after any `401` bounce). Without it the
|
||||
operator changes the password, then every UPS button 403s until they reload.
|
||||
|
||||
**Sign out deletes this browser's cookie; it cannot revoke a copy of the token.** A session is
|
||||
signed rather than stored, so nothing is left to mark as dead — the one thing that reaches every
|
||||
browser at once is the signing key, and that is what a password change rotates. If a machine is
|
||||
lost or a session is suspected, change the password here: that is the revocation, and it is why
|
||||
the same page carries both buttons.
|
||||
|
||||
### Writes need the CSRF token
|
||||
|
||||
A write authorised by the **cookie** must carry `X-CSRF-Token`, validated against that same
|
||||
session (`GET /api/auth/status` is where a page that is already signed in reads it — the login and
|
||||
change-password responses carry it too, minted for the session they just created — and it sits
|
||||
behind the session because a token is worthless to anyone who cannot read that response). `Dash.fetchJSON`
|
||||
fetches it once per browser and attaches it to every non-`GET`/`HEAD` request, so page code does
|
||||
not think about it — and `POST /api/auth/logout` is gated like any other write, because an
|
||||
`<a href="/logout">` planted on a page you read mid-outage is a CSRF that signs you out.
|
||||
Mismatched `Origin` or a `Sec-Fetch-Site` that is not `same-origin`/`none` is refused before the
|
||||
token is even looked at, so a cross-site login post cannot spend your throttle either.
|
||||
|
||||
### Changing the password
|
||||
|
||||
`POST /api/auth/password` with `{"current": "…", "new": "…"}` — a protected write, so it needs the
|
||||
session and the CSRF token like everything else:
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H 'content-type: application/json' -H "Cookie: dash_session=$SESSION" \
|
||||
-H "X-CSRF-Token: $CSRF" -d '{"current":"…","new":"…"}' \
|
||||
http://127.0.0.1:8090/api/auth/password
|
||||
# {"ok":true,"changed":true,"hint":"other browsers are now signed out",
|
||||
# "expires_at":1794982214.0,"csrf":"…"}
|
||||
```
|
||||
|
||||
Nothing forces a change: no expiry, no first-login interstitial, and `DASH_ADMIN_PASSWORD` is never
|
||||
re-applied after the first boot of a database. The only composition rule is length — at least
|
||||
`api.MIN_PASSWORD_LENGTH` (8) characters, not empty once stripped, and different from the current
|
||||
one — each refused with a `422` that names the rule. A symbol/digit mandate produces sticky notes
|
||||
rather than passwords, and the cost of a guess here is PBKDF2 plus the backoff, not the character
|
||||
classes.
|
||||
|
||||
A wrong `current` is `403 {"ok":false,"detail":"current password is wrong"}` and a
|
||||
`password-denied` event, and guesses at that field are throttled on their **own** key
|
||||
(`password:<address>`) — the same arithmetic as the login form, a separate bucket, so this form is
|
||||
never an unthrottled oracle for the old password once a cookie is stolen and one form's lockout
|
||||
does not lock out the other.
|
||||
|
||||
A right one rewrites `auth.verifier` **and** `auth.session_secret`, which is the whole revocation
|
||||
story: a session is a signed cookie with no server-side record, so rotating the signing key is the
|
||||
only way a password change reaches the browsers that are not in front of the form (without it a
|
||||
stolen cookie would stay valid for another `DASH_SESSION_HOURS`). The response carries a fresh
|
||||
cookie and a fresh CSRF token minted from the **new** secret, so the caller who just changed the
|
||||
password is not signed out by their own form, while every other browser gets a `401` on its next
|
||||
request and signs back in with the new password. `password-changed` lands in the event feed — an
|
||||
event, not an overview alert, and not a field on `/api/health` (which is public) or `/api/config`.
|
||||
|
||||
### Scripting the API
|
||||
|
||||
`Authorization: Bearer <session token>` is the scripted caller, and it needs no CSRF token —
|
||||
another site can make your browser send its cookie but cannot read a token out of your shell.
|
||||
The session token is in the login response's `Set-Cookie`, not in its body:
|
||||
|
||||
```bash
|
||||
SESSION=$(curl -si -X POST -H 'content-type: application/json' \
|
||||
-d '{"password":"…"}' http://127.0.0.1:8090/api/auth/login \
|
||||
| tr -d '\r' | sed -n 's/^set-cookie: dash_session=\([^;]*\).*/\1/p' | head -1)
|
||||
curl -s -H "Authorization: Bearer $SESSION" http://127.0.0.1:8090/api/summary # 200
|
||||
curl -s -H "Authorization: Bearer $SESSION" -X POST -H 'content-type: application/json' \
|
||||
-d '{"ups":"apc","command":"test.panel.start"}' http://127.0.0.1:8090/api/ups/command
|
||||
```
|
||||
|
||||
That is the app's one write endpoint **that dials anything** (the `/api/auth/*` writes change only
|
||||
this process and its SQLite `meta` table — invariant 1 is about the fleet), and the bearer header
|
||||
changes nothing about its gates: a command that can cut output still needs `"confirm":"DANGER"`,
|
||||
and is still refused while the UPS is on battery.
|
||||
|
||||
### Lockout
|
||||
|
||||
Five wrong passwords from one address starts a backoff (`429` with `Retry-After`, exponential
|
||||
from 60 s, capped at an hour; a good sign-in clears it, and a restart resets the counters). The
|
||||
change-password form keeps its **own** counter under `password:<address>`, so hammering one form
|
||||
cannot lock you out of the other and neither form is an unthrottled oracle for the same secret.
|
||||
The refusal is the same sentence for every kind of failure — one account, so the response must
|
||||
not rank reasons — and each refused attempt writes a `login-denied` or `password-denied` event you
|
||||
can read at `/api/events?source=auth`. Neither ever becomes an overview alert: a password guess is
|
||||
not an outage, and a page that blinks for both trains you to ignore the ones that matter.
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything comes from the environment (see `.env.example` for the annotated list). At
|
||||
@@ -125,7 +342,12 @@ documentation and is never loaded, and `scripts/dev-server.sh` exports `.env.dev
|
||||
| `NUT_SIMULATE` | `1` replaces upsd with a **labelled** simulator (development only) |
|
||||
| `DASH_READ_ONLY` | `1` disables every mutating endpoint regardless of other flags |
|
||||
| `DASH_DB` | SQLite path (default `data/dashboards.sqlite3`) |
|
||||
| `DASH_ADMIN_PASSWORD` | the admin password **seed**, needed only on the first boot of a database — see *Signing in* above |
|
||||
| `DASH_SESSION_HOURS` | session lifetime (default `720` = 30 days) |
|
||||
| `DASH_COOKIE_SECURE` | `1` marks the session cookie `Secure`; off because dev is `http://127.0.0.1`, and a request that arrived over `X-Forwarded-Proto: https` is treated as secure either way |
|
||||
| `DASH_PBKDF2_ITERATIONS` | verifier cost (default `600000`). The knob exists for the test suite; leave it alone in a deployment. Raising it on a real box does take effect: the next successful sign-in re-hashes a cheaper stored verifier and keeps the signing key |
|
||||
| `POLL_MS_SYSTEM` / `POLL_MS_LLM` / `POLL_MS_UPS` | how often the *browser* refetches each page (10 s / 2 s / 5 s) — shown in the page footer. `/disks` has no `POLL_MS_*` of its own and polls at a fixed 30 s: its payload only moves when the `disks` job re-reads the agents |
|
||||
| `DASH_RELOAD` | `1` makes `uv run dashboards` watch the source and restart on a change — development only, and every poll loop restarts with it |
|
||||
| `DEBUGPY` / `DEBUGPY_PORT` | `DEBUGPY=1` opens a debugpy listener (default 5678) without blocking startup |
|
||||
| `RETAIN_RAW_HOURS` / `RETAIN_MINUTE_DAYS` / `RETAIN_HOUR_DAYS` | history rollup and pruning |
|
||||
|
||||
@@ -133,11 +355,6 @@ A source that is not configured answers `404 {"configured": false}` **without di
|
||||
and the page says "not configured" instead of showing a red panel for something you never
|
||||
installed.
|
||||
|
||||
Nothing that leaves the app carries a credential. A `HOST_TARGETS` userinfo token is parsed
|
||||
into an `Authorization` header, and every URL the browser can see — `/api/config`, a source's
|
||||
error string, the event feed — goes through `config.strip_userinfo` first, because an
|
||||
unreachable host writes its failure message into the event table and `/api/events` serves it.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -151,7 +368,9 @@ src/dashboards/collectors.py ─────────────────
|
||||
sources/host.py · disks.py · llm.py · nut.py poller.py (one task per source)
|
||||
│ samples events │
|
||||
▼ ▼
|
||||
store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ── api.py → web/
|
||||
store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event + meta ── api.py → web/
|
||||
▲
|
||||
auth.py ── verifier + session key, loaded once at startup (no row and no seed → no server)
|
||||
```
|
||||
|
||||
* `poller.py` — every source gets its own asyncio task, its own health record, and its own
|
||||
@@ -164,7 +383,15 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ──
|
||||
poller, so a SQLite write that gets refused is reported (`ok: false`, the real reason, the
|
||||
numbers aging, one line in the event log) instead of freezing the source under a green dot.
|
||||
* `store.py` — raw samples roll up to 1-minute and 1-hour averages and the raw rows are
|
||||
pruned, so a year of history is a few hundred MB rather than a disk emergency.
|
||||
pruned, so a year of history is a few hundred MB rather than a disk emergency. Its `meta`
|
||||
table is the one place state that must outlive a restart goes (`get_meta`/`set_meta`/
|
||||
`delete_meta`), and its first user is the admin credential below.
|
||||
* `auth.py` — the admin login's decisions, in stdlib and without HTTP: a self-describing
|
||||
PBKDF2-HMAC-SHA256 verifier, an HMAC-signed stateless session cookie (no session table, and
|
||||
rotating the stored key is what evicts every other browser), a session-bound CSRF token, and
|
||||
an in-process login backoff. `load_auth_state()` is the startup half: the environment seeds a
|
||||
password **once**, the stored verifier wins from then on, and a server with neither refuses to
|
||||
start (see *Signing in*).
|
||||
* `severity.py` — the single green/amber/red table (`THRESHOLDS`, plus `INVERTED` for the
|
||||
metrics where lower is worse). Rated values in the API payloads travel with a sibling
|
||||
`severity` (`cpu.severity.usage_pct`, `memory.severity`, `disks.severity`, a UPS card's
|
||||
@@ -343,6 +570,40 @@ quietly, which reads exactly like a pass), they drive the production code paths
|
||||
(`collectors.*`, `NutMonitor.collect`) against whatever hardware is reachable, and they never
|
||||
write — a battery test on live hardware is not a unit test.
|
||||
|
||||
`tests/auth_contract.py` is two constants and the reason for them: every test that starts a
|
||||
server seeds the admin password `testpass` (a server with no stored verifier and no seed
|
||||
refuses to start, so a test that wants a running app has to give it a credential the way
|
||||
`.env.dev` does) and hashes it at 1 000 rounds through that same `pbkdf2_iterations` setting
|
||||
instead of the real 600 000 — ~0.3 s per hash times a hundred-odd servers is not a slower suite,
|
||||
it is a suite nobody runs. The real cost is pinned where it belongs
|
||||
(`test_the_default_cost_is_the_owasp_figure` and the config default test), so lowering it here
|
||||
cannot lower it there. Its partner is `tests/auth_client.py`: `sign_in(client)` posts that
|
||||
password once, asserts the sign-in worked, and puts the returned CSRF token on the client's
|
||||
default headers — which is what keeps the ~200 pre-existing HTTP assertions asserting the
|
||||
behaviour they were written for instead of the new gate.
|
||||
|
||||
`tests/test_auth_api.py` is the perimeter, and it is deliberately awkward in one place: every
|
||||
anonymous request runs with `follow_redirects=False`, because a client that follows them would
|
||||
fetch the login page and report `200` for a route that refused it — the way a gated suite ends
|
||||
up passing while testing nothing. It also walks the *served* route table
|
||||
(`test_every_route_that_is_not_public_requires_a_session`), so a route a later phase forgets to
|
||||
protect fails there rather than in a browser, and it proves the one refusal that matters most on
|
||||
the wire rather than on a status code: a `POST /api/ups/command` without the CSRF token answers
|
||||
`403` **and the scripted upsd records nothing**, while the same request with the token still
|
||||
reaches it — and `driver.killpower` still needs the word `DANGER` behind the session, because a
|
||||
fifth gate that quietly replaced the fourth would be the worst kind of regression.
|
||||
|
||||
`tests/test_auth_docs.py` tests the paperwork, because the paperwork is what an operator holds
|
||||
when the dashboard will not come up. It reads `.env.example` and `.env.dev.example` and demands
|
||||
that every `DASH_*` auth setting is declared there and that no secret-shaped **assignment** carries
|
||||
anything but a placeholder (the same allow-list `bash .agents/validate.sh` enforces through
|
||||
`git ls-files`, restated so a plain `uv run pytest` catches it on the working tree) — while the
|
||||
local-testing word stays legal in prose, which is the only place it can be useful. It demands that
|
||||
`scripts/dev-server.sh` still gets its readiness probe and its `status` from the public
|
||||
`/api/health` and has grown no session, token or password of its own, and that both docs still name
|
||||
the four public paths, the reset command, the four variables and the `DASH_COOKIE_SECURE=1`
|
||||
deployment note. Nothing starts a server there; a sentence that quietly disappears is the bug.
|
||||
|
||||
One more file is worth knowing about: `tests/card_contract.py` is the table of
|
||||
`/api/system/snapshot` paths the system page's per-host cards are allowed to read. It is
|
||||
checked against all three captured host shapes (discrete GPU, APU, no GPU), over HTTP, and
|
||||
@@ -429,6 +690,18 @@ podman run --rm -p 8090:8090 --env-file dashboards.env \
|
||||
```
|
||||
|
||||
It listens on `0.0.0.0:8090` as an unprivileged user, and the SQLite history lives in
|
||||
`/var/lib/dashboards` — put a volume there or every restart forgets the charts. With no
|
||||
sources configured `/api/health` answers `200 {"status": "empty"}`, so the container passes
|
||||
its health probe before its env file is right instead of crash-looping.
|
||||
`/var/lib/dashboards` — put a volume there or every restart forgets the charts **and** the admin
|
||||
password, because the verifier and the session key are rows in that database. `dashboards.env`
|
||||
therefore needs `DASH_ADMIN_PASSWORD` for the first start of a new volume; with neither the seed
|
||||
nor a stored verifier the container exits non-zero with the reason instead of coming up open, and
|
||||
the same seed is ignored once the row exists, so it can be dropped from the env file afterwards.
|
||||
With no sources configured `/api/health` answers `200 {"status": "empty"}`, so the container
|
||||
passes its health probe before its env file is right instead of crash-looping.
|
||||
|
||||
Two more lines belong in that quadlet env template because this service faces the public domain:
|
||||
`DASH_COOKIE_SECURE=1`, so the session cookie never rides plain HTTP (the app also treats a
|
||||
request that arrived with `X-Forwarded-Proto: https` as secure, so a misconfigured flag degrades
|
||||
rather than leaks), and the password itself — set once, then removable, since the stored verifier
|
||||
wins. The nginx server block, its `X-Forwarded-*` headers and HSTS live in the deployment repo
|
||||
like every other service here, so a change to how the edge forwards TLS is a change in that repo,
|
||||
not in this one; the only thing this app reads off the proxy is that one scheme header.
|
||||
|
||||
Reference in New Issue
Block a user