diff --git a/openfut-adapter-fifa17/fixtures/redirector.json b/openfut-adapter-fifa17/fixtures/redirector.json new file mode 100644 index 0000000..47239b8 --- /dev/null +++ b/openfut-adapter-fifa17/fixtures/redirector.json @@ -0,0 +1,5 @@ +{ + "10.10.0.120": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331360d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e31302e31302e302e3132303c2f686f73746e616d653e0a0909093c69703e3136383432373634303c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a", + "127.0.0.1": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331350d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3132372e302e302e313c2f686f73746e616d653e0a0909093c69703e323133303730363433333c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a", + "198.51.100.7": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331380d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3139382e35312e3130302e373c2f686f73746e616d653e0a0909093c69703e333332353235363731313c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a" +} diff --git a/openfut-adapter-fifa17/src/lib.rs b/openfut-adapter-fifa17/src/lib.rs index 95e2abf..9e92815 100644 --- a/openfut-adapter-fifa17/src/lib.rs +++ b/openfut-adapter-fifa17/src/lib.rs @@ -20,12 +20,19 @@ //! //! ## Modules //! -//! * [`blaze`] — the Blaze/Fire2 RPC surface. Implemented. +//! * [`blaze`] — the Blaze/Fire2 RPC surface. Implemented, runtime validated. +//! * [`redirector`] — the first hop's `` response. +//! Implemented; TLS transport is still the host's problem and unresolved. //! -//! Still served only by the Python backend, each a separate future module: -//! the redirector (HTTPS + XML `getServerInstance`), the Nucleus OAuth stub, -//! LSX/Origin (`:4216`), roster XML (`:8081`), UTAS/RS4 (`:8099`) and POW/EASFC -//! (`:8094`). +//! Still served only by the Python backend: LSX/Origin (`:4216`), roster XML +//! (`:8081`), UTAS/RS4 (`:8099`) and POW/EASFC (`:8094`). +//! +//! **Nucleus (`:42131`) is deliberately not ported.** Instrumentation across +//! every live session showed the client never dials it: the listener is bound, +//! the handler logs unconditionally on connect, the client fetches the +//! `OSDK_NUCLEUS` config that carries the URL — and makes zero requests. It is +//! dead code on the observed path, so porting it would add an untested +//! component for no parity gain. See the vault's Protocol Findings. //! //! ## Provenance //! @@ -58,5 +65,6 @@ //! ``` pub mod blaze; +pub mod redirector; pub use blaze::{Adapter, AdapterConfig, Session}; diff --git a/openfut-adapter-fifa17/src/redirector/mod.rs b/openfut-adapter-fifa17/src/redirector/mod.rs new file mode 100644 index 0000000..f3700f3 --- /dev/null +++ b/openfut-adapter-fifa17/src/redirector/mod.rs @@ -0,0 +1,193 @@ +//! FIFA 17 Blaze redirector: the first hop. +//! +//! ```text +//! FIFA 17 ──TLS──> redirector ──"connect to :"──> plaintext Fire2 Blaze +//! ``` +//! +//! A different protocol from Blaze itself: HTTPS with an XML body (DirtySDK's +//! ProtoHttp), not Fire2. Exactly one request is ever seen — +//! `POST /redirector/getServerInstance` — observed 9 times across every live +//! session with no other path. +//! +//! # Scope +//! +//! This module owns the **response**, which is game-specific. It does not own +//! TLS or HTTP transport; that belongs to a host, exactly as the Blaze adapter +//! owns dispatch while `openfut-blaze-host` owns the socket. +//! +//! # A TLS constraint that is not this module's problem, but is recorded here +//! +//! Every observed handshake negotiated `AES256-GCM-SHA384` — TLS 1.2 with +//! **static RSA key exchange** (`TLS_RSA_WITH_AES_256_GCM_SHA384`), against a +//! Python server offering `ALL:@SECLEVEL=0` and an RSA-2048 certificate +//! (DirtySDK rejects ECDSA). `rustls` supports only forward-secret (EC)DHE +//! suites, so it cannot serve that negotiation. Whether the client *offers* +//! ECDHE at all is UNKNOWN — OpenSSL follows client preference by default, so +//! preferring static RSA does not prove it is the only option. Instrument a +//! real ClientHello before choosing a TLS stack. + +use std::fmt::Write as _; + +use crate::blaze::config::AdapterConfig; + +/// The only request path the client ever uses. +pub const REQUEST_PATH: &str = "/redirector/getServerInstance"; + +/// Where the client is told to find Blaze. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlazeEndpoint { + /// Advertised hostname or dotted-quad. + pub host: String, + pub port: u16, + /// Whether the Blaze hop is TLS. **0 for FIFA 17** — the second hop is + /// plaintext Fire2, which this field independently confirms. + pub secure: bool, +} + +impl BlazeEndpoint { + /// Blaze lives on 42130 in this deployment; the advertised host comes from + /// config so a split deployment reaches the right machine. + pub fn from_config(cfg: &AdapterConfig) -> BlazeEndpoint { + BlazeEndpoint { + host: cfg.endpoints.advertise.clone(), + port: 42130, + secure: false, + } + } +} + +/// Dotted-quad to the decimal `u32` the client expects in ``. +/// +/// Host byte order, so `127.0.0.1` is `2130706433` (`0x7F000001`). A non-IPv4 +/// advertise value (a hostname) has no numeric form; the oracle falls back to +/// loopback rather than failing, and that behaviour is reproduced — the client +/// reads `` too, so the numeric field is not the only route. +pub fn ip_to_u32(addr: &str) -> u32 { + let octets: Vec = addr + .split('.') + .filter_map(|p| p.parse::().ok()) + .filter(|n| *n <= 255) + .collect(); + if octets.len() == 4 { + (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3] + } else { + (127 << 24) | 1 + } +} + +/// The `` XML body. +/// +/// `
` is a `ServerAddress` union; member 0 selects the +/// `ipAddress` variant `{hostname, ip, port}`. Tabs and newlines are part of +/// the byte-exact output — the client does not care, but parity does. +pub fn server_instance_info_xml(endpoint: &BlazeEndpoint) -> String { + let mut s = String::with_capacity(320); + s.push_str("\n"); + s.push_str("\n"); + s.push_str("\t
\n"); + s.push_str("\t\t\n"); + let _ = writeln!(s, "\t\t\t{}", endpoint.host); + let _ = writeln!(s, "\t\t\t{}", ip_to_u32(&endpoint.host)); + let _ = writeln!(s, "\t\t\t{}", endpoint.port); + s.push_str("\t\t\n"); + s.push_str("\t
\n"); + let _ = writeln!(s, "\t{}", u8::from(endpoint.secure)); + s.push_str("\t\n"); + s.push_str("\t0\n"); + s.push_str("
\n"); + s +} + +/// The complete HTTP response, headers included. +/// +/// `Connection: close` is the oracle's behaviour and the client accepts it — +/// the redirector is a one-shot hop, unlike the long-lived Blaze connection. +pub fn redirect_response(cfg: &AdapterConfig) -> Vec { + let body = server_instance_info_xml(&BlazeEndpoint::from_config(cfg)); + let mut out = String::with_capacity(body.len() + 128); + out.push_str("HTTP/1.1 200 OK\r\n"); + out.push_str("Content-Type: application/xml\r\n"); + let _ = write!(out, "Content-Length: {}\r\n", body.len()); + out.push_str("Connection: close\r\n\r\n"); + out.push_str(&body); + out.into_bytes() +} + +/// Is this request line the one the client sends? +/// +/// Diagnostics only: the oracle answers *any* request with the same body, so +/// dispatch does not branch on this. Reproduced as-is — a redirector that +/// started 404ing unexpected paths would be a behaviour change, not a fix. +pub fn is_get_server_instance(request_line: &str) -> bool { + request_line.starts_with("POST ") && request_line.contains(REQUEST_PATH) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(advertise: &str) -> AdapterConfig { + let mut c = AdapterConfig::default(); + c.endpoints.advertise = advertise.into(); + c + } + + #[test] + 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); + } + + #[test] + fn a_non_ipv4_advertise_falls_back_to_loopback_like_the_oracle() { + // The hostname element still carries the real value, so this is not a + // dead end for the client. + assert_eq!(ip_to_u32("blaze.example.com"), 2_130_706_433); + assert_eq!(ip_to_u32("10.0.0"), 2_130_706_433); + assert_eq!(ip_to_u32("999.1.1.1"), 2_130_706_433); + } + + #[test] + fn secure_is_zero_confirming_the_plaintext_second_hop() { + let body = server_instance_info_xml(&BlazeEndpoint::from_config(&cfg("10.0.0.5"))); + assert!(body.contains("0")); + } + + #[test] + fn response_advertises_the_configured_address_not_a_hardcoded_one() { + let r = String::from_utf8(redirect_response(&cfg("198.51.100.7"))).unwrap(); + assert!(r.contains("198.51.100.7")); + assert!(r.contains("3325256711")); + assert!(r.contains("42130")); + assert!(!r.contains("127.0.0.1")); + } + + #[test] + fn content_length_matches_the_body_exactly() { + let bytes = redirect_response(&cfg("10.10.0.120")); + 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 + .lines() + .find_map(|l| l.strip_prefix("Content-Length: ")) + .and_then(|v| v.trim().parse().ok()) + .expect("content-length present"); + assert_eq!( + declared, + body.len(), + "a wrong length would truncate the XML" + ); + } + + #[test] + fn recognises_the_only_observed_request_line() { + assert!(is_get_server_instance( + "POST /redirector/getServerInstance HTTP/1.1" + )); + assert!(!is_get_server_instance( + "GET /redirector/getServerInstance HTTP/1.1" + )); + assert!(!is_get_server_instance("POST /something/else HTTP/1.1")); + } +} diff --git a/openfut-adapter-fifa17/tests/oracle_parity.rs b/openfut-adapter-fifa17/tests/oracle_parity.rs index 8482133..196f1d9 100644 --- a/openfut-adapter-fifa17/tests/oracle_parity.rs +++ b/openfut-adapter-fifa17/tests/oracle_parity.rs @@ -355,3 +355,48 @@ fn session_state_is_per_connection() { // "fallbacks" never logged in. assert!(!rp.sessions["fallbacks"].logged_in); } + +// ────────────────────────────── redirector ────────────────────────────── +// +// The first hop. A different protocol from Blaze — HTTPS with an XML body — +// but the same rule: byte-for-byte against the oracle. + +#[test] +fn redirect_response_matches_python_oracle_byte_for_byte() { + use openfut_adapter_fifa17::redirector; + + let path = format!("{}/fixtures/redirector.json", env!("CARGO_MANIFEST_DIR")); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {path}: {e}")); + let table: serde_json::Map = serde_json::from_str(&text).expect("valid JSON"); + assert!(table.len() >= 3, "expected several advertised addresses"); + + for (advertise, want_hex) in &table { + let mut cfg = AdapterConfig::default(); + cfg.endpoints.advertise = advertise.clone(); + + let got = redirector::redirect_response(&cfg); + assert_eq!( + hex(&got), + want_hex.as_str().unwrap(), + "\nredirector response differs for advertise={advertise}" + ); + } +} + +/// The advertised Blaze endpoint must follow config, and the fixtures use +/// deliberately different addresses so a hardcoded one cannot pass. +#[test] +fn redirect_response_is_configurable_not_baked() { + use openfut_adapter_fifa17::redirector; + + let mut a = AdapterConfig::default(); + a.endpoints.advertise = "10.0.0.5".into(); + let mut b = AdapterConfig::default(); + b.endpoints.advertise = "10.0.0.6".into(); + + assert_ne!( + redirector::redirect_response(&a), + redirector::redirect_response(&b), + "the advertised address must reach the wire" + ); +}