Fix HLL RCON v2 handshake
This commit is contained in:
@@ -266,7 +266,7 @@ dedicados dentro de `app/providers/`:
|
|||||||
- `providers/public_scoreboard_provider.py` encapsula la semantica actual del
|
- `providers/public_scoreboard_provider.py` encapsula la semantica actual del
|
||||||
scoreboard/CRCON publico bajo el contrato historico
|
scoreboard/CRCON publico bajo el contrato historico
|
||||||
- `providers/rcon_provider.py` encapsula el proveedor live basado en comandos
|
- `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:
|
Proveedores operativos en esta fase:
|
||||||
|
|
||||||
@@ -343,7 +343,8 @@ confirmar si la instancia esta usando `a2s` o `rcon` para live.
|
|||||||
fuente controlada.
|
fuente controlada.
|
||||||
- `a2s_client.py` encapsula una consulta minima A2S_INFO por UDP para probar
|
- `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.
|
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.
|
consultas live de produccion.
|
||||||
- `config.py` centraliza host, puerto y allowlist minima de origenes locales.
|
- `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.
|
- `data_sources.py` define los contratos y la seleccion por entorno para live e historico.
|
||||||
|
|||||||
@@ -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]]:
|
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
|
item
|
||||||
for item in items
|
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(
|
def _enrich_server_item(
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
import struct
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
@@ -14,6 +17,8 @@ from .config import (
|
|||||||
|
|
||||||
|
|
||||||
RCON_BUFFER_SIZE = 32768
|
RCON_BUFFER_SIZE = 32768
|
||||||
|
RCON_HEADER_FORMAT = "<II"
|
||||||
|
RCON_PROTOCOL_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -32,24 +37,51 @@ class RconServerTarget:
|
|||||||
|
|
||||||
|
|
||||||
class HllRconConnection:
|
class HllRconConnection:
|
||||||
"""Tiny synchronous HLL RCON connection using the documented XOR flow."""
|
"""Synchronous HLL RCON v2 connection for lightweight live status queries."""
|
||||||
|
|
||||||
def __init__(self, *, timeout_seconds: float) -> None:
|
def __init__(self, *, timeout_seconds: float) -> None:
|
||||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
self._socket.settimeout(timeout_seconds)
|
self._socket.settimeout(timeout_seconds)
|
||||||
self._xor_key: bytes | None = None
|
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:
|
def connect(self, *, host: str, port: int, password: str) -> None:
|
||||||
self._socket.connect((host, port))
|
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:
|
server_connect_response = self._exchange("ServerConnect", "")
|
||||||
payload = command.encode("utf-8")
|
xor_key_b64 = _expect_text_content(server_connect_response, command_name="ServerConnect")
|
||||||
self._socket.sendall(self._xor(payload))
|
try:
|
||||||
return self._receive().decode("utf-8", errors="replace")
|
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:
|
def close(self) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -58,18 +90,79 @@ class HllRconConnection:
|
|||||||
pass
|
pass
|
||||||
self._socket.close()
|
self._socket.close()
|
||||||
|
|
||||||
def _receive(self) -> bytes:
|
def _exchange(
|
||||||
chunks: list[bytes] = []
|
self,
|
||||||
while True:
|
command: str,
|
||||||
chunk = self._socket.recv(RCON_BUFFER_SIZE)
|
content: dict[str, object] | str = "",
|
||||||
chunks.append(self._xor(chunk))
|
) -> dict[str, object]:
|
||||||
if len(chunk) < RCON_BUFFER_SIZE:
|
request_id = next(self._request_ids)
|
||||||
break
|
self._send_request(request_id=request_id, command=command, content=content)
|
||||||
return b"".join(chunks)
|
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:
|
def _xor(self, payload: bytes) -> bytes:
|
||||||
if not self._xor_key:
|
if not self._xor_key:
|
||||||
raise RuntimeError("The HLL server did not provide an RCON XOR key.")
|
return payload
|
||||||
return bytes(
|
return bytes(
|
||||||
value ^ self._xor_key[index % len(self._xor_key)]
|
value ^ self._xor_key[index % len(self._xor_key)]
|
||||||
for index, value in enumerate(payload)
|
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()
|
resolved_timeout = timeout_seconds or get_rcon_request_timeout_seconds()
|
||||||
with HllRconConnection(timeout_seconds=resolved_timeout) as connection:
|
with HllRconConnection(timeout_seconds=resolved_timeout) as connection:
|
||||||
connection.connect(host=target.host, port=target.port, password=target.password)
|
connection.connect(host=target.host, port=target.port, password=target.password)
|
||||||
server_name = connection.execute("Get Name").strip()
|
session = connection.execute_json(
|
||||||
slots = connection.execute("Get Slots").strip()
|
"GetServerInformation",
|
||||||
game_state = connection.execute("Get GameState").strip()
|
{"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}"
|
resolved_external_id = target.external_server_id or f"rcon:{target.host}:{target.port}"
|
||||||
return {
|
return {
|
||||||
"external_server_id": resolved_external_id,
|
"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",
|
"status": "online",
|
||||||
"players": players,
|
"players": _coerce_optional_int(session.get("playerCount")),
|
||||||
"max_players": max_players,
|
"max_players": _coerce_optional_int(session.get("maxPlayerCount")),
|
||||||
"current_map": current_map,
|
"current_map": _string_or_none(session.get("mapId")) or _string_or_none(session.get("mapName")),
|
||||||
"region": target.region,
|
"region": target.region,
|
||||||
"source_name": target.source_name,
|
"source_name": target.source_name,
|
||||||
"snapshot_origin": "real-rcon",
|
"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]:
|
def _raise_for_status(response: dict[str, object], *, command_name: str) -> None:
|
||||||
if "/" not in payload:
|
status_code = int(response.get("statusCode") or 0)
|
||||||
return None, None
|
if status_code == 200:
|
||||||
left, right = payload.split("/", 1)
|
return
|
||||||
try:
|
status_message = _string_or_none(response.get("statusMessage")) or "Unknown RCON error."
|
||||||
return int(left.strip()), int(right.strip())
|
raise RuntimeError(f"{command_name} failed with RCON status {status_code}: {status_message}")
|
||||||
except ValueError:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_gamestate_value(payload: str, label: str) -> str | None:
|
def _expect_text_content(response: dict[str, object], *, command_name: str) -> str:
|
||||||
prefix = f"{label}:"
|
content = response.get("contentBody")
|
||||||
for line in payload.splitlines():
|
if isinstance(content, str):
|
||||||
if line.startswith(prefix):
|
return content
|
||||||
value = line.removeprefix(prefix).strip()
|
raise RuntimeError(f"The HLL server returned unexpected text content for {command_name}.")
|
||||||
return value or None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _string_or_none(value: object) -> str | None:
|
def _string_or_none(value: object) -> str | None:
|
||||||
@@ -175,6 +263,15 @@ def _string_or_none(value: object) -> str | None:
|
|||||||
return normalized or 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:
|
def _coerce_optional_positive_int(value: object) -> int | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
Reference in New Issue
Block a user