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:
1 parent
53604a6127
commit
00984e1a70
7 files changed
+958
-20
No files matched your search
+89
-6
@@ -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"),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user