diff --git a/fifa17-recon/docker/fifa17-python/.env.example b/fifa17-recon/docker/fifa17-python/.env.example index a5634c6..a648c77 100644 --- a/fifa17-recon/docker/fifa17-python/.env.example +++ b/fifa17-recon/docker/fifa17-python/.env.example @@ -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 diff --git a/openfut-adapter-fifa17/fixtures/redirector.json b/openfut-adapter-fifa17/fixtures/redirector.json index 47239b8..cf11a12 100644 --- a/openfut-adapter-fifa17/fixtures/redirector.json +++ b/openfut-adapter-fifa17/fixtures/redirector.json @@ -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" } diff --git a/openfut-adapter-fifa17/src/blaze/client_config.rs b/openfut-adapter-fifa17/src/blaze/client_config.rs index 4cb873d..faf7867 100644 --- a/openfut-adapter-fifa17/src/blaze/client_config.rs +++ b/openfut-adapter-fifa17/src/blaze/client_config.rs @@ -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(); diff --git a/openfut-adapter-fifa17/src/blaze/config.rs b/openfut-adapter-fifa17/src/blaze/config.rs index bf49202..d47e3ae 100644 --- a/openfut-adapter-fifa17/src/blaze/config.rs +++ b/openfut-adapter-fifa17/src/blaze/config.rs @@ -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 + /// `` 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) -> 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) -> 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://: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://: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"); } diff --git a/openfut-adapter-fifa17/src/blaze/dispatch.rs b/openfut-adapter-fifa17/src/blaze/dispatch.rs index 9bb6ad7..ce77b8d 100644 --- a/openfut-adapter-fifa17/src/blaze/dispatch.rs +++ b/openfut-adapter-fifa17/src/blaze/dispatch.rs @@ -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 { diff --git a/openfut-adapter-fifa17/src/blaze/responses.rs b/openfut-adapter-fifa17/src/blaze/responses.rs index 6cc47b2..74c810d 100644 --- a/openfut-adapter-fifa17/src/blaze/responses.rs +++ b/openfut-adapter-fifa17/src/blaze/responses.rs @@ -489,7 +489,7 @@ mod tests { use super::*; fn cfg() -> AdapterConfig { - AdapterConfig::default() + AdapterConfig::loopback() } #[test] diff --git a/openfut-adapter-fifa17/src/lib.rs b/openfut-adapter-fifa17/src/lib.rs index 9e92815..524420c 100644 --- a/openfut-adapter-fifa17/src/lib.rs +++ b/openfut-adapter-fifa17/src/lib.rs @@ -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 diff --git a/openfut-adapter-fifa17/src/redirector/mod.rs b/openfut-adapter-fifa17/src/redirector/mod.rs index f3700f3..ae75306 100644 --- a/openfut-adapter-fifa17/src/redirector/mod.rs +++ b/openfut-adapter-fifa17/src/redirector/mod.rs @@ -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("0")); } @@ -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 diff --git a/openfut-adapter-fifa17/tests/deployment_config.rs b/openfut-adapter-fifa17/tests/deployment_config.rs new file mode 100644 index 0000000..391f164 --- /dev/null +++ b/openfut-adapter-fifa17/tests/deployment_config.rs @@ -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 { + 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("42130")); + + c.endpoints.blaze_port = 42999; + let after = String::from_utf8(redirector::redirect_response(&c)).unwrap(); + assert!(after.contains("42999"), "{after}"); + assert!(!after.contains("42130")); + // And the advertised host still follows config. + assert!(after.contains(&format!("{ADDR_A}"))); +} + +/// 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"); +} diff --git a/openfut-adapter-fifa17/tests/oracle_parity.rs b/openfut-adapter-fifa17/tests/oracle_parity.rs index 196f1d9..ea0ea25 100644 --- a/openfut-adapter-fifa17/tests/oracle_parity.rs +++ b/openfut-adapter-fifa17/tests/oracle_parity.rs @@ -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!( diff --git a/openfut-blaze-host/client-state.sh b/openfut-blaze-host/client-state.sh index 01b592b..bdcaf9d 100755 --- a/openfut-blaze-host/client-state.sh +++ b/openfut-blaze-host/client-state.sh @@ -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 (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 diff --git a/openfut-blaze-host/gate-evidence.sh b/openfut-blaze-host/gate-evidence.sh index 85e6923..5a13535 100755 --- a/openfut-blaze-host/gate-evidence.sh +++ b/openfut-blaze-host/gate-evidence.sh @@ -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" diff --git a/openfut-blaze-host/src/config.rs b/openfut-blaze-host/src/config.rs index fa8e052..4d7054c 100644 --- a/openfut-blaze-host/src/config.rs +++ b/openfut-blaze-host/src/config.rs @@ -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 { } } +fn optional_opt(key: &str) -> Option { + 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, 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), diff --git a/openfut-blaze-host/tests/live_transport.rs b/openfut-blaze-host/tests/live_transport.rs index 08fa1b3..f092aca 100644 --- a/openfut-blaze-host/tests/live_transport.rs +++ b/openfut-blaze-host/tests/live_transport.rs @@ -105,7 +105,7 @@ fn start_with_capture(capture_path: Option) -> 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"), };