From 00984e1a70503ccdb29324994da8c9b85ab53d27 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Sat, 1 Aug 2026 23:16:11 +0200 Subject: [PATCH] feat(monitor): dashboard tiles + TPS via allowlisted RCON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docker-compose.yml | 20 ++ docker/monitor/collect.go | 363 +++++++++++++++++++++++++++++++++++ docker/monitor/main.go | 95 ++++++++- docker/monitor/main_test.go | 53 +++++ docker/monitor/rcon.go | 188 ++++++++++++++++++ docker/monitor/ui/index.html | 184 +++++++++++++++++- plan/23-monitor.md | 75 ++++++-- 7 files changed, 958 insertions(+), 20 deletions(-) create mode 100644 docker/monitor/collect.go create mode 100644 docker/monitor/main_test.go create mode 100644 docker/monitor/rcon.go diff --git a/docker-compose.yml b/docker-compose.yml index a319720..9a7601c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/monitor/collect.go b/docker/monitor/collect.go new file mode 100644 index 0000000..1133b4d --- /dev/null +++ b/docker/monitor/collect.go @@ -0,0 +1,363 @@ +// Dashboard collectors (phase 2 + 3 of plan/23-monitor.md). +// +// Every tile is fed by its own independently-cached collector. That isolation +// is the whole design: mc-status being slow, the backups dir being unreadable, +// or RCON refusing a connection must degrade exactly one tile and never block +// the summary response or the log stream. On error a collector keeps serving +// its last good value and reports the error alongside it — stale-with-a-reason +// beats a blank page, and beats a spinner that never resolves. +package main + +import ( + "encoding/json" + "net/http" + "os" + "regexp" + "strings" + "sync" + "syscall" + "time" +) + +// --- TTL cache ------------------------------------------------------------- + +// cached memoizes one collector for ttl. Zero value is not usable; see newCached. +type cached[T any] struct { + mu sync.Mutex + val T + have bool + lastErr string + expires time.Time + + ttl time.Duration + fn func() (T, error) +} + +func newCached[T any](ttl time.Duration, fn func() (T, error)) *cached[T] { + return &cached[T]{ttl: ttl, fn: fn} +} + +// get returns the cached value (refreshing if stale) and the last error, if any. +// It never returns an error *instead* of a value — a failed refresh yields the +// previous value plus the error text. +func (c *cached[T]) get() (T, string) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.have && time.Now().Before(c.expires) { + return c.val, c.lastErr + } + v, err := c.fn() + c.expires = time.Now().Add(c.ttl) + if err != nil { + c.lastErr = err.Error() + return c.val, c.lastErr // stale-or-zero, with the reason + } + c.val, c.have, c.lastErr = v, true, "" + return c.val, "" +} + +// --- server status + rosters (via mc-status over mcnet) -------------------- + +// The monitor deliberately does NOT hold Drasl admin credentials. mc-status +// already logs in server-side and exposes a sanitized [{uuid,name}], so we read +// its output instead of duplicating the secret. See plan/15-mc-status.md. + +type mcPlayer struct { + UUID string `json:"uuid"` + Name string `json:"name"` +} + +type statusInfo struct { + Online bool `json:"online"` + Count int64 `json:"count"` + Max int64 `json:"max"` + MOTD string `json:"motd"` + Version string `json:"version"` + Players []mcPlayer `json:"players"` +} + +// v2 subset of the mcstatus.io shape mc-status re-emits. +type v2Wire struct { + Online bool `json:"online"` + Players struct { + Online int64 `json:"online"` + Max int64 `json:"max"` + List []struct { + UUID string `json:"uuid"` + NameClean string `json:"name_clean"` + } `json:"list"` + } `json:"players"` + MOTD struct { + Clean string `json:"clean"` + } `json:"motd"` + Version struct { + NameClean string `json:"name_clean"` + } `json:"version"` +} + +func fetchStatus(client *http.Client, base string) (statusInfo, error) { + var wire v2Wire + // The address segment is ignored by mc-status (it only ever pings its own + // configured target) — any placeholder works. + if err := getJSON(client, base+"/v2/status/java/self", &wire); err != nil { + return statusInfo{}, err + } + out := statusInfo{ + Online: wire.Online, + Count: wire.Players.Online, + Max: wire.Players.Max, + MOTD: wire.MOTD.Clean, + Version: wire.Version.NameClean, + } + for _, p := range wire.Players.List { + out.Players = append(out.Players, mcPlayer{UUID: p.UUID, Name: p.NameClean}) + } + return out, nil +} + +func fetchRegistered(client *http.Client, base string) ([]mcPlayer, error) { + var players []mcPlayer + if err := getJSON(client, base+"/players", &players); err != nil { + return nil, err + } + return players, nil +} + +func getJSON(client *http.Client, url string, dst any) error { + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return &statusCodeError{url: url, code: resp.StatusCode} + } + return json.NewDecoder(resp.Body).Decode(dst) +} + +type statusCodeError struct { + url string + code int +} + +func (e *statusCodeError) Error() string { + return e.url + ": HTTP " + itoa(e.code) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [12]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} + +// --- disk ------------------------------------------------------------------ + +type diskInfo struct { + Path string `json:"path"` + TotalBytes uint64 `json:"total_bytes"` + FreeBytes uint64 `json:"free_bytes"` + UsedPct float64 `json:"used_pct"` +} + +// diskUsage statfs's the world volume. This reports the underlying host +// filesystem, which is exactly what matters: a full disk is the failure that +// actually takes the server down, and backups (15G+ and growing) share it. +func diskUsage(path string) (diskInfo, error) { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return diskInfo{Path: path}, err + } + bs := uint64(st.Bsize) + total := st.Blocks * bs + free := st.Bavail * bs // Bavail, not Bfree — exclude root-reserved blocks + info := diskInfo{Path: path, TotalBytes: total, FreeBytes: free} + if total > 0 { + info.UsedPct = float64(total-free) / float64(total) * 100 + } + return info, nil +} + +// --- backups --------------------------------------------------------------- + +type backupInfo struct { + Newest string `json:"newest"` + SizeBytes int64 `json:"size_bytes"` + Mtime int64 `json:"mtime"` + AgeSeconds int64 `json:"age_seconds"` + Count int `json:"count"` + TotalBytes int64 `json:"total_bytes"` +} + +// backupStatus finds the newest archive written by mc-backup (world-*.tar.gz). +// Age is the number that matters — "backups exist" is not the same claim as +// "a backup ran recently", and only the second one is worth a tile. +func backupStatus(dir string) (backupInfo, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return backupInfo{}, err + } + var out backupInfo + var newest time.Time + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".tar.gz") { + continue + } + fi, err := e.Info() + if err != nil { + continue + } + out.Count++ + out.TotalBytes += fi.Size() + if fi.ModTime().After(newest) { + newest = fi.ModTime() + out.Newest = e.Name() + out.SizeBytes = fi.Size() + } + } + if out.Count > 0 { + out.Mtime = newest.Unix() + out.AgeSeconds = int64(time.Since(newest).Seconds()) + } + return out, nil +} + +// --- session history (derived from the log we already tail) ---------------- + +type sessionEvent struct { + Time string `json:"time"` + Player string `json:"player"` + Action string `json:"action"` // "joined" | "left" +} + +// Matches the message tail of e.g. +// +// [01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft...]: Oier joined the game +var sessionRe = regexp.MustCompile(`]: (\S+) (joined|left) the game\b`) + +// leading [timestamp] of a log line, whatever its format +var stampRe = regexp.MustCompile(`^\[([^\]]+)\]`) + +// events is a bounded history of joins/leaves, filled by the tailer as lines +// arrive. Free: the bytes are already being read for the log stream. It also +// means the cold-start backfill populates history without any persistence. +type events struct { + mu sync.Mutex + buf []sessionEvent + max int +} + +func newEvents(max int) *events { return &events{max: max} } + +func (e *events) observe(line string) { + m := sessionRe.FindStringSubmatch(line) + if m == nil { + return + } + ev := sessionEvent{Player: m[1], Action: m[2]} + if ts := stampRe.FindStringSubmatch(line); ts != nil { + ev.Time = ts[1] + } + + e.mu.Lock() + defer e.mu.Unlock() + e.buf = append(e.buf, ev) + if len(e.buf) > e.max { + e.buf = e.buf[len(e.buf)-e.max:] + } +} + +// recent returns up to n events, newest first. +func (e *events) recent(n int) []sessionEvent { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]sessionEvent, 0, n) + for i := len(e.buf) - 1; i >= 0 && len(out) < n; i-- { + out = append(out, e.buf[i]) + } + return out +} + +// --- assembled summary ----------------------------------------------------- + +type summary struct { + Server statusInfo `json:"server"` + ServerErr string `json:"server_err,omitempty"` + Registered int `json:"registered"` + RosterErr string `json:"roster_err,omitempty"` + Disk diskInfo `json:"disk"` + DiskErr string `json:"disk_err,omitempty"` + Backup backupInfo `json:"backup"` + BackupErr string `json:"backup_err,omitempty"` + TPS *tpsInfo `json:"tps"` + TPSErr string `json:"tps_err,omitempty"` + Sessions []sessionEvent `json:"sessions"` + AvatarURL string `json:"avatar_url"` + AvatarMode string `json:"avatar_mode"` + Now int64 `json:"now"` +} + +// collectors owns every tile source. Assembled once in main and shared. +type collectors struct { + status *cached[statusInfo] + registered *cached[[]mcPlayer] + disk *cached[diskInfo] + backup *cached[backupInfo] + tps *cached[*tpsInfo] + events *events + + avatarURL string + avatarMode string +} + +func (c *collectors) summary() summary { + // Refresh concurrently. Sequentially, a summary during an outage would cost + // the SUM of every timeout (~15s with mc-status hanging and RCON blackholed) + // — and an outage is exactly when someone is staring at this page. Each + // cached has its own mutex, so this is just fan-out. + var ( + wg sync.WaitGroup + status statusInfo + statusErr string + registered []mcPlayer + rosterErr string + disk diskInfo + diskErr string + backup backupInfo + backupErr string + tps *tpsInfo + tpsErr string + ) + wg.Add(5) + go func() { defer wg.Done(); status, statusErr = c.status.get() }() + go func() { defer wg.Done(); registered, rosterErr = c.registered.get() }() + go func() { defer wg.Done(); disk, diskErr = c.disk.get() }() + go func() { defer wg.Done(); backup, backupErr = c.backup.get() }() + go func() { defer wg.Done(); tps, tpsErr = c.tps.get() }() + wg.Wait() + + return summary{ + Server: status, + ServerErr: statusErr, + Registered: len(registered), + RosterErr: rosterErr, + Disk: disk, + DiskErr: diskErr, + Backup: backup, + BackupErr: backupErr, + TPS: tps, + TPSErr: tpsErr, + Sessions: c.events.recent(40), + AvatarURL: c.avatarURL, + AvatarMode: c.avatarMode, + Now: time.Now().Unix(), + } +} diff --git a/docker/monitor/main.go b/docker/monitor/main.go index c068874..4e034fd 100644 --- a/docker/monitor/main.go +++ b/docker/monitor/main.go @@ -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=] — 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"), + } +} diff --git a/docker/monitor/main_test.go b/docker/monitor/main_test.go new file mode 100644 index 0000000..bce1def --- /dev/null +++ b/docker/monitor/main_test.go @@ -0,0 +1,53 @@ +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]) + } +} diff --git a/docker/monitor/rcon.go b/docker/monitor/rcon.go new file mode 100644 index 0000000..022ab4b --- /dev/null +++ b/docker/monitor/rcon.go @@ -0,0 +1,188 @@ +// Minimal Source RCON client — the ONLY thing in this service that talks to +// Minecraft rather than reading a file, and the only place it holds a secret. +// +// Implemented here rather than pulled in as a dependency for one reason: the +// allowlist below. It is a compile-time constant, and there is deliberately no +// code path from an HTTP request to a command string. The UI cannot run +// commands because the server cannot be asked to run one — "read-only" is a +// property of the build, not a rule someone has to remember. +// +// Protocol: https://developer.valvesoftware.com/wiki/Source_RCON_Protocol +// A fresh connection per poll (every 30s). Reconnecting costs nothing at that +// rate and removes all stale-connection handling. +package main + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "net" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + rconTypeAuth int32 = 3 + rconTypeExec int32 = 2 + rconTypeAuthResp int32 = 2 + rconMaxBody = 4096 +) + +// rconAllowed is the complete set of commands this service can ever send. +// Both are pure reads. Do NOT make this configurable — see the file header. +var rconAllowed = map[string]bool{ + "neoforge tps": true, + "list": true, +} + +var errRCONNotAllowed = errors.New("rcon: command not in allowlist") + +// rconQuery dials, authenticates, runs one allowlisted command, and closes. +func rconQuery(addr, password, cmd string, timeout time.Duration) (string, error) { + if !rconAllowed[cmd] { + return "", errRCONNotAllowed + } + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return "", err + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(timeout)) + + if err := rconSend(conn, 1, rconTypeAuth, password); err != nil { + return "", err + } + // Some servers emit an empty RESPONSE_VALUE before the auth result; read + // until the auth response arrives (bounded, so a chatty peer can't spin us). + for i := 0; i < 3; i++ { + id, typ, _, err := rconRecv(conn) + if err != nil { + return "", err + } + if typ != rconTypeAuthResp { + continue + } + if id == -1 { + return "", errors.New("rcon: authentication failed (bad password)") + } + break + } + + if err := rconSend(conn, 2, rconTypeExec, cmd); err != nil { + return "", err + } + + // Responses may span several packets. Read until the peer goes quiet. + var sb strings.Builder + for { + _, _, body, err := rconRecv(conn) + if err != nil { + break + } + sb.WriteString(body) + if sb.Len() > rconMaxBody*4 { + break + } + conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond)) + } + if sb.Len() == 0 { + return "", errors.New("rcon: empty response") + } + return sb.String(), nil +} + +func rconSend(w io.Writer, id, typ int32, body string) error { + buf := new(bytes.Buffer) + // size counts everything after itself: id + type + body + two NUL bytes. + binary.Write(buf, binary.LittleEndian, int32(10+len(body))) + binary.Write(buf, binary.LittleEndian, id) + binary.Write(buf, binary.LittleEndian, typ) + buf.WriteString(body) + buf.Write([]byte{0, 0}) + _, err := w.Write(buf.Bytes()) + return err +} + +func rconRecv(r io.Reader) (id, typ int32, body string, err error) { + var size int32 + if err = binary.Read(r, binary.LittleEndian, &size); err != nil { + return + } + if size < 10 || size > rconMaxBody+16 { + err = errors.New("rcon: implausible packet size " + strconv.Itoa(int(size))) + return + } + buf := make([]byte, size) + if _, err = io.ReadFull(r, buf); err != nil { + return + } + id = int32(binary.LittleEndian.Uint32(buf[0:4])) + typ = int32(binary.LittleEndian.Uint32(buf[4:8])) + body = string(bytes.TrimRight(buf[8:], "\x00")) + return +} + +// --- TPS --------------------------------------------------------------- + +// tpsInfo is the parsed result of `neoforge tps`. +type tpsInfo struct { + TPS float64 `json:"tps"` + MSPT float64 `json:"mspt"` + Dimensions []dimensionT `json:"dimensions"` +} + +type dimensionT struct { + Name string `json:"name"` + TPS float64 `json:"tps"` + MSPT float64 `json:"mspt"` +} + +// NeoForge 1.21.1 prints one line per dimension then an "Overall:" line: +// +// Overworld: 20.000 TPS (0.298 ms/tick) +// Overall: 20.000 TPS (0.357 ms/tick) +// +// Verified against the running server — note this is NOT the older Forge +// "Mean tick time / Mean TPS" wording, and `forge tps` does not exist here +// ("Unknown or incomplete command"). +var tpsLineRe = regexp.MustCompile(`(?m)^(.+?):\s*([0-9.]+)\s*TPS\s*\(([0-9.]+)\s*ms/tick\)`) + +// colorCodes strips Minecraft §-codes and any ANSI escapes a wrapper added. +var colorCodes = regexp.MustCompile("§.|\x1b\\[[0-9;]*m") + +func parseTPS(raw string) (*tpsInfo, error) { + clean := colorCodes.ReplaceAllString(raw, "") + matches := tpsLineRe.FindAllStringSubmatch(clean, -1) + if len(matches) == 0 { + // Most likely "Unknown or incomplete command" — treat as unavailable + // rather than an error that kills the poll loop. + return nil, errors.New("rcon: no TPS lines in response") + } + + out := &tpsInfo{} + for _, m := range matches { + name := strings.TrimSpace(m[1]) + tps, _ := strconv.ParseFloat(m[2], 64) + mspt, _ := strconv.ParseFloat(m[3], 64) + if strings.EqualFold(name, "Overall") { + out.TPS, out.MSPT = tps, mspt + continue + } + out.Dimensions = append(out.Dimensions, dimensionT{Name: name, TPS: tps, MSPT: mspt}) + } + if out.TPS == 0 && len(out.Dimensions) > 0 { + // No "Overall" line — fall back to the worst dimension so the tile still + // says something useful rather than showing 0 TPS. + worst := out.Dimensions[0] + for _, d := range out.Dimensions[1:] { + if d.TPS < worst.TPS { + worst = d + } + } + out.TPS, out.MSPT = worst.TPS, worst.MSPT + } + return out, nil +} diff --git a/docker/monitor/ui/index.html b/docker/monitor/ui/index.html index 57cb25f..a4a6f1e 100644 --- a/docker/monitor/ui/index.html +++ b/docker/monitor/ui/index.html @@ -83,6 +83,53 @@ input[type=search].bad-re { border-color: var(--red); } .chip[data-level=ERROR][aria-pressed="true"] { color: var(--red); border-color: var(--red); } .chip[data-level=WARN][aria-pressed="true"] { color: var(--gold); border-color: var(--gold); } +/* --- dashboard tiles --- */ +#tiles { + flex: none; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 8px; + padding: 10px 12px; + background: var(--bg-2); + border-bottom: 1px solid var(--line); +} +.tile { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px 10px; + min-width: 0; +} +.tile h2 { + margin: 0 0 4px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--dim); +} +.tile .big { font-size: 19px; font-weight: 600; line-height: 1.15; } +.tile .sub { font-size: 11.5px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; } +.tile .sub.warnish { color: var(--gold); } +.tile .sub.badish { color: var(--red); } +.good { color: var(--accent); } +.warn { color: var(--gold); } +.bad { color: var(--red); } + +.bar { height: 5px; background: var(--slot, oklch(0.185 0.014 150)); border-radius: 3px; margin-top: 6px; overflow: hidden; } +.bar > i { display: block; height: 100%; background: var(--accent); } +.bar > i.warn { background: var(--gold); } +.bar > i.bad { background: var(--red); } + +.heads { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; } +.head { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); } +.head img { width: 20px; height: 20px; image-rendering: pixelated; border-radius: 3px; background: var(--bg); } + +.sessions { margin-top: 4px; font-size: 11.5px; line-height: 1.5; max-height: 76px; overflow: auto; } +.sessions div { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sessions .j { color: var(--accent); } +.sessions .l { color: var(--dim); } + #log { flex: 1 1 auto; overflow: auto; @@ -131,6 +178,16 @@ input[type=search].bad-re { border-color: var(--red); } +
+

