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:
oierandClaude Opus 5 committed 2026-08-01 23:16:11 +02:00
1 parent 53604a6127
commit 00984e1a70
7 files changed
+958 -20

No files matched your search

+20
View File
@@ -345,11 +345,31 @@ services:
environment:
MONITOR_LOG_PATH: "/data/logs/latest.log"
MONITOR_BUFFER_LINES: "5000"
# Dashboard tiles. Server status + rosters are read from mc-status over
# mcnet rather than from Drasl directly — mc-status already holds the
# admin credentials and exposes a sanitized list, so this container does
# not need a second copy of that secret.
MONITOR_MCSTATUS_URL: "http://mc-status:8080"
# Player heads. Rendered by NMSR on its own PUBLIC origin, so the browser
# fetches them cross-origin (no auth involved, avatars are public anyway).
MONITOR_AVATAR_URL: "https://avatar.${BASE_DOMAIN}"
MONITOR_AVATAR_MODE: "${AVATAR_MODE:-headiso}"
# TPS via RCON. This is the ONLY credential the monitor holds, and the
# only command it can send is the compile-time allowlist in rcon.go
# (`neoforge tps`, `list`) — no HTTP input reaches a command string, so
# the UI stays incapable of running anything. NeoForge 1.21.1 uses
# `neoforge tps`; `forge tps` does not exist here.
MONITOR_RCON_ADDR: "minecraft:25575"
MONITOR_RCON_PASSWORD: ${RCON_PASSWORD}
MONITOR_TPS_INTERVAL: "30s"
volumes:
# The whole Minecraft data dir, read-only. Only logs/latest.log is read;
# mounting /data rather than /data/logs keeps the path stable if itzg ever
# moves the log dir, and :ro means it cannot touch the world either way.
# It is also what the disk-free tile statfs's.
- mc_data:/data:ro
# Backup freshness tile. Same dir mc-backup writes to, read-only here.
- ./backups:/backups:ro
networks:
- mcnet
+363
View File
@@ -0,0 +1,363 @@
// Dashboard collectors (phase 2 + 3 of plan/23-monitor.md).
//
// Every tile is fed by its own independently-cached collector. That isolation
// is the whole design: mc-status being slow, the backups dir being unreadable,
// or RCON refusing a connection must degrade exactly one tile and never block
// the summary response or the log stream. On error a collector keeps serving
// its last good value and reports the error alongside it — stale-with-a-reason
// beats a blank page, and beats a spinner that never resolves.
package main
import (
"encoding/json"
"net/http"
"os"
"regexp"
"strings"
"sync"
"syscall"
"time"
)
// --- TTL cache -------------------------------------------------------------
// cached memoizes one collector for ttl. Zero value is not usable; see newCached.
type cached[T any] struct {
mu sync.Mutex
val T
have bool
lastErr string
expires time.Time
ttl time.Duration
fn func() (T, error)
}
func newCached[T any](ttl time.Duration, fn func() (T, error)) *cached[T] {
return &cached[T]{ttl: ttl, fn: fn}
}
// get returns the cached value (refreshing if stale) and the last error, if any.
// It never returns an error *instead* of a value — a failed refresh yields the
// previous value plus the error text.
func (c *cached[T]) get() (T, string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.have && time.Now().Before(c.expires) {
return c.val, c.lastErr
}
v, err := c.fn()
c.expires = time.Now().Add(c.ttl)
if err != nil {
c.lastErr = err.Error()
return c.val, c.lastErr // stale-or-zero, with the reason
}
c.val, c.have, c.lastErr = v, true, ""
return c.val, ""
}
// --- server status + rosters (via mc-status over mcnet) --------------------
// The monitor deliberately does NOT hold Drasl admin credentials. mc-status
// already logs in server-side and exposes a sanitized [{uuid,name}], so we read
// its output instead of duplicating the secret. See plan/15-mc-status.md.
type mcPlayer struct {
UUID string `json:"uuid"`
Name string `json:"name"`
}
type statusInfo struct {
Online bool `json:"online"`
Count int64 `json:"count"`
Max int64 `json:"max"`
MOTD string `json:"motd"`
Version string `json:"version"`
Players []mcPlayer `json:"players"`
}
// v2 subset of the mcstatus.io shape mc-status re-emits.
type v2Wire struct {
Online bool `json:"online"`
Players struct {
Online int64 `json:"online"`
Max int64 `json:"max"`
List []struct {
UUID string `json:"uuid"`
NameClean string `json:"name_clean"`
} `json:"list"`
} `json:"players"`
MOTD struct {
Clean string `json:"clean"`
} `json:"motd"`
Version struct {
NameClean string `json:"name_clean"`
} `json:"version"`
}
func fetchStatus(client *http.Client, base string) (statusInfo, error) {
var wire v2Wire
// The address segment is ignored by mc-status (it only ever pings its own
// configured target) — any placeholder works.
if err := getJSON(client, base+"/v2/status/java/self", &wire); err != nil {
return statusInfo{}, err
}
out := statusInfo{
Online: wire.Online,
Count: wire.Players.Online,
Max: wire.Players.Max,
MOTD: wire.MOTD.Clean,
Version: wire.Version.NameClean,
}
for _, p := range wire.Players.List {
out.Players = append(out.Players, mcPlayer{UUID: p.UUID, Name: p.NameClean})
}
return out, nil
}
func fetchRegistered(client *http.Client, base string) ([]mcPlayer, error) {
var players []mcPlayer
if err := getJSON(client, base+"/players", &players); err != nil {
return nil, err
}
return players, nil
}
func getJSON(client *http.Client, url string, dst any) error {
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &statusCodeError{url: url, code: resp.StatusCode}
}
return json.NewDecoder(resp.Body).Decode(dst)
}
type statusCodeError struct {
url string
code int
}
func (e *statusCodeError) Error() string {
return e.url + ": HTTP " + itoa(e.code)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// --- disk ------------------------------------------------------------------
type diskInfo struct {
Path string `json:"path"`
TotalBytes uint64 `json:"total_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedPct float64 `json:"used_pct"`
}
// diskUsage statfs's the world volume. This reports the underlying host
// filesystem, which is exactly what matters: a full disk is the failure that
// actually takes the server down, and backups (15G+ and growing) share it.
func diskUsage(path string) (diskInfo, error) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return diskInfo{Path: path}, err
}
bs := uint64(st.Bsize)
total := st.Blocks * bs
free := st.Bavail * bs // Bavail, not Bfree — exclude root-reserved blocks
info := diskInfo{Path: path, TotalBytes: total, FreeBytes: free}
if total > 0 {
info.UsedPct = float64(total-free) / float64(total) * 100
}
return info, nil
}
// --- backups ---------------------------------------------------------------
type backupInfo struct {
Newest string `json:"newest"`
SizeBytes int64 `json:"size_bytes"`
Mtime int64 `json:"mtime"`
AgeSeconds int64 `json:"age_seconds"`
Count int `json:"count"`
TotalBytes int64 `json:"total_bytes"`
}
// backupStatus finds the newest archive written by mc-backup (world-*.tar.gz).
// Age is the number that matters — "backups exist" is not the same claim as
// "a backup ran recently", and only the second one is worth a tile.
func backupStatus(dir string) (backupInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return backupInfo{}, err
}
var out backupInfo
var newest time.Time
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".tar.gz") {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
out.Count++
out.TotalBytes += fi.Size()
if fi.ModTime().After(newest) {
newest = fi.ModTime()
out.Newest = e.Name()
out.SizeBytes = fi.Size()
}
}
if out.Count > 0 {
out.Mtime = newest.Unix()
out.AgeSeconds = int64(time.Since(newest).Seconds())
}
return out, nil
}
// --- session history (derived from the log we already tail) ----------------
type sessionEvent struct {
Time string `json:"time"`
Player string `json:"player"`
Action string `json:"action"` // "joined" | "left"
}
// Matches the message tail of e.g.
//
// [01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft...]: Oier joined the game
var sessionRe = regexp.MustCompile(`]: (\S+) (joined|left) the game\b`)
// leading [timestamp] of a log line, whatever its format
var stampRe = regexp.MustCompile(`^\[([^\]]+)\]`)
// events is a bounded history of joins/leaves, filled by the tailer as lines
// arrive. Free: the bytes are already being read for the log stream. It also
// means the cold-start backfill populates history without any persistence.
type events struct {
mu sync.Mutex
buf []sessionEvent
max int
}
func newEvents(max int) *events { return &events{max: max} }
func (e *events) observe(line string) {
m := sessionRe.FindStringSubmatch(line)
if m == nil {
return
}
ev := sessionEvent{Player: m[1], Action: m[2]}
if ts := stampRe.FindStringSubmatch(line); ts != nil {
ev.Time = ts[1]
}
e.mu.Lock()
defer e.mu.Unlock()
e.buf = append(e.buf, ev)
if len(e.buf) > e.max {
e.buf = e.buf[len(e.buf)-e.max:]
}
}
// recent returns up to n events, newest first.
func (e *events) recent(n int) []sessionEvent {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]sessionEvent, 0, n)
for i := len(e.buf) - 1; i >= 0 && len(out) < n; i-- {
out = append(out, e.buf[i])
}
return out
}
// --- assembled summary -----------------------------------------------------
type summary struct {
Server statusInfo `json:"server"`
ServerErr string `json:"server_err,omitempty"`
Registered int `json:"registered"`
RosterErr string `json:"roster_err,omitempty"`
Disk diskInfo `json:"disk"`
DiskErr string `json:"disk_err,omitempty"`
Backup backupInfo `json:"backup"`
BackupErr string `json:"backup_err,omitempty"`
TPS *tpsInfo `json:"tps"`
TPSErr string `json:"tps_err,omitempty"`
Sessions []sessionEvent `json:"sessions"`
AvatarURL string `json:"avatar_url"`
AvatarMode string `json:"avatar_mode"`
Now int64 `json:"now"`
}
// collectors owns every tile source. Assembled once in main and shared.
type collectors struct {
status *cached[statusInfo]
registered *cached[[]mcPlayer]
disk *cached[diskInfo]
backup *cached[backupInfo]
tps *cached[*tpsInfo]
events *events
avatarURL string
avatarMode string
}
func (c *collectors) summary() summary {
// Refresh concurrently. Sequentially, a summary during an outage would cost
// the SUM of every timeout (~15s with mc-status hanging and RCON blackholed)
// — and an outage is exactly when someone is staring at this page. Each
// cached has its own mutex, so this is just fan-out.
var (
wg sync.WaitGroup
status statusInfo
statusErr string
registered []mcPlayer
rosterErr string
disk diskInfo
diskErr string
backup backupInfo
backupErr string
tps *tpsInfo
tpsErr string
)
wg.Add(5)
go func() { defer wg.Done(); status, statusErr = c.status.get() }()
go func() { defer wg.Done(); registered, rosterErr = c.registered.get() }()
go func() { defer wg.Done(); disk, diskErr = c.disk.get() }()
go func() { defer wg.Done(); backup, backupErr = c.backup.get() }()
go func() { defer wg.Done(); tps, tpsErr = c.tps.get() }()
wg.Wait()
return summary{
Server: status,
ServerErr: statusErr,
Registered: len(registered),
RosterErr: rosterErr,
Disk: disk,
DiskErr: diskErr,
Backup: backup,
BackupErr: backupErr,
TPS: tps,
TPSErr: tpsErr,
Sessions: c.events.recent(40),
AvatarURL: c.avatarURL,
AvatarMode: c.avatarMode,
Now: time.Now().Unix(),
}
}
+89 -6
View File
@@ -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"),
}
}
+53
View File
@@ -0,0 +1,53 @@
package main
// Locks the two things that are easy to break silently: the RCON allowlist
// (the guarantee that this service cannot run commands) and the `neoforge tps`
// output format, which differs from the older Forge wording.
import "testing"
func TestParseTPSRealOutput(t *testing.T) {
// Captured verbatim from `docker exec minecraft rcon-cli neoforge tps` on prod,
// ANSI resets included.
raw := "Overworld: 20.000 TPS (0.298 ms/tick)\n\x1b[0mThe Nether: 19.100 TPS (0.014 ms/tick)\n" +
"\x1b[0mworld_dimension_nexus:creative: 20.000 TPS (0.009 ms/tick)\n" +
"\x1b[0mOverall: 18.500 TPS (0.357 ms/tick)\n\x1b[0m"
got, err := parseTPS(raw)
if err != nil {
t.Fatalf("err: %v", err)
}
if got.TPS != 18.5 || got.MSPT != 0.357 {
t.Fatalf("overall wrong: %+v", got)
}
if len(got.Dimensions) != 3 {
t.Fatalf("want 3 dims, got %d: %+v", len(got.Dimensions), got.Dimensions)
}
}
func TestParseTPSUnknownCommand(t *testing.T) {
if _, err := parseTPS("Unknown or incomplete command, see below for error\nforge tps<--[HERE]"); err == nil {
t.Fatal("want error for unknown command")
}
}
func TestRCONAllowlist(t *testing.T) {
for _, bad := range []string{"op Oier", "stop", "say hi", "ban x", "", "neoforge tps ", "LIST"} {
if _, err := rconQuery("127.0.0.1:1", "pw", bad, 0); err != errRCONNotAllowed {
t.Fatalf("command %q was not rejected by the allowlist (err=%v)", bad, err)
}
}
}
func TestSessionParse(t *testing.T) {
e := newEvents(10)
e.observe("[01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier joined the game")
e.observe("[01Aug2026 23:01:02.111] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier left the game")
e.observe("[01Aug2026 23:02:00.000] [Server thread/INFO] [x/]: something else entirely")
got := e.recent(10)
if len(got) != 2 {
t.Fatalf("want 2 events, got %d: %+v", len(got), got)
}
if got[0].Action != "left" || got[0].Player != "Oier" || got[0].Time != "01Aug2026 23:01:02.111" {
t.Fatalf("newest-first wrong: %+v", got[0])
}
}
+188
View File
@@ -0,0 +1,188 @@
// Minimal Source RCON client — the ONLY thing in this service that talks to
// Minecraft rather than reading a file, and the only place it holds a secret.
//
// Implemented here rather than pulled in as a dependency for one reason: the
// allowlist below. It is a compile-time constant, and there is deliberately no
// code path from an HTTP request to a command string. The UI cannot run
// commands because the server cannot be asked to run one — "read-only" is a
// property of the build, not a rule someone has to remember.
//
// Protocol: https://developer.valvesoftware.com/wiki/Source_RCON_Protocol
// A fresh connection per poll (every 30s). Reconnecting costs nothing at that
// rate and removes all stale-connection handling.
package main
import (
"bytes"
"encoding/binary"
"errors"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
)
const (
rconTypeAuth int32 = 3
rconTypeExec int32 = 2
rconTypeAuthResp int32 = 2
rconMaxBody = 4096
)
// rconAllowed is the complete set of commands this service can ever send.
// Both are pure reads. Do NOT make this configurable — see the file header.
var rconAllowed = map[string]bool{
"neoforge tps": true,
"list": true,
}
var errRCONNotAllowed = errors.New("rcon: command not in allowlist")
// rconQuery dials, authenticates, runs one allowlisted command, and closes.
func rconQuery(addr, password, cmd string, timeout time.Duration) (string, error) {
if !rconAllowed[cmd] {
return "", errRCONNotAllowed
}
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return "", err
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(timeout))
if err := rconSend(conn, 1, rconTypeAuth, password); err != nil {
return "", err
}
// Some servers emit an empty RESPONSE_VALUE before the auth result; read
// until the auth response arrives (bounded, so a chatty peer can't spin us).
for i := 0; i < 3; i++ {
id, typ, _, err := rconRecv(conn)
if err != nil {
return "", err
}
if typ != rconTypeAuthResp {
continue
}
if id == -1 {
return "", errors.New("rcon: authentication failed (bad password)")
}
break
}
if err := rconSend(conn, 2, rconTypeExec, cmd); err != nil {
return "", err
}
// Responses may span several packets. Read until the peer goes quiet.
var sb strings.Builder
for {
_, _, body, err := rconRecv(conn)
if err != nil {
break
}
sb.WriteString(body)
if sb.Len() > rconMaxBody*4 {
break
}
conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
}
if sb.Len() == 0 {
return "", errors.New("rcon: empty response")
}
return sb.String(), nil
}
func rconSend(w io.Writer, id, typ int32, body string) error {
buf := new(bytes.Buffer)
// size counts everything after itself: id + type + body + two NUL bytes.
binary.Write(buf, binary.LittleEndian, int32(10+len(body)))
binary.Write(buf, binary.LittleEndian, id)
binary.Write(buf, binary.LittleEndian, typ)
buf.WriteString(body)
buf.Write([]byte{0, 0})
_, err := w.Write(buf.Bytes())
return err
}
func rconRecv(r io.Reader) (id, typ int32, body string, err error) {
var size int32
if err = binary.Read(r, binary.LittleEndian, &size); err != nil {
return
}
if size < 10 || size > rconMaxBody+16 {
err = errors.New("rcon: implausible packet size " + strconv.Itoa(int(size)))
return
}
buf := make([]byte, size)
if _, err = io.ReadFull(r, buf); err != nil {
return
}
id = int32(binary.LittleEndian.Uint32(buf[0:4]))
typ = int32(binary.LittleEndian.Uint32(buf[4:8]))
body = string(bytes.TrimRight(buf[8:], "\x00"))
return
}
// --- TPS ---------------------------------------------------------------
// tpsInfo is the parsed result of `neoforge tps`.
type tpsInfo struct {
TPS float64 `json:"tps"`
MSPT float64 `json:"mspt"`
Dimensions []dimensionT `json:"dimensions"`
}
type dimensionT struct {
Name string `json:"name"`
TPS float64 `json:"tps"`
MSPT float64 `json:"mspt"`
}
// NeoForge 1.21.1 prints one line per dimension then an "Overall:" line:
//
// Overworld: 20.000 TPS (0.298 ms/tick)
// Overall: 20.000 TPS (0.357 ms/tick)
//
// Verified against the running server — note this is NOT the older Forge
// "Mean tick time / Mean TPS" wording, and `forge tps` does not exist here
// ("Unknown or incomplete command").
var tpsLineRe = regexp.MustCompile(`(?m)^(.+?):\s*([0-9.]+)\s*TPS\s*\(([0-9.]+)\s*ms/tick\)`)
// colorCodes strips Minecraft §-codes and any ANSI escapes a wrapper added.
var colorCodes = regexp.MustCompile("§.|\x1b\\[[0-9;]*m")
func parseTPS(raw string) (*tpsInfo, error) {
clean := colorCodes.ReplaceAllString(raw, "")
matches := tpsLineRe.FindAllStringSubmatch(clean, -1)
if len(matches) == 0 {
// Most likely "Unknown or incomplete command" — treat as unavailable
// rather than an error that kills the poll loop.
return nil, errors.New("rcon: no TPS lines in response")
}
out := &tpsInfo{}
for _, m := range matches {
name := strings.TrimSpace(m[1])
tps, _ := strconv.ParseFloat(m[2], 64)
mspt, _ := strconv.ParseFloat(m[3], 64)
if strings.EqualFold(name, "Overall") {
out.TPS, out.MSPT = tps, mspt
continue
}
out.Dimensions = append(out.Dimensions, dimensionT{Name: name, TPS: tps, MSPT: mspt})
}
if out.TPS == 0 && len(out.Dimensions) > 0 {
// No "Overall" line — fall back to the worst dimension so the tile still
// says something useful rather than showing 0 TPS.
worst := out.Dimensions[0]
for _, d := range out.Dimensions[1:] {
if d.TPS < worst.TPS {
worst = d
}
}
out.TPS, out.MSPT = worst.TPS, worst.MSPT
}
return out, nil
}
+182 -2
View File
@@ -83,6 +83,53 @@ input[type=search].bad-re { border-color: var(--red); }
.chip[data-level=ERROR][aria-pressed="true"] { color: var(--red); border-color: var(--red); }
.chip[data-level=WARN][aria-pressed="true"] { color: var(--gold); border-color: var(--gold); }
/* --- dashboard tiles --- */
#tiles {
flex: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 8px;
padding: 10px 12px;
background: var(--bg-2);
border-bottom: 1px solid var(--line);
}
.tile {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 10px;
min-width: 0;
}
.tile h2 {
margin: 0 0 4px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--dim);
}
.tile .big { font-size: 19px; font-weight: 600; line-height: 1.15; }
.tile .sub { font-size: 11.5px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; }
.tile .sub.warnish { color: var(--gold); }
.tile .sub.badish { color: var(--red); }
.good { color: var(--accent); }
.warn { color: var(--gold); }
.bad { color: var(--red); }
.bar { height: 5px; background: var(--slot, oklch(0.185 0.014 150)); border-radius: 3px; margin-top: 6px; overflow: hidden; }
.bar > i { display: block; height: 100%; background: var(--accent); }
.bar > i.warn { background: var(--gold); }
.bar > i.bad { background: var(--red); }
.heads { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
.head { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); }
.head img { width: 20px; height: 20px; image-rendering: pixelated; border-radius: 3px; background: var(--bg); }
.sessions { margin-top: 4px; font-size: 11.5px; line-height: 1.5; max-height: 76px; overflow: auto; }
.sessions div { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sessions .j { color: var(--accent); }
.sessions .l { color: var(--dim); }
#log {
flex: 1 1 auto;
overflow: auto;
@@ -131,6 +178,16 @@ input[type=search].bad-re { border-color: var(--red); }
</span>
</header>
<div id="tiles">
<div class="tile" id="t-server"> <h2>Server</h2> <div class="big">—</div><div class="sub"></div></div>
<div class="tile" id="t-tps"> <h2>TPS</h2> <div class="big">—</div><div class="sub"></div></div>
<div class="tile" id="t-players"> <h2>Online</h2> <div class="big">—</div><div class="heads"></div></div>
<div class="tile" id="t-reg"> <h2>Registered</h2><div class="big">—</div><div class="sub"></div></div>
<div class="tile" id="t-disk"> <h2>Disk</h2> <div class="big">—</div><div class="sub"></div><div class="bar"><i></i></div></div>
<div class="tile" id="t-backup"> <h2>Last backup</h2><div class="big">—</div><div class="sub"></div></div>
<div class="tile" id="t-sessions"><h2>Recent joins</h2><div class="sessions"></div></div>
</div>
<div id="log" role="log" aria-live="off"></div>
<button id="live" hidden>jump to live ↓</button>
@@ -159,8 +216,11 @@ input[type=search].bad-re { border-color: var(--red); }
let regex = null;
const levelOn = { ERROR: true, WARN: true, INFO: true };
// log4j lines look like: [12:34:56] [Server thread/INFO] [mod/] message
const LEVEL_RE = /^\[\d{2}:\d{2}:\d{2}\]\s*\[[^\]]*\/([A-Z]+)\]/;
// Two bracket groups, then the level: the timestamp format differs between
// itzg's console output ([12:34:56]) and the file's ([01Aug2026 22:55:34.358]),
// so match the *shape* rather than the timestamp:
// [<anything>] [<thread>/LEVEL] [<logger>]: message
const LEVEL_RE = /^\[[^\]]*\]\s*\[[^\]]*\/([A-Z]+)\]/;
function levelOf(text) {
const m = LEVEL_RE.exec(text);
@@ -286,6 +346,125 @@ input[type=search].bad-re { border-color: var(--red); }
}
}
// --- dashboard tiles ------------------------------------------------------
const SUMMARY_MS = 10000; // tiles move far slower than the log does
const fmtBytes = (n) => {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
return (n / Math.pow(1024, i)).toFixed(i ? 1 : 0) + " " + u[i];
};
const fmtAge = (s) => {
if (s == null) return "—";
if (s < 60) return s + "s ago";
if (s < 3600) return Math.floor(s / 60) + "m ago";
if (s < 86400) return Math.floor(s / 3600) + "h ago";
return Math.floor(s / 86400) + "d ago";
};
// Set a tile's big value, its class, and its subtitle in one go. `err` wins
// over the value: a stale number with no warning would be a lie.
function tile(id, big, bigClass, sub, subClass, err) {
const el = document.getElementById(id);
el.querySelector(".big").textContent = big;
el.querySelector(".big").className = "big " + (bigClass || "");
const s = el.querySelector(".sub");
if (s) {
s.textContent = err ? err : (sub || "");
s.className = "sub " + (err ? "badish" : (subClass || ""));
}
return el;
}
function renderSummary(d) {
// Server
tile("t-server",
d.server.online ? "online" : "offline",
d.server.online ? "good" : "bad",
d.server.online ? (d.server.version || "") + (d.server.motd ? " · " + d.server.motd : "") : "",
"", d.server_err);
// TPS — 20 is the ceiling; below ~19.5 is noticeable, below 15 is bad.
if (d.tps) {
const t = d.tps.tps;
tile("t-tps", t.toFixed(2), t >= 19.5 ? "good" : t >= 15 ? "warn" : "bad",
d.tps.mspt.toFixed(2) + " ms/tick", t >= 19.5 ? "" : "warnish", d.tps_err);
} else {
tile("t-tps", "—", "", "", "", d.tps_err || "unavailable");
}
// Online players + heads (rendered by NMSR on its own public origin)
const pl = d.server.players || [];
tile("t-players", `${d.server.count ?? 0} / ${d.server.max ?? 0}`, pl.length ? "good" : "");
const heads = document.querySelector("#t-players .heads");
heads.replaceChildren();
for (const p of pl) {
const wrap = document.createElement("span");
wrap.className = "head";
if (d.avatar_url && p.uuid) {
const img = document.createElement("img");
img.src = `${d.avatar_url}/${d.avatar_mode}/${p.uuid}?size=40`;
img.alt = "";
img.loading = "lazy";
wrap.appendChild(img);
}
const nm = document.createElement("span");
nm.textContent = p.name || "?";
wrap.appendChild(nm);
heads.appendChild(wrap);
}
tile("t-reg", String(d.registered ?? 0), "", "Drasl accounts", "", d.roster_err);
// Disk — the failure that actually takes the server down.
const used = d.disk.used_pct || 0;
const cls = used >= 90 ? "bad" : used >= 80 ? "warn" : "good";
tile("t-disk", fmtBytes(d.disk.free_bytes) + " free", cls,
used.toFixed(1) + "% used of " + fmtBytes(d.disk.total_bytes), "", d.disk_err);
const bar = document.querySelector("#t-disk .bar > i");
bar.style.width = Math.min(100, used) + "%";
bar.className = cls === "good" ? "" : cls;
// Backup freshness. mc-backup runs every 6h, so >8h means it stopped.
if (d.backup && d.backup.count) {
const age = d.backup.age_seconds;
tile("t-backup", fmtAge(age), age > 28800 ? "bad" : age > 21600 ? "warn" : "good",
`${d.backup.count} archives · ${fmtBytes(d.backup.total_bytes)}`, "", d.backup_err);
} else {
tile("t-backup", "none", "bad", "", "", d.backup_err);
}
// Recent joins/leaves, parsed out of the log we already hold.
const box = document.querySelector("#t-sessions .sessions");
box.replaceChildren();
if (!d.sessions || !d.sessions.length) {
const e = document.createElement("div");
e.textContent = "no joins in buffer";
box.appendChild(e);
} else {
for (const s of d.sessions.slice(0, 12)) {
const row = document.createElement("div");
const who = document.createElement("span");
who.className = s.action === "joined" ? "j" : "l";
who.textContent = `${s.action === "joined" ? "→" : "←"} ${s.player}`;
row.appendChild(who);
row.appendChild(document.createTextNode(" " + (s.time || "")));
box.appendChild(row);
}
}
}
async function pollSummary() {
try {
const res = await fetch("/api/summary", { cache: "no-store" });
if (res.ok) renderSummary(await res.json());
} catch { /* tiles keep their last values; the log poll owns the status dot */ }
finally { setTimeout(pollSummary, SUMMARY_MS); }
}
// --- filter ---------------------------------------------------------------
let debounce;
@@ -331,6 +510,7 @@ input[type=search].bad-re { border-color: var(--red); }
liveEl.addEventListener("click", () => { toBottom(); updateLiveButton(); });
poll();
pollSummary();
})();
</script>
</body>
+63 -12
View File
@@ -118,6 +118,31 @@ owns up/down and uptime *history* — do not rebuild that here.
walk (expensive, low value, cache-or-nothing), container health (needs the
docker socket).
### `/api/summary` — one response, independently-degrading parts
Every tile has its own TTL cache and its own failure mode (`collect.go`). A
collector that fails keeps serving its last good value and reports the reason in
a sibling `*_err` field, so the page shows *stale with a warning* rather than a
blank tile or a spinner that never resolves. Verified: with mc-status killed,
`server_err` is populated while the disk, backup and log tiles keep working.
The five collectors are refreshed **concurrently**. Sequentially, one request
during an outage would cost the sum of every timeout (~15s with mc-status
hanging and RCON blackholed) — and an outage is precisely when someone is
staring at the page. Measured with a dead dependency: 0.24s.
TTLs: status 15s, roster 60s, disk 60s, backup 60s, TPS 30s. The UI polls
`/api/summary` every 10s and `/api/log` every 2s — tiles move far more slowly
than the log does.
### Running as root, on purpose
`/data` is `drwxr-x---` owned by uid 1000 (itzg). A non-root container user
cannot traverse it, so the tailer would read nothing — silently. The container
therefore runs as root, and what actually bounds the damage is that every mount
is `:ro` and no port is published. Do not "harden" this by adding a `USER`
directive without re-checking those permissions first.
## RCON — the one credential, and why it doesn't break constraint 2
TPS is the number you actually want when someone says "the server is lagging",
@@ -129,10 +154,29 @@ Constraint 2 survives because the UI never sends a command: the poller runs a
code path from an HTTP request to an RCON string. Hardcode the allowlist so a
future refactor can't quietly turn this into a web console.
- **The command is `/neoforge tps` on NeoForge 1.21.1**, not `/forge tps`.
Verify against the running server before wiring it, and treat an
unknown-command reply as "TPS unavailable" rather than letting it kill the
poll loop.
- **The command is `neoforge tps` on NeoForge 1.21.1**, not `forge tps`.
Confirmed on the running server; `forge tps` answers *"Unknown or incomplete
command"*. An unknown-command reply is treated as "TPS unavailable" rather
than being allowed to kill the poll loop.
- **The output format is NOT the old Forge "Mean tick time / Mean TPS" wording**
this doc originally assumed. Actual, captured from prod:
```
Overworld: 20.000 TPS (0.298 ms/tick)
The Nether: 20.000 TPS (0.014 ms/tick)
Overall: 20.000 TPS (0.357 ms/tick)
```
One line per dimension plus an `Overall:` line, with `§`/ANSI colour codes
mixed in that have to be stripped. `main_test.go` pins this against the real
captured string — if a NeoForge update changes the wording, that test fails
instead of the tile silently reading zero.
- The RCON client is implemented in-repo (`rcon.go`, ~120 lines of the Source
RCON protocol) rather than pulled in as a dependency, specifically so the
allowlist is a compile-time constant with no code path from HTTP input to a
command string. `main_test.go` asserts `op`, `stop`, `say`, `ban` and
near-misses like `LIST` and a trailing-space `"neoforge tps "` are all
rejected.
- Alternative considered: the `spark` mod. Better data (real profiling), but a
165th jar into a pack already over its stated ceiling, plus its own web
endpoint to secure. Rejected for now; still the upgrade path if RCON TPS
@@ -259,17 +303,24 @@ one-container restart.
- [x] nginx vhost + `MONITOR_ENABLED` gate; render + reload.
- [x] Deploy with `--hard` (world downtime, 0 players online). Verified below.
### Phase 2 — tiles
### Phase 2 — tiles — **DONE**
- [ ] `./backups:/backups:ro` mount; backup-freshness + disk-free tiles.
- [ ] mc-status reuse: online/MOTD/icon, players online w/ NMSR heads,
registered count.
- [ ] Join/leave session history parsed from the buffer.
- [x] `./backups:/backups:ro` mount; backup-freshness + disk-free tiles.
- [x] mc-status reuse: online/MOTD, players online w/ NMSR heads,
registered count. (Icon skipped — the MOTD and version carry the same
information in a row of small tiles, and the favicon is a base64 blob.)
- [x] Join/leave session history parsed from the buffer.
- [x] **Fixed a phase-1 bug found while building this:** the UI's level regex
assumed itzg's console format (`[12:34:56]`), but the *file* uses
`[01Aug2026 22:55:34.358]`. Every line was classified INFO, so the
ERROR/WARN chips silently did nothing. Now matches on shape
(`[…] […/LEVEL]`) instead of on the timestamp.
### Phase 3 — TPS
### Phase 3 — TPS — **DONE**
- [ ] RCON client, compile-time allowlist, 30s poll.
- [ ] Confirm `/neoforge tps` is the right command on the running server.
- [x] RCON client, compile-time allowlist, 30s poll.
- [x] Confirmed `neoforge tps` is the right command, and captured its real
output format (see *RCON* above — it is not what this doc first assumed).
### Follow-ups