185 lines
6.1 KiB
Python
185 lines
6.1 KiB
Python
"""Minimal Hell Let Loose RCON client for live server state queries."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import socket
|
|
from dataclasses import dataclass
|
|
|
|
from .config import (
|
|
DEFAULT_RCON_SOURCE_NAME,
|
|
get_rcon_request_timeout_seconds,
|
|
get_rcon_targets_payload,
|
|
)
|
|
|
|
|
|
RCON_BUFFER_SIZE = 32768
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RconServerTarget:
|
|
"""Configuration needed to query one HLL RCON endpoint."""
|
|
|
|
name: str
|
|
host: str
|
|
port: int
|
|
password: str
|
|
source_name: str
|
|
external_server_id: str | None = None
|
|
region: str | None = None
|
|
game_port: int | None = None
|
|
query_port: int | None = None
|
|
|
|
|
|
class HllRconConnection:
|
|
"""Tiny synchronous HLL RCON connection using the documented XOR flow."""
|
|
|
|
def __init__(self, *, timeout_seconds: float) -> None:
|
|
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
self._socket.settimeout(timeout_seconds)
|
|
self._xor_key: bytes | None = None
|
|
|
|
def connect(self, *, host: str, port: int, password: str) -> None:
|
|
self._socket.connect((host, port))
|
|
self._xor_key = self._socket.recv(RCON_BUFFER_SIZE)
|
|
response = self.execute(f"Login {password}")
|
|
if response != "SUCCESS":
|
|
raise RuntimeError("Invalid RCON password.")
|
|
|
|
def execute(self, command: str) -> str:
|
|
payload = command.encode("utf-8")
|
|
self._socket.sendall(self._xor(payload))
|
|
return self._receive().decode("utf-8", errors="replace")
|
|
|
|
def close(self) -> None:
|
|
try:
|
|
self._socket.shutdown(socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
self._socket.close()
|
|
|
|
def _receive(self) -> bytes:
|
|
chunks: list[bytes] = []
|
|
while True:
|
|
chunk = self._socket.recv(RCON_BUFFER_SIZE)
|
|
chunks.append(self._xor(chunk))
|
|
if len(chunk) < RCON_BUFFER_SIZE:
|
|
break
|
|
return b"".join(chunks)
|
|
|
|
def _xor(self, payload: bytes) -> bytes:
|
|
if not self._xor_key:
|
|
raise RuntimeError("The HLL server did not provide an RCON XOR key.")
|
|
return bytes(
|
|
value ^ self._xor_key[index % len(self._xor_key)]
|
|
for index, value in enumerate(payload)
|
|
)
|
|
|
|
def __enter__(self) -> HllRconConnection:
|
|
return self
|
|
|
|
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
self.close()
|
|
|
|
|
|
def load_rcon_targets() -> tuple[RconServerTarget, ...]:
|
|
"""Load RCON targets from JSON env payload."""
|
|
raw_payload = get_rcon_targets_payload()
|
|
if raw_payload is None:
|
|
return ()
|
|
parsed = json.loads(raw_payload)
|
|
if not isinstance(parsed, list):
|
|
raise ValueError("HLL_BACKEND_RCON_TARGETS must be a JSON array.")
|
|
return tuple(_coerce_rcon_target(item) for item in parsed if isinstance(item, dict))
|
|
|
|
|
|
def query_live_server_state(
|
|
target: RconServerTarget,
|
|
*,
|
|
timeout_seconds: float | None = None,
|
|
) -> dict[str, object]:
|
|
"""Query one HLL server via RCON and normalize it to the live snapshot shape."""
|
|
resolved_timeout = timeout_seconds or get_rcon_request_timeout_seconds()
|
|
with HllRconConnection(timeout_seconds=resolved_timeout) as connection:
|
|
connection.connect(host=target.host, port=target.port, password=target.password)
|
|
server_name = connection.execute("Get Name").strip()
|
|
slots = connection.execute("Get Slots").strip()
|
|
game_state = connection.execute("Get GameState").strip()
|
|
|
|
players, max_players = _parse_slots(slots)
|
|
current_map = _parse_gamestate_value(game_state, "Map")
|
|
resolved_external_id = target.external_server_id or f"rcon:{target.host}:{target.port}"
|
|
return {
|
|
"external_server_id": resolved_external_id,
|
|
"server_name": server_name or target.name,
|
|
"status": "online",
|
|
"players": players,
|
|
"max_players": max_players,
|
|
"current_map": current_map,
|
|
"region": target.region,
|
|
"source_name": target.source_name,
|
|
"snapshot_origin": "real-rcon",
|
|
"source_ref": f"rcon://{target.host}:{target.port}",
|
|
}
|
|
|
|
|
|
def _coerce_rcon_target(raw_target: dict[str, object]) -> RconServerTarget:
|
|
name = str(raw_target.get("name") or "Unnamed RCON target").strip()
|
|
host = str(raw_target.get("host") or "").strip()
|
|
password = str(raw_target.get("password") or "").strip()
|
|
source_name = str(raw_target.get("source_name") or DEFAULT_RCON_SOURCE_NAME).strip()
|
|
port = int(raw_target.get("port") or 0)
|
|
if not host:
|
|
raise ValueError("Each RCON target must define a non-empty host.")
|
|
if port <= 0:
|
|
raise ValueError("Each RCON target must define a valid port.")
|
|
if not password:
|
|
raise ValueError("Each RCON target must define a non-empty password.")
|
|
|
|
return RconServerTarget(
|
|
name=name,
|
|
host=host,
|
|
port=port,
|
|
password=password,
|
|
source_name=source_name or DEFAULT_RCON_SOURCE_NAME,
|
|
external_server_id=_string_or_none(raw_target.get("external_server_id")),
|
|
region=_string_or_none(raw_target.get("region")),
|
|
game_port=_coerce_optional_positive_int(raw_target.get("game_port")),
|
|
query_port=_coerce_optional_positive_int(raw_target.get("query_port")),
|
|
)
|
|
|
|
|
|
def _parse_slots(payload: str) -> tuple[int | None, int | None]:
|
|
if "/" not in payload:
|
|
return None, None
|
|
left, right = payload.split("/", 1)
|
|
try:
|
|
return int(left.strip()), int(right.strip())
|
|
except ValueError:
|
|
return None, None
|
|
|
|
|
|
def _parse_gamestate_value(payload: str, label: str) -> str | None:
|
|
prefix = f"{label}:"
|
|
for line in payload.splitlines():
|
|
if line.startswith(prefix):
|
|
value = line.removeprefix(prefix).strip()
|
|
return value or None
|
|
return None
|
|
|
|
|
|
def _string_or_none(value: object) -> str | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
normalized = value.strip()
|
|
return normalized or None
|
|
|
|
|
|
def _coerce_optional_positive_int(value: object) -> int | None:
|
|
if value is None:
|
|
return None
|
|
coerced = int(value)
|
|
if coerced <= 0:
|
|
raise ValueError("Configured RCON target ports must be positive when defined.")
|
|
return coerced
|