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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-11 17:41:11 +00:00
parent 84e81f2037
commit c9ae914910
9 changed files with 939 additions and 1 deletions
+125
View File
@@ -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<String>, key: impl Into<String>) -> Result<TlsConfig, ConfigError> {
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<RosterConfig, ConfigError> {
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,
}
}
}
+430
View File
@@ -0,0 +1,430 @@
//! 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};
// 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<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(),
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<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;
}
}
}
}
}
/// 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<S: Read>(stream: &mut S, buf: &mut Vec<u8>) -> 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::<usize>().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);
}
}
+15
View File
@@ -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)
}