// 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 }