Serve a web UI for the incident queue, built for phones

Whoever is on call gets paged on a phone, and until now the only ways to
act on a page were the notification's Acknowledge button or a terminal.
Tapping the notification itself opened /api/incidents/{id}, which a
browser can only answer with a 401 in JSON. The server now serves a web
UI at / covering the incident queue, each incident's alerts and timeline
with every action on it, who is on call, the alert feed, and changing
your own password. The notification link now points at /incidents/{id}
in that UI.

It is embedded in the binary and has no build step: plain HTML, CSS and
ES modules under internal/web/static, served with an ETag per file and a
CSP that allows nothing from any other origin. That is how rd-web is
built. It avoids adding a node toolchain to the Dockerfile and the
pipeline for a page this size, and it keeps the page on the same origin
as the API, so no CORS is needed and nothing else has to be deployed.
Paths without a file extension fall back to index.html, so a deep link
survives a reload. An unknown path under /api/ still gets a JSON 404
rather than the page.

Signing in uses a username and password, because pasting a 64-character
API key into a phone at 3am is not a sign-in flow. Users have no
password until one is set through PUT /api/users/{id}/password, or
optionally at bootstrap. A user without a password is exactly where they
were before this commit and can only use API keys. A login sets an
HttpOnly, SameSite=Lax session cookie. It lasts 30 days and slides
forward while in use, so an on-call phone does not sign itself out.
Only the token's hash is stored, as for API keys.

The cookie needs a CSRF guard where a bearer header does not, because
browsers attach cookies to requests other sites make. So cookie-
authenticated requests go through Go 1.25's http.CrossOriginProtection,
and bearer requests do not. A request carrying an Authorization header
is judged on that header alone and never falls back to the cookie.
Changing a password ends every other session of that user. Changing
your own requires the current password, so a phone left signed in
cannot be used to take the account over.

Failed logins are counted per username and per client address. Ten
failures for one username in 15 minutes refuse that username for the
rest of the window, even with the right password. That makes locking
somebody out possible for anyone who knows their username. It was
accepted because the alternative is unlimited guessing, and during a
lockout the notification's Acknowledge button and API keys keep
working. The address limit reads the first X-Forwarded-For hop, since
behind the gateway RemoteAddr is Envoy. It is looser, because a whole
office behind one NAT shares it.

The Secure flag follows TERDUT_PUBLIC_URL, since TLS terminates at the
gateway and the server itself only ever sees plain HTTP. The chart
already defaults that variable to https://<hostname>.

