v0.14.0
## Added
- Checks can be reordered by dragging their cards on the admin checks page. Dragging is off
while a search or type filter is active, because the server takes the whole list. The admin
checks page, the blue team checks page and the scoreboard all list checks in this order, and
a new check goes last
- `POST /api/checks/reorder` takes `check_ids`, every check id in the new order, and needs
BLACK or higher. A list with a duplicate, an unknown id or a missing check is a 400
- A Delete button on the check edit page, behind a confirmation
- `/api/health` says whether this process's scheduler holds the lock (`leader`) or is waiting
(`standby`)
- Login is rate limited per client IP to 1 request a second with a burst of 10. Over the
limit it returns a 429, and the login page tells the user to wait. The limiter is in memory
per replica, so N replicas allow N times that rate
- Cross-origin protection on every API route, using Go's `http.CrossOriginProtection` with the
allowed URLs as trusted origins. It refuses cross-site form posts, which CORS and
SameSite=Lax both let through. Clients that send no `Origin` or `Sec-Fetch-Site`, such as
curl and Bruno, are unaffected
- Security headers. The backend sends `X-Content-Type-Options: nosniff` and a CSP whose
`frame-ancestors` lists only the trusted origins, so the frontend can still frame inject
PDFs. The frontend sends `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`
and a CSP that blocks framing, plugins and `<base>` changes, and drops `X-Powered-By`. The
prod swarm override adds a one-year HSTS header on the HTTPS routers
## Changed
- The JWT cookie is HttpOnly and SameSite=Lax, so script on the page can't read the token. A
second cookie, `hermes_session`, holds the token's claims as unsigned base64url JSON for the
frontend to read. The backend never reads it, and every access check still runs on the JWT.
Anyone signed in before the upgrade has no `hermes_session` cookie and has to sign in again
- The backend accepts only HS256 tokens that carry an expiry. It moved from the unmaintained
`dgrijalva/jwt-go` to `golang-jwt/jwt/v5`
- Every backend process runs both the scheduler and the API. `HERMES_ROLE` is gone, and
`just setup-environment` no longer writes it. Replicas that lose the scheduler lock wait as
standbys. The scheduler's separate health server is gone, since `/api/health` covers it.
One database pool, opened at startup, serves both
- Pausing, resuming and resetting the engine take effect right away. Every write of the engine
state sends `NOTIFY engine_state`, which wakes a paused scheduler. A reset also sends
`NOTIFY engine_reset`, which cancels the round in flight and makes every API reload its
cached scoreboard. Each API checks the engine state every second, so clients see a pause or
resume made through any replica
- The scheduler's round-info message carries only the round id. Each API reads that round
back from the database, so a message that arrives after a reset can't bring a deleted round
back
- `/api/round/info` sends each check's `check_id`, `user_id`, `status` and `success` and
nothing else. Check names, output, error text and timings are gone from it, and the
scoreboard cell tooltips no longer show output or errors
- `checks.toml` has a `sort_order` on every check. Import refuses a check with no
`sort_order`, a negative one, or one an earlier check in the file already uses. A merge
import puts the file's checks after the existing ones and keeps their relative order.
Exports from v0.13.1 and earlier have no `sort_order`, so add one to each check before
importing them
- A destructive users import is refused with a 400 unless the importing account is in
`users.toml`. Once it commits, the importer's session ends and they sign in again with the
password from the export
- Importing a `checks.toml` with no checks, a `users.toml` with no users or a `settings.toml`
with nothing in it is no longer an error. A destructive checks import still needs a
`checks` key, so `checks = []` clears every check
- Bulk import caps the zip at 512 MB, both as uploaded and as decompressed. Each PDF inside
is held to the PDF size limit and every other file to 10 MB, counted on the decompressed
bytes rather than the sizes the zip claims. The zip is read from the upload instead of
being copied into memory first
- `docker-compose.yml` publishes Traefik's ports 80 and 443 in host mode. Ingress mode NATs
every client to one gateway address, which would put every user in the same login
rate-limit bucket. The backend takes the client IP from `X-Forwarded-For`, which Traefik
overwrites
- echo package bumped to 5.4.0
- Existing databases need the new `Checks.sort_order` column added by hand. A fresh database
gets it from setup:
ALTER TABLE Checks ADD COLUMN sort_order INTEGER;
UPDATE Checks c SET sort_order = s.n - 1
FROM (SELECT id, row_number() OVER (ORDER BY id) AS n FROM Checks) s
WHERE c.id = s.id;
ALTER TABLE Checks ALTER COLUMN sort_order SET NOT NULL,
ADD CONSTRAINT checks_sort_order_key UNIQUE (sort_order);
## Fixed
- The public scoreboard showed check output and error text in its tooltips, and network
errors in that text could include a minion's IP. Minions now send their own non-loopback
addresses with each result, and the scheduler replaces them with `[minion]` in output and
errors, chain steps included, before anything is stored
- A scoreboard refreshed from the database could include points from a round that was still
running. It now shows the latest round past its `ends_at`, with scores as they stood when
that round ended
- After an engine reset, the scheduler kept collecting results for a round that no longer
existed, then backed off
- Each new scoreboard connection re-sent the current round, engine state and remaining time
to every connected client. Now only the new client gets them
- A bulk import with destructive users and injects together failed, because the injects were
credited to the importer's old account id, which the users import had just deleted
- A bulk import zip had no size limit, so a small upload could decompress into gigabytes
- A username containing a NUL byte made login return a 500 instead of the usual 401
- Confirm dialogs could be confirmed twice while the first request was still running. Their
buttons are now disabled until it finishes
- The Service and Score labels on the default scoreboard drew over the mobile nav bar