diff --git a/backend/README.md b/backend/README.md index 9f1ea8c..d85ceac 100644 --- a/backend/README.md +++ b/backend/README.md @@ -266,7 +266,7 @@ dedicados dentro de `app/providers/`: - `providers/public_scoreboard_provider.py` encapsula la semantica actual del scoreboard/CRCON publico bajo el contrato historico - `providers/rcon_provider.py` encapsula el proveedor live basado en comandos - RCON `Get Name`, `Get Slots` y `Get GameState` + RCON HLL v2 mediante `ServerConnect`, `Login` y `GetServerInformation` Proveedores operativos en esta fase: @@ -343,7 +343,8 @@ confirmar si la instancia esta usando `a2s` o `rcon` para live. fuente controlada. - `a2s_client.py` encapsula una consulta minima A2S_INFO por UDP para probar servidores reales sin acoplar todavia el backend a una fuente mas compleja. -- `rcon_client.py` encapsula una conexion minima HLL RCON por TCP con XOR para +- `rcon_client.py` encapsula una conexion minima HLL RCON v2 por TCP con + `ServerConnect`, XOR key base64, `authToken` y `GetServerInformation` para consultas live de produccion. - `config.py` centraliza host, puerto y allowlist minima de origenes locales. - `data_sources.py` define los contratos y la seleccion por entorno para live e historico. diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 6e068f8..2622e5d 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -692,12 +692,17 @@ def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, objec def _select_primary_snapshot_items(items: list[dict[str, object]]) -> list[dict[str, object]]: - real_items = [ + preferred_origin = ( + "real-rcon" + if get_live_data_source_kind() == "rcon" + else "real-a2s" + ) + preferred_items = [ item for item in items - if item.get("snapshot_origin") == "real-a2s" + if item.get("snapshot_origin") == preferred_origin ] - return real_items or items + return preferred_items or items def _enrich_server_item( diff --git a/backend/app/rcon_client.py b/backend/app/rcon_client.py index 34f0a0d..86ebaa8 100644 --- a/backend/app/rcon_client.py +++ b/backend/app/rcon_client.py @@ -2,8 +2,11 @@ from __future__ import annotations +import base64 +import itertools import json import socket +import struct from dataclasses import dataclass from .config import ( @@ -14,6 +17,8 @@ from .config import ( RCON_BUFFER_SIZE = 32768 +RCON_HEADER_FORMAT = " 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) 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") + server_connect_response = self._exchange("ServerConnect", "") + 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 RuntimeError("The HLL server returned an invalid RCON XOR key.") from error + if not self._xor_key: + raise RuntimeError("The HLL server returned an empty RCON XOR key.") + + login_response = self._exchange("Login", password) + self._auth_token = _expect_text_content(login_response, command_name="Login") + if not self._auth_token: + raise RuntimeError("The HLL server returned an empty RCON auth token.") + + def execute_json( + self, + command: str, + content: dict[str, object] | str = "", + ) -> dict[str, object]: + response = self._exchange(command, content) + 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 RuntimeError( + f"The HLL server returned invalid JSON content for {command}." + ) from error + if isinstance(parsed, dict): + return parsed + raise RuntimeError(f"The HLL server returned an unexpected payload for {command}.") def close(self) -> None: try: @@ -58,18 +90,79 @@ class HllRconConnection: 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 _exchange( + self, + command: str, + content: dict[str, object] | str = "", + ) -> dict[str, object]: + request_id = next(self._request_ids) + self._send_request(request_id=request_id, command=command, content=content) + response = self._receive_response() + response_request_id = int(response.get("requestId") or 0) + if response_request_id != request_id: + raise RuntimeError( + f"Unexpected RCON response id {response_request_id} for request {request_id}." + ) + _raise_for_status(response, command_name=command) + return response + + def _send_request( + self, + *, + request_id: int, + command: str, + content: dict[str, object] | 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, request_id, len(body)) + self._socket.sendall(header + self._xor(body)) + + def _receive_response(self) -> dict[str, object]: + header_size = struct.calcsize(RCON_HEADER_FORMAT) + header_bytes = self._recv_exact(header_size) + try: + request_id, body_length = struct.unpack(RCON_HEADER_FORMAT, header_bytes) + except struct.error as error: + raise RuntimeError("The HLL server returned an invalid RCON response header.") from error + if body_length <= 0: + raise RuntimeError("The HLL server returned an empty RCON response body.") + + body = self._xor(self._recv_exact(body_length)) + try: + parsed = json.loads(body.decode("utf-8", errors="replace")) + except json.JSONDecodeError as error: + raise RuntimeError("The HLL server returned malformed RCON JSON.") from error + if not isinstance(parsed, dict): + raise RuntimeError("The HLL server returned a non-object RCON response.") + + parsed["requestId"] = request_id + return parsed + + def _recv_exact(self, expected_length: int) -> bytes: + chunks = bytearray() + while len(chunks) < expected_length: + chunk = self._socket.recv(min(RCON_BUFFER_SIZE, expected_length - len(chunks))) + if not chunk: + raise RuntimeError("The HLL RCON connection closed unexpectedly.") + chunks.extend(chunk) + return bytes(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 payload return bytes( value ^ self._xor_key[index % len(self._xor_key)] for index, value in enumerate(payload) @@ -102,20 +195,19 @@ def query_live_server_state( 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() + session = connection.execute_json( + "GetServerInformation", + {"Name": "session", "Value": ""}, + ) - 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, + "server_name": _string_or_none(session.get("serverName")) or target.name, "status": "online", - "players": players, - "max_players": max_players, - "current_map": current_map, + "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")), "region": target.region, "source_name": target.source_name, "snapshot_origin": "real-rcon", @@ -149,23 +241,19 @@ def _coerce_rcon_target(raw_target: dict[str, object]) -> RconServerTarget: ) -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 _raise_for_status(response: dict[str, object], *, command_name: 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." + raise RuntimeError(f"{command_name} failed with RCON status {status_code}: {status_message}") -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 _expect_text_content(response: dict[str, object], *, command_name: str) -> str: + content = response.get("contentBody") + if isinstance(content, str): + return content + raise RuntimeError(f"The HLL server returned unexpected text content for {command_name}.") def _string_or_none(value: object) -> str | None: @@ -175,6 +263,15 @@ def _string_or_none(value: object) -> str | None: return normalized or None +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