adapter: FIFA 17 redirector response; Nucleus deliberately not ported

REDIRECTOR. The first hop's <serverinstanceinfo> XML, byte-for-byte against
the oracle across three advertised addresses. Owns the response only; TLS and
HTTP transport belong to a host, exactly as the Blaze adapter owns dispatch
while the sidecar owns the socket.

The <secure>0</secure> field is the client being told the second hop is
plaintext -- independent corroboration of the plaintext Blaze finding, now
expressed in code.

NUCLEUS IS NOT PORTED, and that is a finding rather than an omission.
Instrumented across every live session:

  listener bound            YES  0.0.0.0:42131 since 00:21:32
  handler logs on connect   YES  unconditional, before any parsing
  client received the URL   YES  OSDK_NUCLEUS fetched 10+ times
  client connected          NO   zero requests, including 4 full FUT flows

So the long-standing nucleusConnect=0.0.0.0 anomaly is explained: FIFA never
follows that URL on this path. The invalid address has never mattered because
nothing dials it. Porting the stub would add an untested component for no
parity gain.

TLS CONSTRAINT RECORDED, NOT RESOLVED. All 9 observed handshakes negotiated
AES256-GCM-SHA384 = TLS 1.2 with STATIC RSA key exchange. rustls supports only
forward-secret (EC)DHE suites and cannot serve that. Whether the client also
OFFERS ECDHE is unknown -- OpenSSL follows client preference by default, so
preferring static RSA does not prove it is the only option. This must be
instrumented from a real ClientHello before a TLS stack is chosen; the module
docs say so rather than guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-11 02:48:12 +00:00
parent ed0ccb8c2b
commit 8aab2c0d41
4 changed files with 256 additions and 5 deletions
@@ -0,0 +1,5 @@
{
"10.10.0.120": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331360d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e31302e31302e302e3132303c2f686f73746e616d653e0a0909093c69703e3136383432373634303c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
"127.0.0.1": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331350d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3132372e302e302e313c2f686f73746e616d653e0a0909093c69703e323133303730363433333c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
"198.51.100.7": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331380d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3139382e35312e3130302e373c2f686f73746e616d653e0a0909093c69703e333332353235363731313c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a"
}
+13 -5
View File
@@ -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 `<serverinstanceinfo>` 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};
@@ -0,0 +1,193 @@
//! FIFA 17 Blaze redirector: the first hop.
//!
//! ```text
//! FIFA 17 ──TLS──> redirector ──"connect to <ip>:<port>"──> 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 `<ip>`.
///
/// 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 `<hostname>` too, so the numeric field is not the only route.
pub fn ip_to_u32(addr: &str) -> u32 {
let octets: Vec<u32> = addr
.split('.')
.filter_map(|p| p.parse::<u32>().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 `<serverinstanceinfo>` XML body.
///
/// `<address member="0">` 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
s.push_str("<serverinstanceinfo>\n");
s.push_str("\t<address member=\"0\">\n");
s.push_str("\t\t<valu>\n");
let _ = writeln!(s, "\t\t\t<hostname>{}</hostname>", endpoint.host);
let _ = writeln!(s, "\t\t\t<ip>{}</ip>", ip_to_u32(&endpoint.host));
let _ = writeln!(s, "\t\t\t<port>{}</port>", endpoint.port);
s.push_str("\t\t</valu>\n");
s.push_str("\t</address>\n");
let _ = writeln!(s, "\t<secure>{}</secure>", u8::from(endpoint.secure));
s.push_str("\t<trialservicename></trialservicename>\n");
s.push_str("\t<defaultdnsaddress>0</defaultdnsaddress>\n");
s.push_str("</serverinstanceinfo>\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<u8> {
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("<secure>0</secure>"));
}
#[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("<hostname>198.51.100.7</hostname>"));
assert!(r.contains("<ip>3325256711</ip>"));
assert!(r.contains("<port>42130</port>"));
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"));
}
}
@@ -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<String, J> = 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"
);
}