redirector: Rust host on vendored OpenSSL; shared typed config extracted

TLS DEPENDENCY, as directed: the openssl crate directly with the `vendored`
feature. NOT native-tls. native-tls abstracts over whatever the platform
provides; here the requirement is the opposite -- precise, evidenced behaviour
for one legacy client -- which needs explicit control of the cipher list,
protocol floor/ceiling and security level. Vendored so a distro libssl update
cannot silently change whether FIFA 17 can connect.

Scoped to this crate alone. Neither OpenFUT Core nor the generic protocol
crates gain an OpenSSL dependency.

CIPHERS driven by the captured retail ClientHello, not by generic legacy
assumptions. The six RSA+AES suites it offers are enabled; RC4 and MD5 are
deliberately NOT, even though the client offers them -- it already negotiates
AES256-GCM-SHA384, so resurrecting RC4 for completeness would weaken the
service for nothing. TLS 1.2 floor and ceiling, matching the observed client;
the floor is not dropped to 1.0 pre-emptively because "the oracle permits it"
is not "the client requires it".

SECURITY LEVEL IS NOT LOWERED. Tried the default policy first, as directed,
and OpenSSL 3.6.3 accepts static-RSA/AES without weakening. No SECLEVEL change
was needed and none is applied; it remains overridable per-listener with
evidence.

CERTIFICATE: the proven Python redirector's material is reused, so the TLS
implementation stays the only variable in an A/B. Verified RSA-2048, CN
winter15.gosredirector.ea.com, cert/key modulus match; the key stays
gitignored.

SHARED CONFIG. New openfut-host-config is now the only crate that reads the
environment, and both hosts resolve endpoints through it. Two hosts each
parsing OPENFUT_ADVERTISE would be exactly the "separate helpers constructing
endpoints from different sources of truth" the address audit forbids.

VERIFICATION BY REAL HANDSHAKE, not by enumeration. The crate exposes no
accessor for a context's configured suites at this version, which turned out
better: the host now rehearses the retail handshake at startup with a client
restricted to exactly FIFA's eight suites and REFUSES TO SERVE if it fails, so
a cipher/version misconfiguration surfaces at boot rather than as an
unexplained failure during a live gate.

Gates 1-5 pass: TLS config unit tests; a FIFA-suite-only client negotiates
TLSv1.2/AES256-GCM-SHA384; each enabled RSA+AES suite negotiable alone; an
RC4-only client is refused; an ECDHE-only client is refused (proving no modern
policy was silently inherited); a full HTTPS round-trip returns bytes
IDENTICAL to the Python oracle's recorded response.

Cargo.lock committed for reproducibility: openssl 0.10.81, openssl-sys 0.9.117,
openssl-src 300.6.1+3.6.3 (OpenSSL 3.6.3). Updating openssl-src is NOT a
routine bump -- it requires re-running the FIFA compatibility gates.

