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:
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user