Files
ulicraft-server-v1/docker/monitor/rcon.go
T
oierandClaude Opus 5 00984e1a70 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>
2026-08-01 23:16:11 +02:00

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
}