Files
OpenFUT/openfut-tls/src/lib.rs
T
funman300 ad406f21bd fix(tls): share bare-probe classification across all FIFA-facing TLS hosts
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.
2026-08-16 20:30:50 +00:00

542 lines
21 KiB
Rust

//! 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<usize>) -> 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<ProtocolVersion, TlsError> {
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<u32>,
}
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<String>,
key_path: impl Into<String>,
cipher_list: impl Into<String>,
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<SslAcceptor, TlsError> {
// `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<String, TlsError> {
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::<Vec<_>>()
.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<Negotiated, TlsError> {
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<Negotiated, String> {
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}");
}
}