Files
languard-servers-manager/backend/adapters/arma3/rcon_client.py
Tran G. (Revernomad) Khoa 6511353b55 feat: implement full backend + frontend server detail, settings, and create server pages
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
2026-04-17 11:58:34 +07:00

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