ad406f21bd
A reachability probe (TcpStream::connect then drop; the launcher preflight makes them) reaches a TLS acceptor as 'unexpected EOF' — byte-identical to the certificate mismatch that cost three live gates. The redirector classified the opening before the acceptor to keep a benign probe from forging a TLS fault, but the roster host (the second FIFA-facing TLS host) did not, so the documented hazard 'remains in any other TLS host that has not adopted it' was live there. Lift the pure policy (PeerOpening + classify_opening) plus a peer_opening(&TcpStream) peek helper into the shared openfut-tls crate (game-independent; +unit tests). The redirector now re-exports them (public API + its probe_classification test unchanged; behaviour identical). The roster host adopts them: a ProbeCount, a probes() handle, and a pre-acceptor peek that logs PROBE and returns instead of failing the handshake. New roster probe_classification integration test (3 cases: bare probe classified, real client after a probe still served 200, speaks-then- fails still reported as a fault). Full workspace tests green; clippy -D clean.
388 lines
14 KiB
Rust
388 lines
14 KiB
Rust
//! # 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;
|
|
|
|
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;
|
|
// Body draining is shared. Behaviour that must be identical across hosts gets
|
|
// one implementation, for the same reason TLS does: the divergence it guards
|
|
// against (an unread body turning the close into an RST) is invisible in any
|
|
// comparison of the response, and cost two live gate attempts to find.
|
|
use openfut_http::drain_body;
|
|
pub use openfut_http::BodyRead;
|
|
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);
|
|
}
|
|
|
|
/// The commit this binary was built from.
|
|
///
|
|
/// Only the commit: a compiled-in cleanliness claim can go stale (cargo will
|
|
/// not re-run a build script for another crate's edit), so the authoritative
|
|
/// comparison happens at launch in `scripts/verify-build-identity.sh`.
|
|
pub const BUILD_COMMIT: &str = env!("OPENFUT_BUILD_COMMIT");
|
|
|
|
/// Build identity, printable without any configuration.
|
|
///
|
|
/// `--identity` exists so the launcher can establish which commit a binary came
|
|
/// from before deciding whether to run it. Machine-readable `key=value`.
|
|
pub fn identity() -> String {
|
|
format!(
|
|
"openfut-redirector-host v{} commit={} profile={} openssl={}",
|
|
env!("CARGO_PKG_VERSION"),
|
|
BUILD_COMMIT,
|
|
if cfg!(debug_assertions) {
|
|
"debug"
|
|
} else {
|
|
"release"
|
|
},
|
|
openfut_tls::openssl_version(),
|
|
)
|
|
}
|
|
|
|
/// One line naming the binary, its linked TLS, and what it will advertise.
|
|
///
|
|
/// Machine-readable `key=value` so the launcher can extract the commit without
|
|
/// guessing at prose.
|
|
pub fn banner(cfg: &RedirectorConfig) -> String {
|
|
format!(
|
|
"openfut-redirector-host v{} commit={} profile={} openssl={} listen={} \
|
|
advertise={}:{} tls_min={} tls_max={} security_level={} cert_sha256={} ciphers={}",
|
|
env!("CARGO_PKG_VERSION"),
|
|
BUILD_COMMIT,
|
|
if cfg!(debug_assertions) {
|
|
"debug"
|
|
} else {
|
|
"release"
|
|
},
|
|
openfut_tls::openssl_version(),
|
|
cfg.listen_on(),
|
|
cfg.adapter.endpoints.advertise,
|
|
cfg.adapter.endpoints.blaze_port,
|
|
// Readable names, not `SslVersion(771)`: this line is gate evidence and
|
|
// is read by humans comparing one run against another.
|
|
cfg.tls.min_version.as_str(),
|
|
cfg.tls.max_version.as_str(),
|
|
cfg.tls
|
|
.security_level
|
|
.map(|l| l.to_string())
|
|
.unwrap_or_else(|| "default".into()),
|
|
// WHICH certificate, not just that one loaded. 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. Printing
|
|
// it unprompted is what turns that into a one-glance comparison.
|
|
openfut_tls::certificate_fingerprint(&cfg.tls.cert_path)
|
|
.unwrap_or_else(|e| format!("unreadable ({e})")),
|
|
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>,
|
|
outcomes: Outcomes,
|
|
probes: ProbeCount,
|
|
}
|
|
|
|
/// How many bare port probes have been classified.
|
|
///
|
|
/// Counted rather than only logged for the same reason [`ConnOutcome`] exists:
|
|
/// a test that asserts on client-visible symptoms cannot tell a probe that was
|
|
/// classified from one that merely failed quietly, so removing the
|
|
/// classification would leave the suite green. This makes it assertable.
|
|
pub type ProbeCount = Arc<std::sync::atomic::AtomicUsize>;
|
|
|
|
/// What the host did on a connection, beyond the bytes it sent.
|
|
///
|
|
/// Recorded because the failure that cost two live gate attempts — a request
|
|
/// body left unread — is invisible to any comparison of the response. The log
|
|
/// line carries the same facts for a live run; this makes them assertable.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ConnOutcome {
|
|
pub id: u64,
|
|
pub request_bytes: usize,
|
|
pub body: BodyRead,
|
|
pub response_bytes: usize,
|
|
}
|
|
|
|
pub type Outcomes = Arc<std::sync::Mutex<Vec<ConnOutcome>>>;
|
|
|
|
/// Keep the record bounded: a long-running redirector must not accumulate one
|
|
/// entry per connection forever.
|
|
const OUTCOME_HISTORY: usize = 64;
|
|
|
|
impl Server {
|
|
/// A handle to the bare-probe counter, obtainable before [`Server::run`].
|
|
pub fn probes(&self) -> ProbeCount {
|
|
self.probes.clone()
|
|
}
|
|
|
|
/// A handle to the connection record, obtainable before [`Server::run`]
|
|
/// consumes the 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();
|
|
let probes = self.probes.clone();
|
|
std::thread::spawn(move || handle(stream, id, &acceptor, &cfg, &outcomes, &probes));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn record(outcomes: &Outcomes, o: ConnOutcome) {
|
|
if let Ok(mut v) = outcomes.lock() {
|
|
if v.len() >= OUTCOME_HISTORY {
|
|
v.remove(0);
|
|
}
|
|
v.push(o);
|
|
}
|
|
}
|
|
|
|
/// Build TLS, rehearse the retail handshake, and bind.
|
|
pub fn bind(cfg: RedirectorConfig) -> std::io::Result<Server> {
|
|
let acceptor = openfut_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 = openfut_tls::self_test(
|
|
&cfg.tls,
|
|
openfut_adapter_fifa17::tls::OBSERVED_CLIENT_SUITES,
|
|
openfut_adapter_fifa17::tls::CLIENT_SNI,
|
|
)
|
|
.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));
|
|
Ok(Server {
|
|
local_addr,
|
|
listener,
|
|
acceptor: Arc::new(acceptor),
|
|
cfg: Arc::new(cfg),
|
|
outcomes: Outcomes::default(),
|
|
probes: ProbeCount::default(),
|
|
})
|
|
}
|
|
|
|
/// Serve until the process is killed.
|
|
pub fn serve(cfg: RedirectorConfig) -> std::io::Result<()> {
|
|
bind(cfg)?.run()
|
|
}
|
|
|
|
/// Re-exported from the shared TLS crate. The classification policy now lives in
|
|
/// `openfut-tls` so every FIFA-facing TLS host shares one implementation; this
|
|
/// host keeps naming them here so its public API and `probe_classification`
|
|
/// integration test are unaffected.
|
|
pub use openfut_tls::{classify_opening, PeerOpening};
|
|
|
|
fn handle(
|
|
stream: TcpStream,
|
|
id: u64,
|
|
acceptor: &SslAcceptor,
|
|
cfg: &RedirectorConfig,
|
|
outcomes: &Outcomes,
|
|
probes: &ProbeCount,
|
|
) {
|
|
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)));
|
|
|
|
// Classify a bare port probe BEFORE the acceptor sees it.
|
|
//
|
|
// A reachability probe — the launcher's preflight makes two per run, and
|
|
// any `TcpStream::connect` then drop is one — opens the connection and
|
|
// closes without sending a byte. Once that reaches the acceptor it fails
|
|
// as "unexpected EOF", which is byte-identical to the signature of a
|
|
// genuine TLS fault. That is not a cosmetic problem: "unexpected EOF" is
|
|
// exactly how the certificate mismatch that cost three live gates
|
|
// presented, so a benign probe forging it poisons the one channel this
|
|
// project gates on. Classified here, the two are never confused again.
|
|
if openfut_tls::peer_opening(&stream) == PeerOpening::ClosedWithoutSpeaking {
|
|
probes.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
log(&format!(
|
|
"conn-{id:04} {peer} PROBE: closed before sending a ClientHello (not a TLS fault)"
|
|
));
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drain the request body, exactly as the oracle does. This is NOT cosmetic:
|
|
// answering and closing while the client is still sending leaves unread data
|
|
// in the receive queue, and Linux turns that close into an RST rather than a
|
|
// FIN. The oracle has always drained; the first version of this host stopped
|
|
// at the header terminator, which is a behaviour difference invisible to any
|
|
// byte-comparison of the response.
|
|
let body_read = drain_body(&mut tls, &mut buf);
|
|
|
|
let head = String::from_utf8_lossy(&buf);
|
|
let line0 = head.lines().next().unwrap_or("").to_string();
|
|
log(&format!(
|
|
"conn-{id:04} {peer} REQ {line0} req_bytes={} body={}",
|
|
buf.len(),
|
|
body_read.describe()
|
|
));
|
|
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 holds the connection open before closing. That dwell is the
|
|
// only other measured difference between the two implementations, and a
|
|
// redirector that closes at 0ms is not the behaviour FIFA 17 was proven
|
|
// against — so it is reproduced rather than assumed harmless.
|
|
record(
|
|
outcomes,
|
|
ConnOutcome {
|
|
id,
|
|
request_bytes: buf.len(),
|
|
body: body_read,
|
|
response_bytes: response.len(),
|
|
},
|
|
);
|
|
|
|
std::thread::sleep(cfg.close_dwell);
|
|
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("commit="), "{b}");
|
|
assert!(b.contains("tls_min="), "{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}");
|
|
}
|
|
}
|