//! # openfut-redirector-host //! //! Transport host for the FIFA 17 Blaze redirector — the first hop. //! //! ```text //! FIFA 17 ──TLS 1.2, static-RSA──> this host ──> //! "connect to :" //! ``` //! //! ## A deliberately small compatibility island //! //! This is the only OpenFUT crate that links OpenSSL, and it does so because a //! retail FIFA 17 client offers exactly eight static-RSA suites and nothing //! forward-secret. Neither OpenFUT Core nor the generic protocol crates gain //! that dependency. See [`tls`] for what the observed ClientHello dictates. //! //! ## Division of responsibility //! //! The host owns the listener, TLS, HTTP framing, connection lifecycle and //! diagnostics. `openfut-adapter-fifa17::redirector` owns the response and //! nothing else — the same split as the Blaze sidecar, where the adapter //! decides what to say and the host owns the socket. //! //! No FUT state lives here. //! //! ## Configuration //! //! Resolved through `openfut-host-config`, the single environment reader, so //! the advertised address reaches this host by the same construction path as //! the Blaze sidecar's. pub mod config; pub mod tls; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use openfut_adapter_fifa17::redirector; use openssl::ssl::SslAcceptor; pub use config::RedirectorConfig; fn log(msg: &str) { let ms = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); eprintln!("[{}.{:03}] {msg}", ms / 1000, ms % 1000); } /// The commit this binary was built from. /// /// Only the commit: a compiled-in cleanliness claim can go stale (cargo will /// not re-run a build script for another crate's edit), so the authoritative /// comparison happens at launch in `scripts/verify-build-identity.sh`. pub const BUILD_COMMIT: &str = env!("OPENFUT_BUILD_COMMIT"); /// Build identity, printable without any configuration. /// /// `--identity` exists so the launcher can establish which commit a binary came /// from before deciding whether to run it. Machine-readable `key=value`. pub fn identity() -> String { format!( "openfut-redirector-host v{} commit={} profile={} openssl={}", env!("CARGO_PKG_VERSION"), BUILD_COMMIT, if cfg!(debug_assertions) { "debug" } else { "release" }, tls::openssl_version(), ) } /// One line naming the binary, its linked TLS, and what it will advertise. /// /// Machine-readable `key=value` so the launcher can extract the commit without /// guessing at prose. pub fn banner(cfg: &RedirectorConfig) -> String { format!( "openfut-redirector-host v{} commit={} profile={} openssl={} listen={} \ advertise={}:{} tls_min={:?} tls_max={:?} security_level={} ciphers={}", env!("CARGO_PKG_VERSION"), BUILD_COMMIT, if cfg!(debug_assertions) { "debug" } else { "release" }, tls::openssl_version(), cfg.listen_on(), cfg.adapter.endpoints.advertise, cfg.adapter.endpoints.blaze_port, cfg.tls.min_version, cfg.tls.max_version, cfg.tls .security_level .map(|l| l.to_string()) .unwrap_or_else(|| "default".into()), cfg.tls.cipher_list, ) } /// A bound listener, so a caller can learn the real port before serving /// (ephemeral ports in tests) and so the self-test runs before anything is /// accepted. pub struct Server { pub local_addr: std::net::SocketAddr, listener: TcpListener, acceptor: Arc, cfg: Arc, outcomes: Outcomes, } /// What the host did on a connection, beyond the bytes it sent. /// /// Recorded because the failure that cost two live gate attempts — a request /// body left unread — is invisible to any comparison of the response. The log /// line carries the same facts for a live run; this makes them assertable. #[derive(Debug, Clone)] pub struct ConnOutcome { pub id: u64, pub request_bytes: usize, pub body: BodyRead, pub response_bytes: usize, } pub type Outcomes = Arc>>; /// Keep the record bounded: a long-running redirector must not accumulate one /// entry per connection forever. const OUTCOME_HISTORY: usize = 64; impl Server { /// A handle to the connection record, obtainable before [`Server::run`] /// consumes the server. pub fn outcomes(&self) -> Outcomes { self.outcomes.clone() } pub fn run(self) -> std::io::Result<()> { let counter = AtomicU64::new(0); for incoming in self.listener.incoming() { let Ok(stream) = incoming else { continue }; let id = counter.fetch_add(1, Ordering::Relaxed) + 1; let (acceptor, cfg) = (self.acceptor.clone(), self.cfg.clone()); let outcomes = self.outcomes.clone(); std::thread::spawn(move || handle(stream, id, &acceptor, &cfg, &outcomes)); } Ok(()) } } fn record(outcomes: &Outcomes, o: ConnOutcome) { if let Ok(mut v) = outcomes.lock() { if v.len() >= OUTCOME_HISTORY { v.remove(0); } v.push(o); } } /// Build TLS, rehearse the retail handshake, and bind. pub fn bind(cfg: RedirectorConfig) -> std::io::Result { let acceptor = tls::build_acceptor(&cfg.tls) .map_err(|e| std::io::Error::other(format!("TLS setup failed: {e}")))?; // Rehearse the retail handshake BEFORE accepting anything: a client // restricted to exactly the suites FIFA offers must connect. Catching a // cipher/version misconfiguration here means it never shows up as an // unexplained failure during a live gate. let neg = tls::self_test(&cfg.tls, tls::OBSERVED_CLIENT_SUITES).map_err(|e| { std::io::Error::other(format!( "self-test failed — a FIFA-like client cannot connect: {e}" )) })?; log(&format!( "SELF-TEST OK: a FIFA-like client negotiates {} / {}", neg.version, neg.cipher )); let listener = TcpListener::bind(cfg.listen_on())?; let local_addr = listener.local_addr()?; log(&banner(&cfg)); Ok(Server { local_addr, listener, acceptor: Arc::new(acceptor), cfg: Arc::new(cfg), outcomes: Outcomes::default(), }) } /// Serve until the process is killed. pub fn serve(cfg: RedirectorConfig) -> std::io::Result<()> { bind(cfg)?.run() } fn handle( stream: TcpStream, id: u64, acceptor: &SslAcceptor, cfg: &RedirectorConfig, outcomes: &Outcomes, ) { let peer = stream .peer_addr() .map(|a| a.to_string()) .unwrap_or_else(|_| "".into()); let _ = stream.set_read_timeout(Some(Duration::from_secs(15))); let _ = stream.set_write_timeout(Some(Duration::from_secs(15))); let mut tls = match acceptor.accept(stream) { Ok(s) => s, Err(e) => { // The most valuable diagnostic this host produces: a handshake // failure names the client and the reason, so a cipher/version // mismatch is obvious rather than looking like a network fault. log(&format!("conn-{id:04} {peer} TLS HANDSHAKE FAILED: {e}")); return; } }; // Exactly what the retail client negotiated. Recorded per connection // because it is the evidence a gate is judged on. { let s = tls.ssl(); log(&format!( "conn-{id:04} {peer} TLS OK version={} cipher={} sni={}", s.version_str(), s.current_cipher().map(|c| c.name()).unwrap_or("?"), s.servername(openssl::ssl::NameType::HOST_NAME) .unwrap_or("") )); } // Read the request head. The oracle answers any request with the same body, // so this is parsed for diagnostics, not for routing — a redirector that // started 404ing unexpected paths would be a behaviour change, not a fix. let mut buf = Vec::new(); let mut chunk = [0u8; 4096]; loop { match tls.read(&mut chunk) { Ok(0) => break, Ok(n) => { buf.extend_from_slice(&chunk[..n]); if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.len() > 65536 { break; } } Err(e) => { log(&format!("conn-{id:04} {peer} read failed: {e}")); return; } } } // Drain the request body, exactly as the oracle does. This is NOT cosmetic: // answering and closing while the client is still sending leaves unread data // in the receive queue, and Linux turns that close into an RST rather than a // FIN. The oracle has always drained; the first version of this host stopped // at the header terminator, which is a behaviour difference invisible to any // byte-comparison of the response. let body_read = drain_body(&mut tls, &mut buf); let head = String::from_utf8_lossy(&buf); let line0 = head.lines().next().unwrap_or("").to_string(); log(&format!( "conn-{id:04} {peer} REQ {line0} req_bytes={} body={}", buf.len(), match body_read { BodyRead::None => "none".to_string(), BodyRead::Complete(n) => format!("{n}B complete"), BodyRead::Short { got, want } => format!("{got}B of {want}B SHORT"), } )); if !line0.is_empty() && !redirector::is_get_server_instance(&line0) { log(&format!( "conn-{id:04} {peer} NOTE: unexpected request line; answering anyway (oracle behaviour)" )); } let response = redirector::redirect_response(&cfg.adapter); if let Err(e) = tls.write_all(&response) { log(&format!("conn-{id:04} {peer} write failed: {e}")); return; } let _ = tls.flush(); log(&format!( "conn-{id:04} {peer} SENT {}B serverinstanceinfo -> {}:{}", response.len(), cfg.adapter.endpoints.advertise, cfg.adapter.endpoints.blaze_port )); // The oracle holds the connection open before closing. That dwell is the // only other measured difference between the two implementations, and a // redirector that closes at 0ms is not the behaviour FIFA 17 was proven // against — so it is reproduced rather than assumed harmless. record( outcomes, ConnOutcome { id, request_bytes: buf.len(), body: body_read, response_bytes: response.len(), }, ); std::thread::sleep(cfg.close_dwell); let _ = tls.shutdown(); } /// What happened when the request body was drained. Reported per connection /// because "the client sent a body we never read" is exactly the class of /// divergence a response-bytes comparison cannot see. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BodyRead { /// No `Content-Length` header — nothing to drain. None, Complete(usize), Short { got: usize, want: usize }, } /// Parse `Content-Length` and read the remainder of the body into `buf`. /// /// Mirrors the oracle: header lookup is case-insensitive, and a client that /// stops early ends the read rather than hanging until the timeout. fn drain_body(stream: &mut S, buf: &mut Vec) -> BodyRead { let Some(sep) = buf.windows(4).position(|w| w == b"\r\n\r\n") else { return BodyRead::None; }; let head = String::from_utf8_lossy(&buf[..sep]).to_string(); let Some(want) = head .lines() .find(|l| l.to_ascii_lowercase().starts_with("content-length")) .and_then(|l| l.split(':').nth(1)) .and_then(|v| v.trim().parse::().ok()) else { return BodyRead::None; }; let mut got = buf.len() - (sep + 4); let mut chunk = [0u8; 4096]; while got < want { match stream.read(&mut chunk) { Ok(0) | Err(_) => return BodyRead::Short { got, want }, Ok(n) => { buf.extend_from_slice(&chunk[..n]); got += n; } } } BodyRead::Complete(got) } #[cfg(test)] mod tests { use super::*; #[test] fn banner_names_the_linked_openssl_and_what_is_advertised() { let cfg = RedirectorConfig::for_test("198.51.100.7"); let b = banner(&cfg); assert!(b.contains("openssl="), "{b}"); assert!(b.contains("commit="), "{b}"); assert!(b.contains("tls_min="), "{b}"); assert!(b.contains("198.51.100.7"), "{b}"); // The cipher list is part of the identity of a compatibility host. assert!(b.contains("AES256-GCM-SHA384"), "{b}"); } }