phase: 11_admin_login

**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.

**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).

**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.

**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.

**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.

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