Files
OpenFUT/openfut-adapter-fifa17/src/roster/mod.rs
T
funman300 84e81f2037 tls: extract a shared listener; move FIFA 17's profile into its adapter
The redirector was the only host that spoke TLS, so its TLS lived inside
it. The roster host needs the same listener, and that made the choice
explicit: share this code or copy it.

Copying it is what already went wrong. On 2026-08-11 the Rust redirector
served one certificate while the container served another. ProtoSSL
caches the server certificate per backend, so the redirector -- the first
TLS connection of a session -- decided what the client expected, and
every later service failed its handshake. Silently: Python's socketserver
swallows ssl.SSLError as OSError. Three gates went to it. One place to
configure TLS is the structural fix, so it exists before the second host
does rather than after.

Split along the line the architecture already draws:

  openfut-tls               how to build an acceptor. Game-independent.
                            Knows nothing about which suites any client
                            offers.
  adapter-fifa17::tls       what FIFA 17 was OBSERVED to offer: the six
                            enabled suites, the two refused, the TLS 1.2
                            window, the EA SNI. Plain strings, so the
                            adapter keeps its lean dependencies -- reading
                            a card table should not build OpenSSL.
  redirector-host           joins the two. Chooses no cipher of its own.

Behaviour is unchanged, and shown to be:

* tests/fifa17_tls_profile.rs carries over every case from the deleted
  module -- FIFA's eight suites negotiate AES256-GCM-SHA384, each enabled
  suite works alone, RC4-only is refused, ECDHE-only is refused. Deleting
  a module must not quietly delete its evidence.
* one test pins the composed values literally against the host as it was
  when gates 1-14 passed. A "pure refactor" that cannot fail is not a
  claim, it is an assumption.
* the rebuilt binary self-tests to the same TLSv1.2 / AES256-GCM-SHA384
  the retail client negotiated at 17:09 today.

Two improvements fall out of having one place to look:

* the startup banner now prints cert_sha256. The mismatch above raised no
  error at startup and broke the client much later with nothing logged;
  it is now the first line of the log.
* tls_min/tls_max print as TLSv1.2 rather than SslVersion(771). This line
  is gate evidence and gets read by people.

Nothing deployed and nothing restarted: FIFA is mid-session on the
running redirector, which is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:30:01 +00:00

220 lines
9.0 KiB
Rust

