diff --git a/scratchpad/relay_stub_test.py b/scratchpad/relay_stub_test.py new file mode 100644 index 0000000..2eb257f --- /dev/null +++ b/scratchpad/relay_stub_test.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python +"""relay_stub_test.py -- Phase-1 verification for btconsole.py --relay mode. + +Spawns the relay with a 3-pilot test egg, then drives stub clients through the +whole protocol: console egg delivery, game HELLO/registration, PEER_UP/DOWN, +unicast + broadcast routing (route rewrite), UDP HELLO/ACK, UDP forward, +UDP->TCP fallback, and the reject paths (bad magic, dup id, out-of-roster, +oversize). Exits 0 with 'ALL PASS' or 1 with the first failure. +""" +import os +import socket +import struct +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOLS = os.path.join(HERE, "..", "tools") +CONSOLE_PORT = 27500 +GAME_PORT = CONSOLE_PORT + 1 + +ENV_TCP = " unicast to 3 + route, payload = recv_env(b) + check(route == 2 and payload == frame, + f"B received A's unicast with route rewritten to sender (route={route})") + + print("== routing: broadcast fan-out ==") + c = game_connect() + hello(c, 4) + for s_, who in ((a, "A"), (b, "B")): + route, payload = recv_env(s_) + check(route == R_PEER_UP and struct.unpack(" unicast 3 + ua.sendto(dg, ("127.0.0.1", GAME_PORT)) + fwd, _ = ub.recvfrom(4096) + check(fwd == dg, "UDP unicast forwarded verbatim") + dg2 = struct.pack(ENV_UDP, R_BCAST, 2, 8) + frame # A -> broadcast + ua.sendto(dg2, ("127.0.0.1", GAME_PORT)) + fwd2, _ = ub.recvfrom(4096) + check(fwd2 == dg2, "UDP broadcast reached B") + # C never sent UDP HELLO -> its copy must fall back to TCP. + route, payload = recv_env(c) + check(route == 2 and payload == frame, + "C (no UDP endpoint) got the broadcast via TCP fallback") + + print("== reject paths ==") + bad = game_connect() + send_env(bad, R_HELLO, struct.pack(" connection dropped") + dup = game_connect() + hello(dup, 2) + check(dup.recv(1) == b"", "duplicate hostID -> dropped") + oor = game_connect() + hello(oor, 99) + check(oor.recv(1) == b"", "out-of-roster hostID -> dropped") + big = game_connect() + big.sendall(struct.pack(ENV_TCP, 3, 5000)) + check(big.recv(1) == b"", "oversize frame -> dropped") + + print("== PEER_DOWN ==") + c.close() + for s_, who in ((a, "A"), (b, "B")): + s_.settimeout(5) + route, payload = recv_env(s_) + check(route == R_PEER_DOWN and struct.unpack(" [ ...] Example (two instances on one box, -net 1501 / -net 1601): python btconsole.py MP.EGG 127.0.0.1:1501 127.0.0.1:1601 + +RELAY MODE (D1 -- internet play; pods dial OUT to this process): + python btconsole.py --relay [--bind ADDR] [--udp-drop PCT] +Example: + python btconsole.py --relay 1500 MP_RELAY.EGG +The relay listens on /tcp (console protocol: streams the egg to each +pod that connects, sends the LAUNCH pair once ALL pods have the egg), on ++1/tcp (the GAME relay: envelope-framed frame router, see +RelayEnvelope below), and on +1/udp (the unreliable channel +forwarder). Pods run with BT_RELAY=: and a unique +BT_SELF=. + + RelayEnvelope (game TCP): { int32 route; uint32 length; } + length payload bytes + route >= 2 : client->relay: unicast to that hostID + relay->client: the sender's hostID (frames also self-identify + via NetworkPacketHeader.fromHost) + route == -1: broadcast to every registered pod except the sender + route == -2: HELLO (payload: int32 magic 'BTR1', int32 myHostID) + route == -3: PEER_UP (payload: int32 hostID) relay->client only + route == -4: PEER_DOWN (payload: int32 hostID) relay->client only + UDP envelope: { int32 route; int32 fromHost; uint32 seq; } + frame + route as above; -2 = HELLO/keepalive (no payload) -> relay answers -5 + (HELLO-ACK). The relay learns each pod's public endpoint from every + inbound datagram and forwards verbatim; unknown target endpoint falls + back to wrapping the frame onto the target's game TCP connection. """ +import random +import selectors import socket import struct import sys @@ -144,7 +171,415 @@ def serve_pod(hostport, egg_bytes): time.sleep(60) +############################################################################# +# RELAY MODE (D1) +############################################################################# + +ENV_FMT_TCP = " expected hostIDs 2..N+1 in egg order. + The relay's ONLY game knowledge; it never parses game frames.""" + entries = [] + in_pilots = False + for raw in open(egg_path, "rb").read().replace(b"\r\n", b"\n").split(b"\n"): + line = raw.strip() + if line.startswith(b"["): + in_pilots = (line.lower() == b"[pilots]") + continue + if in_pilots and line.lower().startswith(b"pilot="): + entries.append(line.split(b"=", 1)[1].decode("ascii", "replace")) + return entries + + +class RelayGameConn: + """One accepted game-TCP connection: ACCEPTED -> (HELLO) REGISTERED -> GONE.""" + def __init__(self, sock, addr): + self.sock = sock + self.addr = addr + self.buf = b"" + self.host_id = None # None until HELLO registers it + + def name(self): + hid = self.host_id if self.host_id is not None else "?" + return f"game[{self.addr[0]}:{self.addr[1]} host={hid}]" + + +class RelayConsoleConn: + """One accepted console connection (legacy raw protocol, relay-terminated).""" + def __init__(self, sock, addr): + self.sock = sock + self.addr = addr + self.egg_sent = False + + +class Relay: + def __init__(self, console_port, egg_path, bind_addr, udp_drop_pct): + self.console_port = console_port + self.game_port = console_port + 1 + self.bind_addr = bind_addr + self.udp_drop_pct = udp_drop_pct + self.egg_bytes = egg_wire_bytes(open(egg_path, "rb").read()) + self.roster = parse_egg_roster(egg_path) + self.expected_ids = set(range(FIRST_GAME_HOST_ID, + FIRST_GAME_HOST_ID + len(self.roster))) + self.sel = selectors.DefaultSelector() + self.console_conns = [] + self.game_conns = [] # RelayGameConn (incl. unregistered) + self.by_host = {} # hostID -> RelayGameConn + self.udp_endpoint = {} # hostID -> (addr, port) + self.udp_sock = None + self.launch_at = None + self.launches_sent = 0 + self.stats = {"tcp_rx": 0, "tcp_tx": 0, "udp_rx": 0, "udp_tx": 0, + "udp_dropped": 0, "udp_tcp_fallback": 0} + self.stats_at = time.time() + STATS_PERIOD + + # ---------------- lifecycle ---------------- + + def run(self): + print(f"[relay] roster: {len(self.roster)} pilot(s) -> hostIDs " + f"{sorted(self.expected_ids)}: {self.roster}", flush=True) + con_l = self._listener(self.console_port) + game_l = self._listener(self.game_port) + self.udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.udp_sock.bind((self.bind_addr, self.game_port)) + self.udp_sock.setblocking(False) + self.sel.register(con_l, selectors.EVENT_READ, ("con_listen", None)) + self.sel.register(game_l, selectors.EVENT_READ, ("game_listen", None)) + self.sel.register(self.udp_sock, selectors.EVENT_READ, ("udp", None)) + print(f"[relay] console {self.bind_addr}:{self.console_port} | game tcp/udp " + f"{self.bind_addr}:{self.game_port} | udp-drop {self.udp_drop_pct}%", + flush=True) + while True: + for key, _ in self.sel.select(timeout=0.5): + kind, obj = key.data + if kind == "con_listen": + self._accept_console(key.fileobj) + elif kind == "game_listen": + self._accept_game(key.fileobj) + elif kind == "console": + self._console_read(obj) + elif kind == "game": + self._game_read(obj) + elif kind == "udp": + self._udp_read() + self._tick_launch() + self._tick_stats() + + def _listener(self, port): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((self.bind_addr, port)) + s.listen(16) + s.setblocking(False) + return s + + # ---------------- console side (legacy protocol, relay-terminated) ---------------- + + def _accept_console(self, listener): + sock, addr = listener.accept() + sock.setblocking(False) + conn = RelayConsoleConn(sock, addr) + self.console_conns.append(conn) + self.sel.register(sock, selectors.EVENT_READ, ("console", conn)) + # Stream the egg immediately (blocking sendall is fine: the pod's console + # pad drains fast and the egg is a few KB). + sock.setblocking(True) + try: + n = 0 + for pkt in egg_packets(self.egg_bytes): + sock.sendall(pkt) + n += 1 + conn.egg_sent = True + print(f"[relay] console conn {addr[0]}:{addr[1]}: egg sent " + f"({n} chunks); {self._eggs_out()}/{len(self.roster)} pods have it", + flush=True) + except OSError as e: + print(f"[relay] console conn {addr[0]}:{addr[1]}: egg send failed {e!r}", + flush=True) + self._drop_console(conn) + return + finally: + try: + sock.setblocking(False) + except OSError: + pass + # GLOBAL launch timer: arm/rearm when the LAST pod has its egg. + if self._eggs_out() >= len(self.roster) and self.launches_sent == 0: + self.launch_at = time.time() + LAUNCH_SETTLE_SECONDS + print(f"[relay] all pods have the egg; LAUNCH pair in " + f"{LAUNCH_SETTLE_SECONDS}s", flush=True) + + def _eggs_out(self): + return sum(1 for c in self.console_conns if c.egg_sent) + + def _console_read(self, conn): + try: + data = conn.sock.recv(4096) + except OSError: + data = b"" + if not data: + print(f"[relay] console conn {conn.addr[0]}:{conn.addr[1]} closed", + flush=True) + self._drop_console(conn) + return + if len(data) >= 24: + (clid, _gid, fh, _ts) = struct.unpack_from("console clientID={clid} fromHost={fh} msgID={mid} " + f"len={mlen}", flush=True) + + def _drop_console(self, conn): + try: + self.sel.unregister(conn.sock) + except (KeyError, ValueError): + pass + try: + conn.sock.close() + except OSError: + pass + if conn in self.console_conns: + self.console_conns.remove(conn) + + def _tick_launch(self): + if self.launch_at is None or self.launches_sent >= 2: + return + if time.time() < self.launch_at: + return + pkt = run_mission_packet() + for conn in list(self.console_conns): + try: + conn.sock.setblocking(True) + conn.sock.sendall(pkt) + conn.sock.setblocking(False) + except OSError as e: + print(f"[relay] launch send failed to {conn.addr}: {e!r}", flush=True) + self.launches_sent += 1 + self.launch_at = time.time() + LAUNCH_STEP_SECONDS + print(f"[relay] RunMission #{self.launches_sent} sent to " + f"{len(self.console_conns)} pod(s)", flush=True) + + # ---------------- game TCP side ---------------- + + def _accept_game(self, listener): + sock, addr = listener.accept() + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + sock.setblocking(False) + conn = RelayGameConn(sock, addr) + self.game_conns.append(conn) + self.sel.register(sock, selectors.EVENT_READ, ("game", conn)) + print(f"[relay] {conn.name()} accepted (awaiting HELLO)", flush=True) + + def _game_read(self, conn): + try: + data = conn.sock.recv(8192) + except OSError: + data = b"" + if not data: + self._drop_game(conn, "closed") + return + conn.buf += data + while len(conn.buf) >= ENV_TCP_SIZE: + route, length = struct.unpack_from(ENV_FMT_TCP, conn.buf, 0) + if length > FRAME_CAP: + self._drop_game(conn, f"oversize frame ({length})") + return + if len(conn.buf) < ENV_TCP_SIZE + length: + break # partial; wait for more + payload = conn.buf[ENV_TCP_SIZE:ENV_TCP_SIZE + length] + conn.buf = conn.buf[ENV_TCP_SIZE + length:] + self.stats["tcp_rx"] += 1 + self._handle_game_frame(conn, route, payload) + if conn.sock.fileno() < 0: # dropped mid-loop + return + + def _handle_game_frame(self, conn, route, payload): + if conn.host_id is None: + # First frame MUST be a valid HELLO. + if route != ROUTE_HELLO or len(payload) < 8: + self._drop_game(conn, f"first frame not HELLO (route={route})") + return + magic, host_id = struct.unpack_from("= FIRST_GAME_HOST_ID: + target = self.by_host.get(route) + if target is None: + return # peer gone; drop silently + env = struct.pack(ENV_FMT_TCP, conn.host_id, len(payload)) + self._send_raw(target, env + payload) + else: + print(f"[relay] {conn.name()} bad route {route} -- ignored", flush=True) + + def _send_control(self, conn, route, host_id): + self._send_raw(conn, struct.pack(ENV_FMT_TCP, route, 4) + + struct.pack("= FIRST_GAME_HOST_ID and route in self.by_host: + targets = [route] + else: + continue + for host_id in targets: + if self.udp_drop_pct and random.random() * 100.0 < self.udp_drop_pct: + self.stats["udp_dropped"] += 1 + continue + ep = self.udp_endpoint.get(host_id) + if ep is not None: + try: + self.udp_sock.sendto(data, ep) # forward VERBATIM + self.stats["udp_tx"] += 1 + continue + except OSError: + pass + # Target's UDP endpoint unknown/broken: wrap the frame onto its + # game TCP connection (asymmetric-UDP-blockage fallback). + frame = data[ENV_UDP_SIZE:] + target = self.by_host.get(host_id) + if target is not None and frame: + env = struct.pack(ENV_FMT_TCP, from_host, len(frame)) + self._send_raw(target, env + frame) + self.stats["udp_tcp_fallback"] += 1 + + # ---------------- stats ---------------- + + def _tick_stats(self): + if time.time() < self.stats_at: + return + self.stats_at = time.time() + STATS_PERIOD + s = self.stats + print(f"[relay-stats] tcp rx/tx {s['tcp_rx']}/{s['tcp_tx']} | " + f"udp rx/tx {s['udp_rx']}/{s['udp_tx']} " + f"(dropped {s['udp_dropped']}, tcp-fallback {s['udp_tcp_fallback']}) | " + f"registered {sorted(self.by_host)} udp-known {sorted(self.udp_endpoint)}", + flush=True) + + +def relay_main(argv): + """argv: --relay [--bind ADDR] [--udp-drop PCT]""" + args = argv[:] + bind_addr = "0.0.0.0" + udp_drop = 0.0 + if "--bind" in args: + i = args.index("--bind") + bind_addr = args[i + 1] + del args[i:i + 2] + if "--udp-drop" in args: + i = args.index("--udp-drop") + udp_drop = float(args[i + 1]) + del args[i:i + 2] + if len(args) != 2: + print(__doc__) + return 2 + console_port = int(args[0]) + egg_path = args[1] + relay = Relay(console_port, egg_path, bind_addr, udp_drop) + if not relay.roster: + print(f"[relay] ERROR: no [pilots] entries in {egg_path}") + return 2 + try: + relay.run() + except KeyboardInterrupt: + print("[relay] shutting down") + return 0 + + def main(): + if len(sys.argv) >= 2 and sys.argv[1] == "--relay": + return relay_main(sys.argv[2:]) if len(sys.argv) < 3: print(__doc__) return 2