Files
panel/agent/internal/rcon/be_rcon.go
T
2026-07-14 19:19:43 -07:00

281 lines
7.9 KiB
Go

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()
}