//! The FUT roster-update response — "is there a squad update to download?".
//!
//! ```text
//! FIFA 17 ──HTTPS GET /fifa17/fut/rosterupdate.xml──> <rosterupdate version="0"/>
//! ```
//!
//! # Why this matters more than its size suggests
//!
//! `checkFUTRostersFlow` downloads this before entering FUT. On success it
//! advances to the hub; on failure it aborts with *"An error occurred
//! downloading the FUT Squad Update"*. It is the last gate before the hub, and
//! because it is a separate TLS connection from the redirector it is also where
//! a certificate mismatch elsewhere in the stack first becomes visible. Three
//! redirector gates were lost to exactly that.
//!
//! # What the oracle actually puts on the wire
//!
//! `roster_server.py` uses `http.server.BaseHTTPRequestHandler`, which shapes
//! the response in ways the handler code does not show:
//!
//! * the status line is **HTTP/1.0**, because `protocol_version` is left at its
//! default — not HTTP/1.1, despite the client asking for 1.1
//! * `send_response` emits `Server:` and `Date:` *before* any header the
//! handler sets, so header order is Server, Date, Content-Type,
//! Content-Length, Connection
//! * **POST answers with headers only.** The handler writes the body `if method
//! == "GET"`, so a POST advertises `Content-Length: 67` and then sends
//! nothing. That is preserved here rather than corrected: it is the behaviour
//! the retail client was proven against, and "obviously a bug" is exactly the
//! kind of judgement that has been wrong before in this port.
//!
//! Any path gets the same answer — the oracle logs `self.path` and never routes
//! on it. A reimplementation that started 404ing unknown paths would be a
//! behaviour change, not a fix.
/// The path the client requests. Recorded for diagnostics; **not** used for
/// routing, because the oracle does not route.
pub const REQUEST_PATH: &str = "/fifa17/fut/rosterupdate.xml";
/// The "no update available" body, byte-for-byte from the oracle.
pub const ROSTER_XML: &[u8] =
b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rosterupdate version=\"0\"/>\n";
/// The `Server:` string the oracle emits.
///
/// Environment-derived, not protocol-derived: it is Python's version string,
/// so it changes when the container's Python does. Kept as a default rather
/// than hardcoded into the response builder so a host can match whatever the
/// deployed oracle actually sends — the same reasoning that makes the
/// advertised address configuration rather than a constant.
pub const ORACLE_SERVER: &str = "BaseHTTP/0.6 Python/3.12.13";
/// Which of the oracle's three handlers a request lands in.
///
/// `Post` is distinct from `Head` in the oracle's *code* (it drains the request
/// body first) but identical in its *response*, so the distinction is kept here
/// for the host's benefit rather than the response builder's.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Get,
Head,
Post,
}
impl Method {
/// Parse the request line's method. Unknown methods are `None` — the oracle
/// only defines `do_GET`/`do_HEAD`/`do_POST`, and `BaseHTTPRequestHandler`
/// answers anything else with its own 501, which is a different response
/// this module deliberately does not claim to reproduce.
pub fn parse(line0: &str) -> Option<Method> {
match line0.split_whitespace().next()? {
"GET" => Some(Method::Get),
"HEAD" => Some(Method::Head),
"POST" => Some(Method::Post),
_ => None,
}
}
/// Only GET carries the body, per the oracle.
pub fn carries_body(self) -> bool {
matches!(self, Method::Get)
}
}
/// Build the response exactly as the oracle would.
///
/// `date` is supplied by the caller rather than read from the clock here, so
/// this stays a pure function and the fixtures can pin it. Format is the one
/// `BaseHTTPRequestHandler.date_time_string` produces: RFC 7231 IMF-fixdate,
/// always GMT.
pub fn roster_response(method: Method, server: &str, date: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(256);
out.extend_from_slice(b"HTTP/1.0 200 OK\r\n");
out.extend_from_slice(format!("Server: {server}\r\n").as_bytes());
out.extend_from_slice(format!("Date: {date}\r\n").as_bytes());
out.extend_from_slice(b"Content-Type: application/xml\r\n");
// Always the body's length, even when no body follows. See the module note.
out.extend_from_slice(format!("Content-Length: {}\r\n", ROSTER_XML.len()).as_bytes());
out.extend_from_slice(b"Connection: close\r\n");
out.extend_from_slice(b"\r\n");
if method.carries_body() {
out.extend_from_slice(ROSTER_XML);
}
out
}
/// `Date:` in the oracle's format, from a Unix timestamp.
///
/// Implemented rather than pulled from a date crate to keep this crate
/// dependency-free, matching the protocol layer's constraint. Civil-date
/// conversion is the standard days-from-epoch algorithm.
pub fn http_date(unix_secs: i64) -> String {
const DAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let days = unix_secs.div_euclid(86_400);
let secs = unix_secs.rem_euclid(86_400);
// 1970-01-01 was a Thursday (index 3).
let dow = DAYS[(days + 3).rem_euclid(7) as usize];
// days-from-civil, inverted (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{dow}, {d:02} {mon} {y} {h:02}:{mi:02}:{s:02} GMT",
mon = MONTHS[(m - 1) as usize],
h = secs / 3600,
mi = (secs % 3600) / 60,
s = secs % 60
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_body_is_the_oracles_67_bytes() {
assert_eq!(ROSTER_XML.len(), 67);
assert!(ROSTER_XML.starts_with(b"<?xml version=\"1.0\" encoding=\"utf-8\"?>"));
assert!(ROSTER_XML.ends_with(b"<rosterupdate version=\"0\"/>\n"));
}
#[test]
fn only_get_carries_the_body() {
let d = "Tue, 11 Aug 2026 05:15:13 GMT";
let get = roster_response(Method::Get, ORACLE_SERVER, d);
let head = roster_response(Method::Head, ORACLE_SERVER, d);
let post = roster_response(Method::Post, ORACLE_SERVER, d);
assert_eq!(get.len(), head.len() + ROSTER_XML.len());
assert_eq!(head, post, "the oracle answers POST exactly as HEAD");
}
/// The quirk, asserted so a future "cleanup" has to argue with a test.
#[test]
fn a_bodiless_response_still_advertises_the_body_length() {
let r = roster_response(Method::Head, ORACLE_SERVER, "Tue, 11 Aug 2026 05:15:13 GMT");
let text = String::from_utf8_lossy(&r);
assert!(text.contains("Content-Length: 67"), "{text}");
assert!(text.ends_with("\r\n\r\n"), "no body may follow");
}
#[test]
fn header_order_matches_basehttprequesthandler() {
let r = roster_response(Method::Get, ORACLE_SERVER, "Tue, 11 Aug 2026 05:15:13 GMT");
let text = String::from_utf8_lossy(&r);
let order: Vec<&str> = [
"Server:",
"Date:",
"Content-Type:",
"Content-Length:",
"Connection:",
]
.iter()
.map(|h| text.find(h).map(|_| *h).unwrap_or("MISSING"))
.collect();
assert!(!order.contains(&"MISSING"), "{text}");
let positions: Vec<usize> = order.iter().map(|h| text.find(h).unwrap()).collect();
let mut sorted = positions.clone();
sorted.sort_unstable();
assert_eq!(
positions, sorted,
"headers out of the oracle's order:\n{text}"
);
assert!(
text.starts_with("HTTP/1.0 200 OK\r\n"),
"must be HTTP/1.0: {text}"
);
}
#[test]
fn methods_parse_and_unknown_ones_are_refused() {
assert_eq!(Method::parse("GET /x HTTP/1.1"), Some(Method::Get));
assert_eq!(Method::parse("HEAD /x HTTP/1.1"), Some(Method::Head));
assert_eq!(Method::parse("POST /x HTTP/1.1"), Some(Method::Post));
// Not reproduced on purpose: the oracle answers these with its own 501.
assert_eq!(Method::parse("PUT /x HTTP/1.1"), None);
assert_eq!(Method::parse(""), None);
}
#[test]
fn http_date_matches_the_oracles_format() {
// 1786425313 == Tue, 11 Aug 2026 05:15:13 GMT, the captured fixture time.
assert_eq!(http_date(1_786_425_313), "Tue, 11 Aug 2026 05:15:13 GMT");
assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
// A leap day, because the civil-date maths is the only real logic here.
assert_eq!(http_date(1_709_164_800), "Thu, 29 Feb 2024 00:00:00 GMT");
}
}