Files
OpenFUT/scripts/openfut-interception-preflight.py
T
funman300 5b8bee286c feat(ops): read-only interception preflight for OpenFUT endpoints
During the 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. That cost a full false-negative acceptance round: a retail
quick-sell landed on staging while production sat untouched, and the launcher
reported the server "not answering".

The failure mode is mechanical, so the check is:

  * openfut-switch.sh status
  * nft rules on OpenFUT ports, split into REDIRECT (interception) and DNAT
    (docker publishing, expected -- reporting those as problems would train the
    reader to ignore the tool)
  * the actual point: loopback vs the ADVERTISED address per port. A redirect
    keyed on the LAN IP is invisible to loopback, which is exactly why the
    cutover probes all passed.

Verdict is CLEAN / INTERCEPTION_PRESENT with exit 0/1/2. Both branches
observed: it reports CLEAN now, and reported INTERCEPTION_PRESENT on a
loopback/advertised disagreement before :4216 was excluded.

:4216 is excluded from the verdict because LSX runs on the game machine --
compose publishes the port but OPENFUT_SERVERS omits lsx, so "published but not
served" is its normal state. It is still printed, marked as expected.

READ-ONLY by design: it never deletes a rule. Clearing interception stays a
deliberate operator act via `openfut-switch.sh off --name <id>`.

Run before production acceptance, client repoints, migrations and retail
protocol tests.
2026-08-22 02:02:00 +00:00

173 lines
6.0 KiB
Python
Executable File

#!/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 <id>`).
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 <id>")
return 1
print("RESULT: CLEAN")
return 0
if __name__ == "__main__":
sys.exit(main())