ef8e449a17f657383e278ea9fe3649e68a48b2a7
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2b0506ddfd |
OPERATOR_GUIDE: fix the blockquote swallowing the secret/port paragraph
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6b0402badc |
park_relay.cmd: resolve a real interpreter -- a second admin account has NO working python
Setting up remote access exposed this: 'python' works for the operator's own account only. It resolves through pyenv SHIMS driven by PYENV/PYENV_HOME/ PYENV_ROOT, and all three are USER-scoped to that account; the single machine-wide PATH entry points at a Python39-32 folder that no longer exists. So a dedicated remote-admin login (the account just created for SSH/RDP) gets NO python at all -- parking the relay from the road would have failed with 'python is not recognized', at night, with players waiting. park_relay.cmd now resolves an interpreter explicitly and prints which one: BT_PYTHON override -> the project's pyenv 3.11.4 (calling account's profile, then the operator's -- Administrators can read it) -> the machine-wide py launcher -> whatever is on PATH -> a clear error naming the fix. Verified both as the operator and with a simulated second-account environment (machine PATH only, PYENV cleared): both resolve 3.11.4. Also dropped a delayed-expansion bug in the first cut (!CAND! without EnableDelayedExpansion would have silently evaluated to nothing). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
05fdd319d6 |
parked relay: a supervisor that keeps it alive + a REMOTE restart the operator can actually reach
Completes the travelling-operator deployment. The relay stays parked on the
home machine (so no player ever edits join.bat) and the console dials in from
anywhere -- but until now two things needed hands on that machine: bringing the
relay back when it died, and restarting it (the only way to clear a wedge or
pick up a roster resize). Both are covered now.
tools/btrelay_park.py -- the supervisor:
* runs the relay with cwd=content\ , which is what pins WHICH
operator_secret.txt is live (the trap documented last commit);
* relaunches on ANY exit, with 2/5/15/30/60s backoff when a relay dies inside
20s, so a permanent fault (port taken, missing egg) cannot become a spin;
* rotates content\parked_relay.log to .1 first, so the dead generation's
evidence survives the relaunch that replaces it;
* Ctrl-C stops it for good. NOT a Windows service on purpose: session 0
would hide the window an operator wants to tail.
tools/park_relay.cmd -- double-click to park; a shortcut to it in shell:startup
gives start-after-reboot with no admin rights.
`restart` on the control port (btconsole.py): sets restart_requested, the run
loop returns, relay_main exits RESTART_EXIT_CODE=42 and the supervisor relaunches
-- re-reading the egg. REFUSED while a mission is running (launches_sent >= 2
and not stop_sent): bouncing then would drop every pod out of a live round.
Local stdin gets the same command for parity.
btoperator.py: Restart Session in REMOTE mode now sends `restart` and reconnects
6s later instead of just tearing down our own link -- the old behaviour looked
like "Restart does nothing" against a parked relay. (QTimer had to be imported;
it was missing, which py_compile does not catch -- it would have been a runtime
NameError on the first click.)
VERIFIED by scratchpad/test_parked_supervisor.py, 12/12, and the full
test_remote_console_e2e.py re-run green afterwards (15/15):
* kill the relay -> a NEW pid is serving again, and the old log is kept as .1;
* remote `restart` -> ACKed, new pid, egg re-read, serving again;
* the supervisor distinguishes the two -- "operator requested a restart" vs
the crash/backoff path -- proven from its own log, not assumed;
* the mid-mission guard is present and wired to launches_sent/stop_sent.
Two harness defects fixed while proving it, both mine: a check written with
`or True` that could never fail (replaced with two real assertions against the
supervisor's log), and a recv that treated the relay's correct
close-after-restart as a ConnectionResetError failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
9df7dff787 |
KB: the parked-relay/remote-console mode is COMPLETE -- record it so it stops being re-derived
Operator caught me re-investigating a feature we already shipped today. Root
cause was a self-contradicting knowledge base, not just my compaction: the
§Modes entry still said "Remote relay -- KNOWN BROKEN: the LAUNCH button can
never enable in this mode", while §Mode-specific traps 60 lines later documented
the same thing as FIXED. Reading the summary line first is exactly what a
future session does. Swept: that was the only stale copy.
Now recorded once, authoritatively (context/operator-console.md §Parked relay +
remote console, and a sysop recipe in docs/OPERATOR_GUIDE.md):
* THE PRODUCTION TOPOLOGY the operator actually wants: the relay parked
permanently on the home machine so no player ever edits join.bat, with the
console dialling in from anywhere (cell internet is fine -- outbound only).
* The exact standalone park command, and why the control port is what makes a
GUI-less relay usable at all (backgrounded, it has no stdin).
* The COMPLETE remote command set as a table, read out of _ctl_command /
_ctl_get_mission / _ctl_set_mission: launch, stop, rearm|newround, get,
set (the six CONTROL_SET_KEYS, written to the relay's OWN egg, effective
next round), ping, plus the log stream and the roster re-issued at AUTH.
Written down so the next session reads instead of re-deriving.
* The hard limits: remote mode ATTACHES -- it cannot start or restart the
relay, cannot resize the roster, cannot edit callsign/mech/colour.
* Multiple operators ARE accepted concurrently (ctl_conns is a list, each
AUTHing independently), so hand-off is seamless -- but nothing arbitrates
two people pressing LAUNCH.
Two live hazards found while verifying, both new:
1. THE SECRET'S CWD TRAP. control_secret() opens a bare relative
"operator_secret.txt" and SILENTLY GENERATES A NEW ONE if absent, so the
live secret depends on the relay's working directory. This repo really has
TWO secret files with DIFFERENT values (repo root and content\) -- a remote
operator holding the wrong one fails AUTH as a CONTROL_AUTH_TIMEOUT drop,
with no "wrong password" anywhere. Documented in both files.
2. operator_secret.txt was untracked but NOT ignored -- one `git add -A` from
publishing a live credential. Now in .gitignore.
Also corrected a nearby overreach: "the console must STAY CONNECTED" is about
the console channel to the PODS; an operator GUI dropping off a parked relay's
control port is harmless and the pods never notice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
c9e561d9ba |
console review round 2: six must-fixes from the adversarial pass (one of its fixes corrected)
The 4-dimension review of 4736cba..820caf8 returned 14 surviving findings. All six must-fixes plus the four deferables are in; one of the review's own prescriptions was wrong and is fixed differently (below). THE REAPER, FINAL DOCTRINE (blocker + major). Rev 2 -- "reap anything silent in the staging window" -- was still wrong twice over: a pad sends exactly ONE message in its life (the egg ACK, L4NET.CPP:1259) and is then silent FOREVER, so any manual-launch hold >180s reaped every healthy ACKed pad and force-relaunched their clients; and a REGISTERED game conn is quiet on TCP while loading, so with BT_RELAY_TCP_ONLY=1 (or blocked UDP) the reaper _abort_round()ed the whole night every ~3 minutes blaming a healthy player. The rule is now: an app-level deadline is valid only while a RESPONSE IS OWED. Only egg-sent-never-ACKed pads are reaped, with the debt clock starting at EGG SEND (a pad that sat through a long held-egg wait gets its full window -- an edge neither review round caught); game conns are never reaped at all. Half-open ghosts are TCP keepalive's job (~130s, faster than the deadline anyway). RE-ARM GUARDED ON EVERY CHANNEL (major). The launch_at guard lived only on the LAUNCH-press path; the ctl `rearm` command, stdin, and the always-lit GUI button reached _rearm_for_new_round unconditionally -- one press mid-mission zeroed the counters, permanently killing the mission clock AND making End Mission print "no mission is running": an unstoppable round, the exact class this feature exists to eliminate. Now refused (loudly) while a mission is running or a pair is in flight, and the button greys while launched. THE REVIEW'S OWN FIX WAS WRONG here: it prescribed refusing on `launches_sent >= 2`, but that stays 2 after a FINISHED round -- applying it verbatim resurrected the original dead-LAUNCH wedge, caught immediately by the regression suite. "Running" is `launches_sent >= 2 AND not stop_sent`. RE-ARM RE-KEYS SEATS (major). It restored the template roster but left seat_beacons/seat_prefs keyed by the trimmed round's positional ids, so round 2's trim minted a departed player's tag into the egg and trimmed a present player's out, shifting every callsign/mech a slot. The re-key block is factored out of _maybe_reset_round (_rekey_seats_to_roster) and shared. RESTART SESSION NO LONGER BAKES A WALK-UP'S NAME INTO THE EGG (major). _stop_session and _start_session now restore the stashed configured callsign/mech into the cells BEFORE _collect_egg can snapshot them (_restore_seat_defaults); previously the departed name went into the egg (name= plus rasterized bitmaps) and was then re-captured as the seat's permanent "configured default". LATE REMOTE OPERATOR GETS A ROSTER (major). The only line the GUI can adopt tags from was printed once at relay startup and aged out of the 400-line control history in ~33 minutes of stats chatter -- AUTH now re-issues the live roster line ahead of the replay, so a remote operator connecting at any point gets pilot lights and a working LAUNCH button. DEFERABLES, all four: _drop_game blames the tag stashed AT REGISTRATION (the live-roster resolve named the wrong tag for a trimmed-round conn dying after a re-arm restore -- and the tag-trusting GUI would clear the wrong seat); an operator-BLANKED callsign cell now restores (empty string is a real configured value; the falsy skip left the departed name up and re-captured it); a returning player displaces their own half-open registered ghost (same IP, no mission running) instead of eating ROSTER FULL until keepalive fires; udp_spoofed now rides the [relay-stats] line when non-zero and the warn set is capped at 256. VERIFIED: rearm suite grown to 25 checks (ACKed pad never reaped however silent; never-ACKed pad reaped; game conns never reaped; the egg-send debt clock; re-arm refused mid-mission, allowed after round end) -- plus net 17/17, roster 22/22, checkctx CLEAN. Live rig: mid-mission `rearm` refused with the message and the mission survived; End Mission -> re-arm -> full re-seat -> second mission launched. One bounded artifact observed and documented: each waiting pod bounces once (identity resync) after an explicit re-arm. Docs rewritten to the final doctrine (context/operator-console.md reaper + re-arm + roster-replay + udp_spoofed sections; OPERATOR_GUIDE + tooltip now say re-arm is between-rounds-only and warn about the one-bounce resync). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3a694485bf |
operator UI: a departed player no longer keeps their seat in the roster
Operator report: "if a player disconnects from the console, it leaves their name
up in the roster ... it should turn their seat back to empty." I had not noticed
or fixed this -- the earlier review raised adjacent roster issues (lights mapped
through the untrimmed tag list after a trim) but not this one.
CAUSE. The GUI only ever WROTE walk-up names into the table. On a disconnect
the relay pops seat_info, the write-back loop hit `if not info: continue`, and
the cell kept the departed callsign for the rest of the session -- while the
pilot light beside it correctly said "waiting". So the table and the light
disagreed and a free seat looked occupied.
Two changes, because the relay's two seat-clearing signals are NOT symmetric:
* SessionMonitor now pops seat_info when a pilot goes idle from EITHER signal.
"PLAYER n LEFT" is only printed inside the beacon-close branch gated on
`if seat not in self.by_host` (the relay's own comment: "never claimed:
player left"), so a player who was fully REGISTERED and then dropped
produced only `game[...] dropped` -- the common case, and the one that left
the name up. Keying "seat is empty" off LEFT alone could never have worked.
* _refresh_pod_status stashes the operator's configured callsign/mech PER
OCCUPANCY when a seat fills, and restores it when the seat empties.
Per-occupancy rather than once at egg load, so an edit the operator makes
while a seat is empty is respected instead of being overwritten by a stale
snapshot.
It restores the CONFIGURED PILOT, not a literal blank, deliberately: that cell is
editable and is what gets written to the egg on Save, so a placeholder like
"-- empty --" would end up in the mission file. The "unoccupied" signal is the
grey `waiting` light beside it.
NOT CHANGED, and explained instead: a vacated seat is held for its player for 90s
(seat_reclaim) so a crash or a reconnect returns them to the same seat and mech.
Assignment skips claimed/reserved/reclaim-held seats, so a brand-new joiner in
that window gets the next free seat, or ROSTER FULL if there wasn't one; after 90s
the seat is fair game. The operator said they were happy with players keeping
their seat/address, so this stays -- it is now documented in both docs rather than
silently removed.
VERIFIED: scratchpad/test_operator_roster.py (new, 14 checks) drives the REAL Qt
widget offscreen and feeds it the REAL relay log lines: a walk-up name appears;
after REGISTERED then `dropped` the light returns to waiting AND the seat shows
the configured pilot again; the seat is reusable by a different player and clears
for them too; the `PLAYER n LEFT` path clears it as well; an operator edit made
while the seat was empty survives an occupancy; and a neighbouring row is never
touched by another seat's traffic. Other suites still pass (rearm 19/19, net
17/17); checkctx CLEAN.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
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>
|