//! # 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; 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; // Body draining is shared. Behaviour that must be identical across hosts gets // one implementation, for the same reason TLS does: the divergence it guards // against (an unread body turning the close into an RST) is invisible in any // comparison of the response, and cost two live gate attempts to find. use openfut_http::drain_body; pub use openfut_http::BodyRead; 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" }, openfut_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={} cert_sha256={} ciphers={}", env!("CARGO_PKG_VERSION"), BUILD_COMMIT, if cfg!(debug_assertions) { "debug" } else { "release" }, openfut_tls::openssl_version(), cfg.listen_on(), cfg.adapter.endpoints.advertise, cfg.adapter.endpoints.blaze_port, // Readable names, not `SslVersion(771)`: this line is gate evidence and // is read by humans comparing one run against another. cfg.tls.min_version.as_str(), cfg.tls.max_version.as_str(), cfg.tls .security_level .map(|l| l.to_string()) .unwrap_or_else(|| "default".into()), // WHICH certificate, not just that one loaded. Serving a different // certificate from the rest of the stack raises no error at startup and // breaks the client much later, with nothing logged anywhere. Printing // it unprompted is what turns that into a one-glance comparison. openfut_tls::certificate_fingerprint(&cfg.tls.cert_path) .unwrap_or_else(|e| format!("unreadable ({e})")), 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, probes: ProbeCount, } /// How many bare port probes have been classified. /// /// Counted rather than only logged for the same reason [`ConnOutcome`] exists: /// a test that asserts on client-visible symptoms cannot tell a probe that was /// classified from one that merely failed quietly, so removing the /// classification would leave the suite green. This makes it assertable. pub type ProbeCount = Arc; /// 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 bare-probe counter, obtainable before [`Server::run`]. pub fn probes(&self) -> ProbeCount { self.probes.clone() } /// 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(); let probes = self.probes.clone(); std::thread::spawn(move || handle(stream, id, &acceptor, &cfg, &outcomes, &probes)); } 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 = openfut_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 = openfut_tls::self_test( &cfg.tls, openfut_adapter_fifa17::tls::OBSERVED_CLIENT_SUITES, openfut_adapter_fifa17::tls::CLIENT_SNI, ) .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(), probes: ProbeCount::default(), }) } /// Serve until the process is killed. pub fn serve(cfg: RedirectorConfig) -> std::io::Result<()> { bind(cfg)?.run() } /// Re-exported from the shared TLS crate. The classification policy now lives in /// `openfut-tls` so every FIFA-facing TLS host shares one implementation; this /// host keeps naming them here so its public API and `probe_classification` /// integration test are unaffected. pub use openfut_tls::{classify_opening, PeerOpening}; fn handle( stream: TcpStream, id: u64, acceptor: &SslAcceptor, cfg: &RedirectorConfig, outcomes: &Outcomes, probes: &ProbeCount, ) { 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))); // Classify a bare port probe BEFORE the acceptor sees it. // // A reachability probe — the launcher's preflight makes two per run, and // any `TcpStream::connect` then drop is one — opens the connection and // closes without sending a byte. Once that reaches the acceptor it fails // as "unexpected EOF", which is byte-identical to the signature of a // genuine TLS fault. That is not a cosmetic problem: "unexpected EOF" is // exactly how the certificate mismatch that cost three live gates // presented, so a benign probe forging it poisons the one channel this // project gates on. Classified here, the two are never confused again. if openfut_tls::peer_opening(&stream) == PeerOpening::ClosedWithoutSpeaking { probes.fetch_add(1, std::sync::atomic::Ordering::Relaxed); log(&format!( "conn-{id:04} {peer} PROBE: closed before sending a ClientHello (not a TLS fault)" )); return; } 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(), body_read.describe() )); 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(); } #[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}"); } }