2 Commits

Author SHA1 Message Date
funman300 5b8bee286c 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.
2026-08-22 02:02:00 +00:00
funman300 ba19954ffb fix(fifa17): consumable stacks carry their real quick-sell value
A production club displayed "Quick sell for 0 coins" for contract cards that
Core would have paid 3/13/32 for. The consumables stack wrapper hard-coded
discardValue (atom 0xd7) to 0.

The old rationale was that the client prices the card itself, the way it does
when we omit discardValue from an item. That is true of the ITEM record and not
of the STACK, and the evidence separates them cleanly:

  * item+0x38 non-zero makes the client SKIP its local computation and show our
    number -- re-proven on the live production client, 16/16 resident cards
    "SERVER-SHOWN (local calc skipped)", including the acceptance card
    235066 -> 40.
  * We send no discardValue inside a consumable's item, so +0x38 is 0 and the
    local computation DOES run and fills +0x3c correctly -- Milestone 1 measured
    3/3/32/38 there, matching this table.
  * The screen still showed 0. So the screen is not reading the item's computed
    +0x3c; it reads the stack's atom 0xd7, which we were sending as 0.

So the number belongs on the stack, and it is the SAME
discard::value_for_definition that computes the payout -- one source, so the
screen and the wallet cannot disagree. Per CARD, not per stack: FUT prices a
card and the stack is only a quantity badge over identical copies. An
unpriceable definition stays 0 rather than inventing a number.

Verified on staging across every populated family, 16/16 stacks shown ==
recovered, none zero:
  contracts 32/3/13 · healing 32/3 · training 3/13/34 · playstyle 38/38/38
  · position 36/38/38/38/38

Two tests lock it: the payout equality (with the exact 3/13/32 the production
club would have been shortchanged on) and per-card-not-per-stack pricing for a
collapsed count=3 stack.

