5b8bee286c
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.
173 lines
6.0 KiB
Python
Executable File
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())
|