Phase 1 of plan/23-monitor.md: a small Go service that tails the Minecraft server's latest.log into an in-memory ring buffer and serves it to an embedded single-page UI (autoscroll, substring/regex filter, level chips). Read-only by construction — /data is mounted :ro, there is no mutating route, and the container holds no credentials. It publishes NO port: caddy's basic_auth vhost is the only way in, which is the point, since the service has no auth of its own and the log carries player IPs, chat and admin commands. Two non-obvious choices, both to avoid prod-only failures: - Polling, not SSE. The chain is host nginx -> caddy -> app, and nginx buffers proxied responses by default, so an EventSource would stall on cochi (whose vhost is an untracked per-host file) and not reproduce locally. Delay was accepted at design time, so polling is strictly better. - The cursor is a sequence number, not a byte offset, and the tailer reopens on inode change or size shrink. itzg rotates latest.log on every server restart, i.e. every deploy — a naive tailer goes silent afterwards while still answering HTTP 200. Verified against rotate, truncate, delete/recreate, partial-line and ring-overflow cases. MONITOR_ENABLED (on|off) gates both layers per host, mirroring FILES_BROWSE. MONITOR_PASSWORD_HASH must be bcrypt AND single-quoted in .env: Compose interpolates $-sequences in .env values, so a bare hash is silently mangled ($2a$14$abc... -> $$2a$$14...) and every login fails with no hint why. Confirmed empirically, and it is the opposite of filestash, which needs $6$. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
419 lines
11 KiB
Go
419 lines
11 KiB
Go
// 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.
|
|
//
|
|
// 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 holds no credentials in this phase.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"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
|
|
|
|
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))
|
|
t.r.append(trimEOL(line))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
t := &tailer{path: path, backfill: bufLines, r: r, state: tailState{Path: path}}
|
|
go t.run(interval)
|
|
|
|
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)
|
|
})
|
|
|
|
// 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
|
|
}
|