No payout logic changed, no taxonomy change, no ownership change, no Python.
250 adapter tests, 122 host, clippy -D warnings clean, fmt clean.
2026-08-22 02:00:40 +00:00
2 changed files with 283 additions and 5 deletions
+111 -5
View File
@@ -33,8 +33,40 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::fut::discard;
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats}; use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
/// The stack's `discardValue` (atom 0xd7) — the number the consumables screen
/// DISPLAYS, per card.
///
/// This used to be hard-coded `0`, on the theory that the client would compute
/// the price itself from `fcc_discardcoins` the way it does for a card whose
/// `discardValue` we omit. That theory was wrong, and the screen showed
/// "Quick sell for 0 coins" on a real production club (operator-observed,
/// 2026-08-22) while Core would have paid 3/13/32 for those same contracts.
///
/// Why the old reasoning failed, from evidence rather than re-derivation:
///
/// * `item+0x38` (the `discardValue` we send) non-zero makes the client SKIP its
/// local computation and display our number — live-proven again on the
/// production client, 16/16 resident cards `SERVER-SHOWN`.
/// * We send no `discardValue` inside a consumable's `item`, so `+0x38` is 0 and
/// the client's local computation DOES run, filling `+0x3c` with the right
/// value — Milestone 1 measured exactly that (3/3/32/38, matching this table).
/// * The screen nonetheless showed 0. So the screen is not reading the item's
/// computed `+0x3c`; it reads the STACK's atom 0xd7, which we were sending as
/// 0.
///
/// So the value belongs here, and it is the SAME number
/// [`discard::value_for_definition`] gives the quick-sell payout — one source, so
/// the screen and the wallet cannot disagree. Per CARD, not per stack: FUT
/// prices a card, and the stack is only a quantity badge over identical copies.
///
/// `None` (definition not priceable) stays `0` rather than inventing a number.
fn stack_discard_value(id: &Fifa17ConsumableIdentity) -> i64 {
discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating).unwrap_or(0)
}
/// Build the consumables-screen body from the club's owned consumable copies. /// Build the consumables-screen body from the club's owned consumable copies.
/// ///
/// Copies are collapsed by `resourceId` into one stack each, in first-seen order /// Copies are collapsed by `resourceId` into one stack each, in first-seen order
@@ -43,10 +75,10 @@ use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeSta
/// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a /// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a
/// different item than the club owns is worse than omitting the stack. /// different item than the club owns is worse than omitting the stack.
/// ///
/// `discardValue` is `0`: the client computes a card's own quick-sell price from /// `discardValue` carries the card's real quick-sell price — see
/// `fcc_discardcoins` on `(cardtype 6, level, rare)`, and `0` is the value the /// [`stack_discard_value`]. It used to be `0` on the theory that the client
/// live-proven oracle sends on this route. Inventing a price from the player /// priced the card itself; the production screen showed "Quick sell for 0 coins"
/// quick-sell table would be a fabricated number the client does not need. /// instead, so the stack atom is what the screen reads.
/// ///
/// The stack's `item` is the FIRST copy, so its `id` is a real owned wire id — a /// The stack's `item` is the FIRST copy, so its `id` is a real owned wire id — a
/// later item operation on the stack therefore addresses a card the club really /// later item operation on the stack therefore addresses a card the club really
@@ -78,7 +110,7 @@ pub fn consumables_response(items: &[Fifa17ConsumableIdentity]) -> (Value, Shape
order.push(id.resource_id); order.push(id.resource_id);
stacks.push(json!({ stacks.push(json!({
"count": 1, "count": 1,
"discardValue": 0, "discardValue": stack_discard_value(id),
"item": shape_consumable_item(*id), "item": shape_consumable_item(*id),
"resourceId": id.resource_id, "resourceId": id.resource_id,
"untradeableCount": i64::from(id.untradeable), "untradeableCount": i64::from(id.untradeable),
@@ -109,6 +141,80 @@ mod tests {
} }
} }
/// A contract card of the given subtype/rating — the family the production
/// screen showed as "0 coins".
fn contract(
item_id: u32,
resource_id: u32,
subtype: i64,
rating: u8,
) -> Fifa17ConsumableIdentity {
Fifa17ConsumableIdentity {
item_id,
resource_id,
asset_id: resource_id,
card_asset_id: 7,
subtype,
rareflag: 0,
rating,
amount: None,
contract: Some(1),
untradeable: true,
}
}
/// The stack atom the screen reads MUST carry the same number the quick-sell
/// pays. A production club displayed "Quick sell for 0 coins" for contracts
/// Core would have paid 3/13/32 for; nothing may reintroduce that gap.
#[test]
fn stack_discard_value_is_the_payout_and_never_a_silent_zero() {
// The three contracts owned by the real production club.
let items = vec![
contract(1, 5_001_004, 201, 60),
contract(2, 5_001_008, 202, 65),
contract(3, 5_001_009, 202, 80),
];
let (body, _) = consumables_response(&items);
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 3);
for (stack, id) in stacks.iter().zip(items.iter()) {
let shown = stack["discardValue"].as_i64().unwrap();
let paid =
discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating)
.expect("a contract definition is priceable");
assert_eq!(
shown, paid,
"displayed must equal payout for {}",
id.resource_id
);
assert!(
shown > 0,
"{} priced at 0 is the bug we just fixed",
id.resource_id
);
}
// The exact recovered values, so a table regression is visible here too.
assert_eq!(stacks[0]["discardValue"], 3);
assert_eq!(stacks[1]["discardValue"], 13);
assert_eq!(stacks[2]["discardValue"], 32);
}
/// Collapsing copies must not multiply the price: FUT prices a CARD, and the
/// stack is a quantity badge over identical copies.
#[test]
fn stack_discard_value_is_per_card_not_per_stack() {
let items = vec![
contract(1, 5_001_009, 202, 80),
contract(2, 5_001_009, 202, 80),
contract(3, 5_001_009, 202, 80),
];
let (body, _) = consumables_response(&items);
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 1);
assert_eq!(stacks[0]["count"], 3);
assert_eq!(stacks[0]["discardValue"], 32, "per card, not 3 x 32");
}
#[test] #[test]
fn identical_copies_collapse_into_one_counted_stack() { fn identical_copies_collapse_into_one_counted_stack() {
// Two copies of 5003103 plus one of 5003112 → two stacks, counts 2 and 1. // Two copies of 5003103 plus one of 5003112 → two stacks, counts 2 and 1.
+172
View File
@@ -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())