From c9ae9149100da99707fd553e83e3a69e96b3a4e4 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 11 Aug 2026 17:41:11 +0000 Subject: [PATCH] roster-host: transport host for the FUT roster update, lifecycle-matched Second consumer of openfut-tls, and the reason it was extracted first. This host contains no roster content and no cipher choice: the adapter owns the 67 bytes and the observed TLS profile, openfut-tls owns the acceptor, and this crate owns accept/read/drain/write/close. Lifecycle was MEASURED, not inherited. The obvious mistake here would have been copying the redirector's 300ms dwell because the other host has one. A probe against the oracle says otherwise: dwell after responding 0 ms (redirector: 300 ms) request body drained POST answered only once it arrives close clean FIN, never RST keep-alive none one request per connection The probe ran against a REPLICA of roster_server.py loaded from its own source, not against :8081 -- http.server.HTTPServer is single-threaded and FIFA was mid-session, so holding a connection open to measure the close would have stalled the game's poll and could have surfaced as the squad-update error. The replica was then confirmed byte-identical to the live oracle under masking, the 1-byte delta being the container's Python version in the Server header. Differential against the live oracle, every field identical, with the Server header compared UNMASKED: GET 230B HEAD 163B POST 163B drained=True reset=False answered_before_body=False keepalive: second request accepted by the socket, never answered Testing follows the redirector's hard-won rule: where a property is visible both to the client and inside the host, it is asserted inside the host via ConnOutcome. A client-side check cannot tell "drained" from "not drained" -- it reads the buffered response either way -- and that exact mistake let a mutation survive once already. 9 parity tests, 6 unit tests, 5/5 mutations killed, including "answer before draining", "hold the connection open like the redirector" and "inherit the redirector's 300ms default". drain_body is duplicated from the redirector deliberately. Unifying it means editing the redirector, and the roster A/B must change exactly one thing. Extraction is scheduled for after the roster gate closes. Not deployed and not switched: Python still serves :8081. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 9 + Cargo.toml | 1 + openfut-roster-host/Cargo.toml | 16 + openfut-roster-host/build.rs | 39 ++ openfut-roster-host/src/config.rs | 125 ++++++ openfut-roster-host/src/lib.rs | 430 +++++++++++++++++++++ openfut-roster-host/src/main.rs | 15 + openfut-roster-host/tests/oracle_parity.rs | 295 ++++++++++++++ openfut-tls/src/lib.rs | 10 +- 9 files changed, 939 insertions(+), 1 deletion(-) create mode 100644 openfut-roster-host/Cargo.toml create mode 100644 openfut-roster-host/build.rs create mode 100644 openfut-roster-host/src/config.rs create mode 100644 openfut-roster-host/src/lib.rs create mode 100644 openfut-roster-host/src/main.rs create mode 100644 openfut-roster-host/tests/oracle_parity.rs diff --git a/Cargo.lock b/Cargo.lock index 10fbb5b..53c925a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3230,6 +3230,15 @@ dependencies = [ "openssl", ] +[[package]] +name = "openfut-roster-host" +version = "0.1.0" +dependencies = [ + "openfut-adapter-fifa17", + "openfut-host-config", + "openfut-tls", +] + [[package]] name = "openfut-tls" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 68b44a7..0ddb683 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "openfut-host-config", "openfut-tls", "openfut-redirector-host", + "openfut-roster-host", "openfut-bridge", "openfut-launcher", "openfut-launcher/openfut-hook", diff --git a/openfut-roster-host/Cargo.toml b/openfut-roster-host/Cargo.toml new file mode 100644 index 0000000..09179f4 --- /dev/null +++ b/openfut-roster-host/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "openfut-roster-host" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "FIFA 17 FUT roster-update transport host" +publish = false + +[dependencies] +openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" } +openfut-host-config = { path = "../openfut-host-config" } +# Same listener as the redirector, by construction rather than by convention. +# Two hosts configuring TLS separately is how the 2026-08-11 certificate +# mismatch happened, and roster is the service where that mismatch first +# became visible to the player. +openfut-tls = { path = "../openfut-tls" } diff --git a/openfut-roster-host/build.rs b/openfut-roster-host/build.rs new file mode 100644 index 0000000..774168b --- /dev/null +++ b/openfut-roster-host/build.rs @@ -0,0 +1,39 @@ +//! Stamp the commit this binary was built from. +//! +//! Deliberately records ONLY the commit — no dirty-tree flag. Cargo will not +//! re-run a build script because another crate's source changed, so a +//! compiled-in "clean" claim can be stale and is therefore not a safeguard. +//! (Verified on the Blaze host: editing the adapter and rebuilding left its +//! flag reading clean.) +//! +//! The authoritative checks run at launch, in `scripts/verify-build-identity.sh`, +//! which compares this stamp against the checkout's real HEAD and inspects the +//! working tree as it is at that moment. + +use std::process::Command; + +fn git(args: &[&str]) -> Option { + let out = Command::new("git").args(args).output().ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn main() { + let commit = git(&["rev-parse", "--short=7", "HEAD"]).unwrap_or_else(|| "unknown".into()); + println!("cargo:rustc-env=OPENFUT_BUILD_COMMIT={commit}"); + + // Committing updates refs/heads/, not the HEAD file, so watching + // HEAD alone leaves the stamp a commit behind. + for p in ["../.git/HEAD", "../.git/index"] { + if std::path::Path::new(p).exists() { + println!("cargo:rerun-if-changed={p}"); + } + } + if let Some(rf) = git(&["symbolic-ref", "-q", "HEAD"]) { + let path = format!("../.git/{rf}"); + if std::path::Path::new(&path).exists() { + println!("cargo:rerun-if-changed={path}"); + } + } +} diff --git a/openfut-roster-host/src/config.rs b/openfut-roster-host/src/config.rs new file mode 100644 index 0000000..30fe1be --- /dev/null +++ b/openfut-roster-host/src/config.rs @@ -0,0 +1,125 @@ +//! Roster host configuration. +//! +//! Endpoints resolve through `openfut-host-config`, the single environment +//! reader, so every host builds client-visible addresses the same way. Only +//! transport settings are parsed here, and none of them are client-visible. + +use std::time::Duration; + +use openfut_adapter_fifa17::roster; +use openfut_host_config::{self as hostcfg, ConfigError}; +use openfut_tls::{ProtocolVersion, TlsConfig}; + +/// How long the oracle holds a roster connection open after responding. +/// +/// **Measured, and deliberately different from the redirector's.** The Blaze +/// redirector sleeps 300ms before closing (`time.sleep(0.3)` in +/// `redir_handle`); `roster_server.py` has no such sleep, and a lifecycle probe +/// against the oracle records `close_ms=0.0` for GET, HEAD and POST alike. +/// +/// Copying the redirector's 300ms because "the other host does it" would have +/// been the obvious mistake: FIFA polls this endpoint roughly every 7-15s, so a +/// needless dwell per poll is a behaviour change, not a safety margin. +pub const ORACLE_CLOSE_DWELL: Duration = Duration::from_millis(0); + +/// Compose the shared listener from the FIFA 17 adapter's observed TLS profile. +/// +/// Identical construction to the redirector host. That is the point: the client +/// caches the certificate presented by the first service it reaches, so every +/// FIFA-facing listener must be built the same way from the same inputs. +fn fifa17_tls(cert: impl Into, key: impl Into) -> Result { + use openfut_adapter_fifa17::tls as profile; + let ver = |s: &str| { + ProtocolVersion::parse(s).map_err(|e| ConfigError(format!("FIFA 17 TLS profile: {e}"))) + }; + Ok(TlsConfig::new( + cert, + key, + profile::CIPHER_LIST, + ver(profile::MIN_VERSION)?, + ver(profile::MAX_VERSION)?, + )) +} + +#[derive(Debug, Clone)] +pub struct RosterConfig { + /// BIND: where this listener binds. Never client-visible. + pub listen_addr: String, + /// Required, with no default, so this host can never collide with the + /// Python roster server it runs beside. + pub listen_port: u16, + pub tls: TlsConfig, + /// The `Server:` header to emit. + /// + /// Environment-derived, not protocol-derived: it carries the *oracle's* + /// Python version, so it changes when the container's Python does. Default + /// is what the container was last observed to send; overridable so matching + /// a redeployed oracle is a configuration change, not a release. + pub server_header: String, + /// Dwell before closing an answered connection. Overridable ONLY so the + /// causal experiment can be run without rebuilding. + pub close_dwell: Duration, +} + +impl RosterConfig { + pub fn from_env() -> Result { + let adapter = hostcfg::adapter_from_env()?; + + let listen_port = hostcfg::required_port( + "OPENFUT_ROSTER_HOST_PORT", + "this host runs beside the working Python roster server and must \ + not collide with it, so the port is explicit and has no default", + )?; + let listen_addr = hostcfg::optional("OPENFUT_ROSTER_HOST_BIND", &adapter.endpoints.bind); + + let cert = hostcfg::required( + "OPENFUT_ROSTER_CERT", + "path to the RSA certificate; it MUST be the same certificate every \ + other FIFA-facing service presents, or the client's cached copy \ + will not match and the handshake fails with nothing logged", + )?; + let key = hostcfg::required("OPENFUT_ROSTER_KEY", "path to the matching private key")?; + + let tls = fifa17_tls(cert, key)?; + + let server_header = + hostcfg::optional("OPENFUT_ROSTER_SERVER_HEADER", roster::ORACLE_SERVER); + + let close_dwell = match hostcfg::optional_opt("OPENFUT_ROSTER_CLOSE_DWELL_MS") { + None => ORACLE_CLOSE_DWELL, + // Invalid input is an error, never a silent fallback: a typo that + // quietly restored the default would make the causal experiment + // report the wrong answer. + Some(v) => Duration::from_millis(v.trim().parse().map_err(|_| { + ConfigError(format!( + "OPENFUT_ROSTER_CLOSE_DWELL_MS is not a number of milliseconds: {v:?}" + )) + })?), + }; + + Ok(RosterConfig { + listen_addr, + listen_port, + tls, + server_header, + close_dwell, + }) + } + + pub fn listen_on(&self) -> String { + format!("{}:{}", self.listen_addr, self.listen_port) + } + + /// Test fixture: an ephemeral port and a certificate pair supplied by the + /// caller, so tests never depend on a deployed path. + #[doc(hidden)] + pub fn for_test(cert: &str, key: &str) -> RosterConfig { + RosterConfig { + listen_addr: "127.0.0.1".into(), + listen_port: 0, + tls: fifa17_tls(cert, key).expect("the adapter's TLS profile must be valid"), + server_header: roster::ORACLE_SERVER.to_string(), + close_dwell: ORACLE_CLOSE_DWELL, + } + } +} diff --git a/openfut-roster-host/src/lib.rs b/openfut-roster-host/src/lib.rs new file mode 100644 index 0000000..1b29a12 --- /dev/null +++ b/openfut-roster-host/src/lib.rs @@ -0,0 +1,430 @@ +//! 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, Ordering}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use openfut_adapter_fifa17::roster::{self, Method}; +// The acceptor comes from the shared crate, so this host never names OpenSSL. +use openfut_tls::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 happened when the request body was drained. +/// +/// Duplicated from the redirector host on purpose, for now. Unifying the two +/// would mean editing the redirector, and the roster A/B must change exactly +/// one thing. This is scheduled for extraction once the roster gate closes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyRead { + /// No `Content-Length` header — nothing to drain. + None, + Complete(usize), + Short { + got: usize, + want: usize, + }, +} + +/// 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>>; + +/// 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, +} + +impl 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(()) + } +} + +/// 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())), + }) +} + +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, +) { + let peer = stream + .peer_addr() + .map(|a| a.to_string()) + .unwrap_or_else(|_| "?".into()); + + 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(), + describe(body), + 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(); +} + +fn describe(b: BodyRead) -> String { + match b { + BodyRead::None => "none".into(), + BodyRead::Complete(n) => format!("{n}B complete"), + BodyRead::Short { got, want } => format!("{got}/{want}B SHORT"), + } +} + +/// `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; + } + } + } + } +} + +/// Parse `Content-Length` and read the remainder of the body into `buf`. +/// +/// Header lookup is case-insensitive, and a client that stops early ends the +/// read rather than hanging. +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 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); + } +} diff --git a/openfut-roster-host/src/main.rs b/openfut-roster-host/src/main.rs new file mode 100644 index 0000000..fe0d316 --- /dev/null +++ b/openfut-roster-host/src/main.rs @@ -0,0 +1,15 @@ +//! Entry point for the FUT roster-update host. + +fn main() -> std::io::Result<()> { + // `--identity` so a lifecycle script can ask the binary which commit it was + // built from, rather than inferring it from a file's mtime. Checking the + // artifact on disk instead of the running process has already produced one + // confidently wrong refusal on this project. + if std::env::args().any(|a| a == "--identity") { + println!("{}", openfut_roster_host::identity()); + return Ok(()); + } + let cfg = openfut_roster_host::RosterConfig::from_env() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?; + openfut_roster_host::serve(cfg) +} diff --git a/openfut-roster-host/tests/oracle_parity.rs b/openfut-roster-host/tests/oracle_parity.rs new file mode 100644 index 0000000..951a156 --- /dev/null +++ b/openfut-roster-host/tests/oracle_parity.rs @@ -0,0 +1,295 @@ +//! The roster host held against the Python oracle's measured behaviour. +//! +//! Response bytes are only half of it. The redirector cost two live gate +//! attempts to a divergence no response comparison could see — a request body +//! left unread, which turns the close into an RST — so the oracle's lifecycle +//! was measured with a probe and every property is asserted here: +//! +//! ```text +//! dwell after responding 0 ms +//! request body drained before the response is written +//! close immediately after, cleanly +//! keep-alive none: one request per connection +//! ``` +//! +//! Where a property is observable both from the client and from inside the +//! host, it is asserted **inside the host** via `ConnOutcome`. That distinction +//! is not pedantry: the first attempt to test body-draining asserted that the +//! client still received its response, and that test passed against a host that +//! did not drain at all — the client reads the buffered response and sees +//! `close_notify` before any reset. + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::time::{Duration, Instant}; + +use openfut_adapter_fifa17::roster; +use openfut_roster_host::{bind, BodyRead, RosterConfig}; + +const PATH: &str = "/fifa17/fut/rosterupdate.xml"; +const BODY: &[u8] = b"probe=1&pad=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn cert_pair() -> (String, String) { + let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/tools"); + ( + format!("{base}/redir_cert.pem"), + format!("{base}/redir_key.pem"), + ) +} + +struct Harness { + addr: std::net::SocketAddr, + outcomes: openfut_roster_host::Outcomes, +} + +fn start() -> Harness { + start_with(|_| {}) +} + +fn start_with(tweak: impl FnOnce(&mut RosterConfig)) -> Harness { + let (c, k) = cert_pair(); + let mut cfg = RosterConfig::for_test(&c, &k); + tweak(&mut cfg); + let server = bind(cfg).expect("bind"); + let addr = server.local_addr; + let outcomes = server.outcomes(); + std::thread::spawn(move || { + let _ = server.run(); + }); + Harness { addr, outcomes } +} + +/// A client that behaves like FIFA: legacy suites, no certificate checking. +fn connect(addr: std::net::SocketAddr) -> openfut_tls::SslStream { + use openfut_tls::{SslConnector, SslMethod, SslVerifyMode}; + let mut b = SslConnector::builder(SslMethod::tls()).expect("connector"); + b.set_cipher_list(openfut_adapter_fifa17::tls::OBSERVED_CLIENT_SUITES) + .expect("ciphers"); + b.set_verify(SslVerifyMode::NONE); + let sock = TcpStream::connect(addr).expect("connect"); + sock.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let ssl = b + .build() + .configure() + .and_then(|c| c.verify_hostname(false).into_ssl("roster-test")) + .expect("ssl"); + let mut s = openfut_tls::SslStream::new(ssl, sock).expect("stream"); + s.connect().expect("handshake"); + s +} + +fn request(method: &str, body: Option<&[u8]>) -> Vec { + let mut r = format!("{method} {PATH} HTTP/1.1\r\nHost: roster-test\r\nAccept: */*\r\n"); + if let Some(b) = body { + r.push_str(&format!("Content-Length: {}\r\n", b.len())); + } + r.push_str("\r\n"); + let mut out = r.into_bytes(); + if let Some(b) = body { + out.extend_from_slice(b); + } + out +} + +fn read_to_eof(s: &mut openfut_tls::SslStream) -> Vec { + let mut out = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + match s.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => out.extend_from_slice(&chunk[..n]), + } + } + out +} + +fn exchange(addr: std::net::SocketAddr, method: &str, body: Option<&[u8]>) -> Vec { + let mut s = connect(addr); + s.write_all(&request(method, body)).expect("write"); + s.flush().ok(); + read_to_eof(&mut s) +} + +fn mask(raw: &[u8]) -> String { + String::from_utf8_lossy(raw) + .split("\r\n") + .map(|l| { + if l.starts_with("Date: ") { + "Date: ".to_string() + } else if l.starts_with("Server: ") { + "Server: ".to_string() + } else { + l.to_string() + } + }) + .collect::>() + .join("\r\n") +} + +/// The bytes the adapter says the oracle sends, masked the same way. +fn expected(method: roster::Method) -> String { + mask(&roster::roster_response( + method, + roster::ORACLE_SERVER, + "IRRELEVANT", + )) +} + +#[test] +fn get_returns_the_oracles_bytes() { + let h = start(); + let got = exchange(h.addr, "GET", None); + assert_eq!(mask(&got), expected(roster::Method::Get)); + assert!(String::from_utf8_lossy(&got).starts_with("HTTP/1.0 200 OK\r\n")); +} + +#[test] +fn head_and_post_are_headers_only_but_still_advertise_the_body() { + let h = start(); + for (m, adapter_m) in [ + ("HEAD", roster::Method::Head), + ("POST", roster::Method::Post), + ] { + let body = if m == "POST" { Some(BODY) } else { None }; + let got = exchange(h.addr, m, body); + assert_eq!(mask(&got), expected(adapter_m), "{m} differs"); + let text = String::from_utf8_lossy(&got); + assert!(text.contains("Content-Length: 67"), "{m}: {text}"); + assert!(text.ends_with("\r\n\r\n"), "{m} must send no body"); + } +} + +/// Asserted on the HOST's record, not on what the client received. +/// +/// A client-side assertion cannot distinguish "drained" from "not drained": +/// it reads the buffered response either way. This is the exact mistake that +/// let a mutation survive on the redirector. +#[test] +fn the_request_body_is_drained_before_the_response_is_written() { + let h = start(); + let _ = exchange(h.addr, "POST", Some(BODY)); + let rec = h.outcomes.lock().unwrap(); + let last = rec.last().expect("a connection was recorded"); + assert_eq!( + last.body, + BodyRead::Complete(BODY.len()), + "the whole body must be read before answering, or the close becomes an RST" + ); + assert!(last.response_bytes > 0); +} + +/// The oracle answers a POST only after the body arrives. Sending the head, +/// pausing, then sending the body proves the host waits rather than replying +/// early and leaving the socket dirty. +#[test] +fn a_late_body_is_waited_for_rather_than_answered_early() { + let h = start(); + let mut s = connect(h.addr); + let head = format!( + "POST {PATH} HTTP/1.1\r\nHost: roster-test\r\nContent-Length: {}\r\n\r\n", + BODY.len() + ); + s.write_all(head.as_bytes()).unwrap(); + s.flush().ok(); + + s.get_ref() + .set_read_timeout(Some(Duration::from_millis(600))) + .unwrap(); + let mut early = [0u8; 64]; + let early_n = s.read(&mut early).unwrap_or(0); + assert_eq!( + early_n, + 0, + "answered before the body arrived: {:?}", + String::from_utf8_lossy(&early[..early_n]) + ); + + s.get_ref() + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + s.write_all(BODY).unwrap(); + s.flush().ok(); + let late = read_to_eof(&mut s); + assert!(!late.is_empty(), "no response after the body arrived"); + assert_eq!(mask(&late), expected(roster::Method::Post)); +} + +/// One request per connection: the response says `Connection: close` and the +/// oracle closes. Measured, not assumed — a keep-alive loop would be a +/// behaviour change invisible in any single response. +#[test] +fn the_connection_closes_after_one_request() { + let h = start(); + let mut s = connect(h.addr); + s.write_all(&request("GET", None)).unwrap(); + s.flush().ok(); + let first = read_to_eof(&mut s); + assert!(!first.is_empty()); + // read_to_eof already ran to EOF; a second request must not be answered. + let _ = s.write_all(&request("GET", None)); + let mut buf = [0u8; 32]; + let n = s.read(&mut buf).unwrap_or(0); + assert_eq!(n, 0, "a second request was answered on the same connection"); +} + +/// The oracle has no post-response sleep — unlike the redirector's 300ms. +/// Inheriting the wrong dwell would add delay to a poll that runs every few +/// seconds. +#[test] +fn the_connection_is_not_held_open_after_responding() { + let h = start(); + let mut s = connect(h.addr); + s.write_all(&request("GET", None)).unwrap(); + s.flush().ok(); + let start = Instant::now(); + let got = read_to_eof(&mut s); + let elapsed = start.elapsed(); + assert!(!got.is_empty()); + assert!( + elapsed < Duration::from_millis(150), + "closed after {elapsed:?}; the roster oracle closes immediately" + ); +} + +/// The dwell stays configurable so the causal experiment — set it and confirm +/// the delay appears — can run without a rebuild. Guards against the knob +/// being quietly ignored. +#[test] +fn the_dwell_is_overridable() { + let h = start_with(|c| c.close_dwell = Duration::from_millis(400)); + let mut s = connect(h.addr); + s.write_all(&request("GET", None)).unwrap(); + s.flush().ok(); + let start = Instant::now(); + let _ = read_to_eof(&mut s); + assert!( + start.elapsed() >= Duration::from_millis(350), + "dwell override had no effect" + ); +} + +/// The oracle never routes on the path — it logs it and answers the same way. +/// A host that started 404ing unknown paths would be a behaviour change. +#[test] +fn any_path_gets_the_same_answer() { + let h = start(); + let mut s = connect(h.addr); + s.write_all(b"GET /something/else HTTP/1.1\r\nHost: x\r\n\r\n") + .unwrap(); + s.flush().ok(); + let got = read_to_eof(&mut s); + assert_eq!(mask(&got), expected(roster::Method::Get)); +} + +/// Masking must not be able to hide a real difference. +#[test] +fn masking_does_not_hide_a_missing_header() { + let good = roster::roster_response(roster::Method::Get, roster::ORACLE_SERVER, "X"); + let without: Vec = String::from_utf8_lossy(&good) + .split("\r\n") + .filter(|l| !l.starts_with("Date: ")) + .collect::>() + .join("\r\n") + .into_bytes(); + assert_ne!(mask(&good), mask(&without)); +} diff --git a/openfut-tls/src/lib.rs b/openfut-tls/src/lib.rs index a0977c2..7bfc7dd 100644 --- a/openfut-tls/src/lib.rs +++ b/openfut-tls/src/lib.rs @@ -33,7 +33,15 @@ use std::fmt; use std::path::Path; -use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslOptions, SslVersion}; +use openssl::ssl::{SslFiletype, SslOptions, SslVersion}; + +/// Re-exported so a transport host never has to name OpenSSL itself. +/// +/// Hosts depend on this crate, not on `openssl`. That keeps the linked TLS +/// implementation a property of one manifest rather than of every host — the +/// version is gate evidence, and a host that could pull its own would be able +/// to drift from the rest of the stack silently. +pub use openssl::ssl::{SslAcceptor, SslConnector, SslMethod, SslStream, SslVerifyMode}; #[derive(Debug)] pub struct TlsError(pub String);