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:
+71
-1
@@ -94,6 +94,59 @@ fn build_router(state: ProxyState) -> Router {
|
|||||||
.with_state(state)
|
.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(
|
async fn serve_tls(
|
||||||
app: Router,
|
app: Router,
|
||||||
cert_pem: Vec<u8>,
|
cert_pem: Vec<u8>,
|
||||||
@@ -118,6 +171,23 @@ async fn serve_tls(
|
|||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
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 {
|
let tls_stream = match acceptor.accept(tcp).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
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 (_, server_conn) = tls_stream.get_ref();
|
||||||
let sni = server_conn.server_name().unwrap_or("(none)");
|
let sni = server_conn.server_name().unwrap_or("(none)");
|
||||||
|
|||||||
Reference in New Issue
Block a user