Files
OpenFUT/openfut-roster-host/src/lib.rs
T
funman300 ad406f21bd fix(tls): share bare-probe classification across all FIFA-facing TLS hosts
A reachability probe (TcpStream::connect then drop; the launcher preflight makes
them) reaches a TLS acceptor as 'unexpected EOF' — byte-identical to the
certificate mismatch that cost three live gates. The redirector classified the
opening before the acceptor to keep a benign probe from forging a TLS fault, but
the roster host (the second FIFA-facing TLS host) did not, so the documented
hazard 'remains in any other TLS host that has not adopted it' was live there.

Lift the pure policy (PeerOpening + classify_opening) plus a peer_opening(&TcpStream)
peek helper into the shared openfut-tls crate (game-independent; +unit tests).
The redirector now re-exports them (public API + its probe_classification test
unchanged; behaviour identical). The roster host adopts them: a ProbeCount, a
probes() handle, and a pre-acceptor peek that logs PROBE and returns instead of
failing the handshake. New roster probe_classification integration test (3 cases:
bare probe classified, real client after a probe still served 200, speaks-then-
fails still reported as a fault). Full workspace tests green; clippy -D clean.
2026-08-16 20:30:50 +00:00

408 lines
14 KiB
Rust

//! FUT roster-update transport host.
//!
//! ```text
//! FIFA 17 ──HTTPS GET /fifa17/fut/rosterupdate.xml──> <rosterupdate version="0"/>
//! ```
//!
//! # 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<Method>,
pub path: String,
pub request_bytes: usize,
pub body: BodyRead,
pub response_bytes: usize,
}
pub type Outcomes = Arc<std::sync::Mutex<Vec<ConnOutcome>>>;
/// 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<AtomicUsize>;
/// 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<SslAcceptor>,
cfg: Arc<RosterConfig>,
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<Server> {
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<S: Read>(stream: &mut S, buf: &mut Vec<u8>) -> 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);
}
}