feat(monitor): dashboard tiles + TPS via allowlisted RCON

Phases 2 and 3 of plan/23-monitor.md. /api/summary adds: server online/MOTD/
version, players online with NMSR heads, registered-account count, disk free on
the world volume, backup freshness, join/leave history, and TPS.

Each tile has its own TTL cache and its own failure mode. A failed collector
keeps serving its last good value and reports the reason in a sibling *_err
field, so the page goes stale-with-a-warning rather than blank. The five
collectors refresh concurrently: sequentially, one request during an outage
would cost the sum of every timeout (~15s), and an outage is exactly when
someone is looking at this page. Measured with a dead dependency: 0.24s.

TPS needs RCON, so the monitor now holds RCON_PASSWORD. Read-only survives
because the allowlist is a compile-time constant and no HTTP input reaches a
command string — the UI cannot run commands because the server cannot be asked
to. The Source RCON client is implemented in-repo rather than added as a
dependency precisely to keep that property local and auditable.

Two things the real server corrected:

- `neoforge tps` prints "Overall: 20.000 TPS (0.357 ms/tick)", not the older
  Forge "Mean tick time / Mean TPS" wording the plan assumed. main_test.go
  pins the real captured output so a format change fails a test instead of
  silently zeroing the tile.

- Rosters come from mc-status, which already holds the Drasl admin credentials
  and exposes a sanitized list, so this container does not get a second copy.

Also fixes a phase-1 bug: the UI's level regex assumed itzg's console timestamp
([12:34:56]) but the file uses [01Aug2026 22:55:34.358], so every line was
classified INFO and the ERROR/WARN chips did nothing. It now matches on shape.

The container runs as root deliberately — /data is drwxr-x--- owned by uid 1000,
so a non-root user cannot traverse it and the tailer would silently read
nothing. The :ro mounts and absent port are what bound the damage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
oierandClaude Opus 5 committed 2026-08-01 23:16:11 +02:00
1 parent 53604a6127
commit 00984e1a70
7 files changed
+958 -20

No files matched your search

+63 -12
View File
@@ -118,6 +118,31 @@ owns up/down and uptime *history* — do not rebuild that here.
walk (expensive, low value, cache-or-nothing), container health (needs the
docker socket).
### `/api/summary` — one response, independently-degrading parts
Every tile has its own TTL cache and its own failure mode (`collect.go`). A
collector that fails keeps serving its last good value and reports the reason in
a sibling `*_err` field, so the page shows *stale with a warning* rather than a
blank tile or a spinner that never resolves. Verified: with mc-status killed,
`server_err` is populated while the disk, backup and log tiles keep working.
The five collectors are refreshed **concurrently**. Sequentially, one request
during an outage would cost the sum of every timeout (~15s with mc-status
hanging and RCON blackholed) — and an outage is precisely when someone is
staring at the page. Measured with a dead dependency: 0.24s.
TTLs: status 15s, roster 60s, disk 60s, backup 60s, TPS 30s. The UI polls
`/api/summary` every 10s and `/api/log` every 2s — tiles move far more slowly
than the log does.
### Running as root, on purpose
`/data` is `drwxr-x---` owned by uid 1000 (itzg). A non-root container user
cannot traverse it, so the tailer would read nothing — silently. The container
therefore runs as root, and what actually bounds the damage is that every mount
is `:ro` and no port is published. Do not "harden" this by adding a `USER`
directive without re-checking those permissions first.
## RCON — the one credential, and why it doesn't break constraint 2
TPS is the number you actually want when someone says "the server is lagging",
@@ -129,10 +154,29 @@ Constraint 2 survives because the UI never sends a command: the poller runs a
code path from an HTTP request to an RCON string. Hardcode the allowlist so a
future refactor can't quietly turn this into a web console.
- **The command is `/neoforge tps` on NeoForge 1.21.1**, not `/forge tps`.
Verify against the running server before wiring it, and treat an
unknown-command reply as "TPS unavailable" rather than letting it kill the
poll loop.
- **The command is `neoforge tps` on NeoForge 1.21.1**, not `forge tps`.
Confirmed on the running server; `forge tps` answers *"Unknown or incomplete
command"*. An unknown-command reply is treated as "TPS unavailable" rather
than being allowed to kill the poll loop.
- **The output format is NOT the old Forge "Mean tick time / Mean TPS" wording**
this doc originally assumed. Actual, captured from prod:
```
Overworld: 20.000 TPS (0.298 ms/tick)
The Nether: 20.000 TPS (0.014 ms/tick)
Overall: 20.000 TPS (0.357 ms/tick)
```
One line per dimension plus an `Overall:` line, with `§`/ANSI colour codes
mixed in that have to be stripped. `main_test.go` pins this against the real
captured string — if a NeoForge update changes the wording, that test fails
instead of the tile silently reading zero.
- The RCON client is implemented in-repo (`rcon.go`, ~120 lines of the Source
RCON protocol) rather than pulled in as a dependency, specifically so the
allowlist is a compile-time constant with no code path from HTTP input to a
command string. `main_test.go` asserts `op`, `stop`, `say`, `ban` and
near-misses like `LIST` and a trailing-space `"neoforge tps "` are all
rejected.
- Alternative considered: the `spark` mod. Better data (real profiling), but a
165th jar into a pack already over its stated ceiling, plus its own web
endpoint to secure. Rejected for now; still the upgrade path if RCON TPS
@@ -259,17 +303,24 @@ one-container restart.
- [x] nginx vhost + `MONITOR_ENABLED` gate; render + reload.
- [x] Deploy with `--hard` (world downtime, 0 players online). Verified below.
### Phase 2 — tiles
### Phase 2 — tiles — **DONE**
- [ ] `./backups:/backups:ro` mount; backup-freshness + disk-free tiles.
- [ ] mc-status reuse: online/MOTD/icon, players online w/ NMSR heads,
registered count.
- [ ] Join/leave session history parsed from the buffer.
- [x] `./backups:/backups:ro` mount; backup-freshness + disk-free tiles.
- [x] mc-status reuse: online/MOTD, players online w/ NMSR heads,
registered count. (Icon skipped — the MOTD and version carry the same
information in a row of small tiles, and the favicon is a base64 blob.)
- [x] Join/leave session history parsed from the buffer.
- [x] **Fixed a phase-1 bug found while building this:** the UI's level regex
assumed itzg's console format (`[12:34:56]`), but the *file* uses
`[01Aug2026 22:55:34.358]`. Every line was classified INFO, so the
ERROR/WARN chips silently did nothing. Now matches on shape
(`[…] […/LEVEL]`) instead of on the timestamp.
### Phase 3 — TPS
### Phase 3 — TPS — **DONE**
- [ ] RCON client, compile-time allowlist, 30s poll.
- [ ] Confirm `/neoforge tps` is the right command on the running server.
- [x] RCON client, compile-time allowlist, 30s poll.
- [x] Confirmed `neoforge tps` is the right command, and captured its real
output format (see *RCON* above — it is not what this doc first assumed).
### Follow-ups