ad406f21bd
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.
408 lines
14 KiB
Rust
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);
|
|
}
|
|
}
|