Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b8bee286c | |||
| ba19954ffb |
@@ -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.
|
||||||
|
|||||||
Executable
+172
@@ -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())
|
||||||
Reference in New Issue
Block a user