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>
231 lines
8.6 KiB
Rust
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");
|
|
}
|
|
}
|