redirector: a port probe must not forge the signature of a TLS fault

"TLS HANDSHAKE FAILED: ... unexpected EOF" is exactly how the certificate
mismatch presented -- the defect that cost three live gate attempts and was
invisible everywhere else. It is the one line this project has learned to
treat as serious.

A reachability probe forges it for free: TcpStream::connect followed by a
drop opens the connection and closes without sending a byte, and the
acceptor reports that as "unexpected EOF". The launcher's preflight makes
two such probes per run. On 2026-08-12 they produced ten of these lines and
sent a whole session diagnosing a client-side fault that did not exist --
autopatch, ptrace_scope and client_arm.sh were all investigated before the
pairing of the timestamps gave it away.

Classify before the acceptor sees the connection: peek one byte, and treat
EOF-before-any-byte as a probe with its own quiet line. A timeout is
deliberately NOT a probe -- a slow or broken client must still reach the
acceptor and produce a real diagnostic, since misclassifying a fault as
benign would defeat the point.

The counter exists because the test needs it. A probe produces no response
either way, so a test written against client-visible behaviour passes with
the classification deleted; asserting on a count is what makes the mutation
detectable. Verified: all three mutations (drop the classification, treat
undetermined as a probe, treat a speaking client as a probe) are killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-12 17:25:21 +00:00
parent c7d4b9f753
commit 4fd5ee2608
2 changed files with 209 additions and 1 deletions
+64 -1
View File
@@ -127,8 +127,17 @@ pub struct Server {
acceptor: Arc<SslAcceptor>,
cfg: Arc<RedirectorConfig>,
outcomes: Outcomes,
probes: ProbeCount,
}
/// How many bare port probes have been classified.
///
/// Counted rather than 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.
pub type ProbeCount = Arc<std::sync::atomic::AtomicUsize>;
/// What the host did on a connection, beyond the bytes it sent.
///
/// Recorded because the failure that cost two live gate attempts — a request
@@ -149,6 +158,11 @@ pub type Outcomes = Arc<std::sync::Mutex<Vec<ConnOutcome>>>;
const OUTCOME_HISTORY: usize = 64;
impl Server {
/// A handle to the bare-probe counter, obtainable before [`Server::run`].
pub fn probes(&self) -> ProbeCount {
self.probes.clone()
}
/// A handle to the connection record, obtainable before [`Server::run`]
/// consumes the server.
pub fn outcomes(&self) -> Outcomes {
@@ -162,7 +176,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(())
}
@@ -209,6 +224,7 @@ pub fn bind(cfg: RedirectorConfig) -> std::io::Result<Server> {
acceptor: Arc::new(acceptor),
cfg: Arc::new(cfg),
outcomes: Outcomes::default(),
probes: ProbeCount::default(),
})
}
@@ -217,12 +233,40 @@ pub fn serve(cfg: RedirectorConfig) -> std::io::Result<()> {
bind(cfg)?.run()
}
/// What a peer did with the connection before any TLS was attempted.
#[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,
}
}
fn handle(
stream: TcpStream,
id: u64,
acceptor: &SslAcceptor,
cfg: &RedirectorConfig,
outcomes: &Outcomes,
probes: &ProbeCount,
) {
let peer = stream
.peer_addr()
@@ -231,6 +275,25 @@ fn handle(
let _ = stream.set_read_timeout(Some(Duration::from_secs(15)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(15)));
// Classify a bare port probe BEFORE the acceptor sees it.
//
// A reachability probe — the launcher's preflight makes two per run, and
// any `TcpStream::connect` then drop is one — opens the connection and
// closes without sending a byte. Once that reaches the acceptor it fails
// as "unexpected EOF", which is byte-identical to the signature of a
// genuine TLS fault. That is not a cosmetic problem: "unexpected EOF" is
// exactly how the certificate mismatch that cost three live gates
// presented, so a benign probe forging it poisons the one channel this
// project gates on. Classified here, the two are never confused again.
let mut first = [0u8; 1];
if classify_opening(&stream.peek(&mut first)) == PeerOpening::ClosedWithoutSpeaking {
probes.fetch_add(1, std::sync::atomic::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) => {