Files
ulicraft-server-v1/docker/monitor/main_test.go
T
oierandClaude Opus 5 00984e1a70 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>
2026-08-01 23:16:11 +02:00

54 lines
2.0 KiB
Go

package main
// Locks the two things that are easy to break silently: the RCON allowlist
// (the guarantee that this service cannot run commands) and the `neoforge tps`
// output format, which differs from the older Forge wording.
import "testing"
func TestParseTPSRealOutput(t *testing.T) {
// Captured verbatim from `docker exec minecraft rcon-cli neoforge tps` on prod,
// ANSI resets included.
raw := "Overworld: 20.000 TPS (0.298 ms/tick)\n\x1b[0mThe Nether: 19.100 TPS (0.014 ms/tick)\n" +
"\x1b[0mworld_dimension_nexus:creative: 20.000 TPS (0.009 ms/tick)\n" +
"\x1b[0mOverall: 18.500 TPS (0.357 ms/tick)\n\x1b[0m"
got, err := parseTPS(raw)
if err != nil {
t.Fatalf("err: %v", err)
}
if got.TPS != 18.5 || got.MSPT != 0.357 {
t.Fatalf("overall wrong: %+v", got)
}
if len(got.Dimensions) != 3 {
t.Fatalf("want 3 dims, got %d: %+v", len(got.Dimensions), got.Dimensions)
}
}
func TestParseTPSUnknownCommand(t *testing.T) {
if _, err := parseTPS("Unknown or incomplete command, see below for error\nforge tps<--[HERE]"); err == nil {
t.Fatal("want error for unknown command")
}
}
func TestRCONAllowlist(t *testing.T) {
for _, bad := range []string{"op Oier", "stop", "say hi", "ban x", "", "neoforge tps ", "LIST"} {
if _, err := rconQuery("127.0.0.1:1", "pw", bad, 0); err != errRCONNotAllowed {
t.Fatalf("command %q was not rejected by the allowlist (err=%v)", bad, err)
}
}
}
func TestSessionParse(t *testing.T) {
e := newEvents(10)
e.observe("[01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier joined the game")
e.observe("[01Aug2026 23:01:02.111] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier left the game")
e.observe("[01Aug2026 23:02:00.000] [Server thread/INFO] [x/]: something else entirely")
got := e.recent(10)
if len(got) != 2 {
t.Fatalf("want 2 events, got %d: %+v", len(got), got)
}
if got[0].Action != "left" || got[0].Player != "Oier" || got[0].Time != "01Aug2026 23:01:02.111" {
t.Fatalf("newest-first wrong: %+v", got[0])
}
}