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>
This commit is contained in:
@@ -107,7 +107,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> AdapterConfig {
|
||||
let mut c = AdapterConfig::default();
|
||||
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();
|
||||
|
||||
@@ -87,28 +87,59 @@ pub struct Endpoints {
|
||||
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,
|
||||
}
|
||||
|
||||
impl Default for Endpoints {
|
||||
/// Loopback, matching the oracle's own defaults for a single-host run.
|
||||
// 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`.
|
||||
///
|
||||
/// A remote deployment MUST override `advertise`; the Python entrypoint
|
||||
/// refuses to start without it, and this default is only appropriate when
|
||||
/// game and backend share a host.
|
||||
fn default() -> Endpoints {
|
||||
/// 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 {
|
||||
advertise: "127.0.0.1".into(),
|
||||
bind: "127.0.0.1".into(),
|
||||
pow_content_host: "127.0.0.1:8080".into(),
|
||||
pow_host: "127.0.0.1:8094".into(),
|
||||
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.
|
||||
@@ -121,24 +152,36 @@ pub struct AdapterConfig {
|
||||
pub server_version: String,
|
||||
}
|
||||
|
||||
impl Default for AdapterConfig {
|
||||
fn default() -> AdapterConfig {
|
||||
AdapterConfig {
|
||||
identity: Identity::default(),
|
||||
endpoints: Endpoints::default(),
|
||||
server_version: "Blaze 15.1.1.3.0 (OpenFUT)\n".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `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://{}:8099/", self.endpoints.advertise)
|
||||
format!(
|
||||
"http://{}:{}/",
|
||||
self.endpoints.advertise, self.endpoints.utas_port
|
||||
)
|
||||
}
|
||||
|
||||
/// `http://<bind>:42131` — the Nucleus OAuth stub.
|
||||
@@ -164,7 +207,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn utas_base_keeps_scheme_and_trailing_slash() {
|
||||
let mut cfg = AdapterConfig::default();
|
||||
let mut cfg = AdapterConfig::loopback();
|
||||
cfg.endpoints.advertise = "10.0.0.5".into();
|
||||
assert_eq!(cfg.utas_base(), "http://10.0.0.5:8099/");
|
||||
}
|
||||
@@ -172,7 +215,7 @@ mod tests {
|
||||
#[test]
|
||||
fn nucleus_follows_bind_not_advertise() {
|
||||
// Documents the oracle's behaviour, including its consequence.
|
||||
let mut cfg = AdapterConfig::default();
|
||||
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");
|
||||
@@ -180,7 +223,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pow_content_url_has_no_trailing_slash() {
|
||||
let mut cfg = AdapterConfig::default();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ mod tests {
|
||||
use openfut_protocol_blaze::heat2::Struct as S;
|
||||
|
||||
fn adapter() -> Adapter {
|
||||
Adapter::new(AdapterConfig::default())
|
||||
Adapter::new(AdapterConfig::loopback())
|
||||
}
|
||||
|
||||
fn req(component: u16, command: u16) -> Header {
|
||||
|
||||
@@ -489,7 +489,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> AdapterConfig {
|
||||
AdapterConfig::default()
|
||||
AdapterConfig::loopback()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user