//! Legacy-TLS listeners for OpenFUT transport hosts. //! //! # Why this crate exists //! //! Every OpenFUT host that faces a game client needs the same thing: an //! OpenSSL acceptor configured for a client far older than the defaults any //! modern TLS stack ships. Before this crate there was exactly one such host, so //! its TLS lived inside it. The moment a second host needed the same setup, the //! choice was to share this code or copy it. //! //! Copying it is not a style question. On 2026-08-11 the Rust redirector served //! one certificate while the rest of the stack served another. FIFA's ProtoSSL //! caches the server certificate per backend, so the first connection of a //! session decided what the client expected and every later service failed its //! handshake — silently, because Python's `socketserver` swallows `ssl.SSLError` //! as `OSError` and logs nothing. Three gates were lost to it. One place to //! configure TLS is the structural fix. //! //! # What belongs here, and what does not //! //! This crate is **game-independent**. It knows how to build an acceptor, not //! which suites any particular client offers. Cipher lists, protocol windows, //! the SNI a client sends, and which suites are deliberately refused are facts //! about a *game*, and live in that game's adapter — see //! `openfut-adapter-fifa17`'s `tls` module. Values arrive here as plain data so //! adapters never take an OpenSSL dependency. //! //! # Scope of every setting //! //! Everything configured here applies to the `SslContext` being built. Nothing //! global is weakened, and no other OpenFUT crate links OpenSSL. use std::fmt; use std::path::Path; use openssl::ssl::{SslFiletype, SslOptions, SslVersion}; /// Re-exported so a transport host never has to name OpenSSL itself. /// /// Hosts depend on this crate, not on `openssl`. That keeps the linked TLS /// implementation a property of one manifest rather than of every host — the /// version is gate evidence, and a host that could pull its own would be able /// to drift from the rest of the stack silently. pub use openssl::ssl::{SslAcceptor, SslConnector, SslMethod, SslStream, SslVerifyMode}; #[derive(Debug)] pub struct TlsError(pub String); impl fmt::Display for TlsError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } impl std::error::Error for TlsError {} /// What a peer did with the connection before any TLS was attempted. /// /// A bare reachability probe — `TcpStream::connect` then drop, which the /// launcher's preflight makes twice per run — opens the connection and closes /// without sending a byte. If that reaches the acceptor it fails as /// `unexpected EOF`, which is **byte-identical** to the signature of the /// certificate mismatch that cost three live gates. Classifying the opening /// before the acceptor sees it keeps a benign probe from forging a TLS fault in /// the one channel this project gates on. Every FIFA-facing TLS host shares this /// policy so the distinction can never regress in just one of them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PeerOpening { /// Connected and closed without sending anything: a reachability probe. ClosedWithoutSpeaking, /// Sent at least one byte, so a real handshake is under way. Spoke, /// Timed out or errored. Deliberately NOT treated as a probe — a slow or /// broken client must still reach the acceptor and produce a real /// diagnostic, because misclassifying a fault as a probe would hide /// precisely what this distinction exists to protect. Undetermined, } /// Classify the result of peeking at the first byte. /// /// Split out as a pure function so the policy is testable without a socket — /// the interesting cases (EOF vs timeout) are awkward to provoke live and easy /// to get backwards. pub fn classify_opening(peek: &std::io::Result) -> PeerOpening { match peek { Ok(0) => PeerOpening::ClosedWithoutSpeaking, Ok(_) => PeerOpening::Spoke, Err(_) => PeerOpening::Undetermined, } } /// Peek one byte to classify a connection before the TLS acceptor sees it. /// /// `MSG_PEEK` leaves the byte in the receive queue, so a subsequent /// `acceptor.accept(stream)` reads the ClientHello intact — this is /// non-destructive for a real handshake and only short-circuits a bare probe. pub fn peer_opening(stream: &std::net::TcpStream) -> PeerOpening { let mut first = [0u8; 1]; classify_opening(&stream.peek(&mut first)) } /// A TLS protocol version, expressed without an OpenSSL type. /// /// Adapters name the version window their client was observed to use; keeping /// that expressible as plain data is what lets them stay dependency-free. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProtocolVersion { Tls10, Tls11, Tls12, Tls13, } impl ProtocolVersion { /// Parse OpenSSL's own spelling, as reported by a handshake. /// /// Deliberately strict: an unrecognised version is an error, never a /// silent fallback to a default. Guessing here would mean serving a /// protocol window nobody chose. pub fn parse(s: &str) -> Result { match s.trim() { "TLSv1" | "TLSv1.0" => Ok(ProtocolVersion::Tls10), "TLSv1.1" => Ok(ProtocolVersion::Tls11), "TLSv1.2" => Ok(ProtocolVersion::Tls12), "TLSv1.3" => Ok(ProtocolVersion::Tls13), other => Err(TlsError(format!("unknown TLS version {other:?}"))), } } pub fn as_str(self) -> &'static str { match self { ProtocolVersion::Tls10 => "TLSv1.0", ProtocolVersion::Tls11 => "TLSv1.1", ProtocolVersion::Tls12 => "TLSv1.2", ProtocolVersion::Tls13 => "TLSv1.3", } } fn to_ssl(self) -> SslVersion { match self { ProtocolVersion::Tls10 => SslVersion::TLS1, ProtocolVersion::Tls11 => SslVersion::TLS1_1, ProtocolVersion::Tls12 => SslVersion::TLS1_2, ProtocolVersion::Tls13 => SslVersion::TLS1_3, } } } /// TLS knobs, all overridable so a failed retail handshake is a configuration /// change rather than a code change. #[derive(Debug, Clone)] pub struct TlsConfig { pub cert_path: String, pub key_path: String, pub cipher_list: String, pub min_version: ProtocolVersion, pub max_version: ProtocolVersion, /// `None` = leave OpenSSL's default policy alone. Only set this if a real /// handshake proves it necessary, and record why. Lowering the security /// level pre-emptively weakens the service to fix a problem nobody has /// demonstrated. pub security_level: Option, } impl TlsConfig { /// Build a config from an adapter's observed profile. /// /// There is intentionally no `Default`: the cipher list and version window /// are evidence about a specific client, and a generic crate has no /// business inventing them. pub fn new( cert_path: impl Into, key_path: impl Into, cipher_list: impl Into, min_version: ProtocolVersion, max_version: ProtocolVersion, ) -> TlsConfig { TlsConfig { cert_path: cert_path.into(), key_path: key_path.into(), cipher_list: cipher_list.into(), min_version, max_version, security_level: None, } } } /// The OpenSSL version this binary is linked against. /// /// Vendored, so it is fixed at build time rather than inherited from the host. /// Printed at startup and recorded in gate evidence. pub fn openssl_version() -> String { openssl::version::version().to_string() } /// Build a listener acceptor from `cfg`. pub fn build_acceptor(cfg: &TlsConfig) -> Result { // `mozilla_intermediate` preloads a modern, forward-secret-only policy — // precisely wrong for a legacy client. It is used only as a starting // builder; every setting that matters is then stated explicitly below, so // nothing is inherited silently. let mut b = SslAcceptor::mozilla_intermediate(SslMethod::tls()) .map_err(|e| TlsError(format!("acceptor: {e}")))?; b.set_min_proto_version(Some(cfg.min_version.to_ssl())) .map_err(|e| TlsError(format!("min proto: {e}")))?; b.set_max_proto_version(Some(cfg.max_version.to_ssl())) .map_err(|e| TlsError(format!("max proto: {e}")))?; // Explicit list, replacing whatever policy the profile brought with it. b.set_cipher_list(&cfg.cipher_list) .map_err(|e| TlsError(format!("cipher list {:?}: {e}", cfg.cipher_list)))?; if let Some(level) = cfg.security_level { b.set_security_level(level); } // SSLv3 is never acceptable, whatever the version floor says. b.set_options(SslOptions::NO_SSLV3); let cert = Path::new(&cfg.cert_path); let key = Path::new(&cfg.key_path); if !cert.exists() { return Err(TlsError(format!( "certificate not found: {}", cfg.cert_path ))); } if !key.exists() { return Err(TlsError(format!("private key not found: {}", cfg.key_path))); } b.set_certificate_chain_file(cert) .map_err(|e| TlsError(format!("certificate {}: {e}", cfg.cert_path)))?; b.set_private_key_file(key, SslFiletype::PEM) .map_err(|e| TlsError(format!("private key {}: {e}", cfg.key_path)))?; // Catches a mismatched pair at startup rather than mid-handshake, where it // would look like a client problem. b.check_private_key() .map_err(|e| TlsError(format!("certificate and key do not match: {e}")))?; Ok(b.build()) } /// The SHA-256 fingerprint of the configured certificate, colon-separated. /// /// Exists so a host can log which certificate it is serving, and so a caller /// can compare hosts. Serving a *different* certificate from the rest of the /// stack is not a visible error at startup — it fails much later, inside a /// client that logs nothing — so the value is worth printing unprompted. pub fn certificate_fingerprint(cert_path: &str) -> Result { let pem = std::fs::read(cert_path) .map_err(|e| TlsError(format!("reading certificate {cert_path}: {e}")))?; let cert = openssl::x509::X509::from_pem(&pem) .map_err(|e| TlsError(format!("parsing certificate {cert_path}: {e}")))?; let digest = cert .digest(openssl::hash::MessageDigest::sha256()) .map_err(|e| TlsError(format!("fingerprint: {e}")))?; Ok(digest .iter() .map(|b| format!("{b:02X}")) .collect::>() .join(":")) } /// What a handshake actually negotiated. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Negotiated { pub version: String, pub cipher: String, } /// Handshake against ourselves with a client restricted to `client_ciphers`. /// /// Enumerating the server's configured list would be the obvious check, but the /// openssl crate exposes no such accessor — and a real handshake is better /// evidence anyway: it proves MUTUAL support and reports what was actually /// chosen, which is the thing a gate is judged on. Passing an adapter's /// observed client suites makes this a direct rehearsal of the retail /// handshake. /// /// `sni` is the name the real client sends, so the rehearsal matches it. pub fn self_test(cfg: &TlsConfig, client_ciphers: &str, sni: &str) -> Result { use openssl::ssl::{SslConnector, SslVerifyMode}; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; let acceptor = build_acceptor(cfg)?; let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| TlsError(format!("bind: {e}")))?; let addr = listener .local_addr() .map_err(|e| TlsError(format!("addr: {e}")))?; let server = std::thread::spawn(move || -> Result { let (sock, _) = listener.accept().map_err(|e| e.to_string())?; let mut tls = acceptor.accept(sock).map_err(|e| e.to_string())?; let neg = { let s = tls.ssl(); Negotiated { version: s.version_str().to_string(), cipher: s .current_cipher() .map(|c| c.name().to_string()) .unwrap_or_default(), } }; let mut buf = [0u8; 64]; let _ = tls.read(&mut buf); let _ = tls.write_all(b"ok"); let _ = tls.flush(); Ok(neg) }); let mut cb = SslConnector::builder(SslMethod::tls()).map_err(|e| TlsError(format!("connector: {e}")))?; cb.set_cipher_list(client_ciphers) .map_err(|e| TlsError(format!("client cipher list: {e}")))?; // The point is cipher/version negotiation, not PKI: the certificate is // self-signed and the real client's checks are patched out. cb.set_verify(SslVerifyMode::NONE); let connector = cb.build(); let sock = TcpStream::connect(addr).map_err(|e| TlsError(format!("connect: {e}")))?; let mut client = connector .configure() .and_then(|c| c.verify_hostname(false).into_ssl(sni)) .and_then(|ssl| openssl::ssl::SslStream::new(ssl, sock)) .map_err(|e| TlsError(format!("client setup: {e}")))?; client .connect() .map_err(|e| TlsError(format!("client handshake failed: {e}")))?; let _ = client.write_all(b"ping"); let _ = client.flush(); let mut buf = [0u8; 8]; let _ = client.read(&mut buf); server .join() .map_err(|_| TlsError("server thread panicked".into()))? .map_err(|e| TlsError(format!("server handshake failed: {e}"))) } #[cfg(test)] mod tests { use super::*; #[test] fn classify_opening_maps_each_case() { // EOF before any byte is the reachability probe this exists to catch. assert_eq!(classify_opening(&Ok(0)), PeerOpening::ClosedWithoutSpeaking); assert_eq!(classify_opening(&Ok(1)), PeerOpening::Spoke); // A timeout must NOT be a probe: a slow or broken client still deserves // a real diagnostic from the acceptor, not a silent probe reclassification. assert_eq!( classify_opening(&Err(std::io::Error::from(std::io::ErrorKind::WouldBlock))), PeerOpening::Undetermined ); } /// A self-signed pair, generated here rather than borrowed from the game /// stack: this crate is game-independent and its tests must not depend on /// FIFA material. Adapter crates test their own profiles. /// /// Generated exactly once per process via `OnceLock`. The first version /// checked "does the cert file exist?" and returned early if so — which is /// a race, because the cert is written before the key. Tests run in /// parallel, so one could observe a certificate whose key had not landed /// yet and fail to build an acceptor. It failed in one run and passed in /// the next, which is exactly the kind of flake that gets rerun instead of /// fixed. fn keypair() -> (String, String) { use std::sync::OnceLock; static PAIR: OnceLock<(String, String)> = OnceLock::new(); PAIR.get_or_init(|| generate_keypair("cert")).clone() } fn generate_keypair(tag: &str) -> (String, String) { use openssl::asn1::Asn1Time; use openssl::hash::MessageDigest; use openssl::pkey::PKey; use openssl::rsa::Rsa; use openssl::x509::{X509Name, X509}; let dir = std::env::temp_dir().join(format!("openfut-tls-test-{}-{tag}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let cert_path = dir.join("cert.pem"); let key_path = dir.join("key.pem"); let rsa = Rsa::generate(2048).unwrap(); let pkey = PKey::from_rsa(rsa).unwrap(); let mut name = X509Name::builder().unwrap(); name.append_entry_by_text("CN", "openfut-tls-test").unwrap(); let name = name.build(); let mut b = X509::builder().unwrap(); b.set_version(2).unwrap(); b.set_subject_name(&name).unwrap(); b.set_issuer_name(&name).unwrap(); b.set_pubkey(&pkey).unwrap(); b.set_not_before(&Asn1Time::days_from_now(0).unwrap()) .unwrap(); b.set_not_after(&Asn1Time::days_from_now(3650).unwrap()) .unwrap(); b.sign(&pkey, MessageDigest::sha256()).unwrap(); let cert = b.build(); // Key first, then certificate: a reader that checks for the cert can // then never find one without its key. std::fs::write(&key_path, pkey.private_key_to_pem_pkcs8().unwrap()).unwrap(); std::fs::write(&cert_path, cert.to_pem().unwrap()).unwrap(); ( cert_path.to_string_lossy().into(), key_path.to_string_lossy().into(), ) } /// A legacy static-RSA profile, standing in for what an adapter supplies. fn cfg() -> TlsConfig { let (c, k) = keypair(); TlsConfig::new( c, k, "AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA", ProtocolVersion::Tls12, ProtocolVersion::Tls12, ) } #[test] fn an_acceptor_builds_from_a_valid_pair() { assert!(build_acceptor(&cfg()).is_ok()); } #[test] fn a_static_rsa_client_completes_a_handshake() { let neg = self_test(&cfg(), "AES256-GCM-SHA384", "example.invalid").expect("handshake"); assert_eq!(neg.cipher, "AES256-GCM-SHA384"); assert_eq!(neg.version, "TLSv1.2"); } /// The configured list is authoritative: a client offering only something /// outside it must fail. Without this, an acceptor that silently kept the /// modern default profile would look identical to a correct one. #[test] fn a_suite_outside_the_configured_list_is_refused() { let r = self_test(&cfg(), "ECDHE-RSA-AES256-GCM-SHA384", "example.invalid"); assert!(r.is_err(), "expected refusal, got {r:?}"); } #[test] fn the_protocol_window_is_enforced() { let (c, k) = keypair(); let mut cfg = TlsConfig::new( c, k, "AES256-GCM-SHA384", ProtocolVersion::Tls12, ProtocolVersion::Tls12, ); assert_eq!( self_test(&cfg, "AES256-GCM-SHA384", "x").unwrap().version, "TLSv1.2" ); // Ceiling raised: TLS 1.3 has its own suites, so a 1.2-only client list // still lands on 1.2 — the window is a window, not a forced version. cfg.max_version = ProtocolVersion::Tls13; assert_eq!( self_test(&cfg, "AES256-GCM-SHA384", "x").unwrap().version, "TLSv1.2" ); } #[test] fn a_missing_certificate_fails_clearly() { let mut c = cfg(); c.cert_path = "/nonexistent/cert.pem".into(); let e = match build_acceptor(&c) { Err(e) => e.to_string(), Ok(_) => panic!("a missing certificate must not build"), }; assert!(e.contains("certificate not found"), "{e}"); } #[test] fn a_mismatched_key_is_caught_at_startup() { let mut c = cfg(); // A valid PEM that is not the key. c.key_path = c.cert_path.clone(); assert!(build_acceptor(&c).is_err(), "mismatch must not build"); } #[test] fn security_level_is_not_lowered_by_default() { assert_eq!(cfg().security_level, None); } #[test] fn versions_round_trip_and_unknown_ones_are_refused() { for v in [ ProtocolVersion::Tls10, ProtocolVersion::Tls11, ProtocolVersion::Tls12, ProtocolVersion::Tls13, ] { assert_eq!(ProtocolVersion::parse(v.as_str()).unwrap(), v); } // OpenSSL reports 1.0 as bare "TLSv1"; accept what a handshake reports. assert_eq!( ProtocolVersion::parse("TLSv1").unwrap(), ProtocolVersion::Tls10 ); assert!(ProtocolVersion::parse("SSLv3").is_err()); assert!(ProtocolVersion::parse("").is_err()); } /// The fingerprint must be the certificate's, so two hosts can be compared. #[test] fn the_fingerprint_matches_openssl_and_differs_between_certificates() { let (c, _) = keypair(); let fp = certificate_fingerprint(&c).expect("fingerprint"); assert_eq!(fp.len(), 32 * 3 - 1, "32 hex pairs, colon separated: {fp}"); assert!(fp.chars().all(|ch| ch.is_ascii_hexdigit() || ch == ':')); // A different certificate must produce a different fingerprint — this // is the whole point of the check that guards cross-host parity. // Generated through the same helper, so it cannot race either. let (other, _) = generate_keypair("alt"); let fp2 = certificate_fingerprint(&other).unwrap(); assert_ne!(fp, fp2); } #[test] fn an_unreadable_certificate_reports_the_path() { let e = certificate_fingerprint("/nonexistent/x.pem") .unwrap_err() .to_string(); assert!(e.contains("/nonexistent/x.pem"), "{e}"); } }