05f6147433
Queued cleanup, run only AFTER the roster gate closed in both directions,
so the live A/B changed exactly one thing.
The two `drain_body` implementations were character-for-character
identical, so the extraction is a move. What it guards is not cosmetic:
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 --
invisible in any comparison of the response, and worth two live gate
attempts to find. Behaviour that must be identical across hosts gets one
implementation, the same reasoning that produced openfut-tls.
SCOPE IS DELIBERATELY NARROW. Only the byte-identical part moved. The two
head-reading loops are NOT identical and stay where they are:
redirector roster
head cap 65536 16384
read chunk 4096 1024
on error abort proceed if any bytes arrived
Those differences are probably accidental, but each host is gate-proven
with the values it has. Unifying them would be a behaviour change wearing
a refactor's clothes -- exactly the mistake this project has already paid
for. They converge later as their own change with their own gate, or not
at all.
Purity shown, not asserted: every existing test in both hosts still
passes (426 workspace tests), and 7/7 mutations are killed, including
three in the SHARED crate that must break both hosts at once and one per
host that skips the drain call.
Three test cases neither host had now exist, because the extracted code
finally had somewhere to be tested directly: a malformed Content-Length,
an unterminated head, and a lookalike header. That last one matters --
`X-Original-Content-Length: 99` would drain 99 bytes that were never sent
if the match were `contains` rather than `starts_with`, and a mutation
confirms the test catches it.
Also fixes a race this run exposed in openfut-tls's own tests: keypair()
returned early if the certificate file existed, but wrote the certificate
BEFORE the key, so a parallel test could observe a cert whose key had not
landed. It failed one run and passed the next -- the kind of flake that
gets rerun instead of fixed. Now generated once per process via OnceLock,
key written first, and the suite was repeated five times to confirm.
Nothing deployed and nothing restarted: the running redirector and roster
are still the gate-proven binaries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
378 lines
12 KiB
Rust
378 lines
12 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, 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::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>>>;
|
|
|
|
/// 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,
|
|
}
|
|
|
|
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<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())),
|
|
})
|
|
}
|
|
|
|
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(),
|
|
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);
|
|
}
|
|
}
|