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
@@ -6,9 +6,9 @@ source_sections: "tools/btoperator.py; tools/btconsole.py (module docstring is a
|
||||
related_topics: [multiplayer, steam-networking, content-archives, pod-hardware, build-and-run]
|
||||
key_terms: [EGG, master, replicant, relay, seat, roster]
|
||||
open_questions:
|
||||
- "UDP endpoint map is keyed on the sender's self-declared fromHost with no check against its TCP peer -- a zombie pod could steal a live pod's downstream (found 2026-07-26, not yet fixed)"
|
||||
- "egg-ACK detection is a fixed-offset parse of one recv() with no reassembly; no frame resync after a malformed frame"
|
||||
- "remote-operator mode can never enable the LAUNCH button (_refresh_pod_status requires a local console_proc)"
|
||||
- "MESH mode's operator buttons are inert: btconsole.main has no stdin reader, so LAUNCH/END/Re-arm are logged as sent and discarded (relay mode is unaffected). Left as-is deliberately -- mesh self-launches."
|
||||
- "_log_launch_readiness can print its 'the mission will STALL -- Stop, reduce the roster' warning on a perfectly healthy launch-with-whoever, because it counts by_host against the remapped roster"
|
||||
- "A straggler's late round-N FIN is indistinguishable on the wire from a pod dying mid-load, so it still triggers a ROUND ABORT (recoverable now via Re-arm, but the misclassification stands)"
|
||||
---
|
||||
|
||||
# The Operator Console + Relay
|
||||
@@ -205,6 +205,33 @@ ever ran live. Regression guards: `scratchpad/test_relay_rearm.py` cases 5b/5c.
|
||||
Half-open sockets are TCP keepalive's job; this deadline is only the backstop for platforms where
|
||||
the keepalive tunables do not take.
|
||||
|
||||
### Console-protocol reassembly [FIXED 2026-07-26]
|
||||
|
||||
The pod's **egg ACK is the launch gate's only signal**, and it used to be detected by parsing a
|
||||
single `recv()` at fixed offset 0 with a `len(data) >= 24` test. Two ways that lost an ACK on a real
|
||||
network (loopback hid both): the 28-byte ACK arriving **split** (16 bytes then 12) was dropped by
|
||||
the length test and never looked at again, and two **coalesced** messages meant only the first was
|
||||
read. A missed ACK means that seat never counts, so the round can never be released — a silent,
|
||||
unrecoverable wedge. `_console_read` now buffers per connection and walks every complete frame
|
||||
(`16 + messageLength`), and an implausible length **drops the connection with a reason** rather than
|
||||
mis-reading that pod for the rest of the night (a stream protocol cannot be resynced by guessing).
|
||||
Bounds: `CONSOLE_MSG_MAX = 65536`, `CONSOLE_INBUF_MAX = 1 MiB`.
|
||||
|
||||
### UDP endpoint anti-hijack [FIXED 2026-07-26]
|
||||
|
||||
`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 — and 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 this never rejects a legitimate pod. Rejections are
|
||||
counted (`udp_spoofed`) and logged once per offending `(host, IP)` pair.
|
||||
|
||||
Limitation worth knowing: two pods on the **same machine** share an IP, so this cannot tell them
|
||||
apart — same-machine trust is assumed.
|
||||
|
||||
Also: every pod-facing send used to flip the socket to blocking with **no timeout** on the relay's
|
||||
**single-threaded** selector loop, so one wedged peer could freeze the whole relay (no launch tick,
|
||||
no UDP fan-out, no control channel). All sends now go through `_send_all_guarded`
|
||||
@@ -249,11 +276,14 @@ doing nothing:
|
||||
LAUNCH / END MISSION / Re-arm are silently discarded in mesh mode. `Apply mission settings` is
|
||||
likewise inert there: the mesh console reads the egg bytes once at startup. Mesh also never
|
||||
prints "WAITING FOR OPERATOR", so the LAUNCH button cannot even enable.
|
||||
- **REMOTE-operator mode: LAUNCH and END MISSION can never enable** — both enable conditions
|
||||
require `console_proc is not None`, and the remote path never sets it. The roster lights are also
|
||||
permanently empty (`SessionMonitor(True, [])` → no tags → `seated` always 0), and
|
||||
`Launch local instances` is refused by the same `console_proc` guard even though the code below
|
||||
it deliberately builds `BT_RELAY` from the remote host.
|
||||
- **REMOTE-operator mode — FIXED 2026-07-26.** LAUNCH and END MISSION could never enable (both
|
||||
conditions required `console_proc is not None`, which the remote path never sets), the roster
|
||||
lights were permanently empty (`SessionMonitor(True, [])` → no tags → `seated` always 0), and
|
||||
`Launch local instances` was refused by the same guard even though the code below it already
|
||||
built `BT_RELAY` from the remote host. Now: all three enable conditions accept **either** channel
|
||||
(`console_proc` *or* `remote_link`), 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 pilot lights and a real seat count.
|
||||
- **`Start session` silently overwrites the egg on disk** (including re-rasterized callsigns), with
|
||||
no prompt and no backup.
|
||||
- **`Apply` writes only the six `[mission]` keys** (map/time/weather/scenario/temperature/length).
|
||||
|
||||
Reference in New Issue
Block a user