relay/console: fix the three remaining hardening items from the review
1. UDP ENDPOINT HIJACK. `from_host` in a UDP envelope is the sender's OWN claim,
and the relay used it directly to refresh that host's downstream endpoint --
so ANY datagram claiming host N silently stole host N's traffic (a zombie pod
from a previous round, a stale NAT mapping, or anyone who guessed a host id).
The victim simply stopped receiving on a channel that still looked healthy.
The claim is now bound to the identity we actually authenticated: the IP of
that host's live TCP game connection. The PORT is deliberately not checked --
it moves on a NAT rebind, which is the whole reason the endpoint map refreshes
per datagram -- and a genuine IP change cannot happen without the TCP
connection breaking and re-registering, so a legitimate pod is never rejected.
Rejections are counted (udp_spoofed) and logged once per offending (host, IP).
Known limit: two pods on one machine share an IP, so this cannot separate
them; same-machine trust is assumed.
2. THE EGG ACK COULD BE MISSED ENTIRELY -- a silent, unrecoverable wedge. The
pod's ACK is the launch gate's ONLY signal, and it was detected by parsing a
single recv() at fixed offset 0 behind a `len(data) >= 24` test. On a real
network (loopback hid both cases) the 28-byte ACK arriving SPLIT -- 16 bytes
then 12 -- was dropped by that test and never looked at again, and two
COALESCED messages meant only the first was read. A missed ACK means that
seat never counts toward the gate, so the round can never be released.
_console_read now buffers per connection and walks every complete frame
(16 + messageLength); an implausible length drops the connection WITH A REASON
rather than mis-reading that pod all night, since a stream protocol cannot be
resynced by guessing. Bounds: CONSOLE_MSG_MAX 64K, CONSOLE_INBUF_MAX 1 MiB.
3. REMOTE-OPERATOR MODE WAS HALF A CONSOLE. LAUNCH and END MISSION could never
enable (both conditions required `console_proc is not None`, which the remote
path never sets), the pilot lights were permanently blank (SessionMonitor was
built with an empty tag list, so `seated` was always 0), and Launch-local was
refused by the same guard even though the code below it already built
BT_RELAY from the remote host. All three enable conditions now accept EITHER
channel, and the monitor ADOPTS THE ROSTER from the relay's
"roster: N pilot(s) -> hostIDs [...]: [...]" line -- which the relay replays to
every newly AUTHed operator -- so a remote operator gets real lights and a
real seat count.
VERIFIED
* scratchpad/test_relay_net.py (new, 17 checks): spoofed datagram cannot move
an endpoint and is counted; a NAT rebind on the same IP still is honoured; an
unregistered host id still dropped; the ACK is found whole, split in two,
split three ways mid-header, and when hiding behind another message;
a garbage length drops the conn; the roster line is adopted, maps a
subsequent SEATED onto a real tag, lifts `seated` off 0, and re-feeding it
preserves known state.
* Live 2-pod rig: real pods ACKed through the new reassembly path (2/2 ready,
zero desync drops), the mission launched and ran, UDP flowed throughout
(93 rx / 85 tx, udp-known [2,3]) with ZERO false spoof rejections, then a
clean StopMission + round RESET.
* scratchpad/test_relay_rearm.py still 19/19; checkctx CLEAN.
Docs updated to match (context/operator-console.md gains reassembly and
anti-hijack sections; the guide no longer claims remote mode is crippled).
Remaining open items are now recorded in the topic's frontmatter: mesh mode's
operator buttons are inert by construction (no stdin reader in that path -- left
alone, mesh self-launches), _log_launch_readiness can cry STALL on a healthy
launch-with-whoever, and a straggler's late FIN is still misread as a pod dying
mid-load.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1cda880c6d
commit
ab5828a866
@@ -0,0 +1,183 @@
|
||||
"""Tests for the three relay/console hardening fixes (2026-07-26).
|
||||
|
||||
1. UDP anti-hijack: a datagram may only refresh the endpoint of the host whose
|
||||
TCP connection it actually shares an IP with.
|
||||
2. Console reassembly: the pod's egg-ACK must be found even when it arrives
|
||||
split across recv() calls, or coalesced with another message.
|
||||
3. Remote-operator roster adoption: the GUI monitor learns the roster from the
|
||||
relay's replayed log line, so a remote operator gets pilot lights and a
|
||||
working LAUNCH button.
|
||||
|
||||
No sockets, no game, no relay process -- everything is driven through fakes.
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
REPO = r"C:\git\bt411"
|
||||
sys.path.insert(0, os.path.join(REPO, "tools"))
|
||||
os.chdir(os.path.join(REPO, "content"))
|
||||
|
||||
import btconsole # noqa: E402
|
||||
|
||||
EGG = os.path.join(REPO, "content", "FOGDAY.EGG")
|
||||
fails = []
|
||||
|
||||
|
||||
def check(label, ok, extra=""):
|
||||
print(" %-56s %s %s" % (label, "OK" if ok else "*** FAIL ***", extra))
|
||||
if not ok:
|
||||
fails.append(label)
|
||||
|
||||
|
||||
def new_relay():
|
||||
return btconsole.Relay(1500, EGG, "127.0.0.1", 0, manual_launch=True)
|
||||
|
||||
|
||||
class FakeGameConn:
|
||||
"""A TCP-registered pod, as by_host holds it."""
|
||||
def __init__(self, ip, port=5000, host_id=2):
|
||||
self.addr = (ip, port)
|
||||
self.host_id = host_id
|
||||
self.last_seen = btconsole.time.time()
|
||||
self.buf = b""
|
||||
|
||||
|
||||
class FakeUdp:
|
||||
"""Serves queued datagrams to _udp_read, then blocks like a real socket."""
|
||||
def __init__(self, datagrams):
|
||||
self.queue = list(datagrams)
|
||||
self.sent = []
|
||||
def recvfrom(self, _n):
|
||||
if not self.queue:
|
||||
raise BlockingIOError()
|
||||
return self.queue.pop(0)
|
||||
def sendto(self, data, endpoint):
|
||||
self.sent.append((data, endpoint))
|
||||
|
||||
|
||||
def udp_frame(route, from_host, seq=1, payload=b"\0\0\0\0"):
|
||||
return struct.pack(btconsole.ENV_FMT_UDP, route, from_host, seq) + payload
|
||||
|
||||
|
||||
print("=== 1. UDP ANTI-HIJACK ===")
|
||||
r = new_relay()
|
||||
r.by_host = {2: FakeGameConn("10.0.0.5", 5000, 2)}
|
||||
r.udp_endpoint = {2: ("10.0.0.5", 6000)}
|
||||
r.game_conns = list(r.by_host.values())
|
||||
|
||||
# (a) a stranger claiming host 2 must NOT move host 2's endpoint
|
||||
r.udp_sock = FakeUdp([(udp_frame(btconsole.ROUTE_BCAST, 2), ("66.66.66.66", 7777))])
|
||||
r._udp_read()
|
||||
check("spoofed datagram did not move the endpoint",
|
||||
r.udp_endpoint[2] == ("10.0.0.5", 6000), str(r.udp_endpoint[2]))
|
||||
check("spoof counted in stats", r.stats.get("udp_spoofed", 0) == 1)
|
||||
|
||||
# (b) the REAL pod on a NEW port (NAT rebind) must still be honoured
|
||||
r.udp_sock = FakeUdp([(udp_frame(btconsole.ROUTE_BCAST, 2), ("10.0.0.5", 9999))])
|
||||
r._udp_read()
|
||||
check("NAT rebind (same IP, new port) accepted",
|
||||
r.udp_endpoint[2] == ("10.0.0.5", 9999), str(r.udp_endpoint[2]))
|
||||
|
||||
# (c) an unregistered host id is still dropped outright
|
||||
before = dict(r.udp_endpoint)
|
||||
r.udp_sock = FakeUdp([(udp_frame(btconsole.ROUTE_BCAST, 99), ("10.0.0.9", 1))])
|
||||
r._udp_read()
|
||||
check("unregistered host id dropped", r.udp_endpoint == before)
|
||||
|
||||
|
||||
print("\n=== 2. CONSOLE REASSEMBLY (the egg-ACK must never be missed) ===")
|
||||
|
||||
ACK = (struct.pack("<iiii", 0, 0, 2, 0) # clientID 0, gameID, fromHost, ts
|
||||
+ struct.pack("<Ii", 12, 4) # messageLength 12, messageID 4
|
||||
+ b"\0" * 4) # -> 16 + 12 = 28 bytes
|
||||
assert len(ACK) == 28
|
||||
|
||||
|
||||
class FakeConsoleSock:
|
||||
def __init__(self, reads):
|
||||
self.reads = list(reads)
|
||||
def recv(self, _n):
|
||||
return self.reads.pop(0) if self.reads else b""
|
||||
|
||||
|
||||
def acking_relay():
|
||||
r = new_relay()
|
||||
r.console_conns = []
|
||||
r._check_launch_gate = lambda: None # isolate: we only test detection
|
||||
conn = btconsole.RelayConsoleConn(FakeConsoleSock([]), ("10.0.0.5", 5001))
|
||||
r.console_conns.append(conn)
|
||||
return r, conn
|
||||
|
||||
|
||||
# (a) the ACK arriving in ONE piece (the old code handled only this)
|
||||
r, conn = acking_relay()
|
||||
conn.sock.reads = [ACK]
|
||||
r._console_read(conn)
|
||||
check("whole ACK detected", conn.acked is True)
|
||||
|
||||
# (b) SPLIT across two reads -- the old fixed-offset parse dropped this silently,
|
||||
# and a missed ACK means that seat never counts toward the launch gate.
|
||||
r, conn = acking_relay()
|
||||
conn.sock.reads = [ACK[:16], ACK[16:]]
|
||||
r._console_read(conn)
|
||||
check("split ACK: not detected on the partial half", conn.acked is False)
|
||||
r._console_read(conn)
|
||||
check("split ACK detected once the rest arrives", conn.acked is True)
|
||||
|
||||
# (c) split at an even nastier boundary (mid-header)
|
||||
r, conn = acking_relay()
|
||||
conn.sock.reads = [ACK[:5], ACK[5:20], ACK[20:]]
|
||||
for _ in range(3):
|
||||
r._console_read(conn)
|
||||
check("ACK split three ways still detected", conn.acked is True)
|
||||
|
||||
# (d) two messages COALESCED into one read -- the old code read only the first
|
||||
r, conn = acking_relay()
|
||||
other = (struct.pack("<iiii", 0, 0, 2, 0) + struct.pack("<Ii", 12, 99) + b"\0" * 4)
|
||||
conn.sock.reads = [other + ACK]
|
||||
r._console_read(conn)
|
||||
check("coalesced messages: the ACK behind another is found", conn.acked is True)
|
||||
|
||||
# (e) an implausible length must drop the connection rather than mis-read forever
|
||||
r, conn = acking_relay()
|
||||
dropped = []
|
||||
r._drop_console = lambda c: dropped.append(c)
|
||||
conn.sock.reads = [struct.pack("<iiii", 0, 0, 2, 0) + struct.pack("<Ii", 1 << 30, 4)]
|
||||
r._console_read(conn)
|
||||
check("garbage messageLength drops the conn", dropped == [conn])
|
||||
|
||||
|
||||
print("\n=== 3. REMOTE OPERATOR: roster adoption + live command channel ===")
|
||||
try:
|
||||
import btoperator # noqa: E402
|
||||
except Exception as e: # PySide6 missing -> skip, do not fail
|
||||
print(" (skipped: cannot import btoperator: %r)" % (e,))
|
||||
else:
|
||||
mon = btoperator.SessionMonitor(True, []) # how remote mode starts
|
||||
check("remote monitor starts with no tags", mon.tags == [])
|
||||
line = ("22:27:39 [relay] roster: 2 pilot(s) -> hostIDs [2, 3]: "
|
||||
"['10.99.0.1:1502', '10.99.0.2:1502']")
|
||||
changed = mon.feed(line)
|
||||
check("roster line adopted", mon.tags == ["10.99.0.1:1502", "10.99.0.2:1502"],
|
||||
str(mon.tags))
|
||||
check("feed reported a change", changed is True)
|
||||
check("every tag got a state", len(mon.state) == 2)
|
||||
|
||||
# a seat line now lands on a known tag, so the pilot light works
|
||||
mon.feed("[relay] PLAYER 1 SEATED (host 2) tag='10.99.0.1:1502' "
|
||||
"callsign='SAURON' mech='lok2'")
|
||||
check("SEATED now maps onto an adopted tag",
|
||||
mon.state["10.99.0.1:1502"] == btoperator.ST_SEATED)
|
||||
seated = sum(1 for s in mon.state.values()
|
||||
if s in (btoperator.ST_SEATED, btoperator.ST_REGISTERED,
|
||||
btoperator.ST_READY))
|
||||
check("seated count is no longer stuck at 0", seated == 1, str(seated))
|
||||
|
||||
# re-feeding the same roster must not churn state
|
||||
mon.feed(line)
|
||||
check("re-feeding the roster preserves known state",
|
||||
mon.state["10.99.0.1:1502"] == btoperator.ST_SEATED)
|
||||
|
||||
print("\n%s" % ("ALL CHECKS PASSED" if not fails else "FAILURES: %s" % fails))
|
||||
sys.exit(1 if fails else 0)
|
||||
Reference in New Issue
Block a user