Gates 6-14 need the retail client and are next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-11 03:28:04 +00:00
parent 0d576a14b7
commit 89f77470f3
10 changed files with 1242 additions and 123 deletions
@@ -0,0 +1,163 @@
//! End-to-end: a real TLS request against the real host, compared with the
//! Python oracle byte-for-byte.
//!
//! The adapter's own suite already proves the response bytes. This proves the
//! whole transport: OpenSSL handshake, HTTP framing, and the same bytes coming
//! back over the wire — the redirector equivalent of the Blaze sidecar's
//! live-transport suite.
use std::io::{Read, Write};
use std::net::TcpStream;
use openfut_redirector_host::{bind, tls, RedirectorConfig};
use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode};
/// Start the real host on an ephemeral port.
fn start(advertise: &str) -> String {
let mut cfg = RedirectorConfig::for_test(advertise);
cfg.listen_port = 0;
let server = bind(cfg).expect("host binds");
let addr = server.local_addr.to_string();
std::thread::spawn(move || {
let _ = server.run();
});
addr
}
/// A client that behaves like the captured FIFA 17 ClientHello.
fn fifa_like_request(addr: &str) -> (String, Vec<u8>) {
let mut b = SslConnector::builder(SslMethod::tls()).expect("connector");
b.set_cipher_list(tls::OBSERVED_CLIENT_SUITES)
.expect("client cipher list");
// The retail client's cert checks are patched out and the oracle cert is
// self-signed; the variable under test is cipher/version negotiation.
b.set_verify(SslVerifyMode::NONE);
let connector = b.build();
let sock = TcpStream::connect(addr).expect("connect");
let ssl = connector
.configure()
.expect("configure")
.verify_hostname(false)
.into_ssl("winter15.gosredirector.ea.com")
.expect("ssl");
let mut stream = openssl::ssl::SslStream::new(ssl, sock).expect("stream");
stream.connect().expect("TLS handshake");
let negotiated = format!(
"{} / {}",
stream.ssl().version_str(),
stream
.ssl()
.current_cipher()
.map(|c| c.name())
.unwrap_or("?")
);
stream
.write_all(
b"POST /redirector/getServerInstance HTTP/1.1\r\n\
Host: winter15.gosredirector.ea.com\r\n\
Content-Type: application/xml\r\n\
Content-Length: 0\r\n\r\n",
)
.expect("write request");
stream.flush().ok();
let mut out = Vec::new();
let mut chunk = [0u8; 4096];
loop {
match stream.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(n) => out.extend_from_slice(&chunk[..n]),
}
}
(negotiated, out)
}
/// Gate 4: a FIFA-like client completes TLS and gets an HTTP response.
#[test]
fn a_fifa_like_client_gets_a_response_over_tls() {
let addr = start("198.51.100.7");
let (negotiated, body) = fifa_like_request(&addr);
assert_eq!(
negotiated, "TLSv1.2 / AES256-GCM-SHA384",
"negotiated the wrong thing"
);
let text = String::from_utf8_lossy(&body);
assert!(text.starts_with("HTTP/1.1 200 OK"), "{text}");
assert!(text.contains("<serverinstanceinfo>"), "{text}");
}
/// Gate 5: what comes back over TLS is byte-identical to the Python oracle's
/// recorded response for the same advertised address.
#[test]
fn the_response_over_tls_is_byte_identical_to_the_oracle() {
let path = format!(
"{}/../openfut-adapter-fifa17/fixtures/redirector.json",
env!("CARGO_MANIFEST_DIR")
);
let text = std::fs::read_to_string(&path).expect("oracle fixtures");
// Minimal extraction: the fixture is a flat {address: hex} object written
// by our own generator, so a full JSON dependency would be overkill here.
let advertise = "198.51.100.7";
let key = format!("\"{advertise}\"");
let start_idx = text.find(&key).expect("fixture for this address");
let rest = &text[start_idx + key.len()..];
let open = rest.find('"').expect("hex opens");
let close = rest[open + 1..].find('"').expect("hex closes");
let want_hex = &rest[open + 1..open + 1 + close];
let want: Vec<u8> = (0..want_hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&want_hex[i..i + 2], 16).expect("hex"))
.collect();
let addr = start(advertise);
let (_, got) = fifa_like_request(&addr);
assert_eq!(
String::from_utf8_lossy(&got),
String::from_utf8_lossy(&want),
"the response over TLS differs from the Python oracle"
);
}
/// The advertised address must come from configuration, over the wire, not
/// just in a unit test.
#[test]
fn the_advertised_address_reaches_the_wire() {
let addr = start("203.0.113.42");
let (_, body) = fifa_like_request(&addr);
let text = String::from_utf8_lossy(&body);
assert!(text.contains("<hostname>203.0.113.42</hostname>"), "{text}");
assert!(text.contains("<ip>3405803818</ip>"), "{text}");
assert!(!text.contains("198.51.100.7"));
}
/// A client that offers only forward-secret suites — i.e. anything modern —
/// must fail, confirming this listener really is the legacy island it claims
/// to be and has not silently acquired a modern policy.
#[test]
fn a_modern_client_cannot_connect_to_this_listener() {
let addr = start("198.51.100.7");
let mut b = SslConnector::builder(SslMethod::tls()).expect("connector");
b.set_cipher_list("ECDHE-RSA-AES256-GCM-SHA384")
.expect("list");
b.set_verify(SslVerifyMode::NONE);
let connector = b.build();
let sock = TcpStream::connect(&addr).expect("connect");
let ssl = connector
.configure()
.expect("configure")
.verify_hostname(false)
.into_ssl("winter15.gosredirector.ea.com")
.expect("ssl");
let mut stream = openssl::ssl::SslStream::new(ssl, sock).expect("stream");
assert!(
stream.connect().is_err(),
"an ECDHE-only client should not negotiate with this listener"
);
}