//! FUT roster-update transport host. //! //! ```text //! FIFA 17 ──HTTPS GET /fifa17/fut/rosterupdate.xml──> //! ``` //! //! # Division of labour //! //! ```text //! openfut-tls how to build the TLS acceptor //! adapter-fifa17::tls what FIFA 17's TLS was observed to be //! adapter-fifa17::roster what bytes the answer is //! this crate accept, read, drain, write, close //! ``` //! //! This host contains no roster content and no cipher choice. It owns the //! socket and the connection lifecycle, nothing else. //! //! # Lifecycle is part of the contract //! //! The redirector proved that byte-identical responses are not behavioural //! parity: answering while the client is still sending leaves unread data in //! the receive queue and Linux turns the close into an RST rather than a FIN. //! So the oracle's lifecycle was *measured* rather than inferred, with a probe //! run against a replica of `roster_server.py` (the live one is single-threaded //! and was serving a live game at the time): //! //! ```text //! dwell after responding 0 ms (the redirector's is 300 ms -- NOT shared) //! request body drained POST is answered only after the body arrives //! close clean FIN, never RST //! keep-alive none one request per connection //! ``` //! //! Every one of those is reproduced here, and asserted by `tests/`. //! //! # Why the roster matters more than its 67 bytes suggest //! //! `checkFUTRostersFlow` downloads this before entering FUT; failure aborts //! with *"An error occurred downloading the FUT Squad Update"*. Because it is a //! separate TLS connection from the redirector, it is also where a certificate //! mismatch anywhere in the stack first becomes visible to the player. use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use openfut_adapter_fifa17::roster::{self, Method}; // Shared with the redirector: see openfut-http for why this is not duplicated. use openfut_http::drain_body; pub use openfut_http::BodyRead; // The acceptor comes from the shared crate, so this host never names OpenSSL. use openfut_tls::{peer_opening, PeerOpening, SslAcceptor}; pub mod config; pub use config::RosterConfig; /// Commit this binary was built from, stamped by `build.rs`. pub const BUILD_COMMIT: &str = env!("OPENFUT_BUILD_COMMIT"); const MAX_HEAD: usize = 16 * 1024; pub fn identity() -> String { format!( "openfut-roster-host v{} commit={} profile={} openssl={}", env!("CARGO_PKG_VERSION"), BUILD_COMMIT, if cfg!(debug_assertions) { "debug" } else { "release" }, openfut_tls::openssl_version(), ) } /// Machine-readable `key=value`, matching the redirector's banner so the two /// hosts can be compared at a glance. /// /// `cert_sha256` is printed unprompted: 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. pub fn banner(cfg: &RosterConfig) -> String { format!( "{} listen={} server_header={:?} close_dwell_ms={} cert_sha256={} ciphers={}", identity(), cfg.listen_on(), cfg.server_header, cfg.close_dwell.as_millis(), openfut_tls::certificate_fingerprint(&cfg.tls.cert_path) .unwrap_or_else(|e| format!("unreadable ({e})")), cfg.tls.cipher_list, ) } /// What the host did on a connection, beyond the bytes it sent. /// /// Recorded because the divergence that cost two gate attempts on the /// redirector — a request body left unread — is invisible to any comparison of /// the response. #[derive(Debug, Clone)] pub struct ConnOutcome { pub id: u64, pub method: Option, pub path: String, pub request_bytes: usize, pub body: BodyRead, pub response_bytes: usize, } pub type Outcomes = Arc>>; /// How many bare port probes have been classified before reaching the acceptor. /// /// Counted, not 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 — and /// mirrors the redirector, the host that first needed the distinction. pub type ProbeCount = Arc; /// Bounded: a host polled every few seconds must not accumulate forever. const OUTCOME_HISTORY: usize = 64; fn record(outcomes: &Outcomes, o: ConnOutcome) { if let Ok(mut v) = outcomes.lock() { if v.len() >= OUTCOME_HISTORY { v.remove(0); } v.push(o); } } pub struct Server { pub local_addr: std::net::SocketAddr, listener: TcpListener, acceptor: Arc, cfg: Arc, outcomes: Outcomes, probes: ProbeCount, } impl Server { pub fn outcomes(&self) -> Outcomes { self.outcomes.clone() } /// A handle to the bare-probe counter, obtainable before [`Server::run`]. pub fn probes(&self) -> ProbeCount { self.probes.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(()) } } /// Build TLS, rehearse the retail handshake, and bind. /// /// The rehearsal runs before the listener accepts anything, so a TLS /// misconfiguration is a startup failure rather than a client-visible one. pub fn bind(cfg: RosterConfig) -> std::io::Result { use openfut_adapter_fifa17::tls as profile; let acceptor = openfut_tls::build_acceptor(&cfg.tls) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?; let neg = openfut_tls::self_test( &cfg.tls, profile::OBSERVED_CLIENT_SUITES, profile::CLIENT_SNI, ) .map_err(|e| { std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("TLS self-test failed: {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()?; Ok(Server { local_addr, listener, acceptor: Arc::new(acceptor), cfg: Arc::new(cfg), outcomes: Arc::new(std::sync::Mutex::new(Vec::new())), probes: ProbeCount::default(), }) } pub fn serve(cfg: RosterConfig) -> std::io::Result<()> { let server = bind(cfg)?; log(&banner(&server.cfg)); server.run() } fn log(msg: &str) { let t = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs_f64()) .unwrap_or_default(); println!("[{t:.3}] {msg}"); } fn handle( stream: TcpStream, id: u64, acceptor: &SslAcceptor, cfg: &RosterConfig, outcomes: &Outcomes, probes: &ProbeCount, ) { let peer = stream .peer_addr() .map(|a| a.to_string()) .unwrap_or_else(|_| "?".into()); // Classify a bare port probe BEFORE the acceptor sees it. A `connect` then // drop (the launcher's preflight makes one per run) otherwise reaches the // acceptor as `unexpected EOF` — byte-identical to the certificate mismatch // that cost three live gates. Shared with the redirector via openfut-tls so // the two hosts can never diverge on this. if peer_opening(&stream) == PeerOpening::ClosedWithoutSpeaking { probes.fetch_add(1, 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) => { log(&format!("conn-{id:04} {peer} TLS HANDSHAKE FAILED: {e}")); return; } }; let mut buf = Vec::with_capacity(1024); if !read_head(&mut tls, &mut buf) { log(&format!("conn-{id:04} {peer} no request read")); return; } let head = String::from_utf8_lossy(&buf).to_string(); let line0 = head.lines().next().unwrap_or_default().to_string(); let method = Method::parse(&line0); let path = line0.split_whitespace().nth(1).unwrap_or("").to_string(); // Drain BEFORE answering, exactly as the oracle does. `do_POST` reads // Content-Length bytes first; a lifecycle probe confirms the oracle does // not answer until the body arrives. Answering early would leave unread // data in the receive queue and turn our close into an RST. let body = drain_body(&mut tls, &mut buf); let response = match method { Some(m) => roster::roster_response(m, &cfg.server_header, &now_http_date()), // The oracle only defines GET/HEAD/POST; anything else is answered by // BaseHTTPRequestHandler's own 501, which this host does not claim to // reproduce. Closing without a response is honest about that rather // than inventing a reply the oracle never sends. None => { log(&format!( "conn-{id:04} {peer} unsupported method in {line0:?}; closing without a response" )); record( outcomes, ConnOutcome { id, method: None, path, request_bytes: buf.len(), body, response_bytes: 0, }, ); let _ = tls.shutdown(); return; } }; let _ = tls.write_all(&response); let _ = tls.flush(); log(&format!( "conn-{id:04} {peer} {line0} req_bytes={} body={} -> {}B", buf.len(), body.describe(), response.len() )); record( outcomes, ConnOutcome { id, method, path, request_bytes: buf.len(), body, response_bytes: response.len(), }, ); // Zero by default: the oracle has no post-response sleep. Kept // configurable so the causal experiment is a config change. if !cfg.close_dwell.is_zero() { std::thread::sleep(cfg.close_dwell); } // One request per connection: the response says `Connection: close` and the // oracle closes. No keep-alive loop, measured and matched. let _ = tls.shutdown(); } /// `Date:` for right now, in the oracle's format. fn now_http_date() -> String { let secs = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); roster::http_date(secs) } /// Read until the end of the request head, or give up. fn read_head(stream: &mut S, buf: &mut Vec) -> bool { let mut chunk = [0u8; 1024]; loop { match stream.read(&mut chunk) { Ok(0) | Err(_) => return !buf.is_empty(), Ok(n) => { buf.extend_from_slice(&chunk[..n]); if buf.windows(4).any(|w| w == b"\r\n\r\n") { return true; } // A client that never terminates its head must not be able to // grow this buffer without bound. if buf.len() > MAX_HEAD { return true; } } } } } #[cfg(test)] mod tests { use super::*; #[test] fn the_default_dwell_is_zero_unlike_the_redirectors() { // The redirector sleeps 300ms; the roster oracle does not. Inheriting // the wrong one would add a needless delay to a poll that runs every // few seconds, and would be a behaviour change rather than caution. assert_eq!(config::ORACLE_CLOSE_DWELL.as_millis(), 0); } #[test] fn identity_names_the_commit_and_linked_openssl() { let i = identity(); assert!(i.contains("commit="), "{i}"); assert!(i.contains("openssl="), "{i}"); } #[test] fn a_body_is_reported_short_when_the_client_stops_early() { let mut buf = b"POST / HTTP/1.1\r\nContent-Length: 10\r\n\r\nabc".to_vec(); let mut rest: &[u8] = b""; assert_eq!( drain_body(&mut rest, &mut buf), BodyRead::Short { got: 3, want: 10 } ); } #[test] fn a_body_split_across_reads_is_completed() { let mut buf = b"POST / HTTP/1.1\r\nContent-Length: 8\r\n\r\nab".to_vec(); let mut rest: &[u8] = b"cdefgh"; assert_eq!(drain_body(&mut rest, &mut buf), BodyRead::Complete(8)); } #[test] fn content_length_is_matched_case_insensitively() { let mut buf = b"POST / HTTP/1.1\r\ncOnTeNt-LeNgTh: 4\r\n\r\n".to_vec(); let mut rest: &[u8] = b"abcd"; assert_eq!(drain_body(&mut rest, &mut buf), BodyRead::Complete(4)); } #[test] fn no_content_length_means_nothing_to_drain() { let mut buf = b"GET / HTTP/1.1\r\nHost: x\r\n\r\n".to_vec(); let mut rest: &[u8] = b""; assert_eq!(drain_body(&mut rest, &mut buf), BodyRead::None); } }