Files
OpenFUT/openfut-adapter-fifa17/src/blaze/config.rs
T
funman300 f451406058 audit: eliminate deployment-address hardcoding; single typed endpoint config
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>
2026-08-11 02:55:50 +00:00

231 lines
8.6 KiB
Rust

//! Adapter configuration: identity and endpoints.
//!
//! Everything deployment-dependent lives here, injected by the caller. No
//! address, port or persona is baked into the response builders — the
//! client/server split exists precisely because the Python responders used to
//! assume loopback, and rebuilding that assumption in Rust would undo it.
//!
//! Note the deliberate asymmetry between *bind* and *advertise*: an advertised
//! URL must carry the address the CLIENT can reach, which on a two-machine
//! deployment is not the address the server binds.
/// The forged account the whole stack agrees on.
///
/// Identity has to be byte-identical across LSX, Blaze, POW and UTAS or the
/// client rejects the session, so this is one struct passed everywhere rather
/// than constants per responder.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub persona_id: i64,
pub persona_name: String,
/// blazeId / userId. Must be non-zero or login is refused.
pub user_id: i64,
/// XREF externalId.
pub ext_id: i64,
pub email: String,
/// Must equal `PreAuthResponse.NASP`.
pub namespace: String,
/// `Blaze::ClientPlatformType`; 4 = pc.
pub client_platform: i64,
/// `PersonaStatus::Code`; 2 = ACTIVE.
pub persona_status: i64,
/// `Blaze::UserSessionType`; 0 = normal user.
pub user_session_type: i64,
/// Fallback locale as a packed four-char int (`'enUS'`). Overwritten per
/// session by the client's own preAuth `LANG`/`LOC`.
pub account_locale: i64,
/// `AccountInfo.LN`, e.g. `"en_US"`.
pub locale: String,
/// EA offer id.
pub content_id: String,
pub entitlement_tag: String,
/// Must contain `"FIFA17PCBoxContent"` or `"FIFA16PC"` or FUT drops the
/// entitlement and the store comes up empty.
pub entitlement_group: String,
pub title_id: String,
pub client_id: String,
pub platform: String,
}
impl Default for Identity {
/// The project's fixed synthetic offline identity.
///
/// A default, not a constant: the launcher can select a different persona,
/// and FUT saves are isolated per persona id.
fn default() -> Identity {
Identity {
persona_id: 33_068_179,
persona_name: "CAGE".into(),
user_id: 33_068_179,
ext_id: 33_068_179,
email: "cage@openfut.local".into(),
namespace: "cem_ea_id".into(),
client_platform: 4,
persona_status: 2,
user_session_type: 0,
account_locale: 0x656E_5553, // 'enUS'
locale: "en_US".into(),
content_id: "1027460".into(),
entitlement_tag: "ONLINE_ACCESS".into(),
entitlement_group: "FIFA17PCBoxContent".into(),
title_id: "309111".into(),
client_id: "FIFA17-PC-SERVER-BLAZE".into(),
platform: "pc".into(),
}
}
}
/// Where the client should be told to go next.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoints {
/// Address handed to the CLIENT for every next hop. On a split deployment
/// this is the backend's LAN address as the game machine sees it.
pub advertise: String,
/// Address the server binds. Not interchangeable with `advertise`.
pub bind: String,
/// `host:port` for POW content.
pub pow_content_host: String,
/// `host:port` for the POW/EASFC API.
pub pow_host: String,
/// Blaze port ADVERTISED to the client by the redirector.
///
/// Our choice, not a protocol constant — the client goes wherever
/// `<serverinstanceinfo>` sends it. Configurable so a sidecar can be
/// advertised on a different port without a rebuild.
pub blaze_port: u16,
/// UTAS/RS4 port in generated `FUT_RS4_*` URLs.
///
/// 8099 is the client's own built-in default (`http://easw.easports.com:8099/`
/// in CardsDLL), so it is the sane value — but it is still deployment
/// configuration, not a constant we are entitled to bake in.
pub utas_port: u16,
pub telemetry_port: i64,
pub ticker_port: i64,
pub qos_port: i64,
}
// NOTE: there is deliberately NO `impl Default for Endpoints`.
//
// A default would silently supply loopback, and a remote deployment that forgot
// to set an address would then advertise `127.0.0.1` to a client on another
// machine — failing far from the cause. Choosing loopback has to be an explicit
// act, so it is a named constructor.
impl Endpoints {
/// Endpoints for a backend the client reaches at `advertise`.
///
/// POW hosts DERIVE from the advertised host, matching what the deployed
/// Python entrypoint does (`POW_HOST="${POW_HOST:-$ADV:8094}"`). They must
/// not fall back to loopback independently: that would leave a remote
/// deployment emitting loopback POW URLs while every other URL was correct.
pub fn advertising(advertise: impl Into<String>) -> Endpoints {
let advertise = advertise.into();
Endpoints {
pow_content_host: format!("{advertise}:8080"),
pow_host: format!("{advertise}:8094"),
bind: advertise.clone(),
advertise,
blaze_port: 42130,
utas_port: 8099,
telemetry_port: 9988,
ticker_port: 8999,
qos_port: 17502,
}
}
/// Explicit local-only / oracle mode: game and backend on one host.
///
/// Named rather than defaulted so that "everything is loopback" is always a
/// decision someone made, and greppable.
pub fn loopback() -> Endpoints {
Endpoints::advertising("127.0.0.1")
}
}
/// Full adapter configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterConfig {
pub identity: Identity,
pub endpoints: Endpoints,
/// `PreAuthResponse.SVER`. Carries a trailing newline in the oracle; kept
/// because it is on the wire, not because it is meaningful.
pub server_version: String,
}
/// `PreAuthResponse.SVER`. On the wire, so it is config rather than a literal.
pub const DEFAULT_SERVER_VERSION: &str = "Blaze 15.1.1.3.0 (OpenFUT)\n";
// No `Default` here either, for the same reason as `Endpoints`.
impl AdapterConfig {
/// Adapter serving a client that reaches this backend at `advertise`.
pub fn advertising(advertise: impl Into<String>) -> AdapterConfig {
AdapterConfig {
identity: Identity::default(),
endpoints: Endpoints::advertising(advertise),
server_version: DEFAULT_SERVER_VERSION.into(),
}
}
/// Explicit local-only / oracle mode.
pub fn loopback() -> AdapterConfig {
AdapterConfig::advertising("127.0.0.1")
}
/// `http://<advertise>:8099/` — the RS4/UTAS base.
///
/// The trailing slash and the scheme are both mandatory: CardsDLL's
/// `ServerSettings::resolve` uses the value verbatim once it contains
/// `"://"`, and the auth path breaks without the slash.
pub fn utas_base(&self) -> String {
format!(
"http://{}:{}/",
self.endpoints.advertise, self.endpoints.utas_port
)
}
/// `http://<bind>:42131` — the Nucleus OAuth stub.
///
/// This derives from **bind**, not advertise, faithfully reproducing the
/// Python oracle. On the live split deployment that makes it
/// `http://0.0.0.0:42131`, which the client cannot dial — see the crate
/// README and the vault. Reproduced deliberately: changing it would break
/// byte parity with the only configuration ever proven to work, and the
/// fix belongs in a separate, live-validated change.
pub fn nucleus_base(&self) -> String {
format!("http://{}:42131", self.endpoints.bind)
}
pub fn pow_content_url(&self) -> String {
format!("http://{}", self.endpoints.pow_content_host)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utas_base_keeps_scheme_and_trailing_slash() {
let mut cfg = AdapterConfig::loopback();
cfg.endpoints.advertise = "10.0.0.5".into();
assert_eq!(cfg.utas_base(), "http://10.0.0.5:8099/");
}
#[test]
fn nucleus_follows_bind_not_advertise() {
// Documents the oracle's behaviour, including its consequence.
let mut cfg = AdapterConfig::loopback();
cfg.endpoints.advertise = "10.0.0.5".into();
cfg.endpoints.bind = "0.0.0.0".into();
assert_eq!(cfg.nucleus_base(), "http://0.0.0.0:42131");
}
#[test]
fn pow_content_url_has_no_trailing_slash() {
let mut cfg = AdapterConfig::loopback();
cfg.endpoints.pow_content_host = "10.0.0.5:8085".into();
assert_eq!(cfg.pow_content_url(), "http://10.0.0.5:8085");
}
}