f451406058
Mandatory OpenFUT architecture audit. Two real defects found and fixed, plus
the config surface tightened so neither class can recur.
DEFECT 1 -- hidden localhost fallback. The Rust host defaulted POW hosts to
127.0.0.1 while every other URL followed OPENFUT_ADVERTISE, so a remote
deployment would emit loopback POW URLs and fail far from the cause. It also
diverged from the deployed Python entrypoint, which derives them
(POW_HOST="${POW_HOST:-$ADV:8094}"). POW endpoints now derive from the
advertised address; explicit overrides still win.
DEFECT 2 -- Default gave loopback silently. `Endpoints::default()` and
`AdapterConfig::default()` supplied 127.0.0.1, so anything constructing a
config by omission got loopback with no signal. Both `Default` impls are
REMOVED. Loopback is now `Endpoints::loopback()` / `AdapterConfig::loopback()`:
an explicit, greppable decision. Production uses `advertising(host)`.
CONFIGURABILITY. `blaze_port` and `utas_port` are now config, not literals.
The advertised Blaze port is our choice -- the client goes wherever
<serverinstanceinfo> sends it -- and 8099 is the client's own built-in default
but still deployment config. A bad port value is an error, not a silent
fallback to the previous one.
TEST-NET EVERYWHERE. Committed fixtures and tests used the lab's real LAN
address; a test that passes because its constant matches the current lab
proves nothing about relocatability. Redirector fixtures regenerated on
RFC 5737 TEST-NET-1/2/3 plus loopback. Harness scripts no longer default the
client IP to the lab address -- client-state.sh now requires it.
SEVEN REQUIRED TESTS in tests/deployment_config.rs plus host-side coverage:
remote config never silently becomes localhost; missing advertise fails
clearly; bind may differ from advertise; changing the Blaze port changes the
redirect; changing the host updates all 200+ generated URLs with no
stragglers; no helper bypasses central config; mutations are detectable.
MUTATION TESTED, and it found a hole in the audit tests themselves. Hardcoding
utas_base, reverting the POW derivation and re-hardcoding the Blaze port were
all caught. Making the redirector read `bind` instead of `advertise` was NOT:
`advertising()` sets bind == advertise, so the two sources were
indistinguishable. That is the single most likely bypass -- the oracle really
does read bind for nucleusConnect -- so the test now forces bind != advertise
and asserts the bind address never reaches the wire. Re-mutated: caught.
Wire behaviour unchanged: oracle fixtures still current, 153 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
178 lines
6.5 KiB
Rust
178 lines
6.5 KiB
Rust
//! `Util::fetchClientConfig` tables.
|
|
//!
|
|
//! Between 227 and 243 key/value rows per CFID, overwhelmingly the same RS4
|
|
//! base URL repeated across 212 endpoint keys. The client resolves a per-call
|
|
//! key (`FUT_RS4_URL_<CALL>`) before a per-module one
|
|
//! (`FUT_RS4_APIURL_<MODULE>`), and any call left unresolved falls back to a
|
|
//! real (dead) EA host — which is what produced "there has been an error
|
|
//! connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset
|
|
//! was served. The table has to be complete, not representative.
|
|
//!
|
|
//! # Why this is data and not code
|
|
//!
|
|
//! The rows are reverse-engineered *configuration*, not logic. They live in
|
|
//! `fixtures/client_config.json`, derived mechanically from the Python oracle
|
|
//! and templated on `{advertise}`, `{bind}`, `{pow_content_host}` and
|
|
//! `{pow_host}` so the adapter stays deployable anywhere. Hand-transcribing 400
|
|
//! string literals would add a class of silent typo no reviewer can catch, and
|
|
//! `openfut-core` already loads its content from `data/` for the same reason.
|
|
//!
|
|
//! The generator does not take its own templating on trust: it substitutes real
|
|
//! addresses back in and diffs against the oracle for every section before
|
|
//! writing the file.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::OnceLock;
|
|
|
|
use super::config::AdapterConfig;
|
|
|
|
/// Rows for every known CFID, plus `__default__` for unknown ones.
|
|
const TABLE_JSON: &str = include_str!("../../fixtures/client_config.json");
|
|
|
|
type Table = BTreeMap<String, Vec<(String, String)>>;
|
|
|
|
fn table() -> &'static Table {
|
|
static TABLE: OnceLock<Table> = OnceLock::new();
|
|
TABLE.get_or_init(|| {
|
|
serde_json::from_str(TABLE_JSON).expect("bundled client_config.json is valid")
|
|
})
|
|
}
|
|
|
|
/// Resolve the rows for a CFID, with addresses substituted in.
|
|
///
|
|
/// Unknown CFIDs deliberately still receive the shared FUT/RS4/POW rows: those
|
|
/// consumers read a merged `_all` store and which section contributes is
|
|
/// unproven, so a present-but-shared table is safer than an empty one.
|
|
pub fn rows_for(cfid: &str, cfg: &AdapterConfig) -> Vec<(String, String)> {
|
|
let t = table();
|
|
let rows = t
|
|
.get(cfid)
|
|
.or_else(|| t.get("__default__"))
|
|
.expect("client_config.json always carries a __default__ section");
|
|
|
|
// URL-level tokens resolve through AdapterConfig so those helpers are the
|
|
// single place a URL shape is defined. Host-level tokens cover the values
|
|
// that are not one of the three standard URLs (roster, POW API).
|
|
let utas_base = cfg.utas_base();
|
|
let nucleus_base = cfg.nucleus_base();
|
|
let pow_content_url = cfg.pow_content_url();
|
|
|
|
rows.iter()
|
|
.map(|(k, v)| {
|
|
let v = if v.contains('{') {
|
|
v.replace("{utas_base}", &utas_base)
|
|
.replace("{nucleus_base}", &nucleus_base)
|
|
.replace("{pow_content_url}", &pow_content_url)
|
|
.replace("{advertise}", &cfg.endpoints.advertise)
|
|
.replace("{bind}", &cfg.endpoints.bind)
|
|
.replace("{pow_content_host}", &cfg.endpoints.pow_content_host)
|
|
.replace("{pow_host}", &cfg.endpoints.pow_host)
|
|
} else {
|
|
v.clone()
|
|
};
|
|
debug_assert!(!v.contains('{'), "unsubstituted token left in {k}: {v}");
|
|
(k.clone(), v)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Fingerprint of the bundled config table.
|
|
///
|
|
/// The table is generated data, so "which binary is this?" is only half the
|
|
/// question — "which data does it carry?" is the other half. A running host
|
|
/// logs this at startup so a live FIFA trace can be tied to an exact table, and
|
|
/// a rebuild that silently picked up regenerated fixtures is visible.
|
|
///
|
|
/// FNV-1a, not a security hash and never used as one.
|
|
pub fn table_fingerprint() -> u64 {
|
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
|
for byte in TABLE_JSON.as_bytes() {
|
|
hash ^= *byte as u64;
|
|
hash = hash.wrapping_mul(0x1000_0000_01b3);
|
|
}
|
|
hash
|
|
}
|
|
|
|
/// Every CFID with its own section. Unknown CFIDs are still valid requests.
|
|
pub fn known_sections() -> Vec<&'static str> {
|
|
table()
|
|
.keys()
|
|
.filter(|k| k.as_str() != "__default__")
|
|
.map(String::as_str)
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn cfg() -> AdapterConfig {
|
|
let mut c = AdapterConfig::loopback();
|
|
c.endpoints.advertise = "198.51.100.7".into();
|
|
c.endpoints.bind = "0.0.0.0".into();
|
|
c.endpoints.pow_content_host = "198.51.100.7:8085".into();
|
|
c.endpoints.pow_host = "198.51.100.7:8094".into();
|
|
c
|
|
}
|
|
|
|
#[test]
|
|
fn bundled_table_parses() {
|
|
assert!(table().contains_key("__default__"));
|
|
assert!(table().contains_key("BlazeSDK"));
|
|
assert!(known_sections().len() >= 10);
|
|
}
|
|
|
|
#[test]
|
|
fn default_section_is_the_shared_fut_base() {
|
|
let rows = rows_for("literally-anything", &cfg());
|
|
assert_eq!(rows.len(), 227);
|
|
assert!(rows.iter().any(|(k, _)| k == "FUT_RS4_BASE_URL"));
|
|
}
|
|
|
|
#[test]
|
|
fn addresses_are_substituted_not_baked() {
|
|
let rows = rows_for("BlazeSDK", &cfg());
|
|
let base = rows
|
|
.iter()
|
|
.find(|(k, _)| k == "FUT_RS4_BASE_URL")
|
|
.expect("base url present");
|
|
assert_eq!(base.1, "http://198.51.100.7:8099/");
|
|
assert!(
|
|
!rows.iter().any(|(_, v)| v.contains('{')),
|
|
"a template token survived substitution"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nucleus_follows_bind_reproducing_the_oracle() {
|
|
let rows = rows_for("BlazeSDK", &cfg());
|
|
let n = rows.iter().find(|(k, _)| k == "nucleusConnect").unwrap();
|
|
assert_eq!(n.1, "http://0.0.0.0:42131");
|
|
}
|
|
|
|
#[test]
|
|
fn roster_section_carries_the_roster_urls() {
|
|
let rows = rows_for("OSDK_ROSTER", &cfg());
|
|
let r = rows.iter().find(|(k, _)| k == "ROSTER_URL").unwrap();
|
|
assert_eq!(r.1, "https://198.51.100.7:8081/fifa17/roster/");
|
|
}
|
|
|
|
#[test]
|
|
fn rows_are_sorted_as_the_wire_requires() {
|
|
// The oracle sorts; the TDF map encoder does not, so order is ours to keep.
|
|
let rows = rows_for("BlazeSDK", &cfg());
|
|
let mut sorted = rows.clone();
|
|
sorted.sort();
|
|
assert_eq!(rows, sorted);
|
|
}
|
|
|
|
#[test]
|
|
fn every_known_section_substitutes_cleanly() {
|
|
for cfid in known_sections() {
|
|
for (k, v) in rows_for(cfid, &cfg()) {
|
|
assert!(!v.contains('{'), "{cfid}/{k} kept a token: {v}");
|
|
}
|
|
}
|
|
}
|
|
}
|