Server

—
+

TPS

—
+

Online

—
+

Registered

—
+

Disk

—
+

Last backup

—
+

Recent joins

+
+
@@ -159,8 +216,11 @@ input[type=search].bad-re { border-color: var(--red); } let regex = null; const levelOn = { ERROR: true, WARN: true, INFO: true }; - // log4j lines look like: [12:34:56] [Server thread/INFO] [mod/] message - const LEVEL_RE = /^\[\d{2}:\d{2}:\d{2}\]\s*\[[^\]]*\/([A-Z]+)\]/; + // Two bracket groups, then the level: the timestamp format differs between + // itzg's console output ([12:34:56]) and the file's ([01Aug2026 22:55:34.358]), + // so match the *shape* rather than the timestamp: + // [] [/LEVEL] []: message + const LEVEL_RE = /^\[[^\]]*\]\s*\[[^\]]*\/([A-Z]+)\]/; function levelOf(text) { const m = LEVEL_RE.exec(text); @@ -286,6 +346,125 @@ input[type=search].bad-re { border-color: var(--red); } } } + // --- dashboard tiles ------------------------------------------------------ + + const SUMMARY_MS = 10000; // tiles move far slower than the log does + + const fmtBytes = (n) => { + if (!n) return "0 B"; + const u = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024))); + return (n / Math.pow(1024, i)).toFixed(i ? 1 : 0) + " " + u[i]; + }; + + const fmtAge = (s) => { + if (s == null) return "—"; + if (s < 60) return s + "s ago"; + if (s < 3600) return Math.floor(s / 60) + "m ago"; + if (s < 86400) return Math.floor(s / 3600) + "h ago"; + return Math.floor(s / 86400) + "d ago"; + }; + + // Set a tile's big value, its class, and its subtitle in one go. `err` wins + // over the value: a stale number with no warning would be a lie. + function tile(id, big, bigClass, sub, subClass, err) { + const el = document.getElementById(id); + el.querySelector(".big").textContent = big; + el.querySelector(".big").className = "big " + (bigClass || ""); + const s = el.querySelector(".sub"); + if (s) { + s.textContent = err ? err : (sub || ""); + s.className = "sub " + (err ? "badish" : (subClass || "")); + } + return el; + } + + function renderSummary(d) { + // Server + tile("t-server", + d.server.online ? "online" : "offline", + d.server.online ? "good" : "bad", + d.server.online ? (d.server.version || "") + (d.server.motd ? " · " + d.server.motd : "") : "", + "", d.server_err); + + // TPS — 20 is the ceiling; below ~19.5 is noticeable, below 15 is bad. + if (d.tps) { + const t = d.tps.tps; + tile("t-tps", t.toFixed(2), t >= 19.5 ? "good" : t >= 15 ? "warn" : "bad", + d.tps.mspt.toFixed(2) + " ms/tick", t >= 19.5 ? "" : "warnish", d.tps_err); + } else { + tile("t-tps", "—", "", "", "", d.tps_err || "unavailable"); + } + + // Online players + heads (rendered by NMSR on its own public origin) + const pl = d.server.players || []; + tile("t-players", `${d.server.count ?? 0} / ${d.server.max ?? 0}`, pl.length ? "good" : ""); + const heads = document.querySelector("#t-players .heads"); + heads.replaceChildren(); + for (const p of pl) { + const wrap = document.createElement("span"); + wrap.className = "head"; + if (d.avatar_url && p.uuid) { + const img = document.createElement("img"); + img.src = `${d.avatar_url}/${d.avatar_mode}/${p.uuid}?size=40`; + img.alt = ""; + img.loading = "lazy"; + wrap.appendChild(img); + } + const nm = document.createElement("span"); + nm.textContent = p.name || "?"; + wrap.appendChild(nm); + heads.appendChild(wrap); + } + + tile("t-reg", String(d.registered ?? 0), "", "Drasl accounts", "", d.roster_err); + + // Disk — the failure that actually takes the server down. + const used = d.disk.used_pct || 0; + const cls = used >= 90 ? "bad" : used >= 80 ? "warn" : "good"; + tile("t-disk", fmtBytes(d.disk.free_bytes) + " free", cls, + used.toFixed(1) + "% used of " + fmtBytes(d.disk.total_bytes), "", d.disk_err); + const bar = document.querySelector("#t-disk .bar > i"); + bar.style.width = Math.min(100, used) + "%"; + bar.className = cls === "good" ? "" : cls; + + // Backup freshness. mc-backup runs every 6h, so >8h means it stopped. + if (d.backup && d.backup.count) { + const age = d.backup.age_seconds; + tile("t-backup", fmtAge(age), age > 28800 ? "bad" : age > 21600 ? "warn" : "good", + `${d.backup.count} archives · ${fmtBytes(d.backup.total_bytes)}`, "", d.backup_err); + } else { + tile("t-backup", "none", "bad", "", "", d.backup_err); + } + + // Recent joins/leaves, parsed out of the log we already hold. + const box = document.querySelector("#t-sessions .sessions"); + box.replaceChildren(); + if (!d.sessions || !d.sessions.length) { + const e = document.createElement("div"); + e.textContent = "no joins in buffer"; + box.appendChild(e); + } else { + for (const s of d.sessions.slice(0, 12)) { + const row = document.createElement("div"); + const who = document.createElement("span"); + who.className = s.action === "joined" ? "j" : "l"; + who.textContent = `${s.action === "joined" ? "→" : "←"} ${s.player}`; + row.appendChild(who); + row.appendChild(document.createTextNode(" " + (s.time || ""))); + box.appendChild(row); + } + } + } + + async function pollSummary() { + try { + const res = await fetch("/api/summary", { cache: "no-store" }); + if (res.ok) renderSummary(await res.json()); + } catch { /* tiles keep their last values; the log poll owns the status dot */ } + finally { setTimeout(pollSummary, SUMMARY_MS); } + } + // --- filter --------------------------------------------------------------- let debounce; @@ -331,6 +510,7 @@ input[type=search].bad-re { border-color: var(--red); } liveEl.addEventListener("click", () => { toBottom(); updateLiveButton(); }); poll(); + pollSummary(); })(); diff --git a/plan/23-monitor.md b/plan/23-monitor.md index 3738ed6..a17eeb0 100644 --- a/plan/23-monitor.md +++ b/plan/23-monitor.md @@ -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