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
+29
-4
@@ -75,6 +75,12 @@ class SessionMonitor:
|
||||
RE_RELAY_HELD = re.compile(
|
||||
r"launch HELD -- still loading: (.+) \((\d+)/(\d+) ready\)")
|
||||
RE_RELAY_RESET = re.compile(r"round RESET")
|
||||
# The relay announces its roster at startup and replays that line to a
|
||||
# newly AUTHed operator. A REMOTE operator has no egg loaded, so this is
|
||||
# the only way it can learn the tags -- without them `state` stayed empty,
|
||||
# every pilot light was invisible and `seated` was always 0.
|
||||
RE_RELAY_ROSTER = re.compile(
|
||||
r"roster: (\d+) pilot\(s\) -> hostIDs \[[^\]]*\]: \[(.*)\]")
|
||||
# A ROUND HAS ENDED / THE LAUNCHER RE-ARMED. Before these, `launched`
|
||||
# could only be cleared by "WAITING FOR OPERATOR" (needs every seat of the
|
||||
# last round to re-ACK) or "round RESET" (needs every pod gone). A games
|
||||
@@ -110,6 +116,14 @@ class SessionMonitor:
|
||||
if m:
|
||||
self.eggs = int(m.group(1))
|
||||
changed = True
|
||||
m = self.RE_RELAY_ROSTER.search(line)
|
||||
if m:
|
||||
tags = re.findall(r"'([^']*)'", m.group(2))
|
||||
if tags and tags != self.tags:
|
||||
# keep any state we already know for a tag that survives
|
||||
self.tags = tags
|
||||
self.state = {t: self.state.get(t, ST_IDLE) for t in tags}
|
||||
changed = True
|
||||
if self.RE_RELAY_WAITOP.search(line):
|
||||
# WAITING FOR OPERATOR = pods are ready to launch. Reset
|
||||
# `launched` so the LAUNCH button re-enables -- this fires
|
||||
@@ -1032,8 +1046,10 @@ class Operator(QMainWindow):
|
||||
|
||||
if self.monitor.launched:
|
||||
head = "LAUNCHED — mission running"
|
||||
if (self.console_proc and not self.end_btn.isEnabled()
|
||||
and not getattr(self, "end_sent", False)):
|
||||
if (not self.end_btn.isEnabled()
|
||||
and not getattr(self, "end_sent", False)
|
||||
and (self.console_proc is not None
|
||||
or getattr(self, "remote_link", None) is not None)):
|
||||
self.end_btn.setEnabled(True)
|
||||
elif self.monitor.held_status:
|
||||
head = "STAGING — " + self.monitor.held_status
|
||||
@@ -1068,10 +1084,15 @@ class Operator(QMainWindow):
|
||||
combo = self.table.cellWidget(r, 2)
|
||||
if mech and combo is not None and combo.currentText() != mech:
|
||||
combo.setCurrentText(mech)
|
||||
# A REMOTE operator has no console_proc -- its command channel is the
|
||||
# control socket -- so requiring one made LAUNCH (and END MISSION below)
|
||||
# permanently dead in remote mode. Accept either channel.
|
||||
have_relay = (self.console_proc is not None
|
||||
or getattr(self, "remote_link", None) is not None)
|
||||
self.launch_btn.setEnabled(
|
||||
(self.monitor.ready or (self.monitor.relay_mode and seated > 0))
|
||||
and not self.monitor.launched
|
||||
and self.console_proc is not None)
|
||||
and have_relay)
|
||||
if self.monitor.stats:
|
||||
self.stats_label.setText(self.monitor.stats)
|
||||
|
||||
@@ -1090,7 +1111,11 @@ class Operator(QMainWindow):
|
||||
QMessageBox.warning(self, "Launch", "btl4.exe not found:\n" + exe)
|
||||
return
|
||||
relay = self.mode.currentIndex() == 0
|
||||
if relay and self.console_proc is None:
|
||||
# Remote mode has no console_proc but IS a live session -- and the env
|
||||
# build below already points BT_RELAY at remote_link.host, so this guard
|
||||
# was the only thing making the feature unreachable there.
|
||||
if (relay and self.console_proc is None
|
||||
and getattr(self, "remote_link", None) is None):
|
||||
QMessageBox.warning(
|
||||
self, "Launch",
|
||||
"The session is not running -- press Start Session first\n"
|
||||
|
||||
Reference in New Issue
Block a user