131 lines
4.2 KiB
Python
131 lines
4.2 KiB
Python
"""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)
|