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

+20
View File
@@ -345,11 +345,31 @@ services:
environment:
MONITOR_LOG_PATH: "/data/logs/latest.log"
MONITOR_BUFFER_LINES: "5000"
# Dashboard tiles. Server status + rosters are read from mc-status over
# mcnet rather than from Drasl directly — mc-status already holds the
# admin credentials and exposes a sanitized list, so this container does
# not need a second copy of that secret.
MONITOR_MCSTATUS_URL: "http://mc-status:8080"
# Player heads. Rendered by NMSR on its own PUBLIC origin, so the browser
# fetches them cross-origin (no auth involved, avatars are public anyway).
MONITOR_AVATAR_URL: "https://avatar.${BASE_DOMAIN}"
MONITOR_AVATAR_MODE: "${AVATAR_MODE:-headiso}"
# TPS via RCON. This is the ONLY credential the monitor holds, and the
# only command it can send is the compile-time allowlist in rcon.go
# (`neoforge tps`, `list`) — no HTTP input reaches a command string, so
# the UI stays incapable of running anything. NeoForge 1.21.1 uses
# `neoforge tps`; `forge tps` does not exist here.
MONITOR_RCON_ADDR: "minecraft:25575"
MONITOR_RCON_PASSWORD: ${RCON_PASSWORD}
MONITOR_TPS_INTERVAL: "30s"
volumes:
# The whole Minecraft data dir, read-only. Only logs/latest.log is read;
# mounting /data rather than /data/logs keeps the path stable if itzg ever
# moves the log dir, and :ro means it cannot touch the world either way.
# It is also what the disk-free tile statfs's.
- mc_data:/data:ro
# Backup freshness tile. Same dir mc-backup writes to, read-only here.
- ./backups:/backups:ro
networks:
- mcnet