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
This commit is contained in:
278
backend/adapters/arma3/rcon_client.py
Normal file
278
backend/adapters/arma3/rcon_client.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user