Files
OpenFUT/scripts/sold-client-ports.py
T
funman300 fbe29da05b fix(scripts): sold-client-ports guard self-matched its own shell, wedging it ON
`pgrep -f FIFA17.exe` matched the remote shell executing it -- the SSH command line
contains the literal pattern -- so fifa_running() always returned True and the port
switcher could never edit openfut.cfg. It refused with "REFUSING to edit ... while a
FIFA client is running" moments after FIFA had actually exited.

Fail-closed, so nothing unsafe happened, but the guard was permanently stuck and
blocked the A/B entirely.

Now matches /proc/<pid>/comm exactly, which is the executable name: the invoking
shell reads as zsh and cannot self-match, while a genuine FIFA process still does.
Validated both directions with the same loop -- it found pid 36958 while FIFA was up,
and reports gone once it exited. Still fail-closed on read errors.

The lesson generalises: a pattern-matching process guard checked over a transport
that carries the pattern in its own argv is self-satisfying, and a guard that can
only ever say "yes" is not a guard.
2026-08-18 02:33:53 +00:00

142 lines
5.2 KiB
Python
Executable File

#!/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
advertises the staging UTAS base). The ONLY client change required is the two Blaze
port lines in `openfut.cfg`; `host` and `https_port` are left alone.
Safety properties, in order of importance:
* The production values are recorded to a sidecar file on the client BEFORE the
first edit, and `restore` reads that sidecar rather than assuming what
production was. If the sidecar is missing, restore refuses.
* `restore` is idempotent and safe to run at any time, including after a crash.
* Every operation re-reads the file afterwards and prints it, so the result is
verified rather than assumed.
* It refuses to edit while a FIFA client is running: the hook reads this file at
connect time, so changing it under a live session is unsafe.
* Only the two known keys are rewritten. Unknown lines 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 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"}
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/<pid>/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 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("--- FIFA client running:", "YES" if fifa_running() else "no")
return cur
def write_ports(values, label):
if fifa_running():
raise SystemExit(
"REFUSING to edit openfut.cfg while a FIFA client is running.\n"
"The hook reads this file at connect time; exit FIFA first."
)
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())