panel v0.9.2 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
package rcon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BattlEyeDialer speaks the BattlEye RCON protocol used by DayZ, Arma 2/3,
|
||||
// Squad, Rising Storm 2, and a handful of other BE-protected games.
|
||||
// Protocol reference: https://www.battleye.com/downloads/BERConProtocol.txt
|
||||
//
|
||||
// Wire format (UDP, all frames start with "BE" + CRC32 + 0xFF + type + payload):
|
||||
//
|
||||
// 0x42 'B'
|
||||
// 0x45 'E'
|
||||
// uint32 crc32 — little-endian CRC32 of bytes from offset 6 onward
|
||||
// 0xFF — packet-header sentinel
|
||||
// uint8 type — 0x00 login, 0x01 command, 0x02 server message
|
||||
// payload — varies by type
|
||||
//
|
||||
// Auth (type 0x00): client sends its admin password; server replies with
|
||||
// a single-byte 0x01 = success, 0x00 = failure.
|
||||
//
|
||||
// Commands (type 0x01): client sends a 1-byte sequence id + the command
|
||||
// text; server echoes the same seq id with the response text.
|
||||
//
|
||||
// Server-pushed messages (type 0x02): the server streams chat/join/leave
|
||||
// events. Client MUST ack each one by echoing the seq id with an empty
|
||||
// payload or the server will disconnect.
|
||||
type BattlEyeDialer struct{}
|
||||
|
||||
// Name identifies this adapter in the module manifest.
|
||||
func (BattlEyeDialer) Name() string { return "be_rcon" }
|
||||
|
||||
const (
|
||||
bePacketHeader = 0xFF
|
||||
bePacketLogin = 0x00
|
||||
bePacketCmd = 0x01
|
||||
bePacketServer = 0x02
|
||||
)
|
||||
|
||||
// Dial performs the login handshake and returns a ready-to-use BE RCON client.
|
||||
func (BattlEyeDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
|
||||
if opts.Host == "" || opts.Port == 0 {
|
||||
return nil, errors.New("be rcon dial: host and port are required")
|
||||
}
|
||||
connectTimeout := opts.ConnectTimeout
|
||||
if connectTimeout == 0 {
|
||||
connectTimeout = 5 * time.Second
|
||||
}
|
||||
addr := &net.UDPAddr{IP: net.ParseIP(opts.Host), Port: opts.Port}
|
||||
if addr.IP == nil {
|
||||
// Allow hostnames too.
|
||||
resolved, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", opts.Host, opts.Port))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve %s: %w", opts.Host, err)
|
||||
}
|
||||
addr = resolved
|
||||
}
|
||||
conn, err := net.DialUDP("udp", nil, addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("be rcon dial %s:%d: %w", opts.Host, opts.Port, err)
|
||||
}
|
||||
|
||||
c := &beClient{conn: conn, addr: addr}
|
||||
if err := c.login(opts.Password, connectTimeout); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
// Kick off the keep-alive goroutine — BattlEye disconnects clients
|
||||
// that don't send traffic for ~45s. We ping every 30s.
|
||||
go c.keepAlive()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type beClient struct {
|
||||
conn *net.UDPConn
|
||||
addr *net.UDPAddr
|
||||
|
||||
seqMu sync.Mutex
|
||||
seq uint8 // next command sequence id
|
||||
|
||||
execMu sync.Mutex
|
||||
closed atomic.Bool
|
||||
// stopKA fires when Close is called; keep-alive loop watches it.
|
||||
stopKA chan struct{}
|
||||
}
|
||||
|
||||
// login sends the auth packet and waits for a 1-byte success response.
|
||||
func (c *beClient) login(password string, timeout time.Duration) error {
|
||||
pkt := buildBEPacket(bePacketLogin, []byte(password))
|
||||
if _, err := c.conn.Write(pkt); err != nil {
|
||||
return fmt.Errorf("be rcon login write: %w", err)
|
||||
}
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
buf := make([]byte, 256)
|
||||
n, err := c.conn.Read(buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("be rcon login read: %w", err)
|
||||
}
|
||||
payloadType, payload, ok := parseBEPacket(buf[:n])
|
||||
if !ok {
|
||||
return errors.New("be rcon login: malformed response")
|
||||
}
|
||||
if payloadType != bePacketLogin {
|
||||
return fmt.Errorf("be rcon login: unexpected packet type 0x%02x", payloadType)
|
||||
}
|
||||
if len(payload) < 1 {
|
||||
return errors.New("be rcon login: empty response payload")
|
||||
}
|
||||
if payload[0] != 0x01 {
|
||||
return errors.New("be rcon login: bad password")
|
||||
}
|
||||
c.stopKA = make(chan struct{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exec runs one command and returns the server's response text. BattlEye
|
||||
// doesn't multi-frame short responses; long ones arrive as multiple packets
|
||||
// with the same seq id + a 1-byte 'part_count / part_index' header. We
|
||||
// concatenate parts in order.
|
||||
func (c *beClient) Exec(ctx context.Context, cmd string) (string, error) {
|
||||
if c.closed.Load() {
|
||||
return "", errors.New("be rcon: client closed")
|
||||
}
|
||||
c.execMu.Lock()
|
||||
defer c.execMu.Unlock()
|
||||
|
||||
c.seqMu.Lock()
|
||||
seq := c.seq
|
||||
c.seq++
|
||||
c.seqMu.Unlock()
|
||||
|
||||
body := make([]byte, 0, len(cmd)+1)
|
||||
body = append(body, seq)
|
||||
body = append(body, []byte(cmd)...)
|
||||
pkt := buildBEPacket(bePacketCmd, body)
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(10 * time.Second)
|
||||
}
|
||||
_ = c.conn.SetDeadline(deadline)
|
||||
defer c.conn.SetDeadline(time.Time{}) //nolint:errcheck
|
||||
|
||||
if _, err := c.conn.Write(pkt); err != nil {
|
||||
return "", fmt.Errorf("be rcon write: %w", err)
|
||||
}
|
||||
|
||||
// Collect response parts. We loop until we've got every declared part
|
||||
// for this seq, or an idle timeout fires with partial data.
|
||||
parts := map[uint8][]byte{}
|
||||
var totalParts uint8
|
||||
for {
|
||||
buf := make([]byte, 4096)
|
||||
n, err := c.conn.Read(buf)
|
||||
if err != nil {
|
||||
if len(parts) > 0 {
|
||||
// Return whatever we got — better than nothing.
|
||||
return reassembleBEParts(parts, totalParts), nil
|
||||
}
|
||||
return "", fmt.Errorf("be rcon read: %w", err)
|
||||
}
|
||||
pType, payload, ok := parseBEPacket(buf[:n])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if pType == bePacketServer {
|
||||
// Server-pushed event (chat/join/leave). Ack it + keep reading.
|
||||
if len(payload) >= 1 {
|
||||
ack := buildBEPacket(bePacketServer, []byte{payload[0]})
|
||||
_, _ = c.conn.Write(ack)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if pType != bePacketCmd || len(payload) < 1 || payload[0] != seq {
|
||||
continue
|
||||
}
|
||||
// Multi-part response format: [seq, 0x00, total_parts, part_index, ...body...]
|
||||
if len(payload) >= 4 && payload[1] == 0x00 {
|
||||
totalParts = payload[2]
|
||||
idx := payload[3]
|
||||
parts[idx] = payload[4:]
|
||||
if uint8(len(parts)) >= totalParts {
|
||||
return reassembleBEParts(parts, totalParts), nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Single-frame response: [seq, ...body...]
|
||||
return string(payload[1:]), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Close tears down the UDP connection + stops the keep-alive loop.
|
||||
func (c *beClient) Close() error {
|
||||
if !c.closed.CompareAndSwap(false, true) {
|
||||
return nil
|
||||
}
|
||||
if c.stopKA != nil {
|
||||
close(c.stopKA)
|
||||
}
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
// keepAlive sends a zero-length command every 30s to keep BE from dropping
|
||||
// the session for inactivity. The protocol explicitly documents this.
|
||||
func (c *beClient) keepAlive() {
|
||||
tick := time.NewTicker(30 * time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c.stopKA:
|
||||
return
|
||||
case <-tick.C:
|
||||
if c.closed.Load() {
|
||||
return
|
||||
}
|
||||
c.execMu.Lock()
|
||||
c.seqMu.Lock()
|
||||
seq := c.seq
|
||||
c.seq++
|
||||
c.seqMu.Unlock()
|
||||
pkt := buildBEPacket(bePacketCmd, []byte{seq})
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
_, _ = c.conn.Write(pkt)
|
||||
c.execMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- wire helpers ----
|
||||
|
||||
// buildBEPacket assembles a BE RCON UDP frame: "BE" + CRC32(body) + body,
|
||||
// where body = 0xFF + type + payload.
|
||||
func buildBEPacket(pType uint8, payload []byte) []byte {
|
||||
body := make([]byte, 0, 2+len(payload))
|
||||
body = append(body, bePacketHeader, pType)
|
||||
body = append(body, payload...)
|
||||
crc := crc32.ChecksumIEEE(body)
|
||||
out := make([]byte, 6+len(body))
|
||||
out[0], out[1] = 'B', 'E'
|
||||
binary.LittleEndian.PutUint32(out[2:6], crc)
|
||||
copy(out[6:], body)
|
||||
return out
|
||||
}
|
||||
|
||||
// parseBEPacket validates a "BE..." frame and returns (type, payload, ok).
|
||||
func parseBEPacket(buf []byte) (uint8, []byte, bool) {
|
||||
if len(buf) < 7 || buf[0] != 'B' || buf[1] != 'E' {
|
||||
return 0, nil, false
|
||||
}
|
||||
crc := binary.LittleEndian.Uint32(buf[2:6])
|
||||
if crc32.ChecksumIEEE(buf[6:]) != crc {
|
||||
// Corrupt packet — accept it anyway for lenience; some mods' BE
|
||||
// extensions are known to skip CRC. Emit a debug break here
|
||||
// later if we start seeing real-world issues.
|
||||
_ = crc
|
||||
}
|
||||
if buf[6] != bePacketHeader {
|
||||
return 0, nil, false
|
||||
}
|
||||
return buf[7], buf[8:], true
|
||||
}
|
||||
|
||||
// reassembleBEParts joins multi-frame command responses in order.
|
||||
func reassembleBEParts(parts map[uint8][]byte, total uint8) string {
|
||||
var b strings.Builder
|
||||
for i := uint8(0); i < total; i++ {
|
||||
b.Write(parts[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package rcon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DockerExecDialer runs per command, opening
|
||||
// a fresh socket inside the container's network namespace each time and
|
||||
// closing it on completion. Fresh process, fresh fd, fresh TCP conn — nothing
|
||||
// is held open between calls.
|
||||
//
|
||||
// Why: ARK Survival Ascended's RCON listener wedges if a polling client holds
|
||||
// a long-lived TCP session — the engine accumulates CLOSE-WAIT sockets and
|
||||
// the listener thread itself dies after enough accumulation. The wedge is
|
||||
// not recoverable without restarting the engine. Per-command exec avoids the
|
||||
// wedge entirely because nothing persists between invocations.
|
||||
//
|
||||
// Trade-off: ~50–200ms of overhead per command for docker exec + rcon-cli
|
||||
// startup. Negligible at ARK SA's polling cadence (30s+); not appropriate
|
||||
// for high-frequency command paths (none exist in panel today).
|
||||
//
|
||||
// Requirements:
|
||||
// - Container has rcon-cli on PATH (acekorneya/asa_server: yes).
|
||||
// - Container env exposes the RCON password to the in-container shell, OR
|
||||
// the agent supplies it via DialOptions.Password (we pass it in).
|
||||
// - Agent process is in the docker group (the panel-agent systemd unit
|
||||
// already declares SupplementaryGroups=docker).
|
||||
type DockerExecDialer struct{}
|
||||
|
||||
func (DockerExecDialer) Name() string { return "docker_exec_rcon" }
|
||||
|
||||
func (DockerExecDialer) Dial(_ context.Context, opts DialOptions) (Client, error) {
|
||||
if opts.ContainerID == "" {
|
||||
return nil, errors.New("docker_exec_rcon dial: ContainerID required")
|
||||
}
|
||||
port := ""
|
||||
if opts.Port != 0 {
|
||||
port = strconv.Itoa(opts.Port)
|
||||
}
|
||||
return &dockerExecClient{
|
||||
containerID: opts.ContainerID,
|
||||
password: opts.Password,
|
||||
port: port,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type dockerExecClient struct {
|
||||
containerID string
|
||||
password string
|
||||
port string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ansiRe matches CSI escape sequences. rcon-cli emits ANSI color resets
|
||||
// after each response (\033[0m); strip them so the panel's parsers see
|
||||
// clean text.
|
||||
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
||||
|
||||
// Exec runs and returns the cleaned stdout.
|
||||
func (c *dockerExecClient) Exec(ctx context.Context, cmd string) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
args := []string{"exec", c.containerID, "rcon-cli", "--host", "localhost"}
|
||||
if c.port != "" {
|
||||
args = append(args, "--port", c.port)
|
||||
}
|
||||
if c.password != "" {
|
||||
args = append(args, "--password", c.password)
|
||||
}
|
||||
args = append(args, cmd)
|
||||
|
||||
dockerCmd := exec.CommandContext(ctx, "docker", args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
dockerCmd.Stdout = &stdout
|
||||
dockerCmd.Stderr = &stderr
|
||||
if err := dockerCmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("docker exec rcon-cli: %s", errMsg)
|
||||
}
|
||||
out := ansiRe.ReplaceAllString(stdout.String(), "")
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
// Close is a no-op — there's nothing held open between Exec calls.
|
||||
func (c *dockerExecClient) Close() error { return nil }
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package rcon provides pluggable RCON adapters. Games speak different
|
||||
// dialects — Source RCON (Counter-Strike, Minecraft with Mojang auth),
|
||||
// telnet line protocol (7 Days to Die), HTTP admin APIs (some community
|
||||
// implementations), stdin-pipe for headless servers without a network
|
||||
// surface. The Agent picks an adapter based on the module manifest's
|
||||
// `rcon.adapter` field.
|
||||
package rcon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a connected RCON session. Exec runs one command and returns its
|
||||
// raw text response. Clients must be safe for concurrent Exec calls — the
|
||||
// poll loop and a future ad-hoc-command path both need to call through the
|
||||
// same session.
|
||||
type Client interface {
|
||||
Exec(ctx context.Context, cmd string) (string, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// DialOptions configures one RCON connection attempt.
|
||||
type DialOptions struct {
|
||||
Host string
|
||||
Port int
|
||||
Password string
|
||||
ConnectTimeout time.Duration
|
||||
// ReadIdle is how long to wait with no new bytes before treating an Exec
|
||||
// response as complete. Telnet has no packet framing, so this heuristic
|
||||
// is needed; Source RCON overrides it to 0 (ignored).
|
||||
ReadIdle time.Duration
|
||||
|
||||
// ContainerID is the Docker container name or ID for stdio/exec-based
|
||||
// adapters that don't use TCP. Ignored by telnet/source_rcon.
|
||||
ContainerID string
|
||||
// Stdio is the backend that knows how to attach to a container's
|
||||
// stdin/stdout. Provided by the Docker runtime when the adapter is
|
||||
// "stdio". Nil for TCP adapters.
|
||||
Stdio StdioBackend
|
||||
}
|
||||
|
||||
// StdioBackend attaches to a running container's combined stdin/stdout/stderr
|
||||
// stream. Needed by the stdio adapter for games whose only admin console is
|
||||
// stdin (Valheim, Terraria, Barotrauma, Enshrouded, Sons Of The Forest).
|
||||
//
|
||||
// Returned ReadWriteCloser writes go to the container's stdin; reads are the
|
||||
// demux-multiplexed stdout+stderr. Close shuts down the hijacked connection.
|
||||
type StdioBackend interface {
|
||||
AttachStdio(ctx context.Context, containerID string) (io.ReadWriteCloser, error)
|
||||
}
|
||||
|
||||
// Dialer establishes RCON connections of a specific adapter kind.
|
||||
type Dialer interface {
|
||||
Dial(ctx context.Context, opts DialOptions) (Client, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
// DialerFor returns the dialer for the given adapter id, or an error if the
|
||||
// adapter is unknown. Adapter ids come directly from the module manifest's
|
||||
// `rcon.adapter` field.
|
||||
func DialerFor(adapter string) (Dialer, error) {
|
||||
switch adapter {
|
||||
case "telnet":
|
||||
return &TelnetDialer{}, nil
|
||||
case "source_rcon":
|
||||
return &SourceDialer{}, nil
|
||||
case "stdio":
|
||||
return &StdioDialer{}, nil
|
||||
case "websocket_rcon":
|
||||
return &WebSocketDialer{}, nil
|
||||
case "docker_exec_rcon":
|
||||
return &DockerExecDialer{}, nil
|
||||
case "be_rcon":
|
||||
return &BattlEyeDialer{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown rcon adapter %q (known: telnet, source_rcon, docker_exec_rcon, stdio, websocket_rcon, be_rcon)", adapter)
|
||||
}
|
||||
}
|
||||
@@ -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 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
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package rcon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StdioDialer "RCON"s a game by attaching to the container's stdin and writing
|
||||
// the admin console command, trusting the game to execute it. It's the correct
|
||||
// choice for games whose admin surface is stdin-only — Valheim, Terraria,
|
||||
// Barotrauma, Enshrouded, Sons Of The Forest — where no TCP RCON exists.
|
||||
//
|
||||
// v1 is write-only: Exec returns empty string. Command output arrives via the
|
||||
// panel's log pipeline (docker logs → agent → bus → SSE) because the game's
|
||||
// replies appear on the same stdout the log stream is tailing. A future
|
||||
// revision could correlate a response window with each write if we ever need
|
||||
// structured replies for state polling.
|
||||
//
|
||||
// Requires the container to have been created with OpenStdin=true; the module
|
||||
// resolver auto-enables that whenever manifest.Rcon.Adapter == "stdio".
|
||||
type StdioDialer struct{}
|
||||
|
||||
// Name identifies this adapter in the module manifest.
|
||||
func (StdioDialer) Name() string { return "stdio" }
|
||||
|
||||
// Dial attaches to the container's stdio stream. A background goroutine drains
|
||||
// the multiplexed stdout/stderr so the stream never blocks from our side; the
|
||||
// docker logs pipeline is the canonical consumer of that output.
|
||||
func (StdioDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
|
||||
if opts.Stdio == nil {
|
||||
return nil, errors.New("stdio dial: Stdio backend required (agent must provide one)")
|
||||
}
|
||||
if opts.ContainerID == "" {
|
||||
return nil, errors.New("stdio dial: ContainerID required")
|
||||
}
|
||||
session, err := opts.Stdio.AttachStdio(ctx, opts.ContainerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdio attach %q: %w", opts.ContainerID, err)
|
||||
}
|
||||
c := &stdioClient{session: session}
|
||||
go func() {
|
||||
// Drain the attached stream so Docker doesn't buffer indefinitely.
|
||||
// We deliberately discard: the log pipeline already surfaces this
|
||||
// output to the panel via a separate docker logs call.
|
||||
_, _ = io.Copy(io.Discard, session)
|
||||
}()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type stdioClient struct {
|
||||
session io.ReadWriteCloser
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Exec writes cmd + "\n" to the container's stdin. Returns empty string; the
|
||||
// game's response (if any) lands in the log stream and is surfaced in the
|
||||
// Console tab.
|
||||
//
|
||||
// The write is bounded by ctx (and a hard 10s fallback). A raw Write to a
|
||||
// hijacked stdin pipe can block FOREVER if the container's stdin read side
|
||||
// stalls (game paused, buffer full, or the process not draining stdin). Because
|
||||
// the agent dispatches RCON inline on its single recv loop, an unbounded write
|
||||
// here freezes the WHOLE agent — no stop/start/anything — until restart. That's
|
||||
// the Valheim+stdio "stop never lands" hang. We run the write in a goroutine and
|
||||
// abandon it on timeout so the dispatcher stays responsive.
|
||||
func (c *stdioClient) Exec(ctx context.Context, cmd string) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.closed {
|
||||
return "", errors.New("stdio: session closed")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
wctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := c.session.Write([]byte(cmd + "\n"))
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stdio write: %w", err)
|
||||
}
|
||||
return "", nil
|
||||
case <-wctx.Done():
|
||||
// The write is wedged — leave the goroutine parked (it'll unblock if the
|
||||
// pipe ever drains, or die when the session closes) but DON'T let it hold
|
||||
// up the caller / the agent's recv loop.
|
||||
return "", fmt.Errorf("stdio write timed out after 10s (container stdin not draining): %w", wctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the hijacked attach connection.
|
||||
func (c *stdioClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.closed {
|
||||
return nil
|
||||
}
|
||||
c.closed = true
|
||||
return c.session.Close()
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package rcon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TelnetDialer speaks 7DTD-style line-oriented telnet: on connect the server
|
||||
// sends a "password:" prompt; after the client writes the password and a
|
||||
// newline, commands are one-line writes and responses are everything read
|
||||
// until the connection goes quiet for a short idle period.
|
||||
type TelnetDialer struct{}
|
||||
|
||||
func (TelnetDialer) Name() string { return "telnet" }
|
||||
|
||||
func (TelnetDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
|
||||
if opts.Host == "" || opts.Port == 0 {
|
||||
return nil, errors.New("telnet dial: host and port are required")
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", opts.Host, opts.Port)
|
||||
|
||||
timeout := opts.ConnectTimeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
|
||||
d := &net.Dialer{Timeout: timeout}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telnet dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
idle := opts.ReadIdle
|
||||
if idle == 0 {
|
||||
idle = 250 * time.Millisecond
|
||||
}
|
||||
c := &telnetClient{
|
||||
conn: conn,
|
||||
readIdle: idle,
|
||||
}
|
||||
if err := c.authenticate(opts.Password); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("telnet auth: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// telnetClient is safe for concurrent Exec via mu serialization.
|
||||
type telnetClient struct {
|
||||
conn net.Conn
|
||||
readIdle time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (c *telnetClient) authenticate(password string) error {
|
||||
// Read until the first "password:" prompt arrives, with a 5s cap.
|
||||
if _, err := c.readUntil("password:", 5*time.Second); err != nil {
|
||||
return fmt.Errorf("read password prompt: %w", err)
|
||||
}
|
||||
if _, err := c.conn.Write([]byte(password + "\n")); err != nil {
|
||||
return fmt.Errorf("write password: %w", err)
|
||||
}
|
||||
// Drain whatever welcome/banner text the server sends after auth.
|
||||
_, _ = c.readQuiet(1500*time.Millisecond, 1500*time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exec sends one line-terminated command and returns everything the server
|
||||
// writes back before going quiet for readIdle.
|
||||
func (c *telnetClient) Exec(ctx context.Context, cmd string) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Wire ctx deadline into the conn's read/write timeouts.
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = c.conn.SetDeadline(dl)
|
||||
defer c.conn.SetDeadline(time.Time{}) //nolint:errcheck
|
||||
}
|
||||
|
||||
if _, err := c.conn.Write([]byte(cmd + "\n")); err != nil {
|
||||
return "", fmt.Errorf("write cmd: %w", err)
|
||||
}
|
||||
return c.readQuiet(c.firstByteTimeout(), c.readIdle)
|
||||
}
|
||||
|
||||
func (c *telnetClient) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
// firstByteTimeout is how long we wait for the server to START replying to a
|
||||
// command. A busy 7DTD server (heavy load, blood moon, GC pause) can take well
|
||||
// over readIdle to send its first byte, so this must be generous — otherwise
|
||||
// readQuiet times out with zero bytes and returns an empty response even though
|
||||
// the command executed fine on the server.
|
||||
func (c *telnetClient) firstByteTimeout() time.Duration {
|
||||
return 4 * time.Second
|
||||
}
|
||||
|
||||
// readQuiet reads a command response. It waits up to `first` for the first byte
|
||||
// (server may be slow to start replying under load), then once bytes arrive it
|
||||
// uses the short `idle` gap to detect end-of-response. Returns what it read, or
|
||||
// an error on a real (non-timeout) failure.
|
||||
func (c *telnetClient) readQuiet(first, idle time.Duration) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
tmp := make([]byte, 4096)
|
||||
for {
|
||||
// Before any data, allow the longer first-byte window; after data
|
||||
// starts flowing, switch to the short idle gap.
|
||||
wait := idle
|
||||
if buf.Len() == 0 {
|
||||
wait = first
|
||||
}
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(wait))
|
||||
n, err := c.conn.Read(tmp)
|
||||
if n > 0 {
|
||||
buf.Write(tmp[:n])
|
||||
}
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
// Timeout with data → normal end of response.
|
||||
// Timeout with NO data even after the first-byte window → the
|
||||
// server genuinely sent nothing (or is unreachable); return
|
||||
// empty and let the caller decide.
|
||||
return buf.String(), nil
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
return buf.String(), nil
|
||||
}
|
||||
return buf.String(), err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readUntil reads up to `overall` time waiting for `substr` (case-insensitive)
|
||||
// to appear in the accumulated buffer. Returns what it saw either way.
|
||||
func (c *telnetClient) readUntil(substr string, overall time.Duration) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
tmp := make([]byte, 4096)
|
||||
deadline := time.Now().Add(overall)
|
||||
want := strings.ToLower(substr)
|
||||
for time.Now().Before(deadline) {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
n, err := c.conn.Read(tmp)
|
||||
if n > 0 {
|
||||
buf.Write(tmp[:n])
|
||||
if strings.Contains(strings.ToLower(buf.String()), want) {
|
||||
return buf.String(), nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
return buf.String(), err
|
||||
}
|
||||
}
|
||||
return buf.String(), fmt.Errorf("timed out after %s waiting for %q", overall, substr)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package rcon
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeTelnetServer speaks enough of the 7DTD protocol to exercise dial+auth+exec:
|
||||
// - sends "Please enter password:" on accept
|
||||
// - waits for client password (any line), then sends "Logon successful.\n"
|
||||
// - for each subsequent command line, responds with its registered output
|
||||
type fakeTelnetServer struct {
|
||||
addr string
|
||||
responses map[string]string
|
||||
}
|
||||
|
||||
func startFakeTelnet(t *testing.T, responses map[string]string) *fakeTelnetServer {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
fs := &fakeTelnetServer{addr: ln.Addr().String(), responses: responses}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go fs.handle(conn)
|
||||
}
|
||||
}()
|
||||
return fs
|
||||
}
|
||||
|
||||
func (fs *fakeTelnetServer) handle(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
_, _ = conn.Write([]byte("*** Connected with 7DTD server.\r\nPlease enter password:"))
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
if _, err := br.ReadString('\n'); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = conn.Write([]byte("\nLogon successful.\n"))
|
||||
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(line)
|
||||
resp, ok := fs.responses[cmd]
|
||||
if !ok {
|
||||
resp = fmt.Sprintf("(unknown command: %s)\n", cmd)
|
||||
}
|
||||
_, _ = conn.Write([]byte(resp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelnetDialAndExec(t *testing.T) {
|
||||
srv := startFakeTelnet(t, map[string]string{
|
||||
"lp": "Total of 3 in the game\n\n1. id=1, Alice, pos=(0,0,0)\n2. id=2, Bob, pos=(1,0,0)\n3. id=3, Carol, pos=(2,0,0)\n\n",
|
||||
"say \"hello\"": "*Server: hello\n",
|
||||
})
|
||||
|
||||
host, port := splitAddr(t, srv.addr)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := TelnetDialer{}.Dial(ctx, DialOptions{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Password: "test",
|
||||
ConnectTimeout: 2 * time.Second,
|
||||
ReadIdle: 150 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
out, err := client.Exec(ctx, "lp")
|
||||
if err != nil {
|
||||
t.Fatalf("Exec lp: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "Total of 3 in the game") {
|
||||
t.Errorf("lp output missing expected substring, got: %q", out)
|
||||
}
|
||||
|
||||
out, err = client.Exec(ctx, `say "hello"`)
|
||||
if err != nil {
|
||||
t.Fatalf(`Exec say: %v`, err)
|
||||
}
|
||||
if !strings.Contains(out, "*Server: hello") {
|
||||
t.Errorf("say output missing, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialerForUnknown(t *testing.T) {
|
||||
if _, err := DialerFor("source_rcon"); err == nil {
|
||||
t.Error("expected error for unknown adapter")
|
||||
}
|
||||
if d, err := DialerFor("telnet"); err != nil || d.Name() != "telnet" {
|
||||
t.Errorf("DialerFor(telnet): d=%v err=%v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
func splitAddr(t *testing.T, addr string) (string, int) {
|
||||
t.Helper()
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split %q: %v", addr, err)
|
||||
}
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil {
|
||||
t.Fatalf("port parse: %v", err)
|
||||
}
|
||||
return host, port
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package rcon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// WebSocketDialer speaks Facepunch's WebSocket RCON — the protocol used
|
||||
// by Rust (app 258550) since they removed Source RCON years ago. A small
|
||||
// subset of games have adopted the same pattern (a few Oxide-compatible
|
||||
// community servers, some Unity-based survival games).
|
||||
//
|
||||
// Wire protocol (JSON text frames over a plain websocket):
|
||||
//
|
||||
// client → server: {"Identifier": N, "Message": "command", "Name": "panel"}
|
||||
// server → client: {"Identifier": N, "Message": "result text",
|
||||
// "Type": "Generic", "Stacktrace": ""}
|
||||
//
|
||||
// The password is passed as the URL path, not a header:
|
||||
//
|
||||
// ws://<host>:<port>/<password>
|
||||
//
|
||||
// Connections are long-lived — Rust's RCON doesn't close after each command
|
||||
// the way Conan's does — but we still redial transparently on EOF/reset
|
||||
// via the tracker's existing logic.
|
||||
type WebSocketDialer struct{}
|
||||
|
||||
// Name identifies this adapter in the module manifest.
|
||||
func (WebSocketDialer) Name() string { return "websocket_rcon" }
|
||||
|
||||
// Dial opens a websocket to ws://host:port/password. Returns a client that
|
||||
// serializes Exec calls; Rust sometimes interleaves async log spam into the
|
||||
// same connection as command replies, so we match responses back by id.
|
||||
func (WebSocketDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
|
||||
if opts.Host == "" || opts.Port == 0 {
|
||||
return nil, errors.New("websocket rcon dial: host and port are required")
|
||||
}
|
||||
connectTimeout := opts.ConnectTimeout
|
||||
if connectTimeout == 0 {
|
||||
connectTimeout = 5 * time.Second
|
||||
}
|
||||
|
||||
wsURL := fmt.Sprintf("ws://%s:%d/%s", opts.Host, opts.Port, url.PathEscape(opts.Password))
|
||||
origin := fmt.Sprintf("http://%s:%d/", opts.Host, opts.Port)
|
||||
cfg, err := websocket.NewConfig(wsURL, origin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("websocket rcon: build config: %w", err)
|
||||
}
|
||||
|
||||
// x/net/websocket.Dial doesn't honor a context; run it in a goroutine
|
||||
// so we can bail out when ctx.Done fires. Rust's authentication is
|
||||
// implicit — bad password = server closes the handshake with a 401.
|
||||
type dialResult struct {
|
||||
ws *websocket.Conn
|
||||
err error
|
||||
}
|
||||
ch := make(chan dialResult, 1)
|
||||
go func() {
|
||||
ws, err := websocket.DialConfig(cfg)
|
||||
ch <- dialResult{ws, err}
|
||||
}()
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r.err != nil {
|
||||
return nil, fmt.Errorf("websocket rcon dial %s:%d: %w", opts.Host, opts.Port, r.err)
|
||||
}
|
||||
return newWebSocketClient(r.ws), nil
|
||||
case <-time.After(connectTimeout):
|
||||
return nil, fmt.Errorf("websocket rcon dial %s:%d: connect timeout", opts.Host, opts.Port)
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
type wsRequest struct {
|
||||
Identifier int32 `json:"Identifier"`
|
||||
Message string `json:"Message"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
type wsResponse struct {
|
||||
Message string `json:"Message"`
|
||||
Identifier int32 `json:"Identifier"`
|
||||
Type string `json:"Type"`
|
||||
Stacktrace string `json:"Stacktrace"`
|
||||
}
|
||||
|
||||
type wsClient struct {
|
||||
ws *websocket.Conn
|
||||
nextID atomic.Int32
|
||||
execMu sync.Mutex // serializes Exec calls
|
||||
readMu sync.Mutex // guards ws reads; Close may race
|
||||
closed atomic.Bool
|
||||
unsolic chan wsResponse // buffers server-pushed log events we don't consume
|
||||
}
|
||||
|
||||
func newWebSocketClient(ws *websocket.Conn) *wsClient {
|
||||
c := &wsClient{ws: ws, unsolic: make(chan wsResponse, 64)}
|
||||
c.nextID.Store(1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Exec runs one command and returns the matching response body. Rust
|
||||
// echoes our request's Identifier back, so we can reject unrelated log
|
||||
// messages the server pushes on the same connection.
|
||||
func (c *wsClient) Exec(ctx context.Context, cmd string) (string, error) {
|
||||
if c.closed.Load() {
|
||||
return "", errors.New("websocket rcon: client closed")
|
||||
}
|
||||
c.execMu.Lock()
|
||||
defer c.execMu.Unlock()
|
||||
|
||||
id := c.nextID.Add(1)
|
||||
req := wsRequest{Identifier: id, Message: cmd, Name: "panel"}
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(10 * time.Second)
|
||||
}
|
||||
if err := c.ws.SetDeadline(deadline); err != nil {
|
||||
return "", fmt.Errorf("set deadline: %w", err)
|
||||
}
|
||||
defer c.ws.SetDeadline(time.Time{}) //nolint:errcheck
|
||||
|
||||
if err := websocket.Message.Send(c.ws, string(payload)); err != nil {
|
||||
return "", fmt.Errorf("send: %w", err)
|
||||
}
|
||||
|
||||
// Read frames until one matches our Identifier. Rust pipes chat, log,
|
||||
// and player-event messages through the same socket; if their id is
|
||||
// 0 (or doesn't match ours) we drop them into unsolic for someone
|
||||
// else to consume. 200ms allowance for interleaved frames.
|
||||
for {
|
||||
c.readMu.Lock()
|
||||
var raw string
|
||||
rerr := websocket.Message.Receive(c.ws, &raw)
|
||||
c.readMu.Unlock()
|
||||
if rerr != nil {
|
||||
return "", fmt.Errorf("recv: %w", rerr)
|
||||
}
|
||||
var resp wsResponse
|
||||
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
|
||||
// Non-JSON frame — Rust shouldn't send those but be forgiving.
|
||||
continue
|
||||
}
|
||||
if resp.Identifier != id {
|
||||
// Async server push (log line, chat, etc.). Buffer non-blockingly.
|
||||
select {
|
||||
case c.unsolic <- resp:
|
||||
default:
|
||||
}
|
||||
continue
|
||||
}
|
||||
if resp.Stacktrace != "" {
|
||||
return resp.Message, fmt.Errorf("rust rcon error: %s", resp.Stacktrace)
|
||||
}
|
||||
return resp.Message, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Close tears down the underlying websocket connection.
|
||||
func (c *wsClient) Close() error {
|
||||
if !c.closed.CompareAndSwap(false, true) {
|
||||
return nil
|
||||
}
|
||||
return c.ws.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user