ad406f21bd
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.
133 lines
4.7 KiB
Rust
133 lines
4.7 KiB
Rust
//! A bare port probe must not be reported as a TLS fault on the roster host.
|
|
//!
|
|
//! `TLS HANDSHAKE FAILED: ... unexpected EOF` is the exact signature the
|
|
//! certificate mismatch produced — the defect that cost three live gate attempts
|
|
//! and was invisible everywhere else. A reachability probe forges it trivially:
|
|
//! `TcpStream::connect` then drop opens the connection and closes it without
|
|
//! sending a byte, which the acceptor reports as `unexpected EOF`. The launcher's
|
|
//! preflight makes such probes, so the roster host — like the redirector — must
|
|
//! classify the opening before the acceptor sees it. The policy is shared via
|
|
//! `openfut-tls`; this proves the roster host actually applies it.
|
|
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpStream;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Duration;
|
|
|
|
use openfut_roster_host::{bind, RosterConfig};
|
|
|
|
fn cert_pair() -> (String, String) {
|
|
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/tools");
|
|
(
|
|
format!("{base}/redir_cert.pem"),
|
|
format!("{base}/redir_key.pem"),
|
|
)
|
|
}
|
|
|
|
/// Start a host on an ephemeral port; hand back its address and its counters.
|
|
fn start() -> (
|
|
std::net::SocketAddr,
|
|
openfut_roster_host::ProbeCount,
|
|
openfut_roster_host::Outcomes,
|
|
) {
|
|
let (c, k) = cert_pair();
|
|
let server = bind(RosterConfig::for_test(&c, &k)).expect("host binds");
|
|
let addr = server.local_addr;
|
|
let (probes, outcomes) = (server.probes(), server.outcomes());
|
|
std::thread::spawn(move || {
|
|
let _ = server.run();
|
|
});
|
|
(addr, probes, outcomes)
|
|
}
|
|
|
|
/// A FIFA-like client: legacy suites, no certificate checking. Sends a GET and
|
|
/// reads the response to EOF.
|
|
fn tls_get(addr: std::net::SocketAddr) -> Vec<u8> {
|
|
use openfut_tls::{SslConnector, SslMethod, SslVerifyMode};
|
|
let mut b = SslConnector::builder(SslMethod::tls()).expect("connector");
|
|
b.set_cipher_list(openfut_adapter_fifa17::tls::OBSERVED_CLIENT_SUITES)
|
|
.expect("ciphers");
|
|
b.set_verify(SslVerifyMode::NONE);
|
|
let sock = TcpStream::connect(addr).expect("connect");
|
|
let ssl = b
|
|
.build()
|
|
.configure()
|
|
.and_then(|c| c.verify_hostname(false).into_ssl("roster-test"))
|
|
.expect("ssl");
|
|
let mut s = openfut_tls::SslStream::new(ssl, sock).expect("stream");
|
|
s.connect().expect("handshake");
|
|
s.write_all(
|
|
b"GET /fifa17/fut/rosterupdate.xml HTTP/1.1\r\nHost: roster-test\r\nAccept: */*\r\n\r\n",
|
|
)
|
|
.expect("write");
|
|
s.flush().ok();
|
|
let mut out = Vec::new();
|
|
let _ = s.read_to_end(&mut out);
|
|
out
|
|
}
|
|
|
|
/// The whole point: connect, send nothing, close — classified as a probe rather
|
|
/// than reaching the acceptor. Asserting on the counter (not on client-visible
|
|
/// behaviour) is deliberate: a probe produces no response either way, so a test
|
|
/// on what the client sees would pass with the classification deleted.
|
|
#[test]
|
|
fn a_bare_connect_and_close_is_classified_as_a_probe() {
|
|
let (addr, probes, outcomes) = start();
|
|
|
|
drop(TcpStream::connect(addr).expect("probe connects"));
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
|
|
assert_eq!(
|
|
probes.load(Ordering::Relaxed),
|
|
1,
|
|
"a connect-and-close was not classified as a probe"
|
|
);
|
|
assert!(
|
|
outcomes.lock().expect("lock").is_empty(),
|
|
"a probe must not be recorded as a served connection"
|
|
);
|
|
}
|
|
|
|
/// A probe must not disturb the host: the next real client still gets served.
|
|
#[test]
|
|
fn a_probe_does_not_break_the_connection_that_follows_it() {
|
|
let (addr, probes, _outcomes) = start();
|
|
|
|
drop(TcpStream::connect(addr).expect("probe connects"));
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
|
|
let response = tls_get(addr);
|
|
assert!(
|
|
response.starts_with(b"HTTP/1.0 200"),
|
|
"real client after a probe got: {:?}",
|
|
String::from_utf8_lossy(response.get(..64).unwrap_or(&response))
|
|
);
|
|
assert_eq!(
|
|
probes.load(Ordering::Relaxed),
|
|
1,
|
|
"the real client was miscounted as a probe"
|
|
);
|
|
}
|
|
|
|
/// The dangerous direction: a client that DOES speak and then fails must still
|
|
/// reach the acceptor and be reported as a fault, not silently reclassified as a
|
|
/// benign probe.
|
|
#[test]
|
|
fn a_client_that_speaks_then_fails_is_not_a_probe() {
|
|
let (addr, probes, _outcomes) = start();
|
|
|
|
let mut sock = TcpStream::connect(addr).expect("connect");
|
|
// One byte of nonsense: the peer has spoken, but it is not a ClientHello, so
|
|
// the handshake genuinely fails.
|
|
sock.write_all(&[0x16]).expect("write");
|
|
sock.flush().ok();
|
|
drop(sock);
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
|
|
assert_eq!(
|
|
probes.load(Ordering::Relaxed),
|
|
0,
|
|
"a failing handshake was silently reclassified as a benign probe"
|
|
);
|
|
}
|