"""Minimal Hell Let Loose RCON client for live server state queries.""" from __future__ import annotations import base64 import itertools import json import socket import struct from collections.abc import Mapping from dataclasses import dataclass from .config import ( DEFAULT_RCON_SOURCE_NAME, get_rcon_request_timeout_seconds, get_rcon_targets_payload, ) RCON_BUFFER_SIZE = 32768 RCON_HEADER_FORMAT = " None: super().__init__(message) self.error_type = error_type self.error_stage = error_stage class HllRconConnection: """Synchronous HLL RCON v2 connection for lightweight live status queries.""" 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 self._auth_token: str | None = None self._request_ids = itertools.count(1) self._current_stage = "tcp_connect" def connect(self, *, host: str, port: int, password: str) -> None: self._run_socket_stage( "tcp_connect", lambda: self._socket.connect((host, port)), ) server_connect_response = self._exchange( "ServerConnect", "", request_stage="server_connect_request", response_stage="server_connect_response", ) self._current_stage = "xor_key_decode" xor_key_b64 = _expect_text_content(server_connect_response, command_name="ServerConnect") try: self._xor_key = base64.b64decode(xor_key_b64) except (ValueError, TypeError) as error: raise RconQueryError( "payload-invalid", "The HLL server returned an invalid RCON XOR key.", error_stage="xor_key_decode", ) from error if not self._xor_key: raise RconQueryError( "unexpected-response", "The HLL server returned an empty RCON XOR key.", error_stage="xor_key_decode", ) login_response = self._exchange( "Login", password, request_stage="login_request", response_stage="login_response", ) self._auth_token = _expect_text_content(login_response, command_name="Login") if not self._auth_token: raise RconQueryError( "unexpected-response", "The HLL server returned an empty RCON auth token.", error_stage="login_response", ) def execute_json( self, command: str, content: dict[str, object] | str = "", ) -> dict[str, object]: stage_prefix = _resolve_command_stage_prefix(command) response = self._exchange( command, content, request_stage=f"{stage_prefix}_request", response_stage=f"{stage_prefix}_response", ) self._current_stage = "payload_decode" content_body = response.get("contentBody") if isinstance(content_body, dict): return content_body if isinstance(content_body, str): try: parsed = json.loads(content_body) except json.JSONDecodeError as error: raise RconQueryError( "payload-invalid", f"The HLL server returned invalid JSON content for {command}.", error_stage="payload_decode", ) from error if isinstance(parsed, dict): return parsed raise RconQueryError( "unexpected-response", f"The HLL server returned an unexpected payload for {command}.", error_stage="unexpected_response", ) def close(self) -> None: try: self._socket.shutdown(socket.SHUT_RDWR) except OSError: pass self._socket.close() def _exchange( self, command: str, content: dict[str, object] | str = "", *, request_stage: str, response_stage: str, ) -> dict[str, object]: request_id = next(self._request_ids) self._send_request( request_id=request_id, command=command, content=content, request_stage=request_stage, ) response = self._receive_response(response_stage=response_stage) response_request_id = int(response.get("requestId") or 0) if response_request_id != request_id: raise RconQueryError( "unexpected-response", f"Unexpected RCON response id {response_request_id} for request {request_id}.", error_stage="unexpected_response", ) _raise_for_status(response, command_name=command, error_stage=response_stage) return response def _send_request( self, *, request_id: int, command: str, content: dict[str, object] | str, request_stage: str, ) -> None: content_body = ( content if isinstance(content, str) else json.dumps(content, separators=(",", ":")) ) body = json.dumps( { "authToken": self._auth_token or "", "version": RCON_PROTOCOL_VERSION, "name": command, "contentBody": content_body, }, separators=(",", ":"), ).encode("utf-8") header = struct.pack( RCON_HEADER_FORMAT, RCON_MAGIC_HEADER_VALUE, request_id, len(body), ) self._run_socket_stage( request_stage, lambda: self._socket.sendall(header + self._xor(body)), ) def _receive_response(self, *, response_stage: str) -> dict[str, object]: header_size = struct.calcsize(RCON_HEADER_FORMAT) header_bytes = self._recv_exact( header_size, stage=response_stage, receive_context="response header", ) try: magic_value, request_id, body_length = struct.unpack( RCON_HEADER_FORMAT, header_bytes, ) except struct.error as error: raise RconQueryError( "payload-invalid", "The HLL server returned an invalid RCON response header.", error_stage=response_stage, ) from error if magic_value != RCON_MAGIC_HEADER_VALUE: raise RconQueryError( "invalid-magic", ( "The HLL server returned an unexpected RCON magic value: " f"{magic_value:#x} (expected {RCON_MAGIC_HEADER_VALUE:#x})." ), error_stage=response_stage, ) if body_length <= 0: raise RconQueryError( "unexpected-response", "The HLL server returned an empty RCON response body.", error_stage=response_stage, ) body = self._xor(self._recv_body(body_length, stage=response_stage)) try: parsed = json.loads(body.decode("utf-8", errors="replace")) except json.JSONDecodeError as error: raise RconQueryError( "payload-invalid", "The HLL server returned malformed RCON JSON.", error_stage="payload_decode", ) from error if not isinstance(parsed, dict): raise RconQueryError( "unexpected-response", "The HLL server returned a non-object RCON response.", error_stage="unexpected_response", ) parsed["requestId"] = request_id return parsed def _recv_body(self, expected_length: int, *, stage: str) -> bytes: chunks = bytearray() original_timeout = self._socket.gettimeout() body_timeout_seconds = min(3.0, original_timeout or 3.0) self._socket.settimeout(body_timeout_seconds) try: while len(chunks) < expected_length: self._current_stage = stage try: chunk = self._socket.recv( min(RCON_BUFFER_SIZE, expected_length - len(chunks)) ) except (TimeoutError, socket.timeout) as error: raise RconQueryError( "timeout", ( f"Timed out during {stage} while waiting for response body " f"({len(chunks)}/{expected_length} bytes received)." ), error_stage=stage, ) from error except OSError as error: raise RconQueryError( _classify_socket_error_type(error), f"RCON socket error during {stage}: {error}", error_stage=stage, ) from error if not chunk: raise RconQueryError( "connection-closed", ( "The HLL RCON connection closed unexpectedly while waiting for " f"response body ({len(chunks)}/{expected_length} bytes received)." ), error_stage=stage, ) chunks.extend(chunk) finally: self._socket.settimeout(original_timeout) return bytes(chunks) def _recv_exact( self, expected_length: int, *, stage: str, receive_context: str, ) -> bytes: chunks = bytearray() while len(chunks) < expected_length: self._current_stage = stage try: chunk = self._socket.recv(min(RCON_BUFFER_SIZE, expected_length - len(chunks))) except (TimeoutError, socket.timeout) as error: raise RconQueryError( "timeout", ( f"Timed out during {stage} while waiting for {receive_context} " f"({len(chunks)}/{expected_length} bytes received)." ), error_stage=stage, ) from error except OSError as error: raise RconQueryError( _classify_socket_error_type(error), f"RCON socket error during {stage}: {error}", error_stage=stage, ) from error if not chunk: raise RconQueryError( "connection-closed", ( "The HLL RCON connection closed unexpectedly while waiting for " f"{receive_context} ({len(chunks)}/{expected_length} bytes received)." ), error_stage=stage, ) chunks.extend(chunk) return bytes(chunks) def _xor(self, payload: bytes) -> bytes: if not self._xor_key: return payload 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 _run_socket_stage(self, stage: str, operation: object) -> object: self._current_stage = stage try: return operation() except (TimeoutError, socket.timeout) as error: raise RconQueryError( "timeout", f"Timed out during {stage}.", error_stage=stage, ) from error except OSError as error: raise RconQueryError( _classify_socket_error_type(error), f"RCON socket error during {stage}: {error}", error_stage=stage, ) from error 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.""" sample = query_live_server_sample(target, timeout_seconds=timeout_seconds) return dict(sample["normalized"]) def query_live_server_sample( target: RconServerTarget, *, timeout_seconds: float | None = None, ) -> dict[str, object]: """Query one HLL server and return both normalized and raw session data.""" resolved_timeout = timeout_seconds or get_rcon_request_timeout_seconds() try: with HllRconConnection(timeout_seconds=resolved_timeout) as connection: connection.connect(host=target.host, port=target.port, password=target.password) session = connection.execute_json( "GetServerInformation", {"Name": "session", "Value": ""}, ) except RconQueryError: raise except (TimeoutError, socket.timeout) as error: raise RconQueryError( "timeout", f"Timed out after {resolved_timeout:.1f}s while querying {target.host}:{target.port}.", ) from error except ConnectionRefusedError as error: raise RconQueryError( "connection-refused", f"Connection refused by {target.host}:{target.port}.", ) from error except OSError as error: raise RconQueryError( _classify_socket_error_type(error), f"RCON socket error against {target.host}:{target.port}: {error}", ) from error except RuntimeError as error: raise RconQueryError( _classify_runtime_error_type(error), str(error), error_stage=getattr(error, "error_stage", None), ) from error resolved_external_id = target.external_server_id or f"rcon:{target.host}:{target.port}" return { "target": { "target_key": build_rcon_target_key(target), "name": target.name, "host": target.host, "port": target.port, "external_server_id": target.external_server_id, "region": target.region, "game_port": target.game_port, "query_port": target.query_port, "source_name": target.source_name, }, "normalized": { "external_server_id": resolved_external_id, "server_name": _string_or_none(session.get("serverName")) or target.name, "status": "online", "players": _coerce_optional_int(session.get("playerCount")), "max_players": _coerce_optional_int(session.get("maxPlayerCount")), "current_map": ( _string_or_none(session.get("mapId")) or _string_or_none(session.get("mapName")) ), "game_mode": _string_or_none(session.get("gameMode")), "allied_score": _coerce_optional_int(session.get("alliedScore")), "axis_score": _coerce_optional_int(session.get("axisScore")), "winner": _resolve_rcon_winner( _coerce_optional_int(session.get("alliedScore")), _coerce_optional_int(session.get("axisScore")), ), "allied_faction": _string_or_none(session.get("alliedFaction")), "axis_faction": _string_or_none(session.get("axisFaction")), "allied_players": _coerce_optional_int(session.get("alliedPlayerCount")), "axis_players": _coerce_optional_int(session.get("axisPlayerCount")), "remaining_match_time_seconds": _coerce_optional_int(session.get("remainingMatchTime")), "match_time_seconds": _coerce_optional_int(session.get("matchTime")), "queue_count": _coerce_optional_int(session.get("queueCount")), "max_queue_count": _coerce_optional_int(session.get("maxQueueCount")), "vip_queue_count": _coerce_optional_int(session.get("vipQueueCount")), "max_vip_queue_count": _coerce_optional_int(session.get("maxVipQueueCount")), "region": target.region, "source_name": target.source_name, "snapshot_origin": "real-rcon", "source_ref": f"rcon://{target.host}:{target.port}", }, "raw_session": session, } def build_rcon_target_key(target: RconServerTarget) -> str: """Build a stable local key for one configured RCON target.""" external_server_id = _string_or_none(target.external_server_id) if external_server_id: return external_server_id return f"rcon:{target.host}:{target.port}" def _coerce_rcon_target(raw_target: dict[str, object]) -> RconServerTarget: slug = _string_or_none(raw_target.get("slug")) external_server_id = _string_or_none(raw_target.get("external_server_id")) or slug name = _string_or_none(raw_target.get("name")) or _slug_to_display_name(slug) or "Unnamed RCON target" host = _required_string(raw_target, "host") password = _required_string(raw_target, "password") source_name = _string_or_none(raw_target.get("source_name")) or DEFAULT_RCON_SOURCE_NAME port = _required_positive_int(raw_target, "port") 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 positive '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=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 _raise_for_status( response: dict[str, object], *, command_name: str, error_stage: str, ) -> None: status_code = int(response.get("statusCode") or 0) if status_code == 200: return status_message = _string_or_none(response.get("statusMessage")) or "Unknown RCON error." if command_name == "Login" and status_code in {401, 403}: raise RconQueryError( "auth/login", f"{command_name} failed with RCON status {status_code}: {status_message}", error_stage=error_stage, ) raise RconQueryError( "unexpected-response", f"{command_name} failed with RCON status {status_code}: {status_message}", error_stage=error_stage, ) def _expect_text_content(response: dict[str, object], *, command_name: str) -> str: content = response.get("contentBody") if isinstance(content, str): return content raise RconQueryError( "unexpected-response", f"The HLL server returned unexpected text content for {command_name}.", error_stage="unexpected_response", ) def _resolve_command_stage_prefix(command: str) -> str: normalized_command = str(command or "").strip().lower() stage_prefix_by_command = { "serverconnect": "server_connect", "login": "login", "getserverinformation": "get_server_information", } return stage_prefix_by_command.get(normalized_command, normalized_command or "rcon_command") def _string_or_none(value: object) -> str | None: if not isinstance(value, str): return None normalized = value.strip() return normalized or None def _resolve_rcon_winner(allied_score: int | None, axis_score: int | None) -> str | None: if allied_score is None or axis_score is None: return None if allied_score > axis_score: return "allied" if axis_score > allied_score: return "axis" return "draw" def _coerce_optional_int(value: object) -> int | None: if value is None: return None try: return int(value) except (TypeError, ValueError): return 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 def _required_string(raw_target: Mapping[str, object], field_name: str) -> str: value = _string_or_none(raw_target.get(field_name)) if value is None: available_fields = ", ".join(sorted(raw_target.keys())) raise ValueError( f"Each RCON target must define a non-empty '{field_name}'. " f"Available fields: {available_fields or 'none'}." ) return value def _required_positive_int(raw_target: Mapping[str, object], field_name: str) -> int: raw_value = raw_target.get(field_name) try: value = int(raw_value) except (TypeError, ValueError) as error: available_fields = ", ".join(sorted(raw_target.keys())) raise ValueError( f"Each RCON target must define a valid integer '{field_name}'. " f"Available fields: {available_fields or 'none'}." ) from error if value <= 0: raise ValueError(f"Each RCON target must define a positive '{field_name}'.") return value def _slug_to_display_name(slug: str | None) -> str | None: normalized_slug = _string_or_none(slug) if normalized_slug is None: return None if normalized_slug.startswith("comunidad-hispana-"): suffix = normalized_slug.removeprefix("comunidad-hispana-") if suffix.isdigit(): return f"Comunidad Hispana #{suffix.zfill(2)}" parts = [part for part in normalized_slug.replace("_", "-").split("-") if part] if not parts: return None return " ".join(part.upper() if part.isdigit() else part.capitalize() for part in parts) def _classify_socket_error_type(error: OSError) -> str: if isinstance(error, TimeoutError): return "timeout" if isinstance(error, ConnectionRefusedError): return "connection-refused" if getattr(error, "errno", None) in {10060, 110, 60}: return "timeout" return "other-error" def _classify_runtime_error_type(error: RuntimeError) -> str: message = str(error).lower() if "auth token" in message or "login failed" in message or "status 401" in message or "status 403" in message: return "auth/login" if "invalid magic" in message: return "invalid-magic" if "closed unexpectedly" in message or "closed connection" in message: return "connection-closed" if "invalid json" in message or "unexpected payload" in message or "malformed" in message or "invalid rcon" in message: return "payload-invalid" if "timed out" in message: return "timeout" if "unexpected" in message: return "unexpected-response" return "other-error"