Schedule editing, statistics and user management stay in terdut-tui for
now. The API they use is unchanged, and bearer authentication behaves
exactly as before.
This commit is contained in:
Niklas Ye
2026-09-19 17:48:21 +02:00
parent 9669b8f477
commit dc3879eca6
31 changed files with 3494 additions and 38 deletions
+64 -5
View File
@@ -7,6 +7,7 @@ Incident management server for teams using Prometheus Alertmanager.
- Incident workflow: acknowledge, assign, snooze, note, resolve, with a full timeline
- On-call schedule management, with new incidents auto-assigned to whoever is on call
- Alert and incident statistics, including MTTA and MTTR
- Web UI for phones and desktops, served by the same binary
- REST API with per-user API key authentication
- Single binary, SQLite storage — trivial to self-host
@@ -29,10 +30,11 @@ The server starts on `:8080` with a `terdut.db` file in the working directory.
```bash
curl -X POST http://localhost:8080/api/bootstrap \
-H "Content-Type: application/json" \
-d '{"username": "admin", "email": "admin@example.com"}'
-d '{"username": "admin", "email": "admin@example.com", "password": "<at least 10 characters>"}'
```
Save the `api_key.key` value from the response — it is shown **once only**.
Save the `api_key.key` value from the response — it is shown **once only**. The
`password` is optional and is what signs you in to the [web UI](#web-ui).
Use it as a bearer token for all subsequent requests:
@@ -41,6 +43,48 @@ export KEY=<your-key>
curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
```
### Web UI
The server serves a web UI at `/`: the incident queue, each incident's alerts
and timeline with every action (acknowledge, assign, snooze, note, resolve,
archive), who is on call, the alert feed, and changing your own password. It is
built for a phone first. On a phone it has a bottom tab bar and a sticky action
bar, it follows the system's dark mode, and it can be added to the home screen.
From 900px wide it switches to a sidebar with the queue and the incident side by
side. Schedule editing, statistics and user management remain in
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
You sign in with a username and password. Users have no password until one is
set, and a user without one can only use API keys:
```bash
# an admin sets someone's first password with their API key
curl -X PUT http://localhost:8080/api/users/2/password \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"password": "<at least 10 characters>"}'
```
After that, users change it themselves under *Account*. Changing your own
password requires the current one.
How a browser stays signed in:
- A successful login sets an `HttpOnly`, `SameSite=Lax` session cookie. It lasts
30 days and slides forward while it is used, so an on-call phone stays signed
in.
- The cookie is marked `Secure` when `TERDUT_PUBLIC_URL` starts with `https://`,
so set it to the HTTPS address. TLS terminates at the gateway and the server
itself only ever sees plain HTTP.
- Requests authenticated by the cookie are checked for cross-origin use (Go's
`http.CrossOriginProtection`). That is the CSRF guard. Bearer-key clients are
not affected.
- Setting a password signs that user out everywhere else.
- Ten failed logins for one username within 15 minutes lock that username for
the rest of the window.
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
the web UI (`/incidents/{id}`).
### Docker
```bash
@@ -107,7 +151,7 @@ Set `backupSidecar.enabled=false` if you back the volume up some other way.
| `TERDUT_NTFY_URL` | — | ntfy server to publish push notifications to. Empty disables notifications entirely |
| `TERDUT_NTFY_TOKEN` | — | Bearer token for an access-controlled ntfy |
| `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call |
| `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server, for the link and Acknowledge button inside a notification |
| `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server: the notification's link into the web UI, its Acknowledge button, and whether the session cookie is `Secure` |
| `TERDUT_NOTIFY_REPEAT` | `15m` | How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats |
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
@@ -375,21 +419,32 @@ of the last heartbeat, and the heartbeat's labels are on the incident's
### Authentication
All endpoints except `/api/bootstrap` and `/api/alertmanager/webhook` require:
All endpoints except `/api/bootstrap`, `/api/alertmanager/webhook`,
`/api/notify/ack/{token}`, `/api/login` and `/api/logout` require either an API key:
```
Authorization: Bearer <api-key>
```
or the web UI's session cookie. A request that carries an `Authorization` header
is judged on that header alone.
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures |
| `POST` | `/api/logout` | Ends the session and clears the cookie |
| `GET` | `/api/me` | The caller: `{user, has_password}` |
### Users
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/bootstrap` | Create first user + API key (only works on empty DB) |
| `POST` | `/api/bootstrap` | Create first user + API key `{"username","email","password"?}` (only works on empty DB) |
| `GET` | `/api/users` | List users |
| `POST` | `/api/users` | Create user `{"username","email"}` |
| `DELETE` | `/api/users/{id}` | Delete user (cascades to keys) |
| `PUT` | `/api/users/{id}/notify` | Set push notification target `{"ntfy_topic"}` — empty string clears it |
| `PUT` | `/api/users/{id}/password` | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions |
| `POST` | `/api/users/{id}/api-keys` | Issue API key `{"name"}` — key shown once |
| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | Revoke API key |
@@ -668,6 +723,10 @@ adds `-race`, which CI does not. The sweeper, the notifier goroutine and the dea
sweep all touch the same single database connection, and a race between them would surface as
a flaky incident in production rather than as a red build.
The web UI lives in `internal/web/static/` as plain HTML, CSS and ES modules,
embedded into the binary with `go:embed`. It has no build step and no npm, so
editing a file and restarting the server is the whole loop.
## Releasing
```