Backend: - Complete FastAPI backend with 42+ REST endpoints (auth, servers, config, players, bans, missions, mods, games, system) - Game adapter architecture with Arma 3 as first-class adapter - WebSocket real-time events for status, metrics, logs, players - Background thread system (process monitor, metrics, log tail, RCon poller) - Fernet encryption for sensitive config fields at rest - JWT auth with admin/viewer roles, bcrypt password hashing - SQLite with WAL mode, parameterized queries, migration system - APScheduler cleanup jobs for logs, metrics, events Frontend: - Server Detail page with 7 tabs (overview, config, players, bans, missions, mods, logs) - Settings page with password change and admin user management - Create Server wizard (4-step; known bug: silent validation failure) - New hooks: useServerDetail, useAuth, useGames - New components: ServerHeader, ConfigEditor, PlayerTable, BanTable, MissionList, ModList, LogViewer, PasswordChange, UserManager - WebSocket onEvent callback for real-time log accumulation - 120 unit tests passing (Vitest + React Testing Library) Docs: - Added .gitignore, CLAUDE.md, README.md - Updated FRONTEND.md, ARCHITECTURE.md with current implementation state - Added .env.example for backend configuration Known issues: - Create Server form: "Next" buttons don't validate before advancing, causing silent submit failure when fields are invalid - Config sub-tabs need UX redesign for non-technical users
278 lines
9.0 KiB
Python
278 lines
9.0 KiB
Python
"""
|
|
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' <crc32 LE> 0xFF <type> <payload>"""
|
|
body = bytes([0xFF, pkt_type]) + payload
|
|
crc = zlib.crc32(body) & 0xFFFFFFFF
|
|
crc_bytes = struct.pack("<I", crc)
|
|
return b"BE" + crc_bytes + body
|
|
|
|
def _build_command_packet(self, seq: int, command: str) -> 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("<I", data[2:6])[0]
|
|
body = data[6:]
|
|
computed_crc = zlib.crc32(body) & 0xFFFFFFFF
|
|
return stored_crc == computed_crc
|
|
|
|
# ── Command send (must be called with self._lock held) ──
|
|
|
|
def _send_command_locked(self, command: str) -> 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 |