Files
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

502 lines
15 KiB
Go

// monitor — admin-only, read-only view of the Minecraft server's latest.log.
//
// 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:
//
// - The cursor is a SEQUENCE NUMBER, not a byte offset. itzg rotates
// latest.log -> YYYY-MM-DD-N.log.gz on every server restart (i.e. on every
// deploy), which makes byte offsets wrong routinely rather than rarely.
//
// - The tailer reopens on inode change OR size shrink. Both happen on every
// deploy. A tailer that merely follows an already-open fd goes permanently
// silent after the first restart while still answering HTTP 200 — the
// "monitor says everything is fine" failure. This is the single most likely
// way this service breaks, so it is checked every tick.
//
// - Transport is POLLING, not SSE. The request chain is host nginx -> caddy ->
// here, and nginx buffers proxied responses by default; an EventSource would
// stall on prod (where the vhost is an untracked per-host file) and not
// reproduce locally. Delay is acceptable by design, so polling is strictly
// better here.
//
// - No persistence. On boot we prime the ring from the tail of the file on
// 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 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 (
"bufio"
"bytes"
_ "embed"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
//go:embed ui/index.html
var indexHTML []byte
// --- ring buffer -----------------------------------------------------------
// ring is a fixed-capacity circular buffer of log lines with monotonic sequence
// numbers. Sequence numbers never restart while the process lives, so a client
// cursor stays valid across log rotations.
type ring struct {
mu sync.Mutex
buf []string
head int // index of the oldest line
n int // lines currently held
next int64 // sequence number the next appended line will get
}
func newRing(capacity int) *ring {
if capacity < 1 {
capacity = 1
}
return &ring{buf: make([]string, capacity)}
}
func (r *ring) append(s string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.n == len(r.buf) {
// Full: overwrite the oldest and advance head. O(1) — deliberately not a
// slice shuffle, because boot spam appends thousands of lines a second.
r.buf[r.head] = s
r.head = (r.head + 1) % len(r.buf)
} else {
r.buf[(r.head+r.n)%len(r.buf)] = s
r.n++
}
r.next++
}
// snapshot returns every buffered line plus the cursor to resume from.
func (r *ring) snapshot() ([]string, int64) {
r.mu.Lock()
defer r.mu.Unlock()
return r.collect(r.next - int64(r.n)), r.next
}
// since returns the lines after `seq`.
//
// dropped > 0 means the caller fell behind the buffer and lines were lost;
// reset means the caller must discard what it has and take these lines as the
// new truth (it fell behind, or the server restarted and its cursor is now in
// the future). Without this the UI would lie by omission after any burst.
func (r *ring) since(seq int64) (lines []string, next int64, dropped int64, reset bool) {
r.mu.Lock()
defer r.mu.Unlock()
first := r.next - int64(r.n)
switch {
case seq > r.next:
// Cursor from a previous process lifetime — sequence numbering restarted.
seq, reset = first, true
case seq < first:
dropped, seq, reset = first-seq, first, true
}
return r.collect(seq), r.next, dropped, reset
}
// collect copies lines from `seq` onward. Caller holds r.mu.
func (r *ring) collect(seq int64) []string {
first := r.next - int64(r.n)
start := int(seq - first)
if start < 0 {
start = 0
}
out := make([]string, 0, r.n-start)
for i := start; i < r.n; i++ {
out = append(out, r.buf[(r.head+i)%len(r.buf)])
}
return out
}
func (r *ring) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.n
}
// --- tailer ----------------------------------------------------------------
// tailState is what the UI shows about the file itself. Mtime is the useful
// one: it lets the page distinguish "the server is quiet" from "the tailer is
// stuck", which HTTP status alone can never do.
type tailState struct {
Path string `json:"path"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Size int64 `json:"size"`
Mtime int64 `json:"mtime"`
Rotations int64 `json:"rotations"`
}
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
dev uint64
ino uint64
mu sync.Mutex
state tailState
}
func (t *tailer) snapshotState() tailState {
t.mu.Lock()
defer t.mu.Unlock()
return t.state
}
func (t *tailer) setErr(err error) {
t.mu.Lock()
defer t.mu.Unlock()
t.state.OK = false
if err != nil {
t.state.Error = err.Error()
}
}
func (t *tailer) run(interval time.Duration) {
for {
t.tick()
time.Sleep(interval)
}
}
func (t *tailer) tick() {
fi, err := os.Stat(t.path)
if err != nil {
// Missing file is normal before the server's first boot, and during the
// window where itzg has rotated but not yet recreated latest.log.
t.closeFile()
t.setErr(err)
return
}
dev, ino, ok := statKey(fi)
if !ok {
t.setErr(errUnsupportedStat)
return
}
switch {
case t.f == nil:
// First open of this process: seed the ring from the tail of the file so
// the page has history immediately. Subsequent opens start at 0.
if err := t.open(fi, t.backfill > 0); err != nil {
t.setErr(err)
return
}
case dev != t.dev || ino != t.ino:
// Rotated: latest.log is a different file now. Read the new one whole.
log.Printf("tail: %s rotated (ino %d -> %d), reopening", t.path, t.ino, ino)
t.closeFile()
if err := t.open(fi, false); err != nil {
t.setErr(err)
return
}
t.mu.Lock()
t.state.Rotations++
t.mu.Unlock()
case fi.Size() < t.pos:
// Truncated in place (same inode, smaller). Re-read from the start.
log.Printf("tail: %s truncated (%d -> %d), rewinding", t.path, t.pos, fi.Size())
t.pos = 0
}
t.drain(fi.Size())
t.mu.Lock()
t.state.OK = true
t.state.Error = ""
t.state.Size = fi.Size()
t.state.Mtime = fi.ModTime().Unix()
t.mu.Unlock()
}
func (t *tailer) open(fi os.FileInfo, backfill bool) error {
f, err := os.Open(t.path)
if err != nil {
return err
}
t.f = f
t.dev, t.ino, _ = statKey(fi)
t.pos = 0
if backfill {
t.pos = backfillOffset(f, fi.Size(), t.backfill)
}
return nil
}
func (t *tailer) closeFile() {
if t.f != nil {
t.f.Close()
t.f = nil
}
}
// drain reads every COMPLETE line between pos and size into the ring.
//
// A trailing partial line (the writer is mid-write) does not advance pos, so it
// is re-read on the next tick once its newline lands. That keeps the "never emit
// half a line" guarantee without any partial-line state to get wrong.
func (t *tailer) drain(size int64) {
if t.f == nil || size <= t.pos {
return
}
if _, err := t.f.Seek(t.pos, io.SeekStart); err != nil {
t.setErr(err)
return
}
rd := bufio.NewReaderSize(t.f, 64<<10)
for {
line, err := rd.ReadString('\n')
if err != nil {
// io.EOF with a partial line: leave pos before it and retry next tick.
return
}
t.pos += int64(len(line))
clean := trimEOL(line)
t.r.append(clean)
if t.ev != nil {
t.ev.observe(clean)
}
}
}
// backfillOffset returns the byte offset of the start of the last `want` lines,
// scanning at most maxScan bytes back from the end. Bounded on purpose: a
// 164-mod server's latest.log is mostly boot spam and can be large, and we only
// ever want the tail of it.
func backfillOffset(f *os.File, size int64, want int) int64 {
const maxScan = 8 << 20
start := size - maxScan
if start < 0 {
start = 0
}
buf := make([]byte, size-start)
if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF {
return size
}
count := 0
for i := len(buf) - 1; i >= 0; i-- {
if buf[i] != '\n' || i == len(buf)-1 {
continue // the final newline terminates the last line, it starts nothing
}
if count++; count >= want {
return start + int64(i) + 1
}
}
if start == 0 {
return 0
}
// Fewer than `want` lines in the window — begin at the first COMPLETE line so
// we never emit the tail half of a line we sliced through.
if idx := bytes.IndexByte(buf, '\n'); idx >= 0 {
return start + int64(idx) + 1
}
return size
}
func trimEOL(s string) string {
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
s = s[:len(s)-1]
}
return s
}
type stringError string
func (e stringError) Error() string { return string(e) }
const errUnsupportedStat = stringError("stat: unsupported platform (no syscall.Stat_t)")
func statKey(fi os.FileInfo) (dev, ino uint64, ok bool) {
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return 0, 0, false
}
return uint64(st.Dev), uint64(st.Ino), true
}
// --- http ------------------------------------------------------------------
type logResponse struct {
Lines []string `json:"lines"`
Seq int64 `json:"seq"`
Dropped int64 `json:"dropped"`
Reset bool `json:"reset"`
Total int `json:"total"`
Tail tailState `json:"tail"`
}
func main() {
listen := envOr("MONITOR_LISTEN", ":8080")
path := envOr("MONITOR_LOG_PATH", "/data/logs/latest.log")
bufLines, err := strconv.Atoi(envOr("MONITOR_BUFFER_LINES", "5000"))
if err != nil || bufLines < 1 {
log.Fatalf("invalid MONITOR_BUFFER_LINES: %q", os.Getenv("MONITOR_BUFFER_LINES"))
}
interval, err := time.ParseDuration(envOr("MONITOR_TAIL_INTERVAL", "500ms"))
if err != nil {
log.Fatalf("invalid MONITOR_TAIL_INTERVAL: %v", err)
}
r := newRing(bufLines)
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
// spurious "dropped" notice on first load.
mux.HandleFunc("/api/log", func(w http.ResponseWriter, req *http.Request) {
var resp logResponse
if raw := req.URL.Query().Get("since"); raw != "" {
seq, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
http.Error(w, "bad since", http.StatusBadRequest)
return
}
resp.Lines, resp.Seq, resp.Dropped, resp.Reset = r.since(seq)
} else {
resp.Lines, resp.Seq = r.snapshot()
}
resp.Total = r.count()
resp.Tail = t.snapshotState()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
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).
mux.HandleFunc("/healthz", func(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("ok"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/" {
http.NotFound(w, req)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Write(indexHTML)
})
log.Printf("monitor listening on %s, tailing %s (buffer=%d lines, interval=%s)",
listen, path, bufLines, interval)
log.Fatal(http.ListenAndServe(listen, mux))
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
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"),
}
}