diff --git a/openfut-blaze-host/blaze-switch.sh b/openfut-blaze-host/blaze-switch.sh index 0bbfafe..2296820 100755 --- a/openfut-blaze-host/blaze-switch.sh +++ b/openfut-blaze-host/blaze-switch.sh @@ -1,167 +1,73 @@ #!/usr/bin/env bash -# Switch the Blaze hop between the Python backend and the Rust sidecar, -# WITHOUT modifying the Python backend. +# Blaze switch — COMPATIBILITY WRAPPER over openfut-switch.sh. # # blaze-switch.sh status -# blaze-switch.sh on Blaze -> Rust +# blaze-switch.sh on Blaze -> Rust sidecar # blaze-switch.sh off Blaze -> Python (rollback) # -# WHY NAT RATHER THAN RECONFIGURING THE REDIRECTOR +# The CLI and output are unchanged from the version used for gates 5–10, so the +# validated Blaze runbook and `sidecar.sh`'s cross-check keep working exactly as +# before. All iptables logic now lives in `openfut-switch.sh`: one +# implementation, because two scripts editing the same table diverge and then +# disagree about what is installed. # -# The Python redirector advertises a hardcoded `BLAZE_PORT = 42130` -# (blaze_responder_v3b.py:173), so pointing the client at another port would -# mean editing the frozen oracle and rebuilding the container. A scoped NAT rule -# changes nothing in Python, applies instantly, and rolls back with one command -# — exactly the property the A/B needs. -# -# SCOPE, deliberately narrow -# -# Rules match ONLY traffic to :42130: -# * PREROUTING — the FIFA client on the game machine (the real path) -# * OUTPUT — this host's own connections, so the switch can be smoke -# tested locally before FIFA is involved -# -# Traffic to 127.0.0.1:42130 is deliberately NOT matched, so Python stays -# directly reachable on loopback while the switch is on. That is what lets -# check-live-parity.sh compare real Python against real Rust rather than -# accidentally comparing Rust against itself. -# -# MATCHING RULES BY THE BARE TAG, ON PURPOSE -# -# An earlier version matched `--comment "tag"` with quotes. This iptables emits -# the comment unquoted, so removal silently found nothing — and because the -# post-removal verification used the SAME matcher, it confirmed its own failure -# and reported a successful rollback that had not happened. A rollback that lies -# is worse than one that fails. -# -# Two lessons are baked in below: match the bare tag string so no output-format -# assumption can be wrong, and verify with a predicate that does not share the -# removal's failure mode. +# The only Blaze-specific knowledge left here is the intercepted port, 42130, +# which the generic tool never assumes. set -uo pipefail -TAG="openfut-blaze-switch" -PY_PORT=42130 -SUDO="" -[[ $EUID -eq 0 ]] || SUDO=sudo +HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +GENERIC="$HERE/openfut-switch.sh" +BLAZE_PORT=42130 +NAME=blaze +# Tag used before the switch was generalised. Rules installed by the gate 5-10 +# tooling still carry it, so they must remain removable — a rename that orphans +# live NAT rules is worse than no rename at all. +LEGACY_TAG=openfut-blaze-switch -die() { echo "blaze-switch: $*" >&2; exit 1; } - -# Full rule specs carrying our tag, as `-A CHAIN ...` lines. -tagged_specs() { - $SUDO iptables -t nat -S 2>/dev/null | grep -F -- "$TAG" || true -} - -# Independent verifier: a different command and a different output format from -# the one used to build delete commands, so a parsing bug cannot hide itself. -count_tagged() { - $SUDO iptables-save -t nat 2>/dev/null | grep -cF -- "$TAG" || true -} - -# Behavioural check: is anything still redirecting our port? -redirects_to() { - $SUDO iptables -t nat -S 2>/dev/null \ - | grep -E -- "--dport ${PY_PORT}\b" \ - | grep -F -- "REDIRECT" || true -} - -cmd_status() { - local specs count - specs="$(tagged_specs)" - count="$(count_tagged)" - - if [[ -z "$specs" && "$count" == "0" ]]; then - echo "Blaze is served by PYTHON (no switch rules)" - else - echo "Blaze is redirected to the RUST sidecar:" - [[ -n "$specs" ]] && echo "$specs" | sed 's/^/ /' - fi - - # Disagreement between the two views means one of them is parsing wrongly — - # report it rather than trusting either. - local n_specs - n_specs="$(printf '%s' "$specs" | grep -c . || true)" - if [[ "$n_specs" != "$count" ]]; then - echo " WARNING: rule views disagree (specs=$n_specs, save=$count)" >&2 - fi - - local other - other="$(redirects_to | grep -vF -- "$TAG" || true)" - if [[ -n "$other" ]]; then - echo " note: other REDIRECT rules also touch port $PY_PORT:" >&2 - echo "$other" | sed 's/^/ /' >&2 - fi - return 0 -} - -remove_rules() { - local removed=0 spec - while IFS= read -r spec; do - [[ -n "$spec" ]] || continue - # `-A CHAIN args…` -> `-D CHAIN args…` - # shellcheck disable=SC2086 - if $SUDO iptables -t nat -D ${spec#-A } 2>/dev/null; then - removed=$((removed + 1)) - else - echo "blaze-switch: failed to delete: $spec" >&2 - fi - done < <(tagged_specs) - echo "$removed" -} - -cmd_on() { - local ip="${1:-}" port="${2:-}" - [[ -n "$ip" && -n "$port" ]] || die "usage: blaze-switch.sh on " - - # Never stack rules: start from a known state. - remove_rules >/dev/null - - $SUDO iptables -t nat -I PREROUTING 1 -p tcp -d "$ip" --dport "$PY_PORT" \ - -m comment --comment "$TAG" -j REDIRECT --to-ports "$port" \ - || die "failed to add PREROUTING rule" - $SUDO iptables -t nat -I OUTPUT 1 -p tcp -d "$ip" --dport "$PY_PORT" \ - -m comment --comment "$TAG" -j REDIRECT --to-ports "$port" \ - || die "failed to add OUTPUT rule" - - local count - count="$(count_tagged)" - [[ "$count" == "2" ]] || die "expected 2 rules after 'on', found $count" - - echo "Blaze -> RUST: $ip:$PY_PORT now lands on local port $port" - echo " 127.0.0.1:$PY_PORT still reaches PYTHON (unmatched by design)" - echo " roll back with: $0 off" - echo - echo " NOTE: while this is on, the sidecar MUST stay up. Stopping it without" - echo " switching off leaves Blaze pointing at a dead port." -} - -cmd_off() { - local before removed after - before="$(count_tagged)" - removed="$(remove_rules)" - after="$(count_tagged)" - - if [[ "$after" != "0" ]]; then - echo "FAILED: $after switch rule(s) still present after removing $removed" >&2 - tagged_specs | sed 's/^/ /' >&2 - return 1 - fi - # Independent of the tag entirely: nothing should still be redirecting the - # Blaze port. Catches a rule that lost its comment somehow. - local stray - stray="$(redirects_to)" - if [[ -n "$stray" ]]; then - echo "FAILED: a REDIRECT rule still targets port $PY_PORT:" >&2 - echo "$stray" | sed 's/^/ /' >&2 - return 1 - fi - - echo "Blaze -> PYTHON: removed $removed rule(s) (was $before), verified none remain" - return 0 -} +[[ -x "$GENERIC" ]] || { echo "blaze-switch: missing $GENERIC" >&2; exit 2; } case "${1:-}" in - status) shift; cmd_status "$@" ;; - on) shift; cmd_on "$@" ;; - off) shift; cmd_off "$@" ;; - *) sed -n '2,10p' "$0" | sed 's/^# \?//'; exit 2 ;; + status) + # Translate the generic report into the wording the Blaze runbook and + # sidecar.sh already match on ("redirected to the RUST" / "served by + # PYTHON"). Kept verbatim so the proven tooling does not change. + out="$("$GENERIC" status --name "$NAME" --legacy-tag "$LEGACY_TAG" --intercept-port "$BLAZE_PORT" 2>&1)" + rc=$? + if grep -q '^INACTIVE' <<<"$out"; then + echo "Blaze is served by PYTHON (no switch rules)" + else + echo "Blaze is redirected to the RUST sidecar:" + grep -E '^\s+(blaze|openfut-switch)' <<<"$out" | sed 's/^/ /' + grep -E '^\s+!!|\?\?' <<<"$out" >&2 || true + fi + exit $rc + ;; + on) + shift + ip="${1:-}"; port="${2:-}" + [[ -n "$ip" && -n "$port" ]] || { + echo "usage: blaze-switch.sh on " >&2; exit 2; } + "$GENERIC" on --name "$NAME" --legacy-tag "$LEGACY_TAG" --server-ip "$ip" \ + --intercept-port "$BLAZE_PORT" --target-port "$port" >/dev/null || exit 1 + echo "Blaze -> RUST: $ip:$BLAZE_PORT now lands on local port $port" + echo " 127.0.0.1:$BLAZE_PORT still reaches PYTHON (unmatched by design)" + echo " roll back with: $0 off" + echo + echo " NOTE: while this is on, the sidecar MUST stay up. Stopping it without" + echo " switching off leaves Blaze pointing at a dead port." + ;; + off) + out="$("$GENERIC" off --name "$NAME" --legacy-tag "$LEGACY_TAG" --intercept-port "$BLAZE_PORT" 2>&1)" + rc=$? + if [[ $rc -ne 0 ]]; then + echo "$out" >&2 + exit $rc + fi + n="$(sed -nE 's/.*removed ([0-9]+) rule.*/\1/p' <<<"$out")" + echo "Blaze -> PYTHON: removed ${n:-0} rule(s), verified none remain" + ;; + *) + sed -n '2,8p' "$0" | sed 's/^# \?//' + exit 2 + ;; esac diff --git a/openfut-blaze-host/openfut-switch.sh b/openfut-blaze-host/openfut-switch.sh new file mode 100755 index 0000000..caac7d2 --- /dev/null +++ b/openfut-blaze-host/openfut-switch.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env bash +# Generic client-side service interception for OpenFUT. +# +# openfut-switch.sh on --server-ip --intercept-port

