""" BERConClient — BattlEye RCon UDP client for Arma3. Implements the BattlEye RCon protocol version 2. Reference: https://www.battleye.com/downloads/BERConProtocol.txt Thread safety: This client is NOT thread-safe by itself. The RemoteAdminPollerThread serializes all calls through a single thread. For the send_command() called from HTTP request handlers, use a threading.Lock. """ from __future__ import annotations import logging import socket import struct import threading import time import zlib logger = logging.getLogger(__name__) _SOCKET_TIMEOUT = 5.0 _LOGIN_TIMEOUT = 5.0 _RESPONSE_TIMEOUT = 5.0 _MAX_RESPONSE_PARTS = 10 _KEEPALIVE_INTERVAL = 30.0 class BERConClient: """ BattlEye RCon UDP client. Usage: client = BERConClient(host="127.0.0.1", port=2302, password="secret") client.connect() # raises ConnectionError on failure players = client.get_players() client.send_command("say -1 Hello") client.disconnect() """ def __init__(self, host: str, port: int, password: str) -> None: self._host = host self._port = port self._password = password self._sock: socket.socket | None = None self._seq = 0 self._connected = False self._lock = threading.Lock() self._last_activity = 0.0 # ── Public API ── def connect(self) -> None: """Open UDP socket and perform BattlEye login handshake.""" with self._lock: if self._connected: return self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self._sock.settimeout(_SOCKET_TIMEOUT) self._sock.connect((self._host, self._port)) login_payload = self._password.encode("ascii", errors="replace") packet = self._build_packet(0x00, login_payload) self._sock.send(packet) self._last_activity = time.monotonic() deadline = time.monotonic() + _LOGIN_TIMEOUT while time.monotonic() < deadline: try: data = self._sock.recv(4096) except socket.timeout: break if not self._verify_checksum(data): continue if len(data) >= 9 and data[7] == 0x00: if data[8] == 0x01: self._connected = True self._seq = 0 logger.info("BERConClient: logged in to %s:%d", self._host, self._port) return else: self._sock.close() self._sock = None raise ConnectionError( f"BattlEye login rejected at {self._host}:{self._port}" ) self._sock.close() self._sock = None raise ConnectionError( f"BattlEye login timed out at {self._host}:{self._port}" ) def disconnect(self) -> None: with self._lock: self._connected = False if self._sock is not None: try: self._sock.close() except OSError as exc: logger.debug("BERConClient: error closing socket during disconnect: %s", exc) self._sock = None @property def is_connected(self) -> bool: return self._connected def send_command(self, command: str) -> str: """Send a BattlEye command and return the response string.""" with self._lock: if not self._connected or self._sock is None: raise ConnectionError("BERConClient: not connected") return self._send_command_locked(command) def get_players(self) -> list[dict]: """Send 'players' command and parse the response.""" response = self.send_command("players") return self._parse_players(response) def keepalive(self) -> None: """Send a keepalive packet if the connection has been idle.""" if not self._connected: return elapsed = time.monotonic() - self._last_activity if elapsed >= _KEEPALIVE_INTERVAL: try: self.send_command("") except Exception as exc: logger.debug("BERConClient: keepalive failed: %s", exc) # ── Packet building ── def _build_packet(self, pkt_type: int, payload: bytes) -> bytes: """Build a BattlEye packet: 'B' 'E' 0xFF """ body = bytes([0xFF, pkt_type]) + payload crc = zlib.crc32(body) & 0xFFFFFFFF crc_bytes = struct.pack(" bytes: payload = bytes([seq]) + command.encode("ascii", errors="replace") return self._build_packet(0x01, payload) def _build_ack_packet(self, seq: int) -> bytes: return self._build_packet(0x02, bytes([seq])) def _verify_checksum(self, data: bytes) -> bool: """Verify the CRC32 checksum in the received packet.""" if len(data) < 8: return False if data[0:2] != b"BE": return False stored_crc = struct.unpack(" str: seq = self._seq self._seq = (self._seq + 1) % 256 packet = self._build_command_packet(seq, command) self._sock.send(packet) self._last_activity = time.monotonic() parts: dict[int, str] = {} total_parts: int | None = None deadline = time.monotonic() + _RESPONSE_TIMEOUT while time.monotonic() < deadline: try: data = self._sock.recv(65535) except socket.timeout: break if not self._verify_checksum(data): continue if len(data) < 9: continue pkt_type = data[7] # Server message — acknowledge and ignore if pkt_type == 0x02: srv_seq = data[8] ack = self._build_ack_packet(srv_seq) try: self._sock.send(ack) except OSError as exc: logger.debug("BERConClient: failed to send ack for server message %d: %s", srv_seq, exc) continue # Command response if pkt_type == 0x01: resp_seq = data[8] if resp_seq != seq: continue payload = data[9:] # Check if multi-part if len(payload) >= 3 and payload[0] == 0x00: total_parts = payload[1] part_index = payload[2] part_text = payload[3:].decode("utf-8", errors="replace") parts[part_index] = part_text if len(parts) == total_parts: break else: # Single-part response return payload.decode("utf-8", errors="replace") if total_parts is not None and parts: return "".join(parts[i] for i in sorted(parts.keys())) return "" # ── Player parsing ── def _parse_players(self, response: str) -> list[dict]: """Parse the 'players' command response.""" players = [] lines = response.split("\n") for line in lines: line = line.strip() if not line: continue if line.startswith("Players on") or line.startswith("-") or line.startswith("("): continue parts = line.split(None, 4) if len(parts) < 4: continue try: number = int(parts[0]) except ValueError: continue ip_port = parts[1] ping_str = parts[2] guid_part = parts[3] name = parts[4].strip() if len(parts) > 4 else "" ip = ip_port port = 0 if ":" in ip_port: ip, port_str = ip_port.rsplit(":", 1) try: port = int(port_str) except ValueError: port = 0 try: ping = int(ping_str) except ValueError: ping = 0 uid = guid_part.split("(")[0] is_admin = "(Admin)" in name name = name.replace("(Admin)", "").strip() players.append({ "number": number, "uid": uid, "name": name, "ip": ip, "port": port, "ping": ping, "is_admin": is_admin, "slot_id": number, }) return players