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.
This commit is contained in:
funman300
2026-08-16 20:30:50 +00:00
parent 12fb9fc38b
commit ad406f21bd
4 changed files with 230 additions and 31 deletions
+59
View File
@@ -54,6 +54,52 @@ impl fmt::Display for TlsError {
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
@@ -299,6 +345,19 @@ pub fn self_test(cfg: &TlsConfig, client_ciphers: &str, sni: &str) -> Result<Neg
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.