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,225 @@
|
||||
//! # openfut-redirector-host
|
||||
//!
|
||||
//! Transport host for the FIFA 17 Blaze redirector — the first hop.
|
||||
//!
|
||||
//! ```text
|
||||
//! FIFA 17 ──TLS 1.2, static-RSA──> this host ──> <serverinstanceinfo>
|
||||
//! "connect to <adv>:<blaze_port>"
|
||||
//! ```
|
||||
//!
|
||||
//! ## A deliberately small compatibility island
|
||||
//!
|
||||
//! This is the only OpenFUT crate that links OpenSSL, and it does so because a
|
||||
//! retail FIFA 17 client offers exactly eight static-RSA suites and nothing
|
||||
//! forward-secret. Neither OpenFUT Core nor the generic protocol crates gain
|
||||
//! that dependency. See [`tls`] for what the observed ClientHello dictates.
|
||||
//!
|
||||
//! ## Division of responsibility
|
||||
//!
|
||||
//! The host owns the listener, TLS, HTTP framing, connection lifecycle and
|
||||
//! diagnostics. `openfut-adapter-fifa17::redirector` owns the response and
|
||||
//! nothing else — the same split as the Blaze sidecar, where the adapter
|
||||
//! decides what to say and the host owns the socket.
|
||||
//!
|
||||
//! No FUT state lives here.
|
||||
//!
|
||||
//! ## Configuration
|
||||
//!
|
||||
//! Resolved through `openfut-host-config`, the single environment reader, so
|
||||
//! the advertised address reaches this host by the same construction path as
|
||||
//! the Blaze sidecar's.
|
||||
|
||||
pub mod config;
|
||||
pub mod tls;
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use openfut_adapter_fifa17::redirector;
|
||||
use openssl::ssl::SslAcceptor;
|
||||
|
||||
pub use config::RedirectorConfig;
|
||||
|
||||
fn log(msg: &str) {
|
||||
let ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
eprintln!("[{}.{:03}] {msg}", ms / 1000, ms % 1000);
|
||||
}
|
||||
|
||||
/// One line naming the binary, its linked TLS, and what it will advertise.
|
||||
pub fn banner(cfg: &RedirectorConfig) -> String {
|
||||
format!(
|
||||
"openfut-redirector-host v{} openssl={} listen={} advertise={}:{} ciphers={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
tls::openssl_version(),
|
||||
cfg.listen_on(),
|
||||
cfg.adapter.endpoints.advertise,
|
||||
cfg.adapter.endpoints.blaze_port,
|
||||
cfg.tls.cipher_list,
|
||||
)
|
||||
}
|
||||
|
||||
/// A bound listener, so a caller can learn the real port before serving
|
||||
/// (ephemeral ports in tests) and so the self-test runs before anything is
|
||||
/// accepted.
|
||||
pub struct Server {
|
||||
pub local_addr: std::net::SocketAddr,
|
||||
listener: TcpListener,
|
||||
acceptor: Arc<SslAcceptor>,
|
||||
cfg: Arc<RedirectorConfig>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
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());
|
||||
std::thread::spawn(move || handle(stream, id, &acceptor, &cfg));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build TLS, rehearse the retail handshake, and bind.
|
||||
pub fn bind(cfg: RedirectorConfig) -> std::io::Result<Server> {
|
||||
let acceptor = tls::build_acceptor(&cfg.tls)
|
||||
.map_err(|e| std::io::Error::other(format!("TLS setup failed: {e}")))?;
|
||||
// Rehearse the retail handshake BEFORE accepting anything: a client
|
||||
// restricted to exactly the suites FIFA offers must connect. Catching a
|
||||
// cipher/version misconfiguration here means it never shows up as an
|
||||
// unexplained failure during a live gate.
|
||||
let neg = tls::self_test(&cfg.tls, tls::OBSERVED_CLIENT_SUITES).map_err(|e| {
|
||||
std::io::Error::other(format!(
|
||||
"self-test failed — a FIFA-like client cannot connect: {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()?;
|
||||
log(&banner(&cfg));
|
||||
log(&format!(
|
||||
"TLS min={:?} max={:?} security_level={}",
|
||||
cfg.tls.min_version,
|
||||
cfg.tls.max_version,
|
||||
cfg.tls
|
||||
.security_level
|
||||
.map(|l| l.to_string())
|
||||
.unwrap_or_else(|| "default (not lowered)".into())
|
||||
));
|
||||
Ok(Server {
|
||||
local_addr,
|
||||
listener,
|
||||
acceptor: Arc::new(acceptor),
|
||||
cfg: Arc::new(cfg),
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve until the process is killed.
|
||||
pub fn serve(cfg: RedirectorConfig) -> std::io::Result<()> {
|
||||
bind(cfg)?.run()
|
||||
}
|
||||
|
||||
fn handle(stream: TcpStream, id: u64, acceptor: &SslAcceptor, cfg: &RedirectorConfig) {
|
||||
let peer = stream
|
||||
.peer_addr()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|_| "<unknown>".into());
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(15)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(15)));
|
||||
|
||||
let mut tls = match acceptor.accept(stream) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// The most valuable diagnostic this host produces: a handshake
|
||||
// failure names the client and the reason, so a cipher/version
|
||||
// mismatch is obvious rather than looking like a network fault.
|
||||
log(&format!("conn-{id:04} {peer} TLS HANDSHAKE FAILED: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Exactly what the retail client negotiated. Recorded per connection
|
||||
// because it is the evidence a gate is judged on.
|
||||
{
|
||||
let s = tls.ssl();
|
||||
log(&format!(
|
||||
"conn-{id:04} {peer} TLS OK version={} cipher={} sni={}",
|
||||
s.version_str(),
|
||||
s.current_cipher().map(|c| c.name()).unwrap_or("?"),
|
||||
s.servername(openssl::ssl::NameType::HOST_NAME)
|
||||
.unwrap_or("<none>")
|
||||
));
|
||||
}
|
||||
|
||||
// Read the request head. The oracle answers any request with the same body,
|
||||
// so this is parsed for diagnostics, not for routing — a redirector that
|
||||
// started 404ing unexpected paths would be a behaviour change, not a fix.
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
loop {
|
||||
match tls.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.len() > 65536 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log(&format!("conn-{id:04} {peer} read failed: {e}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let head = String::from_utf8_lossy(&buf);
|
||||
let line0 = head.lines().next().unwrap_or("").to_string();
|
||||
log(&format!("conn-{id:04} {peer} REQ {line0}"));
|
||||
if !line0.is_empty() && !redirector::is_get_server_instance(&line0) {
|
||||
log(&format!(
|
||||
"conn-{id:04} {peer} NOTE: unexpected request line; answering anyway (oracle behaviour)"
|
||||
));
|
||||
}
|
||||
|
||||
let response = redirector::redirect_response(&cfg.adapter);
|
||||
if let Err(e) = tls.write_all(&response) {
|
||||
log(&format!("conn-{id:04} {peer} write failed: {e}"));
|
||||
return;
|
||||
}
|
||||
let _ = tls.flush();
|
||||
log(&format!(
|
||||
"conn-{id:04} {peer} SENT {}B serverinstanceinfo -> {}:{}",
|
||||
response.len(),
|
||||
cfg.adapter.endpoints.advertise,
|
||||
cfg.adapter.endpoints.blaze_port
|
||||
));
|
||||
|
||||
// The oracle closes after responding; the redirector is a one-shot hop.
|
||||
let _ = tls.shutdown();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn banner_names_the_linked_openssl_and_what_is_advertised() {
|
||||
let cfg = RedirectorConfig::for_test("198.51.100.7");
|
||||
let b = banner(&cfg);
|
||||
assert!(b.contains("openssl="), "{b}");
|
||||
assert!(b.contains("198.51.100.7"), "{b}");
|
||||
// The cipher list is part of the identity of a compatibility host.
|
||||
assert!(b.contains("AES256-GCM-SHA384"), "{b}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user