panel v0.9.1 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:01:33 -07:00
commit 0a941f3ba6
2161 changed files with 300771 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
package rcon
import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
"time"
)
// SourceDialer speaks Valve's Source RCON protocol — the standard RCON of
// Minecraft, CS2, Rust, Valheim-with-plugins, and most Source-engine games.
//
// Protocol summary (little-endian throughout):
//
// int32 size = bytes that follow (i.e. frame length 4)
// int32 id = client-chosen, server echoes
// int32 type = 3 auth-request, 2 exec, 0 response, 2 auth-response
// body ASCIIZ = command string or response body, null-terminated
// byte trailing null = required empty string sentinel
//
// size = 4 (id) + 4 (type) + len(body) + 1 (body NUL) + 1 (sentinel NUL)
type SourceDialer struct{}
// Name identifies this adapter in the module manifest.
func (SourceDialer) Name() string { return "source_rcon" }
const (
pktAuth int32 = 3
pktExecCommand int32 = 2
pktResponseValue int32 = 0
pktAuthResponse int32 = 2
minPktSize = 10
maxPktSize = 4110
)
func (SourceDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
if opts.Host == "" || opts.Port == 0 {
return nil, errors.New("source rcon dial: host and port are required")
}
timeout := opts.ConnectTimeout
if timeout == 0 {
timeout = 5 * time.Second
}
d := &net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", opts.Host, opts.Port))
if err != nil {
return nil, fmt.Errorf("source rcon dial %s:%d: %w", opts.Host, opts.Port, err)
}
c := &sourceClient{conn: conn, reader: bufio.NewReader(conn), nextID: 1}
if err := c.authenticate(opts.Password); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("source rcon auth: %w", err)
}
return c, nil
}
type sourceClient struct {
conn net.Conn
reader *bufio.Reader
mu sync.Mutex
nextID int32
}
// authenticate sends SERVERDATA_AUTH and waits for SERVERDATA_AUTH_RESPONSE.
// Valve's spec says servers send an empty RESPONSE_VALUE first (id echoed),
// then the AUTH_RESPONSE (id = original on success, 1 on failure). Some
// implementations skip the probe — we accept either ordering.
func (c *sourceClient) authenticate(password string) error {
id := c.assignID()
if err := c.writePacket(id, pktAuth, password); err != nil {
return err
}
// ASA's RCON handler is slow to answer AUTH during the first few
// minutes after "Server has completed startup" — sometimes 3-4s per
// reply when the server is still loading tribe data / saving a
// snapshot. The previous 2s per-read deadline + 5s overall was
// tripping the tracker into its 32s exponential backoff on every
// auth, stranding the panel with "RCON not yet connected" for 5-10
// min even though the server was otherwise fine. Give AUTH a 12s
// outer window and 8s per-read — enough slack for ASA's slowest
// responses without letting a truly dead connection hang forever.
deadline := time.Now().Add(12 * time.Second)
for time.Now().Before(deadline) {
_ = c.conn.SetReadDeadline(time.Now().Add(8 * time.Second))
gotID, gotType, _, err := c.readPacket()
if err != nil {
return err
}
if gotType == pktAuthResponse {
// Spec: id == -1 means auth failed (bad password).
// Any other id means success. Most servers echo our request id
// but some (Conan Exiles, Palworld, a few others) return 0 or
// an unrelated id — treat that as success, don't reject.
if gotID == -1 {
return errors.New("invalid password")
}
return nil
}
// Probe echo (RESPONSE_VALUE with body ""), keep reading for the
// AUTH_RESPONSE that follows.
}
return errors.New("auth timeout")
}
// Exec sends a SERVERDATA_EXECCOMMAND and concatenates RESPONSE_VALUE bodies
// until the server goes silent. For responses that straddle multiple packets
// the spec recommends a "bogus packet trick" (send a second empty packet and
// watch for its echo) but short command output fits in one packet in practice.
func (c *sourceClient) Exec(ctx context.Context, cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if dl, ok := ctx.Deadline(); ok {
_ = c.conn.SetDeadline(dl)
defer c.conn.SetDeadline(time.Time{}) //nolint:errcheck
}
id := c.assignID()
if err := c.writePacket(id, pktExecCommand, cmd); err != nil {
return "", fmt.Errorf("write: %w", err)
}
var out strings.Builder
// First read waits longer — some servers (Palworld especially) take 13s
// to build the response for commands like ShowPlayers. Subsequent reads
// use a tight idle timeout so we exit promptly once the stream goes quiet.
const (
firstTimeout = 5 * time.Second
idleTimeout = 800 * time.Millisecond
)
readDeadline := firstTimeout
for {
_ = c.conn.SetReadDeadline(time.Now().Add(readDeadline))
readDeadline = idleTimeout
gotID, gotType, body, err := c.readPacket()
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() && out.Len() > 0 {
return out.String(), nil
}
if errors.Is(err, io.EOF) && out.Len() > 0 {
return out.String(), nil
}
return out.String(), err
}
// Source RCON spec says the server MUST echo our request id on
// RESPONSE_VALUE. Palworld always returns id=0; Conan Exiles
// returns a different arbitrary id. We hold mu.Lock() for the
// whole Exec so only one command is ever in flight — ID matching
// isn't needed for correctness, so accept any id the server
// hands us (except -1, which is reserved for auth failures).
if gotID == -1 {
continue
}
// Spec: RESPONSE_VALUE (type 0) carries command output. Conan
// Exiles sends command output via AUTH_RESPONSE (type 2) instead —
// non-spec but functional. We're past the auth phase by this point
// so accepting either type is safe; anything else we skip.
if gotType != pktResponseValue && gotType != pktAuthResponse {
continue
}
out.WriteString(body)
// Body shorter than ~4000 bytes → likely single-packet response.
if len(body) < 3800 {
return out.String(), nil
}
}
}
// Close shuts down the underlying TCP connection.
func (c *sourceClient) Close() error { return c.conn.Close() }
func (c *sourceClient) assignID() int32 {
c.nextID++
if c.nextID <= 0 {
c.nextID = 1
}
return c.nextID
}
// writePacket wire-encodes a Source RCON packet.
func (c *sourceClient) writePacket(id, ptype int32, body string) error {
bodyBytes := []byte(body)
pktSize := int32(4 + 4 + len(bodyBytes) + 2) // id + type + body + 2×NUL
if pktSize > maxPktSize {
return fmt.Errorf("source rcon: body too large (%d bytes, max %d)", len(bodyBytes), maxPktSize-10)
}
buf := make([]byte, 4+pktSize)
binary.LittleEndian.PutUint32(buf[0:4], uint32(pktSize))
binary.LittleEndian.PutUint32(buf[4:8], uint32(id))
binary.LittleEndian.PutUint32(buf[8:12], uint32(ptype))
copy(buf[12:], bodyBytes)
// trailing two NUL bytes already zero
_, err := c.conn.Write(buf)
return err
}
// readPacket reads one framed Source RCON packet from the underlying reader.
// Returns (id, type, body, err). Body has trailing NULs stripped.
func (c *sourceClient) readPacket() (int32, int32, string, error) {
var sizeBuf [4]byte
if _, err := io.ReadFull(c.reader, sizeBuf[:]); err != nil {
return 0, 0, "", err
}
size := int32(binary.LittleEndian.Uint32(sizeBuf[:]))
if size < minPktSize || size > maxPktSize {
return 0, 0, "", fmt.Errorf("source rcon: bogus packet size %d", size)
}
rest := make([]byte, size)
if _, err := io.ReadFull(c.reader, rest); err != nil {
return 0, 0, "", err
}
id := int32(binary.LittleEndian.Uint32(rest[0:4]))
ptype := int32(binary.LittleEndian.Uint32(rest[4:8]))
bodyEnd := size - 2 // trim both trailing NULs
if bodyEnd < 8 {
bodyEnd = 8
}
body := string(rest[8:bodyEnd])
return id, ptype, body, nil
}