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:
@@ -3,7 +3,7 @@
|
||||
# OPENFUT_ADVERTISE — the address of THIS host as seen from the game machine
|
||||
# (105). The responders advertise it to the client for every next hop (Blaze,
|
||||
# roster, UTAS, POW). Compose refuses to start without it.
|
||||
OPENFUT_ADVERTISE=10.10.0.120
|
||||
OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
|
||||
|
||||
# OPENFUT_BIND — address the listeners bind inside the container.
|
||||
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"10.10.0.120": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331360d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e31302e31302e302e3132303c2f686f73746e616d653e0a0909093c69703e3136383432373634303c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
|
||||
"127.0.0.1": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331350d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3132372e302e302e313c2f686f73746e616d653e0a0909093c69703e323133303730363433333c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
|
||||
"198.51.100.7": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331380d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3139382e35312e3130302e373c2f686f73746e616d653e0a0909093c69703e333332353235363731313c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a"
|
||||
"192.0.2.1": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331350d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3139322e302e322e313c2f686f73746e616d653e0a0909093c69703e333232313232353938353c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
|
||||
"198.51.100.7": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331380d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3139382e35312e3130302e373c2f686f73746e616d653e0a0909093c69703e333332353235363731313c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
|
||||
"203.0.113.42": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331380d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3230332e302e3131332e34323c2f686f73746e616d653e0a0909093c69703e333430353830333831383c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a"
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Deployment-address audit: the configured address must reach every
|
||||
//! client-visible endpoint, and nothing may quietly substitute its own.
|
||||
//!
|
||||
//! OpenFUT has to run on arbitrary addresses. The development topology is
|
||||
//! deployment configuration, not architecture, so no crate may contain a
|
||||
//! production destination, an advertised address, or a hidden localhost
|
||||
//! fallback.
|
||||
//!
|
||||
//! Two TEST-NET addresses are used throughout (RFC 5737), deliberately not the
|
||||
//! lab's real LAN addresses: a test that passes only because its constant
|
||||
//! happens to match the current lab proves nothing about relocatability.
|
||||
|
||||
use openfut_adapter_fifa17::blaze::{client_config, AdapterConfig, Endpoints};
|
||||
use openfut_adapter_fifa17::redirector;
|
||||
|
||||
/// TEST-NET-2 and TEST-NET-3. Never routable, never ours, and obviously not a
|
||||
/// lab address to anyone reading a failure.
|
||||
const ADDR_A: &str = "198.51.100.7";
|
||||
const ADDR_B: &str = "203.0.113.42";
|
||||
|
||||
fn cfg(advertise: &str) -> AdapterConfig {
|
||||
AdapterConfig::advertising(advertise)
|
||||
}
|
||||
|
||||
/// Every client-visible string a config produces, for wholesale comparison.
|
||||
fn client_visible_surface(cfg: &AdapterConfig) -> Vec<String> {
|
||||
let mut out = vec![
|
||||
cfg.utas_base(),
|
||||
cfg.nucleus_base(),
|
||||
cfg.pow_content_url(),
|
||||
String::from_utf8(redirector::redirect_response(cfg)).unwrap(),
|
||||
];
|
||||
for section in client_config::known_sections() {
|
||||
for (k, v) in client_config::rows_for(section, cfg) {
|
||||
out.push(format!("{section}/{k}={v}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// (5) Changing the advertised host must update every applicable generated URL,
|
||||
/// with no recompilation and no leftovers.
|
||||
#[test]
|
||||
fn changing_the_advertised_host_updates_every_client_visible_url() {
|
||||
let a = client_visible_surface(&cfg(ADDR_A));
|
||||
let b = client_visible_surface(&cfg(ADDR_B));
|
||||
|
||||
assert_eq!(a.len(), b.len(), "the surface itself must not change shape");
|
||||
|
||||
let a_has = a.iter().filter(|s| s.contains(ADDR_A)).count();
|
||||
let b_has = b.iter().filter(|s| s.contains(ADDR_B)).count();
|
||||
assert!(a_has > 200, "expected the address throughout, saw {a_has}");
|
||||
assert_eq!(a_has, b_has, "the same entries must carry the new address");
|
||||
|
||||
// Nothing may retain the old address after reconfiguration.
|
||||
let stragglers: Vec<&String> = b.iter().filter(|s| s.contains(ADDR_A)).collect();
|
||||
assert!(
|
||||
stragglers.is_empty(),
|
||||
"these kept the previous address: {:?}",
|
||||
&stragglers[..stragglers.len().min(5)]
|
||||
);
|
||||
}
|
||||
|
||||
/// (1) A remote configuration must not silently become localhost anywhere.
|
||||
#[test]
|
||||
fn remote_configuration_never_silently_becomes_localhost() {
|
||||
let c = cfg(ADDR_A);
|
||||
// Allowlisted: two OAuth redirect targets that are literals in the oracle
|
||||
// and are never dialled (see the compatibility exceptions in the vault).
|
||||
const ALLOWED_LOOPBACK_KEYS: [&str; 2] = ["identityRedirectUri", "redirect_uri"];
|
||||
|
||||
for entry in client_visible_surface(&c) {
|
||||
if entry.contains("127.0.0.1") || entry.contains("localhost") {
|
||||
assert!(
|
||||
ALLOWED_LOOPBACK_KEYS.iter().any(|k| entry.contains(k)),
|
||||
"unexpected loopback in a remote configuration: {entry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POW hosts in particular must derive from advertise, not fall back alone.
|
||||
assert!(c.endpoints.pow_content_host.starts_with(ADDR_A));
|
||||
assert!(c.endpoints.pow_host.starts_with(ADDR_A));
|
||||
assert!(c.pow_content_url().contains(ADDR_A));
|
||||
}
|
||||
|
||||
/// (3) Bind and advertise are different concepts and must never be conflated.
|
||||
#[test]
|
||||
fn bind_can_differ_from_advertise() {
|
||||
let mut c = cfg(ADDR_A);
|
||||
c.endpoints.bind = "0.0.0.0".into();
|
||||
|
||||
assert_eq!(c.endpoints.advertise, ADDR_A);
|
||||
assert_eq!(c.endpoints.bind, "0.0.0.0");
|
||||
// The advertised surface follows advertise, not bind.
|
||||
assert!(c.utas_base().contains(ADDR_A));
|
||||
assert!(!c.utas_base().contains("0.0.0.0"));
|
||||
|
||||
// COMPATIBILITY EXCEPTION, reproduced deliberately: nucleusConnect follows
|
||||
// BIND in the oracle. Instrumentation showed the client never dials it, so
|
||||
// this is cosmetic on the observed path. Asserted so the exception cannot
|
||||
// be "fixed" by accident without this test failing and forcing the decision
|
||||
// to be made explicitly.
|
||||
assert_eq!(c.nucleus_base(), "http://0.0.0.0:42131");
|
||||
}
|
||||
|
||||
/// (4) The advertised Blaze port must reach the redirect result.
|
||||
#[test]
|
||||
fn changing_the_blaze_port_changes_the_redirect() {
|
||||
let mut c = cfg(ADDR_A);
|
||||
let before = String::from_utf8(redirector::redirect_response(&c)).unwrap();
|
||||
assert!(before.contains("<port>42130</port>"));
|
||||
|
||||
c.endpoints.blaze_port = 42999;
|
||||
let after = String::from_utf8(redirector::redirect_response(&c)).unwrap();
|
||||
assert!(after.contains("<port>42999</port>"), "{after}");
|
||||
assert!(!after.contains("<port>42130</port>"));
|
||||
// And the advertised host still follows config.
|
||||
assert!(after.contains(&format!("<hostname>{ADDR_A}</hostname>")));
|
||||
}
|
||||
|
||||
/// The UTAS port is deployment configuration too, not a constant we own.
|
||||
#[test]
|
||||
fn changing_the_utas_port_changes_every_rs4_url() {
|
||||
let mut c = cfg(ADDR_A);
|
||||
assert!(c.utas_base().contains(":8099/"));
|
||||
|
||||
c.endpoints.utas_port = 9099;
|
||||
assert_eq!(c.utas_base(), format!("http://{ADDR_A}:9099/"));
|
||||
|
||||
let rows = client_config::rows_for("BlazeSDK", &c);
|
||||
let base = rows.iter().find(|(k, _)| k == "FUT_RS4_BASE_URL").unwrap();
|
||||
assert_eq!(base.1, format!("http://{ADDR_A}:9099/"));
|
||||
assert!(!rows.iter().any(|(_, v)| v.contains(":8099")));
|
||||
}
|
||||
|
||||
/// (6) No service-specific helper may construct an endpoint from a different
|
||||
/// source of truth than the central configuration.
|
||||
#[test]
|
||||
fn no_helper_bypasses_the_central_configuration() {
|
||||
// bind MUST differ from advertise here. With them equal, a helper that
|
||||
// wrongly reads `bind` is indistinguishable from one that reads
|
||||
// `advertise` — and reading `bind` is the single most likely bypass,
|
||||
// because the oracle really does it for nucleusConnect. Mutation-tested:
|
||||
// with bind == advertise this test could not detect that substitution.
|
||||
let mut c = cfg(ADDR_B);
|
||||
c.endpoints.bind = "0.0.0.0".into();
|
||||
|
||||
// Every URL-shaped helper resolves through the same Endpoints.
|
||||
assert!(c.utas_base().contains(ADDR_B));
|
||||
assert!(c.pow_content_url().contains(ADDR_B));
|
||||
let ep = redirector::BlazeEndpoint::from_config(&c);
|
||||
assert_eq!(
|
||||
ep.host, c.endpoints.advertise,
|
||||
"the redirector must advertise the ADVERTISED host, not the bind address"
|
||||
);
|
||||
assert_ne!(
|
||||
ep.host, c.endpoints.bind,
|
||||
"bind must not leak into the wire"
|
||||
);
|
||||
let xml = String::from_utf8(redirector::redirect_response(&c)).unwrap();
|
||||
assert!(xml.contains(ADDR_B));
|
||||
assert!(
|
||||
!xml.contains("0.0.0.0"),
|
||||
"the bind address must never reach the client"
|
||||
);
|
||||
assert_eq!(ep.port, c.endpoints.blaze_port);
|
||||
|
||||
// And the config table's URL tokens resolve through those same helpers,
|
||||
// rather than re-deriving a URL shape of their own.
|
||||
let rows = client_config::rows_for("BlazeSDK", &c);
|
||||
let base = rows.iter().find(|(k, _)| k == "FUT_RS4_BASE_URL").unwrap();
|
||||
assert_eq!(base.1, c.utas_base());
|
||||
let nucleus = rows.iter().find(|(k, _)| k == "nucleusConnect").unwrap();
|
||||
assert_eq!(nucleus.1, c.nucleus_base());
|
||||
}
|
||||
|
||||
/// (7) Mutating the configuration must make these tests fail — a suite that
|
||||
/// passes regardless of the configured address would prove nothing.
|
||||
#[test]
|
||||
fn configuration_mutations_are_detectable() {
|
||||
let a = cfg(ADDR_A);
|
||||
let b = cfg(ADDR_B);
|
||||
|
||||
// Each of these is what a mutation would have to defeat.
|
||||
assert_ne!(a.utas_base(), b.utas_base());
|
||||
assert_ne!(a.pow_content_url(), b.pow_content_url());
|
||||
assert_ne!(
|
||||
redirector::redirect_response(&a),
|
||||
redirector::redirect_response(&b)
|
||||
);
|
||||
assert_ne!(
|
||||
client_config::rows_for("BlazeSDK", &a),
|
||||
client_config::rows_for("BlazeSDK", &b)
|
||||
);
|
||||
|
||||
let mut port_changed = a.clone();
|
||||
port_changed.endpoints.blaze_port += 1;
|
||||
assert_ne!(
|
||||
redirector::redirect_response(&a),
|
||||
redirector::redirect_response(&port_changed)
|
||||
);
|
||||
}
|
||||
|
||||
/// Loopback must be a named, deliberate choice — not something a caller can
|
||||
/// reach by omission.
|
||||
#[test]
|
||||
fn loopback_is_explicit_not_a_default() {
|
||||
let l = Endpoints::loopback();
|
||||
assert_eq!(l.advertise, "127.0.0.1");
|
||||
assert!(l.pow_content_host.starts_with("127.0.0.1"));
|
||||
|
||||
// `Endpoints::default()` and `AdapterConfig::default()` deliberately do not
|
||||
// exist; this test documents that, and the crate would not compile if they
|
||||
// were reintroduced and used by accident elsewhere.
|
||||
let explicit = AdapterConfig::loopback();
|
||||
assert_eq!(explicit.endpoints.advertise, "127.0.0.1");
|
||||
}
|
||||
@@ -88,7 +88,7 @@ fn config_from(record: &J) -> (AdapterConfig, i64) {
|
||||
bind: st(record, "bind"),
|
||||
pow_content_host: st(record, "pow_content_host"),
|
||||
pow_host: st(record, "pow_host"),
|
||||
..Endpoints::default()
|
||||
..Endpoints::loopback()
|
||||
};
|
||||
let cfg = AdapterConfig {
|
||||
identity,
|
||||
@@ -371,7 +371,7 @@ fn redirect_response_matches_python_oracle_byte_for_byte() {
|
||||
assert!(table.len() >= 3, "expected several advertised addresses");
|
||||
|
||||
for (advertise, want_hex) in &table {
|
||||
let mut cfg = AdapterConfig::default();
|
||||
let mut cfg = AdapterConfig::loopback();
|
||||
cfg.endpoints.advertise = advertise.clone();
|
||||
|
||||
let got = redirector::redirect_response(&cfg);
|
||||
@@ -389,9 +389,9 @@ fn redirect_response_matches_python_oracle_byte_for_byte() {
|
||||
fn redirect_response_is_configurable_not_baked() {
|
||||
use openfut_adapter_fifa17::redirector;
|
||||
|
||||
let mut a = AdapterConfig::default();
|
||||
let mut a = AdapterConfig::loopback();
|
||||
a.endpoints.advertise = "10.0.0.5".into();
|
||||
let mut b = AdapterConfig::default();
|
||||
let mut b = AdapterConfig::loopback();
|
||||
b.endpoints.advertise = "10.0.0.6".into();
|
||||
|
||||
assert_ne!(
|
||||
|
||||
@@ -22,7 +22,13 @@
|
||||
# 1 when it does.
|
||||
set -uo pipefail
|
||||
|
||||
CLIENT="${1:-${OPENFUT_CLIENT_IP:-10.10.0.105}}"
|
||||
CLIENT="${1:-${OPENFUT_CLIENT_IP:-}}"
|
||||
if [[ -z "$CLIENT" ]]; then
|
||||
echo "usage: client-state.sh <client-ip> (or set OPENFUT_CLIENT_IP)" >&2
|
||||
echo " no default: the lab's address is deployment config, not architecture," >&2
|
||||
echo " and a default that matches the current lab hides the coupling." >&2
|
||||
exit 2
|
||||
fi
|
||||
CONTAINER="${OPENFUT_PY_CONTAINER:-openfut-fut-backend}"
|
||||
live=0
|
||||
|
||||
|
||||
@@ -136,7 +136,11 @@ PYLOG=/tmp/blaze_responder.log
|
||||
if docker exec openfut-fut-backend test -f "$PYLOG" 2>/dev/null; then
|
||||
docker exec openfut-fut-backend sh -c "grep -E 'REDIR SENT|BLAZE CONNECT from|closed' $PYLOG" \
|
||||
> "$DEST/python-blaze.log" 2>/dev/null || true
|
||||
CLIENT="${OPENFUT_CLIENT_IP:-10.10.0.105}"
|
||||
CLIENT="${OPENFUT_CLIENT_IP:-}"
|
||||
if [[ -z "$CLIENT" ]]; then
|
||||
both " OPENFUT_CLIENT_IP not set — skipping the client-specific correlation"
|
||||
both " (set it to the FIFA machine's address for positive/negative observation)"
|
||||
fi
|
||||
redirs="$(grep -c "REDIR SENT ('$CLIENT'" "$DEST/python-blaze.log" 2>/dev/null || echo 0)"
|
||||
blazes="$(grep -c "BLAZE CONNECT from ('$CLIENT'" "$DEST/python-blaze.log" 2>/dev/null || echo 0)"
|
||||
both " client $CLIENT — redirector hops served by Python: $redirs"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
|
||||
use openfut_adapter_fifa17::blaze::config as adapter_config;
|
||||
use openfut_adapter_fifa17::blaze::{AdapterConfig, Endpoints, Identity};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -51,6 +52,23 @@ fn required(key: &str, why: &str) -> Result<String, ConfigError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_opt(key: &str) -> Option<String> {
|
||||
env::var(key).ok().filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// An optional numeric port. A present-but-invalid value is an ERROR, not a
|
||||
/// silent fallback — a typo must not quietly leave the previous port in place.
|
||||
fn optional_port(key: &str) -> Result<Option<u16>, ConfigError> {
|
||||
match optional_opt(key) {
|
||||
None => Ok(None),
|
||||
Some(v) => v
|
||||
.trim()
|
||||
.parse()
|
||||
.map(Some)
|
||||
.map_err(|_| ConfigError(format!("{key} is not a valid port: {v:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional(key: &str, default: &str) -> String {
|
||||
env::var(key)
|
||||
.ok()
|
||||
@@ -87,13 +105,25 @@ impl HostConfig {
|
||||
|
||||
let listen_addr = optional("OPENFUT_BLAZE_HOST_BIND", &config_bind);
|
||||
|
||||
let endpoints = Endpoints {
|
||||
advertise,
|
||||
bind: config_bind,
|
||||
pow_content_host: optional("POW_CONTENT_HOST", "127.0.0.1:8080"),
|
||||
pow_host: optional("POW_HOST", "127.0.0.1:8094"),
|
||||
..Endpoints::default()
|
||||
};
|
||||
// Derived from the ADVERTISED address, never loopback. The deployed
|
||||
// Python entrypoint does the same (`POW_HOST="${POW_HOST:-$ADV:8094}"`),
|
||||
// and an independent loopback fallback here would leave a remote
|
||||
// deployment emitting loopback POW URLs while every other URL was right
|
||||
// — a failure that surfaces far from its cause.
|
||||
let mut endpoints = Endpoints::advertising(&advertise);
|
||||
endpoints.bind = config_bind;
|
||||
if let Some(v) = optional_opt("POW_CONTENT_HOST") {
|
||||
endpoints.pow_content_host = v;
|
||||
}
|
||||
if let Some(v) = optional_opt("POW_HOST") {
|
||||
endpoints.pow_host = v;
|
||||
}
|
||||
if let Some(p) = optional_port("OPENFUT_BLAZE_ADVERTISED_PORT")? {
|
||||
endpoints.blaze_port = p;
|
||||
}
|
||||
if let Some(p) = optional_port("OPENFUT_UTAS_PORT")? {
|
||||
endpoints.utas_port = p;
|
||||
}
|
||||
|
||||
Ok(HostConfig {
|
||||
listen_addr,
|
||||
@@ -111,7 +141,7 @@ impl HostConfig {
|
||||
adapter: AdapterConfig {
|
||||
identity: Identity::default(),
|
||||
endpoints,
|
||||
server_version: AdapterConfig::default().server_version,
|
||||
server_version: adapter_config::DEFAULT_SERVER_VERSION.into(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -134,6 +164,10 @@ mod tests {
|
||||
"OPENFUT_BIND",
|
||||
"OPENFUT_BLAZE_HOST_PORT",
|
||||
"OPENFUT_BLAZE_HOST_BIND",
|
||||
"POW_CONTENT_HOST",
|
||||
"POW_HOST",
|
||||
"OPENFUT_BLAZE_ADVERTISED_PORT",
|
||||
"OPENFUT_UTAS_PORT",
|
||||
];
|
||||
let saved: Vec<_> = keys.iter().map(|k| (*k, env::var(k).ok())).collect();
|
||||
for k in keys {
|
||||
@@ -145,10 +179,15 @@ mod tests {
|
||||
assert!(err.contains("OPENFUT_ADVERTISE"), "{err}");
|
||||
|
||||
// Missing port is refused too — no default that could collide.
|
||||
env::set_var("OPENFUT_ADVERTISE", "10.0.0.5");
|
||||
env::set_var("OPENFUT_ADVERTISE", "198.51.100.7");
|
||||
let err = HostConfig::from_env().unwrap_err().to_string();
|
||||
assert!(err.contains("OPENFUT_BLAZE_HOST_PORT"), "{err}");
|
||||
|
||||
// (2) A missing advertised address FAILS CLEARLY — never defaulted.
|
||||
// Re-asserted here because it is the single most important rule:
|
||||
// a backend that guesses its own reachable address advertises a
|
||||
// wrong one to a remote client and fails far from the cause.
|
||||
|
||||
// A non-numeric port is a clear error, not a silent fallback.
|
||||
env::set_var("OPENFUT_BLAZE_HOST_PORT", "not-a-port");
|
||||
let err = HostConfig::from_env().unwrap_err().to_string();
|
||||
@@ -159,7 +198,7 @@ mod tests {
|
||||
env::set_var("OPENFUT_BIND", "0.0.0.0");
|
||||
let cfg = HostConfig::from_env().expect("configured");
|
||||
assert_eq!(cfg.listen_on(), "0.0.0.0:42230");
|
||||
assert_eq!(cfg.adapter.endpoints.advertise, "10.0.0.5");
|
||||
assert_eq!(cfg.adapter.endpoints.advertise, "198.51.100.7");
|
||||
// The adapter's nucleus URL follows the CONFIG bind, reproducing the
|
||||
// oracle's behaviour rather than the listener's address.
|
||||
assert_eq!(cfg.adapter.nucleus_base(), "http://0.0.0.0:42131");
|
||||
@@ -170,6 +209,35 @@ mod tests {
|
||||
assert_eq!(cfg.listen_on(), "127.0.0.1:42230");
|
||||
assert_eq!(cfg.adapter.nucleus_base(), "http://0.0.0.0:42131");
|
||||
|
||||
// (1) POW endpoints DERIVE from advertise; no independent loopback
|
||||
// fallback. This was a real defect: they defaulted to 127.0.0.1
|
||||
// while every other URL followed the advertised address, so a
|
||||
// remote deployment emitted loopback POW URLs.
|
||||
env::remove_var("POW_CONTENT_HOST");
|
||||
env::remove_var("POW_HOST");
|
||||
env::set_var("OPENFUT_ADVERTISE", "198.51.100.7");
|
||||
let cfg = HostConfig::from_env().expect("configured");
|
||||
assert_eq!(cfg.adapter.endpoints.pow_content_host, "198.51.100.7:8080");
|
||||
assert_eq!(cfg.adapter.endpoints.pow_host, "198.51.100.7:8094");
|
||||
assert!(cfg.adapter.pow_content_url().contains("198.51.100.7"));
|
||||
assert!(!cfg.adapter.pow_content_url().contains("127.0.0.1"));
|
||||
|
||||
// Explicit overrides still win (the deployment remaps POW content).
|
||||
env::set_var("POW_CONTENT_HOST", "203.0.113.42:8085");
|
||||
let cfg = HostConfig::from_env().expect("configured");
|
||||
assert_eq!(cfg.adapter.endpoints.pow_content_host, "203.0.113.42:8085");
|
||||
env::remove_var("POW_CONTENT_HOST");
|
||||
|
||||
// (4) The advertised Blaze port is configurable, and a bad value is an
|
||||
// error rather than a silent fallback to the old one.
|
||||
env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "42999");
|
||||
let cfg = HostConfig::from_env().expect("configured");
|
||||
assert_eq!(cfg.adapter.endpoints.blaze_port, 42999);
|
||||
env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "not-a-port");
|
||||
let err = HostConfig::from_env().unwrap_err().to_string();
|
||||
assert!(err.contains("not a valid port"), "{err}");
|
||||
env::remove_var("OPENFUT_BLAZE_ADVERTISED_PORT");
|
||||
|
||||
for (k, v) in saved {
|
||||
match v {
|
||||
Some(v) => env::set_var(k, v),
|
||||
|
||||
@@ -105,7 +105,7 @@ fn start_with_capture(capture_path: Option<String>) -> Harness {
|
||||
bind: st(&cfg_rec, "bind"),
|
||||
pow_content_host: st(&cfg_rec, "pow_content_host"),
|
||||
pow_host: st(&cfg_rec, "pow_host"),
|
||||
..Endpoints::default()
|
||||
..Endpoints::loopback()
|
||||
},
|
||||
server_version: st(id, "server_version"),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user