Commit Graph
2 Commits
Author SHA1 Message Date
arcattackandClaude Opus 5 ab5828a866 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>
2026-07-26 08:35:09 -05:00
arcattackandClaude Opus 5 1cda880c6d console/relay: document it properly + fix two regressions the review caught
DOCS (the ask: after a compaction this session lost track of how the console
works and launched the wrong program, twice).

  * NEW context/operator-console.md -- the dedicated topic that was missing.
    Leads with the thing I got wrong: btoperator.py is the PySide6 GUI the
    operator uses; btconsole.py is the headless relay it spawns.  Then ports,
    the route table, the roster/seat/identity model, the full round lifecycle
    with every launch gate, liveness, mode-specific traps, and log locations.
  * NEW docs/OPERATOR_GUIDE.md -- sysop-facing: start the console, set up a
    mission, watch pods arrive, launch, run back-to-back rounds, what to press
    when LAUNCH looks dead, a troubleshooting table keyed on the exact log
    lines, and what to save BEFORE restarting a session (Start Session
    truncates operator_relay.log, so restarting to clear a problem destroys the
    evidence of it).
  * CLAUDE.md: two Quick Lookup rows + a DO-NOT entry naming the two programs,
    so the distinction survives the next compaction.
  * context/multiplayer.md: a pointer out of the scattered console notes to the
    new topic (they were buried across ~8 places in a large file, which is
    exactly why they evaporated).

FIXES -- both are regressions in my own previous commit, found by the review
pass, and one would have made a games night WORSE:

  * THE REAPER WOULD HAVE KILLED HEALTHY PLAYERS.  It was gated only on "no
    mission running", which a round RESET satisfies -- so it was armed for the
    whole BETWEEN-ROUNDS wait, and that is a period when a pod is legitimately
    byte-silent: its seat beacon is write-only for the process lifetime
    (L4NET.CPP: "the relay ignores its silence") and its console pad has no egg
    yet so it cannot ACK.  A real night showed 12-minute and 5-minute gaps; the
    180s deadline would have dropped healthy pods and forced their clients to
    relaunch.  It now runs ONLY in the active staging window, skips pads with no
    egg and conns that have not HELLO'd, and last_seen is also stamped from
    inbound UDP (a pod streaming updates while its TCP idles was being counted
    as silent).  Half-open detection is keepalive's job; this is just a backstop.
  * UnboundLocalError in the operator UI.  My end_sent reset was an `elif` in
    the chain that assigns `head`, so that branch left `head` unbound -- a crash
    on the first status refresh after End Mission, which is exactly the path the
    relay's "StopMission sent" line produces.  Moved out of the chain.

Plus one pre-existing wedge with the same symptom as the reported bug, live-
proven in operator_relay.log (~5 minutes of a night lost): _abort_round clears
eggs_released BEFORE the survivors' sockets close, so _maybe_reset_round's own
`if not self.eggs_released: return` skips the template restore forever -- the
roster stays trimmed, the release gates can never be met, and walk-ups get
ROSTER FULL.  _rearm_for_new_round's restore is therefore now UNCONDITIONAL (it
was gated on eggs_released, which made Re-arm useless in the one state that most
needs it) and it clears round_hold_until so an abort's settle window is not
inherited.  Aborts are ordinary: nothing on the wire distinguishes a straggler's
late FIN from a pod dying mid-load.

scratchpad/test_relay_rearm.py now 19 checks, all passing, including the two new
regression guards (a 15-minute-silent waiting pod is NOT reaped; a pad that was
never sent an egg is NOT reaped) and the mid-pair no-re-arm guard.

Still open, recorded in the new topic's frontmatter: the UDP endpoint map trusts
the sender's self-declared fromHost; the egg-ACK is a fixed-offset parse of one
recv with no reassembly; remote-operator mode can never enable LAUNCH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:36:14 -05:00