Files
panel/agent/internal/rcon/source.go
T
2026-07-14 23:18:05 -07:00

230 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}