feat(rcon): add Source RCON client + status parser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e25e7098f6
commit
b95a82b8a4
2 changed files with 307 additions and 0 deletions
176
l4d2web/services/rcon.py
Normal file
176
l4d2web/services/rcon.py
Normal file
|
|
@ -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("<iii", size, req_id, ptype) + body_bytes)
|
||||
|
||||
|
||||
def _recv_packet(sock: socket.socket) -> tuple[int, int, str]:
|
||||
size = struct.unpack("<i", _recvall(sock, 4))[0]
|
||||
payload = _recvall(sock, size)
|
||||
req_id, ptype = struct.unpack("<ii", payload[:8])
|
||||
body = payload[8:].rstrip(b"\x00").decode("utf-8", errors="replace")
|
||||
return req_id, ptype, body
|
||||
|
||||
|
||||
def _recvall(sock: socket.socket, n: int) -> 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<name>[^"]*)"\s+'
|
||||
r"(?P<sid>STEAM_\d+:(?P<y>\d+):(?P<z>\d+))\s+"
|
||||
r"(?P<connected>[\d:]+)\s+"
|
||||
r"(?P<ping>\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}")
|
||||
131
l4d2web/tests/test_rcon.py
Normal file
131
l4d2web/tests/test_rcon.py
Normal file
|
|
@ -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("<iii", size, req_id, ptype) + body_bytes
|
||||
|
||||
|
||||
def _unpack_one(conn: socket.socket) -> tuple[int, int, str]:
|
||||
raw_size = conn.recv(4)
|
||||
size = struct.unpack("<i", raw_size)[0]
|
||||
payload = b""
|
||||
while len(payload) < size:
|
||||
payload += conn.recv(size - len(payload))
|
||||
req_id, ptype = struct.unpack("<ii", payload[:8])
|
||||
body = payload[8:].rstrip(b"\x00").decode("utf-8", errors="replace")
|
||||
return req_id, ptype, body
|
||||
|
||||
|
||||
@contextmanager
|
||||
def fake_rcon_server(handler) -> 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)
|
||||
Loading…
Reference in a new issue