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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user