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

+89 -6
View File
@@ -1,8 +1,14 @@
// monitor — admin-only, read-only view of the Minecraft server's latest.log.
//
// Phase 1 (see plan/23-monitor.md): a tailer that follows /data/logs/latest.log
// into an in-memory ring buffer, a polling JSON API over that buffer, and an
// embedded single-page UI. Nothing else. No RCON, no dashboard tiles yet.
// See plan/23-monitor.md. A tailer follows /data/logs/latest.log into an
// in-memory ring buffer (/api/log), a set of independently-cached collectors
// feed the dashboard tiles (/api/summary, collect.go), and both are served to
// an embedded single-page UI by polling.
//
// The service is read-only in every sense. /data and /backups are mounted :ro,
// no route mutates anything, and the single credential it holds — the RCON
// password — can only send commands from a compile-time allowlist that no HTTP
// input can reach (rcon.go).
//
// Design notes that are NOT obvious from the code:
//
@@ -26,7 +32,12 @@
// disk, so a restart costs nothing that isn't still there.
//
// This service publishes no port (mcnet only) and is reached exclusively through
// caddy's basic_auth vhost. It holds no credentials in this phase.
// caddy's basic_auth vhost.
//
// It runs as root inside the container ON PURPOSE: /data is drwxr-x--- owned by
// uid 1000 (itzg), so a non-root user cannot even traverse it and the tailer
// would silently read nothing. Every mount is :ro, which is what actually bounds
// the damage here.
package main
import (
@@ -34,11 +45,13 @@ import (
"bytes"
_ "embed"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"syscall"
"time"
@@ -148,6 +161,7 @@ type tailer struct {
path string
backfill int
r *ring
ev *events // session history, derived from the same bytes (may be nil)
f *os.File
pos int64 // byte offset of the next unread COMPLETE line
@@ -273,7 +287,11 @@ func (t *tailer) drain(size int64) {
return
}
t.pos += int64(len(line))
t.r.append(trimEOL(line))
clean := trimEOL(line)
t.r.append(clean)
if t.ev != nil {
t.ev.observe(clean)
}
}
}
@@ -359,9 +377,12 @@ func main() {
}
r := newRing(bufLines)
t := &tailer{path: path, backfill: bufLines, r: r, state: tailState{Path: path}}
ev := newEvents(500)
t := &tailer{path: path, backfill: bufLines, r: r, ev: ev, state: tailState{Path: path}}
go t.run(interval)
col := buildCollectors(ev)
mux := http.NewServeMux()
// GET /api/log[?since=<seq>] — omit `since` for a full buffer without a
@@ -386,6 +407,15 @@ func main() {
json.NewEncoder(w).Encode(resp)
})
// GET /api/summary — every dashboard tile in one response. Each collector is
// independently cached and degrades on its own; a failure shows up as a
// *_err field next to a stale value rather than failing the request.
mux.HandleFunc("/api/summary", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(col.summary())
})
// Liveness only. Deliberately NOT tied to the log file: Minecraft being down
// is a legitimate state, and Uptime Kuma probes this over mcnet (bypassing
// caddy, hence unauthenticated by construction).
@@ -416,3 +446,56 @@ func envOr(key, def string) string {
}
return def
}
func envDur(key, def string) time.Duration {
d, err := time.ParseDuration(envOr(key, def))
if err != nil {
log.Fatalf("invalid %s: %v", key, err)
}
return d
}
// buildCollectors wires every dashboard tile source. TTLs are per-collector and
// chosen against what each thing actually costs: mc-status is a local HTTP hop
// (cheap, and it caches its own upstream ping), statfs is free, the backups dir
// is a directory scan, RCON opens a socket to the game server.
func buildCollectors(ev *events) *collectors {
base := strings.TrimRight(envOr("MONITOR_MCSTATUS_URL", "http://mc-status:8080"), "/")
dataDir := envOr("MONITOR_DATA_DIR", "/data")
backupDir := envOr("MONITOR_BACKUP_DIR", "/backups")
// Short timeout everywhere: a hung dependency must not hold the summary.
client := &http.Client{Timeout: 5 * time.Second}
rconAddr := envOr("MONITOR_RCON_ADDR", "minecraft:25575")
rconPass := os.Getenv("MONITOR_RCON_PASSWORD")
tpsInterval := envDur("MONITOR_TPS_INTERVAL", "30s")
return &collectors{
status: newCached(envDur("MONITOR_STATUS_TTL", "15s"), func() (statusInfo, error) {
return fetchStatus(client, base)
}),
registered: newCached(envDur("MONITOR_ROSTER_TTL", "60s"), func() ([]mcPlayer, error) {
return fetchRegistered(client, base)
}),
disk: newCached(envDur("MONITOR_DISK_TTL", "60s"), func() (diskInfo, error) {
return diskUsage(dataDir)
}),
backup: newCached(envDur("MONITOR_BACKUP_TTL", "60s"), func() (backupInfo, error) {
return backupStatus(backupDir)
}),
tps: newCached(tpsInterval, func() (*tpsInfo, error) {
if rconPass == "" {
return nil, errors.New("rcon: MONITOR_RCON_PASSWORD unset (TPS disabled)")
}
raw, err := rconQuery(rconAddr, rconPass, "neoforge tps", 5*time.Second)
if err != nil {
return nil, err
}
return parseTPS(raw)
}),
events: ev,
avatarURL: strings.TrimRight(envOr("MONITOR_AVATAR_URL", ""), "/"),
avatarMode: envOr("MONITOR_AVATAR_MODE", "headiso"),
}
}