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>
502 lines
15 KiB
Go
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"),
|
|
}
|
|
}
|