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
+33 -3
View File
@@ -43,7 +43,7 @@
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -52,7 +52,7 @@ use openfut_adapter_fifa17::roster::{self, Method};
use openfut_http::drain_body;
pub use openfut_http::BodyRead;
// The acceptor comes from the shared crate, so this host never names OpenSSL.
use openfut_tls::SslAcceptor;
use openfut_tls::{peer_opening, PeerOpening, SslAcceptor};
pub mod config;
pub use config::RosterConfig;
@@ -112,6 +112,15 @@ pub struct ConnOutcome {
pub type Outcomes = Arc<std::sync::Mutex<Vec<ConnOutcome>>>;
/// How many bare port probes have been classified before reaching the acceptor.
///
/// Counted, not only logged, for the same reason [`ConnOutcome`] exists: a test
/// that asserts on client-visible symptoms cannot tell a probe that was
/// classified from one that merely failed quietly, so removing the
/// classification would leave the suite green. This makes it assertable — and
/// mirrors the redirector, the host that first needed the distinction.
pub type ProbeCount = Arc<AtomicUsize>;
/// Bounded: a host polled every few seconds must not accumulate forever.
const OUTCOME_HISTORY: usize = 64;
@@ -130,6 +139,7 @@ pub struct Server {
acceptor: Arc<SslAcceptor>,
cfg: Arc<RosterConfig>,
outcomes: Outcomes,
probes: ProbeCount,
}
impl Server {
@@ -137,6 +147,11 @@ impl Server {
self.outcomes.clone()
}
/// A handle to the bare-probe counter, obtainable before [`Server::run`].
pub fn probes(&self) -> ProbeCount {
self.probes.clone()
}
pub fn run(self) -> std::io::Result<()> {
let counter = AtomicU64::new(0);
for incoming in self.listener.incoming() {
@@ -144,7 +159,8 @@ impl Server {
let id = counter.fetch_add(1, Ordering::Relaxed) + 1;
let (acceptor, cfg) = (self.acceptor.clone(), self.cfg.clone());
let outcomes = self.outcomes.clone();
std::thread::spawn(move || handle(stream, id, &acceptor, &cfg, &outcomes));
let probes = self.probes.clone();
std::thread::spawn(move || handle(stream, id, &acceptor, &cfg, &outcomes, &probes));
}
Ok(())
}
@@ -184,6 +200,7 @@ pub fn bind(cfg: RosterConfig) -> std::io::Result<Server> {
acceptor: Arc::new(acceptor),
cfg: Arc::new(cfg),
outcomes: Arc::new(std::sync::Mutex::new(Vec::new())),
probes: ProbeCount::default(),
})
}
@@ -207,12 +224,25 @@ fn handle(
acceptor: &SslAcceptor,
cfg: &RosterConfig,
outcomes: &Outcomes,
probes: &ProbeCount,
) {
let peer = stream
.peer_addr()
.map(|a| a.to_string())
.unwrap_or_else(|_| "?".into());
// Classify a bare port probe BEFORE the acceptor sees it. A `connect` then
// drop (the launcher's preflight makes one per run) otherwise reaches the
// acceptor as `unexpected EOF` — byte-identical to the certificate mismatch
// that cost three live gates. Shared with the redirector via openfut-tls so
// the two hosts can never diverge on this.
if peer_opening(&stream) == PeerOpening::ClosedWithoutSpeaking {
probes.fetch_add(1, Ordering::Relaxed);
log(&format!(
"conn-{id:04} {peer} PROBE: closed before sending a ClientHello (not a TLS fault)"
));
return;
}
let mut tls = match acceptor.accept(stream) {
Ok(s) => s,
Err(e) => {