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.
This commit is contained in:
Executable
+172
@@ -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 <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())
|
||||
Reference in New Issue
Block a user