openfut-bridge: handshake-independent ClientHello SNI capture

Peek the ClientHello with TcpStream::peek and parse SNI before the TLS
handshake, so we learn the hostname even when the client's ProtoSSL rejects
our self-signed cert (CertificateUnknown). tokio-rustls 0.24 lacks
LazyConfigAcceptor, so parse_sni() walks the record/handshake/extensions by
hand with full bounds checks. This is what revealed the redirected EA :443
dials as PIN/River telemetry rather than the Blaze redirector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-03 15:58:26 -07:00
parent e4108fcff8
commit 80107b7800
+71 -1
View File
@@ -94,6 +94,59 @@ fn build_router(state: ProxyState) -> Router {
.with_state(state)
}
/// Extract the SNI host_name from a raw TLS ClientHello, if present.
///
/// This is handshake-independent: we parse the bytes the client sends first, so we
/// learn the hostname even when the TLS handshake later fails (e.g. the client's
/// ProtoSSL rejects our self-signed cert). All indexing is bounds-checked; on any
/// malformed/short input we return None rather than panic.
///
/// ClientHello layout walked here: TLS record header (type=0x16, version, length) →
/// handshake header (type=0x01, length) → client_version, 32-byte random,
/// session_id, cipher_suites, compression_methods → extensions. The SNI extension
/// (type 0x0000) holds a server_name_list whose first entry (name_type 0x00 =
/// host_name) is the hostname.
fn parse_sni(buf: &[u8]) -> Option<String> {
if buf.len() < 5 || buf[0] != 0x16 {
return None; // not a TLS handshake record
}
let mut p = 5;
if buf.len() < p + 4 || buf[p] != 0x01 {
return None; // not a ClientHello
}
p += 4; // handshake msg_type(1) + length(3)
p += 2 + 32; // client_version(2) + random(32)
let sid_len = *buf.get(p)? as usize;
p += 1 + sid_len;
let cs_len = u16::from_be_bytes([*buf.get(p)?, *buf.get(p + 1)?]) as usize;
p += 2 + cs_len;
let cm_len = *buf.get(p)? as usize;
p += 1 + cm_len;
let ext_total = u16::from_be_bytes([*buf.get(p)?, *buf.get(p + 1)?]) as usize;
p += 2;
let ext_end = (p + ext_total).min(buf.len());
while p + 4 <= ext_end {
let etype = u16::from_be_bytes([buf[p], buf[p + 1]]);
let elen = u16::from_be_bytes([buf[p + 2], buf[p + 3]]) as usize;
p += 4;
if p + elen > buf.len() {
break;
}
if etype == 0x0000 {
// SNI ext: server_name_list_len(2), name_type(1), name_len(2), name
let d = &buf[p..p + elen];
if d.len() >= 5 && d[2] == 0x00 {
let nlen = u16::from_be_bytes([d[3], d[4]]) as usize;
if 5 + nlen <= d.len() {
return String::from_utf8(d[5..5 + nlen].to_vec()).ok();
}
}
}
p += elen;
}
None
}
async fn serve_tls(
app: Router,
cert_pem: Vec<u8>,
@@ -118,6 +171,23 @@ async fn serve_tls(
let app = app.clone();
tokio::spawn(async move {
// Peek the ClientHello (without consuming it) and log the SNI up front, so
// we capture the hostname even if the handshake below fails on cert pinning.
{
let mut peek = [0u8; 2048];
match tcp.peek(&mut peek).await {
Ok(n) if n > 0 => match parse_sni(&peek[..n]) {
Some(sni) => tracing::info!("ClientHello SNI (peek): {sni}"),
None => tracing::info!(
"ClientHello peek: no SNI ({n} bytes, first=0x{:02x})",
peek[0]
),
},
Ok(_) => tracing::info!("ClientHello peek: 0 bytes"),
Err(e) => tracing::warn!("ClientHello peek failed: {e}"),
}
}
let tls_stream = match acceptor.accept(tcp).await {
Ok(s) => s,
Err(e) => {
@@ -126,7 +196,7 @@ async fn serve_tls(
}
};
// Log SNI so we know which hostname the client is connecting for.
// Post-handshake SNI (only fires on success; the peek above is the reliable one).
{
let (_, server_conn) = tls_stream.get_ref();
let sni = server_conn.server_name().unwrap_or("(none)");