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>
189 lines
5.5 KiB
Go
189 lines
5.5 KiB
Go
// 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
|
|
}
|