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:18:05 -07:00
commit 4cf3471398
2161 changed files with 300831 additions and 0 deletions
+280
View File
@@ -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()
}
+97
View File
@@ -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: ~50200ms 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 }
+81
View File
@@ -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)
}
}
+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
}
+110
View File
@@ -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()
}
+168
View File
@@ -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)
}
+128
View File
@@ -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
}
+179
View File
@@ -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()
}