diff --git a/scripts/openfut-interception-preflight.py b/scripts/openfut-interception-preflight.py new file mode 100755 index 0000000..2e24d76 --- /dev/null +++ b/scripts/openfut-interception-preflight.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Report host-level packet interception affecting OpenFUT endpoints. + +WHY THIS EXISTS +--------------- +During the 2026-08-22 Rust production cutover, four stale `openfut-switch` nft +rules were still redirecting production-facing traffic to staging: + + 42127 -> :42227 8081 -> :8281 8094 -> :18094 8099 -> :18106 + +They matched `ip daddr 10.10.0.120`, so every server-side probe via 127.0.0.1 or +the container IP passed while the CLIENT was refused or silently sent to +staging. That cost an entire false-negative acceptance round: a retail +quick-sell landed on staging while production sat untouched, and the launcher +reported "OpenFUT server is not answering". + +The lesson is mechanical, so the check is too: a connectivity gate that only +probes loopback proves nothing about what the client reaches. + +READ-ONLY. This tool never deletes a rule. Removing interception is a +deliberate operator act (`openfut-switch.sh off --name `). + +Exit status: 0 CLEAN, 1 INTERCEPTION_PRESENT, 2 could not determine. + + python3 scripts/openfut-interception-preflight.py + python3 scripts/openfut-interception-preflight.py --advertise 10.10.0.120 +""" +from __future__ import annotations + +import argparse +import re +import shutil +import socket +import subprocess +import sys + +# The endpoints a FIFA 17 client actually dials, plus the staging twins that +# stale rules historically pointed at. +PRODUCTION_PORTS = { + 8099: "UTAS (Rust utas-host)", + 8081: "roster", + 8094: "POW api", + 8085: "POW content", + 4216: "LSX (client-side)", + 42127: "Blaze redirector", + 42130: "Blaze main", + 42131: "Nucleus", +} + +# LSX runs on the GAME machine, not here: the compose file publishes 4216 but +# `OPENFUT_SERVERS` excludes lsx by default, so "published but not served" is +# its normal state and must not be reported as interception. Every other port +# above is expected to be served on this host. +NOT_SERVED_HERE = {4216} +STAGING_PORTS = {8299: "staging UTAS", 42327: "staging redirector", + 42330: "staging blaze main", 8281: "staging roster", + 18094: "staging POW", 18106: "retired season-shim"} +ALL_PORTS = dict(PRODUCTION_PORTS) +ALL_PORTS.update(STAGING_PORTS) + + +def _run(cmd: list[str]) -> str: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=20) + return r.stdout + except Exception: + return "" + + +def switch_status() -> tuple[str, list[str]]: + """`openfut-switch.sh status`, which owns the redirect lifecycle.""" + for path in ("/home/alex/OpenFUT/openfut-blaze-host/openfut-switch.sh", + "openfut-blaze-host/openfut-switch.sh"): + if shutil.which("bash") and subprocess.run( + ["test", "-x", path], capture_output=True).returncode == 0: + out = _run([path, "status"]) + active = [l.strip() for l in out.splitlines() + if "->" in l and "openfut-switch" in l] + return ("INACTIVE" if "INACTIVE" in out else + ("ACTIVE" if active else "UNKNOWN")), active + return "UNKNOWN", [] + + +def nft_redirects(advertise: str) -> tuple[list[str], list[str]]: + """Split nft rules touching our ports into REDIRECTs (interception) and + Docker's own DNAT (legitimate publishing).""" + out = _run(["sudo", "-n", "nft", "list", "ruleset"]) + if not out: + out = _run(["nft", "list", "ruleset"]) + redirects, dnats = [], [] + port_re = re.compile(r"dport (\d+)") + for line in out.splitlines(): + s = line.strip() + m = port_re.search(s) + if not m or int(m.group(1)) not in ALL_PORTS: + continue + if "redirect to" in s: + redirects.append(s) + elif "dnat to" in s: + dnats.append(s) + return redirects, dnats + + +def reachable(host: str, port: int, timeout: float = 2.0) -> bool: + s = socket.socket() + s.settimeout(timeout) + try: + s.connect((host, port)) + return True + except OSError: + return False + finally: + s.close() + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--advertise", default="10.10.0.120", + help="the address the CLIENT dials (not loopback)") + args = ap.parse_args() + adv = args.advertise + + print("OpenFUT interception preflight — advertise=%s" % adv) + print() + + state, active = switch_status() + print("openfut-switch : %s" % state) + for a in active: + print(" %s" % a) + + redirects, dnats = nft_redirects(adv) + print("\nnft REDIRECTs on OpenFUT ports : %d%s" + % (len(redirects), " <-- INTERCEPTION" if redirects else "")) + for r in redirects: + print(" %s" % r[:150]) + print("nft DNAT (docker publishing) : %d (expected, not interception)" + % len(dnats)) + + # The point of the whole tool: compare loopback with the address the client + # actually dials. A redirect keyed on the LAN IP is invisible to loopback. + print("\neffective endpoint, loopback vs advertised:") + disagree = [] + for port, label in sorted(PRODUCTION_PORTS.items()): + lo = reachable("127.0.0.1", port) + wan = reachable(adv, port) + if lo == wan: + flag = "" + elif port in NOT_SERVED_HERE: + flag = " (not served here -- expected)" + else: + flag = " <-- DISAGREE" + if lo != wan and port not in NOT_SERVED_HERE: + disagree.append((port, label, lo, wan)) + print(" :%-6d %-24s loopback=%-5s advertised=%-5s%s" + % (port, label, lo, wan, flag)) + + intercepted = bool(redirects) or state == "ACTIVE" or bool(disagree) + print() + if intercepted: + print("RESULT: INTERCEPTION_PRESENT") + if disagree: + print(" loopback and the advertised address disagree on: %s" + % ", ".join(":%d" % p for p, _, _, _ in disagree)) + print(" Nothing was changed. To clear a switch rule, run explicitly:") + print(" openfut-blaze-host/openfut-switch.sh off --name ") + return 1 + print("RESULT: CLEAN") + return 0 + + +if __name__ == "__main__": + sys.exit(main())