diff --git a/src/main.rs b/src/main.rs index da07748..d6e2f4a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { + 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, @@ -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)");