From b95a82b8a49adb3d3846e665ad767cd409b45b62 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Tue, 12 May 2026 21:31:32 +0200 Subject: [PATCH] feat(rcon): add Source RCON client + status parser Co-Authored-By: Claude Sonnet 4.6 --- l4d2web/services/rcon.py | 176 +++++++++++++++++++++++++++++++++++++ l4d2web/tests/test_rcon.py | 131 +++++++++++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 l4d2web/services/rcon.py create mode 100644 l4d2web/tests/test_rcon.py diff --git a/l4d2web/services/rcon.py b/l4d2web/services/rcon.py new file mode 100644 index 0000000..0bee2cc --- /dev/null +++ b/l4d2web/services/rcon.py @@ -0,0 +1,176 @@ +"""Source RCON client + status parser. + +Pure stdlib. One TCP connection per query — fine at our scale (loopback +~10-20ms round-trip; pooling not worth the complexity). + +Source RCON wire format: + size : little-endian int32 (count of the bytes that follow) + req_id: little-endian int32 + ptype : little-endian int32 + body : utf-8 string, null-terminated + pad : one extra null byte + +Packet types: + SERVERDATA_AUTH = 3 (client -> server) + SERVERDATA_EXECCOMMAND = 2 (client -> server) + SERVERDATA_AUTH_RESPONSE= 2 (server -> client) + SERVERDATA_RESPONSE_VALUE = 0 (server -> client) + +After auth, the server sends a type=0 empty packet *first* and then the +type=2 auth response. req_id == -1 on the auth response = bad password. +""" +from __future__ import annotations + +import re +import socket +import struct +from dataclasses import dataclass + + +SERVERDATA_AUTH = 3 +SERVERDATA_EXECCOMMAND = 2 +SERVERDATA_AUTH_RESPONSE = 2 +SERVERDATA_RESPONSE_VALUE = 0 + +_STEAM_ID_BASE = 76561197960265728 + + +class RconError(Exception): + """Network, timeout, or protocol error.""" + + +class RconAuthError(RconError): + """The server rejected the password.""" + + +@dataclass(slots=True, frozen=True) +class PlayerRow: + steam_id_64: str + name: str + connected_seconds: int + ping: int + + +@dataclass(slots=True, frozen=True) +class StatusResponse: + map: str + players: int + max_players: int + bots: int + hibernating: bool + roster: list[PlayerRow] + + +def query_status( + host: str, port: int, password: str, *, timeout: float = 2.0 +) -> StatusResponse: + """Connect to the RCON port, authenticate, send `status`, return parsed result.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + try: + try: + sock.connect((host, port)) + except OSError as exc: + raise RconError(f"connect failed: {exc}") from exc + + try: + _send_packet(sock, 1, SERVERDATA_AUTH, password) + # Drain the leading empty type-0 packet; then read the real auth response. + r1 = _recv_packet(sock) + r2 = _recv_packet(sock) + auth = r2 if r1[1] == SERVERDATA_RESPONSE_VALUE else r1 + if auth[0] == -1: + raise RconAuthError("bad rcon password") + + _send_packet(sock, 2, SERVERDATA_EXECCOMMAND, "status") + _, _, body = _recv_packet(sock) + except (OSError, socket.timeout) as exc: + raise RconError(f"rcon i/o error: {exc}") from exc + finally: + sock.close() + + return parse_status(body) + + +def _send_packet(sock: socket.socket, req_id: int, ptype: int, body: str) -> None: + body_bytes = body.encode("utf-8") + b"\x00\x00" + size = 4 + 4 + len(body_bytes) + sock.sendall(struct.pack(" tuple[int, int, str]: + size = struct.unpack(" bytes: + data = b"" + while len(data) < n: + chunk = sock.recv(n - len(data)) + if not chunk: + raise RconError("rcon connection closed") + data += chunk + return data + + +# --- Status parsing ------------------------------------------------------- + +_MAP_RE = re.compile(r"^map\s*:\s*(\S+)", re.MULTILINE) +_PLAYERS_RE = re.compile( + r"^players\s*:\s*(\d+)\s+humans,\s*(\d+)\s+bots\s*\((\d+)\s+max\)" + r"\s*\((not hibernating|hibernating)\)", + re.MULTILINE, +) +# A status player row: starts with `#`, then variable numeric prefixes, +# then a quoted name, then STEAM_X:Y:Z, then connected time, then ping. +_PLAYER_RE = re.compile( + r'^#\s+(?:\d+\s+)+"(?P[^"]*)"\s+' + r"(?PSTEAM_\d+:(?P\d+):(?P\d+))\s+" + r"(?P[\d:]+)\s+" + r"(?P\d+)\s+", + re.MULTILINE, +) + + +def parse_status(body: str) -> StatusResponse: + map_match = _MAP_RE.search(body) + if not map_match: + raise RconError(f"status: no map line in response\n{body!r}") + players_match = _PLAYERS_RE.search(body) + if not players_match: + raise RconError(f"status: no players line\n{body!r}") + + roster: list[PlayerRow] = [] + for m in _PLAYER_RE.finditer(body): + y = int(m.group("y")) + z = int(m.group("z")) + roster.append( + PlayerRow( + steam_id_64=str(_STEAM_ID_BASE + (z * 2) + y), + name=m.group("name"), + connected_seconds=_parse_duration(m.group("connected")), + ping=int(m.group("ping")), + ) + ) + + return StatusResponse( + map=map_match.group(1), + players=int(players_match.group(1)), + bots=int(players_match.group(2)), + max_players=int(players_match.group(3)), + hibernating=(players_match.group(4) == "hibernating"), + roster=roster, + ) + + +def _parse_duration(text: str) -> int: + """Parse Source's connected duration: HH:MM:SS or MM:SS -> seconds.""" + parts = [int(p) for p in text.split(":")] + if len(parts) == 2: + return parts[0] * 60 + parts[1] + if len(parts) == 3: + return parts[0] * 3600 + parts[1] * 60 + parts[2] + raise RconError(f"unparseable connected duration: {text!r}") diff --git a/l4d2web/tests/test_rcon.py b/l4d2web/tests/test_rcon.py new file mode 100644 index 0000000..29ee0fe --- /dev/null +++ b/l4d2web/tests/test_rcon.py @@ -0,0 +1,131 @@ +"""Source RCON client tests against an in-process TCP fixture. + +The handshake quirk we verified live: after a SERVERDATA_AUTH (type=3) the +server sends a SERVERDATA_RESPONSE_VALUE (type=0) FIRST and THEN the +SERVERDATA_AUTH_RESPONSE (type=2). The auth response's req_id == -1 means +bad password. The client must consume both packets before sending the +command. +""" +from __future__ import annotations + +import socket +import struct +import threading +from contextlib import contextmanager +from typing import Iterator + +import pytest + +from l4d2web.services.rcon import ( + RconAuthError, + RconError, + query_status, +) + + +def _pack(req_id: int, ptype: int, body: str) -> bytes: + body_bytes = body.encode("utf-8") + b"\x00\x00" + size = 4 + 4 + len(body_bytes) + return struct.pack(" tuple[int, int, str]: + raw_size = conn.recv(4) + size = struct.unpack(" Iterator[int]: + """Start a TCP server on an ephemeral port; handler(conn) runs in a thread.""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + server.listen(1) + port = server.getsockname()[1] + server.settimeout(3.0) + + def serve() -> None: + try: + conn, _ = server.accept() + try: + handler(conn) + finally: + conn.close() + except Exception: + pass + + t = threading.Thread(target=serve, daemon=True) + t.start() + try: + yield port + finally: + server.close() + t.join(timeout=1.0) + + +def test_auth_success_then_status(monkeypatch: pytest.MonkeyPatch) -> None: + response_body = ( + "hostname: Left 4 Dead 2\n" + "version : 2.2.4.3 9309 secure (unknown)\n" + "udp/ip : 127.0.0.1:27016 [ public 1.2.3.4:27016 ]\n" + "os : Linux Dedicated\n" + "map : c1m2_streets\n" + "players : 1 humans, 0 bots (4 max) (not hibernating) (reserved 1860000e7e5e446)\n" + "\n" + "# userid name uniqueid connected ping loss state rate adr\n" + '# 2 1 "Crone" STEAM_1:0:12376499 00:21 185 20 active 30000 91.55.5.100:27005\n' + "#end\n" + ) + + def handler(conn: socket.socket) -> None: + req_id, ptype, body = _unpack_one(conn) + assert ptype == 3 + assert body == "letmein" + conn.sendall(_pack(req_id, 0, "")) + conn.sendall(_pack(req_id, 2, "")) + cmd_id, cmd_type, cmd = _unpack_one(conn) + assert cmd_type == 2 + assert cmd == "status" + conn.sendall(_pack(cmd_id, 0, response_body)) + + with fake_rcon_server(handler) as port: + result = query_status("127.0.0.1", port, "letmein", timeout=2.0) + + assert result.map == "c1m2_streets" + assert result.players == 1 + assert result.bots == 0 + assert result.max_players == 4 + assert result.hibernating is False + assert len(result.roster) == 1 + p = result.roster[0] + assert p.name == "Crone" + assert p.steam_id_64 == "76561197985018726" # 76561197960265728 + 0 + 12376499*2 + assert p.connected_seconds == 21 + assert p.ping == 185 + + +def test_auth_failure_raises(monkeypatch: pytest.MonkeyPatch) -> None: + def handler(conn: socket.socket) -> None: + req_id, _, _ = _unpack_one(conn) + conn.sendall(_pack(req_id, 0, "")) + conn.sendall(_pack(-1, 2, "")) # bad password sentinel + + with fake_rcon_server(handler) as port: + with pytest.raises(RconAuthError): + query_status("127.0.0.1", port, "wrong", timeout=2.0) + + +def test_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None: + def handler(conn: socket.socket) -> None: + import time + time.sleep(3.0) + + with fake_rcon_server(handler) as port: + with pytest.raises(RconError): + query_status("127.0.0.1", port, "x", timeout=0.3)