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