--target-port \ +# --name [--client-ip ] +# openfut-switch.sh off --name +# openfut-switch.sh status [--name ] +# +# ONE implementation. `blaze-switch.sh` is a thin compatibility wrapper over +# this; there is deliberately no second copy of the iptables logic, because two +# scripts manipulating the same table diverge and then disagree about what is +# installed. +# +# WHAT IT DOES +# +# Redirects traffic destined for : to a local +# , so a replacement or observer can sit in front of a service +# without touching that service. Loopback is never matched: the rule is scoped +# to the server address, so 127.0.0.1: keeps reaching the +# original process and stays usable as an oracle. +# +# TWO INDEPENDENT VIEWS, ON PURPOSE +# +# Rules are CREATED and DELETED by their comment tag. They are VERIFIED by +# parsing the kernel's own rule fields — chain, destination, dport, to-ports — +# with no reference to the comment. An earlier version of the Blaze switch +# matched `--comment "tag"` with quotes this iptables does not emit, so removal +# found nothing AND the verification used the same broken matcher, confirming a +# rollback that had not happened. A verifier must not share the failure mode of +# the thing it verifies. +set -uo pipefail + +TAG_PREFIX="openfut-switch" +SUDO="" +[[ $EUID -eq 0 ]] || SUDO=sudo + +die() { echo "openfut-switch: $*" >&2; exit 2; } + +# ------------------------------------------------------------------ parsing +# +# Structured view of the nat table. Parses FIELDS, not comment text — this is +# the independent verifier referred to above. +rules_json() { + $SUDO iptables-save -t nat 2>/dev/null | python3 -c ' +import json, re, sys +out = [] +for line in sys.stdin: + line = line.strip() + if not line.startswith("-A "): + continue + parts = line.split() + def val(flag): + try: + return parts[parts.index(flag) + 1] + except (ValueError, IndexError): + return None + # Comment may be quoted or bare depending on iptables version; correctness + # never depends on which, because every check below can use the fields. + m = re.search(r"--comment\s+(\"([^\"]*)\"|(\S+))", line) + comment = (m.group(2) or m.group(3)) if m else None + out.append({ + "chain": parts[1], + "dest": (val("-d") or "").split("/")[0], + "src": (val("-s") or "").split("/")[0], + "dport": val("--dport"), + "target": val("-j"), + "to_ports": val("--to-ports"), + "comment": comment, + "spec": line, + }) +print(json.dumps(out)) +' +} + +# Rules bearing a given switch name. +rules_named() { + rules_json | python3 -c ' +import json, sys +name = sys.argv[1] +print(json.dumps([r for r in json.load(sys.stdin) if r["comment"] == name])) +' "$1" +} + +# Any REDIRECT touching a port, whoever owns it. Used to spot conflicts and +# stale rules that lost or never had our tag. +redirects_on_port() { + rules_json | python3 -c ' +import json, sys +port = sys.argv[1] +print(json.dumps([r for r in json.load(sys.stdin) + if r["target"] == "REDIRECT" and r["dport"] == port])) +' "$1" +} + +# ------------------------------------------------------------------- args +CMD="${1:-}"; shift || true +NAME=""; SERVER=""; CLIENT=""; IPORT=""; TPORT=""; LEGACY="" +while [[ $# -gt 0 ]]; do + case "$1" in + --name) NAME="${2:-}"; shift 2 ;; + --server-ip) SERVER="${2:-}"; shift 2 ;; + --client-ip) CLIENT="${2:-}"; shift 2 ;; + --intercept-port) IPORT="${2:-}"; shift 2 ;; + --target-port) TPORT="${2:-}"; shift 2 ;; + # A tag this switch previously used. Rules carrying it are OURS and must + # still be removable, otherwise renaming a switch orphans live NAT rules + # that no tool can clean up while `off` cheerfully reports success. + --legacy-tag) LEGACY="${2:-}"; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done + +tag_for() { echo "${TAG_PREFIX}:$1"; } + +is_port() { [[ "$1" =~ ^[0-9]+$ ]] && (( $1 > 0 && $1 < 65536 )); } + +# ------------------------------------------------------------------ status +cmd_status() { + local tag all + if [[ -n "$NAME" ]]; then + tag="$(tag_for "$NAME")" + all="$(rules_json | python3 -c ' +import json,sys +tags=[t for t in sys.argv[1:] if t] +print(json.dumps([r for r in json.load(sys.stdin) if r["comment"] in tags])) +' "$tag" "$LEGACY")" + else + tag="" + all="$(rules_json | python3 -c ' +import json,sys +print(json.dumps([r for r in json.load(sys.stdin) + if (r["comment"] or "").startswith("'"$TAG_PREFIX"':')]))')" + fi + + python3 - "$all" "$tag" <<'PY' +import json, sys +rules = json.loads(sys.argv[1]) +tag = sys.argv[2] + +if not rules: + print("INACTIVE: no switch rules%s" % (f" named {tag}" if tag else "")) + raise SystemExit(0) + +# Group by the SEMANTIC identity of the redirect, not by comment text. +groups = {} +for r in rules: + key = (r["dest"], r["dport"], r["to_ports"], r["comment"]) + groups.setdefault(key, []).append(r) + +print("ACTIVE:") +problems = [] +for (dest, dport, to, comment), rs in sorted(groups.items()): + chains = ",".join(sorted(r["chain"] for r in rs)) + print(f" {comment}: {dest}:{dport} -> :{to} [{chains}]") + # A healthy switch installs exactly one PREROUTING and one OUTPUT rule. + per_chain = {} + for r in rs: + per_chain[r["chain"]] = per_chain.get(r["chain"], 0) + 1 + for chain, n in per_chain.items(): + if n > 1: + problems.append(f"DUPLICATE: {n} identical rules in {chain} for {comment}") + for want in ("PREROUTING", "OUTPUT"): + if want not in per_chain: + problems.append(f"INCOMPLETE: {comment} has no {want} rule") + +# Several different targets for one name is inconsistent state. +by_name = {} +for (dest, dport, to, comment), _ in groups.items(): + by_name.setdefault(comment, set()).add((dest, dport, to)) +for comment, variants in by_name.items(): + if len(variants) > 1: + problems.append(f"CONFLICT: {comment} has {len(variants)} different redirects: {sorted(variants)}") + +if problems: + print() + for p in problems: + print(f" !! {p}") + raise SystemExit(1) +PY + local rc=$? + + # Foreign or untagged redirects on the same port are reported, never touched. + if [[ -n "$IPORT" ]]; then + local foreign + foreign="$(redirects_on_port "$IPORT" | python3 -c ' +import json,sys +tag=sys.argv[1] +tags=set(sys.argv[1:]) +o=[r for r in json.load(sys.stdin) if r["comment"] not in tags] +print("\n".join(" ?? untagged/foreign: %s" % r["spec"] for r in o))' "$(tag_for "$NAME")" "$LEGACY")" + [[ -n "$foreign" ]] && { echo "$foreign" >&2; rc=1; } + fi + return $rc +} + +# ---------------------------------------------------------------------- on +cmd_on() { + [[ -n "$NAME" ]] || die "--name is required" + [[ -n "$SERVER" ]] || die "--server-ip is required (the backend the client dials)" + is_port "${IPORT:-}" || die "--intercept-port must be a port" + is_port "${TPORT:-}" || die "--target-port must be a port" + [[ "$IPORT" != "$TPORT" ]] || die "--intercept-port and --target-port must differ" + + local tag; tag="$(tag_for "$NAME")" + + # Never stack: start from a known state for THIS name only. + cmd_off_quiet "$tag" + [[ -n "$LEGACY" ]] && cmd_off_quiet "$LEGACY" + + local scope=() + [[ -n "$CLIENT" ]] && scope=(-s "$CLIENT") + + $SUDO iptables -t nat -I PREROUTING 1 -p tcp "${scope[@]}" -d "$SERVER" --dport "$IPORT" \ + -m comment --comment "$tag" -j REDIRECT --to-ports "$TPORT" \ + || die "failed to add PREROUTING rule" + # OUTPUT covers this host's own connections so the switch can be smoke tested + # locally. Loopback is still unmatched: it is scoped to the server address. + $SUDO iptables -t nat -I OUTPUT 1 -p tcp -d "$SERVER" --dport "$IPORT" \ + -m comment --comment "$tag" -j REDIRECT --to-ports "$TPORT" \ + || die "failed to add OUTPUT rule" + + # Verify from the kernel's fields, not from what we think we just ran. + local ok + ok="$(rules_named "$tag" | python3 -c ' +import json,sys +rs=json.load(sys.stdin); dest,dport,to=sys.argv[1:4] +good=[r for r in rs if r["dest"]==dest and r["dport"]==dport and r["to_ports"]==to + and r["target"]=="REDIRECT"] +chains={r["chain"] for r in good} +print("yes" if len(good)==2 and chains=={"PREROUTING","OUTPUT"} else "no:%d:%s"%(len(good),sorted(chains))) +' "$SERVER" "$IPORT" "$TPORT")" + [[ "$ok" == "yes" ]] || die "rule verification failed after install ($ok)" + + echo "$NAME ON: $SERVER:$IPORT -> local :$TPORT" + echo " 127.0.0.1:$IPORT still reaches the original service (scoped to $SERVER)" + echo " roll back with: $0 off --name $NAME" +} + +# --------------------------------------------------------------------- off +# +# Removes ONLY rules bearing this switch's exact tag. Anything else that +# redirects the same port is reported, never deleted — precise removal, not +# broad deletion. +cmd_off_quiet() { + local tag="$1" + # Delete by RECONSTRUCTED FIELDS, never by re-feeding the raw `iptables-save` + # line through the shell. + # + # `iptables-save` prints `--comment "tag"` WITH quotes on this version. Word- + # splitting that back into an argv leaves the quote characters inside the + # comment value, so iptables looks for a rule whose comment literally contains + # `"` and finds nothing — a silent no-op delete, and the same + # comment-formatting trap that produced the original lying rollback. Rules + # with a bare comment deleted fine, which is exactly what made it look like it + # worked. + # + # Fields are passed as argv elements, so no quoting survives to be + # misinterpreted. + # Unit Separator, NOT tab. Tab is an IFS *whitespace* character, so bash + # collapses runs of it and drops empties — an absent `-s` therefore shifted + # every later field left, producing `-s --dport + # --to-ports ''`. Those deletes failed harmlessly here, but a shifted spec + # that happened to match a real rule would delete the wrong one. + local chain dest src dport to + while IFS=$'\x1f' read -r chain dest src dport to; do + [[ -n "$chain" ]] || continue + local args=(-t nat -D "$chain" -p tcp) + [[ -n "$src" ]] && args+=(-s "$src") + [[ -n "$dest" ]] && args+=(-d "$dest") + args+=(--dport "$dport" -m comment --comment "$tag" -j REDIRECT --to-ports "$to") + $SUDO iptables "${args[@]}" 2>/dev/null + done < <(rules_named "$tag" | python3 -c ' +import json,sys +for r in json.load(sys.stdin): + print("\x1f".join([r["chain"], r["dest"] or "", r["src"] or "", + r["dport"] or "", r["to_ports"] or ""]))') +} + +cmd_off() { + [[ -n "$NAME" ]] || die "--name is required" + local tag; tag="$(tag_for "$NAME")" + + local before legacy_before=0 + before="$(rules_named "$tag" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')" + if [[ -n "$LEGACY" ]]; then + legacy_before="$(rules_named "$LEGACY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')" + fi + + cmd_off_quiet "$tag" + [[ -n "$LEGACY" ]] && cmd_off_quiet "$LEGACY" + before=$(( before + legacy_before )) + + # Independent verification: re-read the table and check the FIELDS. + local after + after="$(rules_named "$tag" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')" + if [[ -n "$LEGACY" ]]; then + after=$(( after + $(rules_named "$LEGACY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))') )) + fi + if [[ "$after" != "0" ]]; then + echo "FAILED: $after rule(s) named $tag still present after removing $before" >&2 + rules_named "$tag" | python3 -c 'import json,sys +for r in json.load(sys.stdin): print(" "+r["spec"])' >&2 + return 1 + fi + + # A redirect on that port owned by someone else is a conflict to report, not + # something this switch may delete. + if [[ -n "$IPORT" ]]; then + local others + others="$(redirects_on_port "$IPORT" | python3 -c 'import json,sys +rs=json.load(sys.stdin) +print("\n".join(" "+r["spec"] for r in rs))')" + if [[ -n "$others" ]]; then + echo "WARNING: other REDIRECT rule(s) still target port $IPORT (not ours, not removed):" >&2 + echo "$others" >&2 + return 1 + fi + fi + + echo "$NAME OFF: removed $before rule(s), verified none remain" +} + +case "$CMD" in + on) cmd_on ;; + off) cmd_off ;; + status) cmd_status ;; + *) sed -n '2,12p' "$0" | sed 's/^# \?//'; exit 2 ;; +esac