#!/usr/bin/env python3 """Switch the FIFA client's Blaze ports between PRODUCTION and STAGING, reversibly. The staging sold experiment needs the client to talk to the staging Blaze, which is what advertises the staging UTAS base. Two files are involved and the distinction matters, because getting it wrong silently runs the whole experiment against production: * `openfut.cfg` in the game directory is what the injected hook reads at connect time -- but it is a DERIVED artifact. * `~/.config/openfut-launcher/config.json` is the AUTHORITATIVE source. The launcher reconciles openfut.cfg from it, fail-closed, immediately before every launch. An edit to openfut.cfg alone is therefore destroyed by the next launch: observed live, with openfut.cfg's mtime 2s before the FIFA process start and the hook logging `blaze_redir=42127 blaze_main=42130` after it had been set to staging. So this script owns BOTH, source first. Safety properties, in order of importance: * Production values are recorded to a sidecar next to each file BEFORE the first edit, and `restore` reads those sidecars rather than assuming what production was. Missing sidecar means restore refuses. * `restore` is idempotent and safe at any time, including after a crash. * Every operation re-reads both files afterwards and prints them, so the result is verified rather than assumed. * It refuses to edit while FIFA is running (the hook reads openfut.cfg at connect time) AND while the launcher is running (the launcher holds its config in memory and would write the stale values straight back over ours). * Only known keys are rewritten. Unknown lines and unrelated JSON keys are passed through untouched, and a missing key is an error rather than a silent append. python3 scripts/sold-client-ports.py show python3 scripts/sold-client-ports.py staging # 42327 / 42330 python3 scripts/sold-client-ports.py restore # back to the recorded values """ import json import subprocess import sys CLIENT = "alex@10.10.0.105" CFG = "/mnt/games/FIFA 17/openfut.cfg" SIDECAR = "/mnt/games/FIFA 17/openfut.cfg.openfut-prod-ports" KEYS = ("blaze_redirector_port", "blaze_main_port") STAGING = {"blaze_redirector_port": "42327", "blaze_main_port": "42330"} # The launcher is the real owner; openfut.cfg is regenerated from these. LAUNCHER_CFG = "/home/alex/.config/openfut-launcher/config.json" LAUNCHER_SIDECAR = "/home/alex/.config/openfut-launcher/config.json.openfut-prod-ports" LAUNCHER_KEY = {"blaze_redirector_port": "openfut_blaze_redirector_port", "blaze_main_port": "openfut_blaze_main_port"} def ssh(script): r = subprocess.run(["ssh", "-o", "BatchMode=yes", CLIENT, script], capture_output=True, text=True, timeout=60) if r.returncode != 0: raise SystemExit(f"ssh failed ({r.returncode}): {r.stderr.strip()}") return r.stdout def read_cfg(): return ssh(f'cat "{CFG}"') def parse(text): out = {} for line in text.splitlines(): if "=" in line and not line.strip().startswith("#"): k, _, v = line.partition("=") out[k.strip()] = v.strip() return out def fifa_running(): """True if a real FIFA 17 process exists on the client. Matches /proc//comm exactly rather than `pgrep -f FIFA17.exe`: the pattern form self-matched the remote shell running it (the SSH command line contains the literal string), so the guard was permanently stuck ON and could never report "not running". comm is the executable name, so the shell reads as zsh/bash and only a genuine FIFA process matches. Still fail-closed: any read error or unexpected output is treated as "running". """ out = ssh( "for d in /proc/[0-9]*; do " "[ -r \"$d/comm\" ] && [ \"$(cat $d/comm 2>/dev/null)\" = FIFA17.exe ] " "&& echo ${d#/proc/}; done || true" ).strip() return bool(out) def launcher_running(): """True if openfut-launcher is up. Its `comm` is truncated by the kernel to 15 chars ("openfut-launche"), which has bitten this project before, so match the truncated form rather than the full name.""" out = ssh( "for d in /proc/[0-9]*; do c=$(cat $d/comm 2>/dev/null); " "case \"$c\" in openfut-launche*) echo ${d#/proc/};; esac; done || true" ).strip() return bool(out) def read_launcher(): return ssh(f'cat "{LAUNCHER_CFG}"') def launcher_ports(text): d = json.loads(text) return {k: str(d.get(LAUNCHER_KEY[k])) for k in KEYS} def write_launcher(values): """Rewrite only the two port keys, preserving every other setting and the file's formatting conventions, then verify by re-reading.""" text = read_launcher() d = json.loads(text) for k in KEYS: if LAUNCHER_KEY[k] not in d: raise SystemExit( f"key {LAUNCHER_KEY[k]!r} absent from {LAUNCHER_CFG}; refusing to guess") existing = ssh(f'cat "{LAUNCHER_SIDECAR}" 2>/dev/null || true').strip() if not existing: rec = "\n".join(f"{k}={d[LAUNCHER_KEY[k]]}" for k in KEYS) ssh(f'cat > "{LAUNCHER_SIDECAR}" <<\'EOF\'\n{rec}\nEOF') print(f"recorded launcher production ports to {LAUNCHER_SIDECAR}:\n{rec}") for k, v in values.items(): d[LAUNCHER_KEY[k]] = int(v) body = json.dumps(d, indent=2, sort_keys=True) ssh(f'cat > "{LAUNCHER_CFG}" <<\'EOF\'\n{body}\nEOF') after = launcher_ports(read_launcher()) for k, v in values.items(): if after.get(k) != str(v): raise SystemExit( f"VERIFY FAILED in launcher config: {k} is {after.get(k)!r}, want {v!r}") print(f"--- launcher config now: {after}") def show(): text = read_cfg() print(f"--- {CFG}") print(text.rstrip()) cur = parse(text) print("--- blaze ports:", {k: cur.get(k) for k in KEYS}) side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip() print("--- recorded production ports:", side or "(none recorded yet)") print(f"--- {LAUNCHER_CFG} (authoritative)") print("--- launcher blaze ports:", launcher_ports(read_launcher())) lside = ssh(f'cat "{LAUNCHER_SIDECAR}" 2>/dev/null || true').strip() print("--- recorded launcher production ports:", lside or "(none recorded yet)") print("--- FIFA client running:", "YES" if fifa_running() else "no") print("--- launcher running:", "YES" if launcher_running() else "no") return cur def write_ports(values, label): if fifa_running(): raise SystemExit( "REFUSING to edit while a FIFA client is running.\n" "The hook reads openfut.cfg at connect time; exit FIFA first." ) if launcher_running(): raise SystemExit( "REFUSING to edit while openfut-launcher is running.\n" "The launcher holds its config in memory and reconciles openfut.cfg from\n" "it fail-closed before every launch, so it would write the production\n" "ports straight back over ours. Quit the launcher first." ) # Source of truth first, then the derived file. write_launcher(values) text = read_cfg() cur = parse(text) for k in KEYS: if k not in cur: raise SystemExit(f"key {k!r} is absent from {CFG}; refusing to guess it") # Record production values once, before the first mutation, so restore never # has to assume what they were. existing_sidecar = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip() if not existing_sidecar and label == "staging": rec = "\n".join(f"{k}={cur[k]}" for k in KEYS) ssh(f'cat > "{SIDECAR}" <<\'EOF\'\n{rec}\nEOF') print(f"recorded production ports to {SIDECAR}:\n{rec}") out = [] for line in text.splitlines(): k = line.partition("=")[0].strip() out.append(f"{k}={values[k]}" if k in values else line) body = "\n".join(out) + "\n" ssh(f'cat > "{CFG}" <<\'EOF\'\n{body.rstrip()}\nEOF') after = parse(read_cfg()) for k, v in values.items(): if after.get(k) != v: raise SystemExit(f"VERIFY FAILED: {k} is {after.get(k)!r}, expected {v!r}") print(f"--- now pointing at {label}") print(read_cfg().rstrip()) def main(): if len(sys.argv) != 2 or sys.argv[1] not in ("show", "staging", "restore"): print(__doc__) return 2 cmd = sys.argv[1] if cmd == "show": show() return 0 if cmd == "staging": write_ports(STAGING, "staging") return 0 side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip() if not side: raise SystemExit( f"no recorded production ports at {SIDECAR}; refusing to guess.\n" "Set them by hand and verify with `show`." ) write_ports(parse(side), "production (restored)") return 0 if __name__ == "__main__": sys.exit(main())