Files
OpenFUT/openfut-tls/src/lib.rs
T
funman300 05f6147433 http: extract the shared body drain; fix a flaky test race it exposed
Queued cleanup, run only AFTER the roster gate closed in both directions,
so the live A/B changed exactly one thing.

The two `drain_body` implementations were character-for-character
identical, so the extraction is a move. What it guards is not cosmetic:
answering while the client is still sending leaves unread data in the
receive queue and Linux turns the close into an RST rather than a FIN --
invisible in any comparison of the response, and worth two live gate
attempts to find. Behaviour that must be identical across hosts gets one
implementation, the same reasoning that produced openfut-tls.

SCOPE IS DELIBERATELY NARROW. Only the byte-identical part moved. The two
head-reading loops are NOT identical and stay where they are:

              redirector   roster
  head cap    65536        16384
  read chunk  4096         1024
  on error    abort        proceed if any bytes arrived

Those differences are probably accidental, but each host is gate-proven
with the values it has. Unifying them would be a behaviour change wearing
a refactor's clothes -- exactly the mistake this project has already paid
for. They converge later as their own change with their own gate, or not
at all.

Purity shown, not asserted: every existing test in both hosts still
passes (426 workspace tests), and 7/7 mutations are killed, including
three in the SHARED crate that must break both hosts at once and one per
host that skips the drain call.

Three test cases neither host had now exist, because the extracted code
finally had somewhere to be tested directly: a malformed Content-Length,
an unterminated head, and a lookalike header. That last one matters --
`X-Original-Content-Length: 99` would drain 99 bytes that were never sent
if the match were `contains` rather than `starts_with`, and a mutation
confirms the test catches it.

Also fixes a race this run exposed in openfut-tls's own tests: keypair()
returned early if the certificate file existed, but wrote the certificate
BEFORE the key, so a parallel test could observe a cert whose key had not
landed. It failed one run and passed the next -- the kind of flake that
gets rerun instead of fixed. Now generated once per process via OnceLock,
key written first, and the suite was repeated five times to confirm.

Nothing deployed and nothing restarted: the running redirector and roster
are still the gate-proven binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:04:08 +00:00

483 lines
18 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 {}
/// 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::*;
/// 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}");
}
}