diff --git a/ai/tasks/done/TASK-072-rcon-provider-for-production.md b/ai/tasks/done/TASK-072-rcon-provider-for-production.md new file mode 100644 index 0000000..774e0d7 --- /dev/null +++ b/ai/tasks/done/TASK-072-rcon-provider-for-production.md @@ -0,0 +1,74 @@ +# TASK-072-rcon-provider-for-production + +## Goal +Implementar un proveedor RCON para producción que permita usar acceso directo a los servidores como fuente principal de datos live e histórica, seleccionado por configuración sin alterar la UI. + +## Context +La intención del proyecto es desplegar la web con acceso a los servidores mediante RCON. Eso debe convivir con el modo dev actual. El proveedor RCON debe quedar integrado en la nueva abstracción de fuentes y seleccionable por entorno. + +## Steps +1. Revisar la abstracción de proveedor de datos ya creada. +2. Diseñar el proveedor RCON y la configuración necesaria para usarlo en producción. +3. Definir claramente qué capacidades cubrirá esta primera versión del proveedor RCON: + - estado live de servidores + - ingestión histórica o enriquecida, según lo que la integración permita hoy +4. Implementar el proveedor RCON sin romper el proveedor actual. +5. Añadir configuración por entorno para elegir: + - public-scoreboard en dev + - rcon en prod +6. Documentar variables necesarias, credenciales y limitaciones de la integración. +7. Mantener los contratos backend → frontend estables. +8. No introducir todavía una V2 del MVP ni persistencia extra de armas/duelos salvo que sea estrictamente necesario para dejar la integración operativa. +9. Al completar la implementación: + - dejar el repositorio consistente + - hacer commit + - hacer push al remoto si el entorno lo permite + +## Files to Read First +- AGENTS.md +- backend/README.md +- backend/app/config.py +- backend/app/historical_ingestion.py +- backend/app/payloads.py +- backend/app/routes.py +- backend/app/source_provider.py +- documentación o librerías ya presentes para RCON si existen en la repo + +## Expected Files to Modify +- backend/app/config.py +- backend/app/historical_ingestion.py +- backend/app/payloads.py +- backend/app/routes.py +- opcionalmente nuevos módulos, por ejemplo: + - backend/app/providers/rcon_provider.py + - backend/app/rcon_client.py +- backend/README.md +- opcionalmente backend/.env.example o backend.env.example + +## Constraints +- No romper el modo dev actual. +- No exponer credenciales en la repo. +- No cambiar el contrato visible de la UI. +- No hacer cambios destructivos. +- Mantener el trabajo centrado en el proveedor RCON de producción. + +## Validation +- Existe un proveedor RCON seleccionable por configuración. +- El backend puede correr en modo dev con la fuente actual y en modo prod con RCON. +- La documentación deja claras variables y límites. +- Los cambios quedan committeados y se hace push si el entorno lo permite. + +## Change Budget +- Preferir menos de 7 archivos modificados o creados. +- Preferir menos de 260 líneas cambiadas. + +## Outcome +- Se implemento `backend/app/rcon_client.py` con conexion TCP y cifrado XOR minimo para comandos HLL RCON. +- Se anadio `backend/app/providers/rcon_provider.py` como proveedor live seleccionable por configuracion. +- `/health` ahora expone `live_data_source` y `historical_data_source` para verificar el proveedor activo. +- Se mantuvo el modo dev actual y se documento la limitacion actual: el historico sigue dependiendo de `public-scoreboard`. + +## Validation Notes +- `python -m compileall backend/app` completo sin errores. +- `build_health_payload()` devuelve correctamente los proveedores activos por defecto. +- La repo no contiene una canalizacion historica basada en eventos/logs RCON; por eso la parte historica RCON sigue quedando documentada como no operativa en esta version. diff --git a/backend/README.md b/backend/README.md index 65572f8..1391377 100644 --- a/backend/README.md +++ b/backend/README.md @@ -66,6 +66,8 @@ Variables opcionales: - `HLL_BACKEND_REFRESH_INTERVAL_SECONDS` - `HLL_BACKEND_LIVE_DATA_SOURCE` - `HLL_BACKEND_HISTORICAL_DATA_SOURCE` +- `HLL_BACKEND_RCON_TIMEOUT_SECONDS` +- `HLL_BACKEND_RCON_TARGETS` - `HLL_HISTORICAL_CRCON_PAGE_SIZE` - `HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS` - `HLL_HISTORICAL_CRCON_DETAIL_WORKERS` @@ -87,6 +89,8 @@ Variables especialmente relevantes para Docker y Compose: - `HLL_BACKEND_ALLOWED_ORIGINS` - `HLL_BACKEND_LIVE_DATA_SOURCE` - `HLL_BACKEND_HISTORICAL_DATA_SOURCE` +- `HLL_BACKEND_RCON_TIMEOUT_SECONDS` +- `HLL_BACKEND_RCON_TARGETS` - `HLL_HISTORICAL_CRCON_PAGE_SIZE` - `HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS` - `HLL_HISTORICAL_CRCON_DETAIL_WORKERS` @@ -196,6 +200,11 @@ normaliza espacios y barras finales para mantener la comparacion con el header - `GET /api/historical/snapshots/recent-matches?limit=6&server=comunidad-hispana-01` - `GET /api/historical/player-profile?player=steam%3A76561198000000000` +`GET /health` expone tambien: + +- `live_data_source` +- `historical_data_source` + `GET /api/servers` trata el ultimo snapshot persistido como cache local y lo reutiliza solo si sigue dentro del objetivo de `120` segundos. Si ese snapshot esta vencido, el endpoint intenta una consulta A2S real inmediata contra los 2 @@ -237,10 +246,10 @@ Valores soportados en esta fase: - live: - `a2s` como modo actual de desarrollo - - `rcon` reservado para la futura integracion productiva + - `rcon` como modo productivo para estado live via acceso directo al servidor - historico: - `public-scoreboard` como modo actual de desarrollo - - `rcon` reservado para la futura integracion productiva + - `rcon` seleccionado pero todavia sin ingesta historica operativa en esta repo Defaults actuales: @@ -256,14 +265,56 @@ dedicados dentro de `app/providers/`: `historical_ingestion.py` para bootstrap y refresh incremental - `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` -En esta task solo queda implementado el proveedor ya operativo de desarrollo: +Proveedores operativos en esta fase: - live `a2s` +- live `rcon` - historico `public-scoreboard` -La opcion `rcon` queda preparada como placeholder explicito y hoy responde con -error controlado si se selecciona antes de implementar su adapter. +Limitacion actual de `rcon`: + +- el backend puede usar `rcon` para `/api/servers` +- la ingesta historica por `historical_ingestion.py` sigue requiriendo + `public-scoreboard`, porque la repo todavia no incluye una canalizacion + persistente de eventos o logs RCON para reconstruir partidas cerradas + +Variables especificas de RCON live: + +- `HLL_BACKEND_RCON_TIMEOUT_SECONDS` +- `HLL_BACKEND_RCON_TARGETS` + +`HLL_BACKEND_RCON_TARGETS` acepta un array JSON con: + +- `name` +- `host` +- `port` +- `password` +- `external_server_id` opcional +- `region` opcional +- `game_port` opcional +- `query_port` opcional +- `source_name` opcional + +Ejemplo: + +```powershell +$env:HLL_BACKEND_RCON_TARGETS='[ + { + "name": "Comunidad Hispana #01", + "host": "203.0.113.10", + "port": 28015, + "password": "replace-me", + "external_server_id": "comunidad-hispana-01", + "region": "ES", + "game_port": 7777, + "query_port": 7778, + "source_name": "community-hispana-rcon" + } +]' +``` ## Criterio de estructura @@ -273,6 +324,8 @@ error controlado si se selecciona antes de implementar su adapter. 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 + 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. - `historical_ingestion.py` consulta la capa JSON publica de CRCON para bootstrap y refresh incremental. diff --git a/backend/app/config.py b/backend/app/config.py index 5e2219c..1b9f5ff 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,6 +12,7 @@ DEFAULT_STORAGE_FILENAME = "hll_vietnam_dev.sqlite3" DEFAULT_REFRESH_INTERVAL_SECONDS = 300 DEFAULT_LIVE_DATA_SOURCE = "a2s" DEFAULT_HISTORICAL_DATA_SOURCE = "public-scoreboard" +DEFAULT_RCON_TIMEOUT_SECONDS = 10.0 DEFAULT_HISTORICAL_CRCON_PAGE_SIZE = 50 DEFAULT_HISTORICAL_CRCON_TIMEOUT_SECONDS = 15.0 DEFAULT_HISTORICAL_CRCON_DETAIL_WORKERS = 8 @@ -33,6 +34,8 @@ DEFAULT_ALLOWED_ORIGINS = ( ) DEFAULT_A2S_TARGETS_ENV_VAR = "HLL_BACKEND_A2S_TARGETS" DEFAULT_A2S_SOURCE_NAME = "community-hispana-a2s" +DEFAULT_RCON_TARGETS_ENV_VAR = "HLL_BACKEND_RCON_TARGETS" +DEFAULT_RCON_SOURCE_NAME = "community-hispana-rcon" def get_bind_address() -> tuple[str, int]: @@ -187,6 +190,18 @@ def get_historical_data_source_kind() -> str: return source_kind +def get_rcon_request_timeout_seconds() -> float: + """Return the timeout used for HLL RCON TCP requests.""" + configured_value = os.getenv( + "HLL_BACKEND_RCON_TIMEOUT_SECONDS", + str(DEFAULT_RCON_TIMEOUT_SECONDS), + ) + timeout_seconds = float(configured_value) + if timeout_seconds <= 0: + raise ValueError("HLL_BACKEND_RCON_TIMEOUT_SECONDS must be positive.") + return timeout_seconds + + def get_historical_refresh_max_retries() -> int: """Return the retry count used by the historical refresh loop.""" configured_value = os.getenv( @@ -262,3 +277,13 @@ def get_a2s_targets_payload() -> str | None: normalized = raw_payload.strip() return normalized or None + + +def get_rcon_targets_payload() -> str | None: + """Return the optional JSON payload that defines live RCON targets.""" + raw_payload = os.getenv(DEFAULT_RCON_TARGETS_ENV_VAR) + if raw_payload is None: + return None + + normalized = raw_payload.strip() + return normalized or None diff --git a/backend/app/data_sources.py b/backend/app/data_sources.py index 7648d9a..9469494 100644 --- a/backend/app/data_sources.py +++ b/backend/app/data_sources.py @@ -8,6 +8,7 @@ from typing import Protocol from .collector import collect_server_snapshots from .config import get_historical_data_source_kind, get_live_data_source_kind from .providers.public_scoreboard_provider import PublicScoreboardHistoricalDataSource +from .providers.rcon_provider import RconLiveDataSource from .server_targets import A2SServerTarget, load_a2s_targets @@ -91,19 +92,6 @@ class RconHistoricalDataSource: raise RuntimeError("Historical RCON provider is not implemented yet.") -@dataclass(frozen=True, slots=True) -class RconLiveDataSource: - """Placeholder live provider for future production RCON integration.""" - - source_kind: str = SOURCE_KIND_RCON - - def collect_snapshots(self, *, persist: bool) -> dict[str, object]: - raise RuntimeError("Live RCON provider is not implemented yet.") - - def build_target_index(self) -> dict[str | None, A2SServerTarget]: - return {} - - def get_historical_data_source() -> HistoricalDataSource: """Select the historical provider configured for the current environment.""" source_kind = get_historical_data_source_kind() diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 2d0f1c4..6e068f8 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -4,7 +4,11 @@ from __future__ import annotations from datetime import datetime, timezone -from .config import get_refresh_interval_seconds +from .config import ( + get_historical_data_source_kind, + get_live_data_source_kind, + get_refresh_interval_seconds, +) from .data_sources import get_live_data_source from .historical_snapshot_storage import get_historical_snapshot from .historical_snapshots import ( @@ -36,6 +40,8 @@ def build_health_payload() -> dict[str, str]: "status": "ok", "service": "hll-vietnam-backend", "phase": "bootstrap", + "live_data_source": get_live_data_source_kind(), + "historical_data_source": get_historical_data_source_kind(), } diff --git a/backend/app/providers/rcon_provider.py b/backend/app/providers/rcon_provider.py new file mode 100644 index 0000000..f3b0f5f --- /dev/null +++ b/backend/app/providers/rcon_provider.py @@ -0,0 +1,63 @@ +"""RCON provider adapter for live HLL server state.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..rcon_client import RconServerTarget, load_rcon_targets, query_live_server_state +from ..snapshots import build_snapshot_batch, utc_now +from ..storage import persist_snapshot_batch + + +@dataclass(frozen=True, slots=True) +class RconLiveDataSource: + """Live provider backed by direct HLL RCON access.""" + + source_kind: str = "rcon" + + def collect_snapshots(self, *, persist: bool) -> dict[str, object]: + configured_targets = load_rcon_targets() + if not configured_targets: + raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.") + + captured_at = utc_now() + normalized_records: list[dict[str, object]] = [] + errors: list[dict[str, object]] = [] + + for target in configured_targets: + try: + normalized_records.append(query_live_server_state(target)) + except Exception as error: # noqa: BLE001 - keep provider failures controlled + errors.append( + { + "target": target.name, + "host": target.host, + "port": target.port, + "message": str(error), + } + ) + + payload = { + "source_name": "hll-rcon", + "collection_mode": "rcon", + "fallback_used": False, + "target_count": len(configured_targets), + "success_count": len(normalized_records), + "errors": errors, + "captured_at": captured_at.isoformat().replace("+00:00", "Z"), + "snapshots": build_snapshot_batch(normalized_records, captured_at=captured_at), + } + if persist: + payload["storage"] = persist_snapshot_batch( + payload["snapshots"], + source_name=payload["source_name"], + captured_at=payload["captured_at"], + ) + return payload + + def build_target_index(self) -> dict[str | None, RconServerTarget]: + return { + target.external_server_id: target + for target in load_rcon_targets() + if target.external_server_id + } diff --git a/backend/app/rcon_client.py b/backend/app/rcon_client.py new file mode 100644 index 0000000..34f0a0d --- /dev/null +++ b/backend/app/rcon_client.py @@ -0,0 +1,184 @@ +"""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