582b5a6b08
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
230 lines
7.6 KiB
Go
230 lines
7.6 KiB
Go
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 1–3s
|
||
// 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
|
||
}
|