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:
funman300
2026-08-11 02:55:50 +00:00
parent 8aab2c0d41
commit f451406058
14 changed files with 395 additions and 54 deletions
@@ -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();
+66 -23
View File
@@ -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");
}
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -53,7 +53,7 @@
//! use openfut_protocol_blaze::fire2::{Header, MsgType};
//! use openfut_protocol_blaze::heat2::Struct;
//!
//! let adapter = Adapter::new(AdapterConfig::default());
//! let adapter = Adapter::new(AdapterConfig::loopback());
//! let mut session = Session::new("session-key", 0x656E5553);
//!
//! // Util::ping
+8 -7
View File
@@ -45,12 +45,13 @@ pub struct BlazeEndpoint {
}
impl BlazeEndpoint {
/// Blaze lives on 42130 in this deployment; the advertised host comes from
/// config so a split deployment reaches the right machine.
/// Both host and port come from configuration. Neither is a protocol
/// constant: the client goes wherever this response sends it, so hardcoding
/// either would make the deployment un-relocatable.
pub fn from_config(cfg: &AdapterConfig) -> BlazeEndpoint {
BlazeEndpoint {
host: cfg.endpoints.advertise.clone(),
port: 42130,
port: cfg.endpoints.blaze_port,
secure: false,
}
}
@@ -127,7 +128,7 @@ mod tests {
use super::*;
fn cfg(advertise: &str) -> AdapterConfig {
let mut c = AdapterConfig::default();
let mut c = AdapterConfig::loopback();
c.endpoints.advertise = advertise.into();
c
}
@@ -136,7 +137,7 @@ mod tests {
fn ip_encoding_is_host_order_decimal() {
assert_eq!(ip_to_u32("127.0.0.1"), 2_130_706_433);
assert_eq!(ip_to_u32("198.51.100.7"), 3_325_256_711);
assert_eq!(ip_to_u32("10.10.0.120"), 168_427_640);
assert_eq!(ip_to_u32("203.0.113.42"), 3_405_803_818);
}
#[test]
@@ -150,7 +151,7 @@ mod tests {
#[test]
fn secure_is_zero_confirming_the_plaintext_second_hop() {
let body = server_instance_info_xml(&BlazeEndpoint::from_config(&cfg("10.0.0.5")));
let body = server_instance_info_xml(&BlazeEndpoint::from_config(&cfg("203.0.113.42")));
assert!(body.contains("<secure>0</secure>"));
}
@@ -165,7 +166,7 @@ mod tests {
#[test]
fn content_length_matches_the_body_exactly() {
let bytes = redirect_response(&cfg("10.10.0.120"));
let bytes = redirect_response(&cfg("203.0.113.42"));
let text = String::from_utf8(bytes).unwrap();
let (head, body) = text.split_once("\r\n\r\n").expect("header/body split");
let declared: usize = head