// 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=] — 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"), } }