From db8d333ed6325831f2bffefddd82ff83d62697ab Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:35:11 +0300 Subject: [PATCH 1/5] Noisy-network peer Close Errors Classification --- src/maestro/helpers.rs | 42 ++++++++++++++++++++++-- src/proxy/client.rs | 23 ++++++++++++- src/proxy/tests/client_security_tests.rs | 40 ++++++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index 49c5347..32e6a54 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -231,7 +231,8 @@ fn print_help() { #[cfg(test)] mod tests { - use super::resolve_runtime_config_path; + use super::{is_expected_handshake_eof, resolve_runtime_config_path}; + use crate::error::{ProxyError, StreamError}; #[test] fn resolve_runtime_config_path_anchors_relative_to_startup_cwd() { @@ -299,6 +300,20 @@ mod tests { let _ = std::fs::remove_dir(&startup_cwd); } + + #[test] + fn expected_handshake_eof_matches_connection_reset() { + let err = ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)); + assert!(is_expected_handshake_eof(&err)); + } + + #[test] + fn expected_handshake_eof_matches_stream_io_unexpected_eof() { + let err = ProxyError::Stream(StreamError::Io(std::io::Error::from( + std::io::ErrorKind::UnexpectedEof, + ))); + assert!(is_expected_handshake_eof(&err)); + } } pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) { @@ -428,7 +443,30 @@ pub(crate) async fn wait_until_admission_open(admission_rx: &mut watch::Receiver } pub(crate) fn is_expected_handshake_eof(err: &crate::error::ProxyError) -> bool { - err.to_string().contains("expected 64 bytes, got 0") + matches!( + err, + crate::error::ProxyError::Io(ioe) + if matches!( + ioe.kind(), + std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::NotConnected + ) + ) || matches!(err, crate::error::ProxyError::Stream(crate::error::StreamError::UnexpectedEof)) + || matches!( + err, + crate::error::ProxyError::Stream(crate::error::StreamError::Io(ioe)) + if matches!( + ioe.kind(), + std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::NotConnected + ) + ) } pub(crate) async fn load_startup_proxy_config_snapshot( diff --git a/src/proxy/client.rs b/src/proxy/client.rs index 0937a8f..67fea54 100644 --- a/src/proxy/client.rs +++ b/src/proxy/client.rs @@ -331,10 +331,31 @@ fn record_handshake_failure_class( error: &ProxyError, ) { let class = match error { - ProxyError::Io(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => { + ProxyError::Io(err) + if matches!( + err.kind(), + std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::NotConnected + ) => + { "expected_64_got_0" } ProxyError::Stream(StreamError::UnexpectedEof) => "expected_64_got_0", + ProxyError::Stream(StreamError::Io(err)) + if matches!( + err.kind(), + std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::NotConnected + ) => + { + "expected_64_got_0" + } _ => "other", }; record_beobachten_class(beobachten, config, peer_ip, class); diff --git a/src/proxy/tests/client_security_tests.rs b/src/proxy/tests/client_security_tests.rs index 85af766..480b33d 100644 --- a/src/proxy/tests/client_security_tests.rs +++ b/src/proxy/tests/client_security_tests.rs @@ -2493,6 +2493,46 @@ fn unexpected_eof_is_classified_without_string_matching() { ); } +#[test] +fn connection_reset_is_classified_as_expected_handshake_close() { + let beobachten = BeobachtenStore::new(); + let mut config = ProxyConfig::default(); + config.general.beobachten = true; + config.general.beobachten_minutes = 1; + + let reset = ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)); + let peer_ip: IpAddr = "198.51.100.202".parse().unwrap(); + + record_handshake_failure_class(&beobachten, &config, peer_ip, &reset); + + let snapshot = beobachten.snapshot_text(Duration::from_secs(60)); + assert!( + snapshot.contains("[expected_64_got_0]"), + "ConnectionReset must be classified as expected handshake close" + ); +} + +#[test] +fn stream_io_unexpected_eof_is_classified_without_string_matching() { + let beobachten = BeobachtenStore::new(); + let mut config = ProxyConfig::default(); + config.general.beobachten = true; + config.general.beobachten_minutes = 1; + + let eof = ProxyError::Stream(StreamError::Io(std::io::Error::from( + std::io::ErrorKind::UnexpectedEof, + ))); + let peer_ip: IpAddr = "198.51.100.203".parse().unwrap(); + + record_handshake_failure_class(&beobachten, &config, peer_ip, &eof); + + let snapshot = beobachten.snapshot_text(Duration::from_secs(60)); + assert!( + snapshot.contains("[expected_64_got_0]"), + "StreamError::Io(UnexpectedEof) must be classified as expected handshake close" + ); +} + #[test] fn non_eof_error_is_classified_as_other() { let beobachten = BeobachtenStore::new(); From 8684378030202cef96eaedf9604e65a90cbf9eed Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:46:18 +0300 Subject: [PATCH 2/5] Human-readable Peer Close Classification --- src/maestro/helpers.rs | 149 ++++++++++++++++++++++++++++++++------- src/maestro/listeners.rs | 68 ++++++++++-------- 2 files changed, 162 insertions(+), 55 deletions(-) diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index 32e6a54..37b6563 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -231,7 +231,10 @@ fn print_help() { #[cfg(test)] mod tests { - use super::{is_expected_handshake_eof, resolve_runtime_config_path}; + use super::{ + expected_handshake_close_description, is_expected_handshake_eof, peer_close_description, + resolve_runtime_config_path, + }; use crate::error::{ProxyError, StreamError}; #[test] @@ -314,6 +317,67 @@ mod tests { ))); assert!(is_expected_handshake_eof(&err)); } + + #[test] + fn peer_close_description_is_human_readable_for_all_peer_close_kinds() { + let cases = [ + ( + std::io::ErrorKind::ConnectionReset, + "Peer reset TCP connection (RST)", + ), + ( + std::io::ErrorKind::ConnectionAborted, + "Peer aborted TCP connection during transport", + ), + ( + std::io::ErrorKind::BrokenPipe, + "Peer closed write side (broken pipe)", + ), + ( + std::io::ErrorKind::NotConnected, + "Socket was already closed by peer", + ), + ]; + + for (kind, expected) in cases { + let err = ProxyError::Io(std::io::Error::from(kind)); + assert_eq!(peer_close_description(&err), Some(expected)); + } + } + + #[test] + fn handshake_close_description_is_human_readable_for_all_expected_kinds() { + let cases = [ + ( + ProxyError::Io(std::io::Error::from(std::io::ErrorKind::UnexpectedEof)), + "Peer closed before sending full 64-byte MTProto handshake", + ), + ( + ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)), + "Peer reset TCP connection during initial MTProto handshake", + ), + ( + ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionAborted)), + "Peer aborted TCP connection during initial MTProto handshake", + ), + ( + ProxyError::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)), + "Peer closed write side before MTProto handshake completed", + ), + ( + ProxyError::Io(std::io::Error::from(std::io::ErrorKind::NotConnected)), + "Handshake socket was already closed by peer", + ), + ( + ProxyError::Stream(StreamError::UnexpectedEof), + "Peer closed before sending full 64-byte MTProto handshake", + ), + ]; + + for (err, expected) in cases { + assert_eq!(expected_handshake_close_description(&err), Some(expected)); + } + } } pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) { @@ -443,30 +507,65 @@ pub(crate) async fn wait_until_admission_open(admission_rx: &mut watch::Receiver } pub(crate) fn is_expected_handshake_eof(err: &crate::error::ProxyError) -> bool { - matches!( - err, - crate::error::ProxyError::Io(ioe) - if matches!( - ioe.kind(), - std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::NotConnected - ) - ) || matches!(err, crate::error::ProxyError::Stream(crate::error::StreamError::UnexpectedEof)) - || matches!( - err, - crate::error::ProxyError::Stream(crate::error::StreamError::Io(ioe)) - if matches!( - ioe.kind(), - std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::NotConnected - ) - ) + expected_handshake_close_description(err).is_some() +} + +pub(crate) fn peer_close_description(err: &crate::error::ProxyError) -> Option<&'static str> { + fn from_kind(kind: std::io::ErrorKind) -> Option<&'static str> { + match kind { + std::io::ErrorKind::ConnectionReset => Some("Peer reset TCP connection (RST)"), + std::io::ErrorKind::ConnectionAborted => { + Some("Peer aborted TCP connection during transport") + } + std::io::ErrorKind::BrokenPipe => Some("Peer closed write side (broken pipe)"), + std::io::ErrorKind::NotConnected => Some("Socket was already closed by peer"), + _ => None, + } + } + + match err { + crate::error::ProxyError::Io(ioe) => from_kind(ioe.kind()), + crate::error::ProxyError::Stream(crate::error::StreamError::Io(ioe)) => { + from_kind(ioe.kind()) + } + _ => None, + } +} + +pub(crate) fn expected_handshake_close_description( + err: &crate::error::ProxyError, +) -> Option<&'static str> { + fn from_kind(kind: std::io::ErrorKind) -> Option<&'static str> { + match kind { + std::io::ErrorKind::UnexpectedEof => { + Some("Peer closed before sending full 64-byte MTProto handshake") + } + std::io::ErrorKind::ConnectionReset => { + Some("Peer reset TCP connection during initial MTProto handshake") + } + std::io::ErrorKind::ConnectionAborted => { + Some("Peer aborted TCP connection during initial MTProto handshake") + } + std::io::ErrorKind::BrokenPipe => { + Some("Peer closed write side before MTProto handshake completed") + } + std::io::ErrorKind::NotConnected => { + Some("Handshake socket was already closed by peer") + } + _ => None, + } + } + + match err { + crate::error::ProxyError::Io(ioe) => from_kind(ioe.kind()), + crate::error::ProxyError::Stream(crate::error::StreamError::UnexpectedEof) => { + Some("Peer closed before sending full 64-byte MTProto handshake") + } + crate::error::ProxyError::Stream(crate::error::StreamError::Io(ioe)) => { + from_kind(ioe.kind()) + } + _ => None, + } } pub(crate) async fn load_startup_proxy_config_snapshot( diff --git a/src/maestro/listeners.rs b/src/maestro/listeners.rs index 796eb9e..b393f3f 100644 --- a/src/maestro/listeners.rs +++ b/src/maestro/listeners.rs @@ -24,7 +24,10 @@ use crate::transport::middle_proxy::MePool; use crate::transport::socket::set_linger_zero; use crate::transport::{ListenOptions, UpstreamManager, create_listener, find_listener_processes}; -use super::helpers::{is_expected_handshake_eof, print_proxy_links}; +use super::helpers::{ + expected_handshake_close_description, is_expected_handshake_eof, peer_close_description, + print_proxy_links, +}; pub(crate) struct BoundListeners { pub(crate) listeners: Vec<(TcpListener, bool)>, @@ -485,29 +488,9 @@ pub(crate) fn spawn_tcp_accept_loops( Ok(guard) => *guard, Err(_) => None, }; - let peer_closed = matches!( - &e, - crate::error::ProxyError::Io(ioe) - if matches!( - ioe.kind(), - std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::NotConnected - ) - ) || matches!( - &e, - crate::error::ProxyError::Stream( - crate::error::StreamError::Io(ioe) - ) - if matches!( - ioe.kind(), - std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::NotConnected - ) - ); + let peer_close_reason = peer_close_description(&e); + let handshake_close_reason = + expected_handshake_close_description(&e); let me_closed = matches!( &e, @@ -518,12 +501,23 @@ pub(crate) fn spawn_tcp_accept_loops( crate::error::ProxyError::Proxy(msg) if msg == ROUTE_SWITCH_ERROR_MSG ); - match (peer_closed, me_closed) { - (true, _) => { + match (peer_close_reason, me_closed) { + (Some(reason), _) => { if let Some(real_peer) = real_peer { - debug!(peer = %peer_addr, real_peer = %real_peer, error = %e, "Connection closed by client"); + debug!( + peer = %peer_addr, + real_peer = %real_peer, + error = %e, + close_reason = reason, + "Connection closed by peer" + ); } else { - debug!(peer = %peer_addr, error = %e, "Connection closed by client"); + debug!( + peer = %peer_addr, + error = %e, + close_reason = reason, + "Connection closed by peer" + ); } } (_, true) => { @@ -541,10 +535,24 @@ pub(crate) fn spawn_tcp_accept_loops( } } _ if is_expected_handshake_eof(&e) => { + let reason = handshake_close_reason.unwrap_or( + "Peer closed during initial handshake", + ); if let Some(real_peer) = real_peer { - info!(peer = %peer_addr, real_peer = %real_peer, error = %e, "Connection closed during initial handshake"); + info!( + peer = %peer_addr, + real_peer = %real_peer, + error = %e, + close_reason = reason, + "Connection closed during initial handshake" + ); } else { - info!(peer = %peer_addr, error = %e, "Connection closed during initial handshake"); + info!( + peer = %peer_addr, + error = %e, + close_reason = reason, + "Connection closed during initial handshake" + ); } } _ => { From 67357310f79b76d70d9986c9d1559380d18f3c52 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:29:18 +0300 Subject: [PATCH 3/5] TLS 1.2/1.3 Correctness + Full ServerHello + Rustfmt Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/cli.rs | 1 + src/config/defaults.rs | 4 + src/config/hot_reload.rs | 1 + src/config/types.rs | 8 ++ src/maestro/helpers.rs | 4 +- src/maestro/listeners.rs | 5 +- src/protocol/tests/tls_security_tests.rs | 30 +++++ src/protocol/tls.rs | 122 ++++++++++++++++++ src/proxy/handshake.rs | 24 +++- src/tls_front/emulator.rs | 112 ++++++++++++++-- ...mulator_profile_fidelity_security_tests.rs | 7 + .../tests/emulator_security_tests.rs | 7 + 12 files changed, 301 insertions(+), 24 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 2e24017..bda7d92 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -689,6 +689,7 @@ tls_domain = "{domain}" mask = true mask_port = 443 fake_cert_len = 2048 +serverhello_compact = false tls_full_cert_ttl_secs = 90 [access] diff --git a/src/config/defaults.rs b/src/config/defaults.rs index d1761d1..64fa2ac 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -575,6 +575,10 @@ pub(crate) fn default_tls_new_session_tickets() -> u8 { 0 } +pub(crate) fn default_serverhello_compact() -> bool { + false +} + pub(crate) fn default_tls_full_cert_ttl_secs() -> u64 { 90 } diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index 6337a06..eb84ccd 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -624,6 +624,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b || old.censorship.server_hello_delay_min_ms != new.censorship.server_hello_delay_min_ms || old.censorship.server_hello_delay_max_ms != new.censorship.server_hello_delay_max_ms || old.censorship.tls_new_session_tickets != new.censorship.tls_new_session_tickets + || old.censorship.serverhello_compact != new.censorship.serverhello_compact || old.censorship.tls_full_cert_ttl_secs != new.censorship.tls_full_cert_ttl_secs || old.censorship.alpn_enforce != new.censorship.alpn_enforce || old.censorship.mask_proxy_protocol != new.censorship.mask_proxy_protocol diff --git a/src/config/types.rs b/src/config/types.rs index f422e4e..b1260c7 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1723,9 +1723,16 @@ pub struct AntiCensorshipConfig { #[serde(default = "default_tls_new_session_tickets")] pub tls_new_session_tickets: u8, + /// Enable compact ServerHello payload mode. + /// When false, FakeTLS always uses full ServerHello payload behavior. + /// When true, compact certificate payload mode can be used by TTL policy. + #[serde(default = "default_serverhello_compact")] + pub serverhello_compact: bool, + /// TTL in seconds for sending full certificate payload per client IP. /// First client connection per (SNI domain, client IP) gets full cert payload. /// Subsequent handshakes within TTL use compact cert metadata payload. + /// Applied only when `serverhello_compact` is enabled. #[serde(default = "default_tls_full_cert_ttl_secs")] pub tls_full_cert_ttl_secs: u64, @@ -1820,6 +1827,7 @@ impl Default for AntiCensorshipConfig { server_hello_delay_min_ms: default_server_hello_delay_min_ms(), server_hello_delay_max_ms: default_server_hello_delay_max_ms(), tls_new_session_tickets: default_tls_new_session_tickets(), + serverhello_compact: default_serverhello_compact(), tls_full_cert_ttl_secs: default_tls_full_cert_ttl_secs(), alpn_enforce: default_alpn_enforce(), mask_proxy_protocol: 0, diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index 37b6563..b888fb4 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -549,9 +549,7 @@ pub(crate) fn expected_handshake_close_description( std::io::ErrorKind::BrokenPipe => { Some("Peer closed write side before MTProto handshake completed") } - std::io::ErrorKind::NotConnected => { - Some("Handshake socket was already closed by peer") - } + std::io::ErrorKind::NotConnected => Some("Handshake socket was already closed by peer"), _ => None, } } diff --git a/src/maestro/listeners.rs b/src/maestro/listeners.rs index b393f3f..84bd0f1 100644 --- a/src/maestro/listeners.rs +++ b/src/maestro/listeners.rs @@ -535,9 +535,8 @@ pub(crate) fn spawn_tcp_accept_loops( } } _ if is_expected_handshake_eof(&e) => { - let reason = handshake_close_reason.unwrap_or( - "Peer closed during initial handshake", - ); + let reason = handshake_close_reason + .unwrap_or("Peer closed during initial handshake"); if let Some(real_peer) = real_peer { info!( peer = %peer_addr, diff --git a/src/protocol/tests/tls_security_tests.rs b/src/protocol/tests/tls_security_tests.rs index 3008e57..1edece4 100644 --- a/src/protocol/tests/tls_security_tests.rs +++ b/src/protocol/tests/tls_security_tests.rs @@ -1383,6 +1383,8 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() { &session_id, &cached, false, + true, + ClientHelloTlsVersion::Tls13, &rng, Some(b"h2".to_vec()), 0, @@ -1624,6 +1626,34 @@ fn test_extract_alpn_multiple() { assert_eq!(alpn_str, vec!["h2", "spdy", "h3"]); } +#[test] +fn detect_client_hello_tls_version_prefers_supported_versions_tls13() { + let supported_versions = vec![4, 0x03, 0x04, 0x03, 0x03]; + let ch = build_client_hello_with_exts(vec![(0x002b, supported_versions)], "example.com"); + assert_eq!( + detect_client_hello_tls_version(&ch), + Some(ClientHelloTlsVersion::Tls13) + ); +} + +#[test] +fn detect_client_hello_tls_version_falls_back_to_legacy_tls12() { + let ch = build_client_hello_with_exts(Vec::new(), "example.com"); + assert_eq!( + detect_client_hello_tls_version(&ch), + Some(ClientHelloTlsVersion::Tls12) + ); +} + +#[test] +fn detect_client_hello_tls_version_rejects_malformed_supported_versions() { + // list_len=3 is invalid because version vector must contain u16 pairs. + let malformed_supported_versions = vec![3, 0x03, 0x04, 0x03]; + let ch = + build_client_hello_with_exts(vec![(0x002b, malformed_supported_versions)], "example.com"); + assert!(detect_client_hello_tls_version(&ch).is_none()); +} + #[test] fn extract_sni_rejects_zero_length_host_name() { let mut sni_ext = Vec::new(); diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index 613106e..ae8e40a 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -811,6 +811,128 @@ pub fn extract_alpn_from_client_hello(handshake: &[u8]) -> Vec> { out } +/// ClientHello TLS generation inferred from handshake fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientHelloTlsVersion { + Tls12, + Tls13, +} + +/// Detect TLS generation from a ClientHello. +/// +/// The parser prefers `supported_versions` (0x002b) when present and falls back +/// to `legacy_version` for compatibility with TLS 1.2 style hellos. +pub fn detect_client_hello_tls_version(handshake: &[u8]) -> Option { + if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE { + return None; + } + + let record_len = u16::from_be_bytes([handshake[3], handshake[4]]) as usize; + if handshake.len() < 5 + record_len { + return None; + } + + let mut pos = 5; // after record header + if handshake.get(pos) != Some(&0x01) { + return None; // not ClientHello + } + pos += 1; // message type + + if pos + 3 > handshake.len() { + return None; + } + let handshake_len = ((handshake[pos] as usize) << 16) + | ((handshake[pos + 1] as usize) << 8) + | handshake[pos + 2] as usize; + pos += 3; // handshake length bytes + if pos + handshake_len > 5 + record_len { + return None; + } + + if pos + 2 + 32 > handshake.len() { + return None; + } + let legacy_version = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]); + pos += 2 + 32; // version + random + + let session_id_len = *handshake.get(pos)? as usize; + pos += 1 + session_id_len; + if pos + 2 > handshake.len() { + return None; + } + + let cipher_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize; + pos += 2 + cipher_len; + if pos >= handshake.len() { + return None; + } + + let comp_len = *handshake.get(pos)? as usize; + pos += 1 + comp_len; + if pos + 2 > handshake.len() { + return None; + } + + let ext_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize; + pos += 2; + let ext_end = pos + ext_len; + if ext_end > handshake.len() { + return None; + } + + let mut saw_supported_versions = false; + while pos + 4 <= ext_end { + let etype = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]); + let elen = u16::from_be_bytes([handshake[pos + 2], handshake[pos + 3]]) as usize; + pos += 4; + if pos + elen > ext_end { + return None; + } + + if etype == extension_type::SUPPORTED_VERSIONS { + saw_supported_versions = true; + if elen < 1 { + return None; + } + let list_len = handshake[pos] as usize; + if list_len == 0 || list_len % 2 != 0 || 1 + list_len > elen { + return None; + } + + let mut has_tls12 = false; + let mut ver_pos = pos + 1; + let ver_end = ver_pos + list_len; + while ver_pos + 1 < ver_end { + let version = u16::from_be_bytes([handshake[ver_pos], handshake[ver_pos + 1]]); + if version == 0x0304 { + return Some(ClientHelloTlsVersion::Tls13); + } + if version == 0x0303 || version == 0x0302 || version == 0x0301 { + has_tls12 = true; + } + ver_pos += 2; + } + + if has_tls12 { + return Some(ClientHelloTlsVersion::Tls12); + } + return None; + } + + pos += elen; + } + + if saw_supported_versions { + return None; + } + + if legacy_version >= 0x0303 { + Some(ClientHelloTlsVersion::Tls12) + } else { + None + } +} + /// Check if bytes look like a TLS ClientHello pub fn is_tls_handshake(first_bytes: &[u8]) -> bool { if first_bytes.len() < 3 { diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index a9ab0ff..cdfd844 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -1119,6 +1119,10 @@ where } else { None }; + // Fail-closed to TLS 1.3 semantics when ClientHello version is ambiguous: + // this avoids leaking certificate payload on malformed probes. + let client_tls_version = tls::detect_client_hello_tls_version(handshake) + .unwrap_or(tls::ClientHelloTlsVersion::Tls13); if client_sni.is_some() && matched_tls_domain.is_none() && preferred_user_hint.is_none() { let sni = client_sni.as_deref().unwrap_or_default(); @@ -1439,12 +1443,18 @@ where let selected_domain = matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str()); let cached_entry = cache.get(selected_domain).await; - let use_full_cert_payload = cache - .take_full_cert_budget_for_ip( - peer.ip(), - Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), - ) - .await; + let use_full_cert_payload = if config.censorship.serverhello_compact + && matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12) + { + cache + .take_full_cert_budget_for_ip( + peer.ip(), + Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), + ) + .await + } else { + true + }; Some((cached_entry, use_full_cert_payload)) } else { None @@ -1465,6 +1475,8 @@ where validation_session_id_slice, &cached_entry, use_full_cert_payload, + config.censorship.serverhello_compact, + client_tls_version, rng, selected_alpn.clone(), config.censorship.tls_new_session_tickets, diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index af51ca0..609aeaf 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -5,7 +5,9 @@ use crate::protocol::constants::{ MAX_TLS_CIPHERTEXT_SIZE, TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_VERSION, }; -use crate::protocol::tls::{TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key}; +use crate::protocol::tls::{ + ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key, +}; use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo, TlsProfileSource}; use crc32fast::Hasher; @@ -190,6 +192,8 @@ pub fn build_emulated_server_hello( session_id: &[u8], cached: &CachedTlsData, use_full_cert_payload: bool, + serverhello_compact: bool, + client_tls_version: ClientHelloTlsVersion, rng: &SecureRandom, alpn: Option>, new_session_tickets: u8, @@ -265,20 +269,33 @@ pub fn build_emulated_server_hello( } } }; - let compact_payload = cached - .cert_info - .as_ref() - .and_then(build_compact_cert_info_payload) - .and_then(hash_compact_cert_info_payload); - let selected_payload: Option<&[u8]> = if use_full_cert_payload { + let compact_payload = if serverhello_compact { cached - .cert_payload + .cert_info .as_ref() - .map(|payload| payload.certificate_message.as_slice()) - .filter(|payload| !payload.is_empty()) - .or(compact_payload.as_deref()) + .and_then(build_compact_cert_info_payload) + .and_then(hash_compact_cert_info_payload) } else { - compact_payload.as_deref() + None + }; + let full_payload = cached + .cert_payload + .as_ref() + .map(|payload| payload.certificate_message.as_slice()) + .filter(|payload| !payload.is_empty()); + let selected_payload: Option<&[u8]> = match client_tls_version { + ClientHelloTlsVersion::Tls13 => None, + ClientHelloTlsVersion::Tls12 => { + if serverhello_compact { + if use_full_cert_payload { + full_payload.or(compact_payload.as_deref()) + } else { + compact_payload.as_deref() + } + } else { + full_payload + } + } }; if let Some(payload) = selected_payload { @@ -402,6 +419,7 @@ mod tests { use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; + use crate::protocol::tls::ClientHelloTlsVersion; fn first_app_data_payload(response: &[u8]) -> &[u8] { let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize; @@ -448,6 +466,8 @@ mod tests { &[0x22; 16], &cached, true, + true, + ClientHelloTlsVersion::Tls12, &rng, None, 0, @@ -474,6 +494,8 @@ mod tests { &[0x33; 16], &cached, true, + true, + ClientHelloTlsVersion::Tls12, &rng, None, 0, @@ -506,6 +528,8 @@ mod tests { &[0x55; 16], &cached, false, + true, + ClientHelloTlsVersion::Tls12, &rng, None, 0, @@ -529,6 +553,68 @@ mod tests { ); } + #[test] + fn test_build_emulated_server_hello_tls13_never_uses_cert_payload() { + let cert_msg = vec![0x0b, 0x00, 0x00, 0x05, 0x00, 0xaa, 0xbb, 0xcc, 0xdd]; + let cached = make_cached(Some(TlsCertPayload { + cert_chain_der: vec![vec![0x30, 0x01, 0x00]], + certificate_message: cert_msg.clone(), + })); + + let rng = SecureRandom::new(); + let response = build_emulated_server_hello( + b"secret", + &[0x56; 32], + &[0x78; 16], + &cached, + true, + true, + ClientHelloTlsVersion::Tls13, + &rng, + None, + 0, + ); + + let payload = first_app_data_payload(&response); + assert!( + !payload.starts_with(&cert_msg), + "TLS 1.3 response path must not expose certificate payload bytes" + ); + } + + #[test] + fn test_build_emulated_server_hello_compact_disabled_skips_compact_payload() { + let mut cached = make_cached(None); + cached.cert_info = Some(crate::tls_front::types::ParsedCertificateInfo { + not_after_unix: Some(1_900_000_000), + not_before_unix: Some(1_700_000_000), + issuer_cn: Some("Issuer".to_string()), + subject_cn: Some("example.com".to_string()), + san_names: vec!["example.com".to_string()], + }); + + let rng = SecureRandom::new(); + let response = build_emulated_server_hello( + b"secret", + &[0x90; 32], + &[0x91; 16], + &cached, + false, + false, + ClientHelloTlsVersion::Tls12, + &rng, + Some(b"h2".to_vec()), + 0, + ); + + let payload = first_app_data_payload(&response); + let expected_alpn_marker = [0x00u8, 0x10, 0x00, 0x05, 0x00, 0x03, 0x02, b'h', b'2']; + assert!( + payload.starts_with(&expected_alpn_marker), + "when compact mode is disabled and no full cert payload exists, the random/alpn path must be used" + ); + } + #[test] fn test_build_emulated_server_hello_ignores_tail_records_for_profiled_tls() { let mut cached = make_cached(None); @@ -545,6 +631,8 @@ mod tests { &[0x34; 16], &cached, false, + true, + ClientHelloTlsVersion::Tls13, &rng, None, 0, diff --git a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs index 1a40e9b..ba0e137 100644 --- a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs +++ b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs @@ -4,6 +4,7 @@ use crate::crypto::SecureRandom; use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; +use crate::protocol::tls::ClientHelloTlsVersion; use crate::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::types::{ CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsProfileSource, @@ -62,6 +63,8 @@ fn emulated_server_hello_keeps_single_change_cipher_spec_for_client_compatibilit &[0x72; 16], &cached, false, + true, + ClientHelloTlsVersion::Tls13, &rng, None, 0, @@ -84,6 +87,8 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() { &[0x82; 16], &cached, false, + true, + ClientHelloTlsVersion::Tls13, &rng, None, 0, @@ -104,6 +109,8 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() { &[0x92; 16], &cached, false, + true, + ClientHelloTlsVersion::Tls13, &rng, None, 2, diff --git a/src/tls_front/tests/emulator_security_tests.rs b/src/tls_front/tests/emulator_security_tests.rs index 24e04ed..ce493bb 100644 --- a/src/tls_front/tests/emulator_security_tests.rs +++ b/src/tls_front/tests/emulator_security_tests.rs @@ -4,6 +4,7 @@ use crate::crypto::SecureRandom; use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; +use crate::protocol::tls::ClientHelloTlsVersion; use crate::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::types::{ CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource, @@ -55,6 +56,8 @@ fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() { &[0x22; 16], &cached, true, + true, + ClientHelloTlsVersion::Tls13, &rng, Some(oversized_alpn), 0, @@ -91,6 +94,8 @@ fn emulated_server_hello_embeds_full_alpn_marker_when_body_can_fit() { &[0x41; 16], &cached, true, + true, + ClientHelloTlsVersion::Tls13, &rng, Some(b"h2".to_vec()), 0, @@ -119,6 +124,8 @@ fn emulated_server_hello_prefers_cert_payload_over_alpn_marker() { &[0x42; 16], &cached, true, + true, + ClientHelloTlsVersion::Tls12, &rng, Some(b"h2".to_vec()), 0, From 493f5c96802d930b7f71deafa08f90729fdc1b32 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:22:05 +0300 Subject: [PATCH 4/5] ALPN in TLS Fetcher Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/protocol/tls.rs | 6 - src/tls_front/fetcher.rs | 435 ++++++++++++++++++++++++++++++++++----- 2 files changed, 388 insertions(+), 53 deletions(-) diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index ae8e40a..5c18135 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -880,7 +880,6 @@ pub fn detect_client_hello_tls_version(handshake: &[u8]) -> Option Option Option= 0x0303 { Some(ClientHelloTlsVersion::Tls12) } else { diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index aad956e..5a1ca91 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -20,6 +20,7 @@ use rustls::client::ClientConfig; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::{DigitallySignedStruct, Error as RustlsError}; +use x25519_dalek::{X25519_BASEPOINT_BYTES, x25519}; use x509_parser::certificate::X509Certificate; use x509_parser::prelude::FromDer; @@ -275,7 +276,7 @@ fn remember_profile_success( ); } -fn build_client_config() -> Arc { +fn build_client_config(alpn_protocols: &[&[u8]]) -> Arc { let root = rustls::RootCertStore::empty(); let provider = rustls::crypto::ring::default_provider(); @@ -288,6 +289,7 @@ fn build_client_config() -> Arc { config .dangerous() .set_certificate_verifier(Arc::new(NoVerify)); + config.alpn_protocols = alpn_protocols.iter().map(|proto| proto.to_vec()).collect(); Arc::new(config) } @@ -359,6 +361,22 @@ fn profile_alpn(profile: TlsFetchProfile) -> &'static [&'static [u8]] { } } +fn profile_alpn_labels(profile: TlsFetchProfile) -> &'static [&'static str] { + const H2_HTTP11: &[&str] = &["h2", "http/1.1"]; + const HTTP11: &[&str] = &["http/1.1"]; + match profile { + TlsFetchProfile::ModernChromeLike | TlsFetchProfile::ModernFirefoxLike => H2_HTTP11, + TlsFetchProfile::CompatTls12 | TlsFetchProfile::LegacyMinimal => HTTP11, + } +} + +fn profile_session_id_len(profile: TlsFetchProfile) -> usize { + match profile { + TlsFetchProfile::ModernChromeLike | TlsFetchProfile::ModernFirefoxLike => 32, + TlsFetchProfile::CompatTls12 | TlsFetchProfile::LegacyMinimal => 0, + } +} + fn profile_supported_versions(profile: TlsFetchProfile) -> &'static [u16] { const MODERN: &[u16] = &[0x0304, 0x0303]; const COMPAT: &[u16] = &[0x0303, 0x0304]; @@ -413,8 +431,17 @@ fn build_client_hello( body.extend_from_slice(&rng.bytes(32)); } - // Session ID: empty - body.push(0); + // Use non-empty Session ID for modern TLS 1.3-like profiles to reduce middlebox friction. + let session_id_len = profile_session_id_len(profile); + let session_id = if session_id_len == 0 { + Vec::new() + } else if deterministic { + deterministic_bytes(&format!("tls-fetch-session:{sni}:{}", profile.as_str()), session_id_len) + } else { + rng.bytes(session_id_len) + }; + body.push(session_id.len() as u8); + body.extend_from_slice(&session_id); let mut cipher_suites = profile_cipher_suites(profile).to_vec(); if grease_enabled { @@ -433,16 +460,26 @@ fn build_client_hello( // === Extensions === let mut exts = Vec::new(); + let mut push_extension = |ext_type: u16, data: &[u8]| { + exts.extend_from_slice(&ext_type.to_be_bytes()); + exts.extend_from_slice(&(data.len() as u16).to_be_bytes()); + exts.extend_from_slice(data); + }; + // server_name (SNI) let sni_bytes = sni.as_bytes(); let mut sni_ext = Vec::with_capacity(5 + sni_bytes.len()); sni_ext.extend_from_slice(&(sni_bytes.len() as u16 + 3).to_be_bytes()); - sni_ext.push(0); // host_name + sni_ext.push(0); sni_ext.extend_from_slice(&(sni_bytes.len() as u16).to_be_bytes()); sni_ext.extend_from_slice(sni_bytes); - exts.extend_from_slice(&0x0000u16.to_be_bytes()); - exts.extend_from_slice(&(sni_ext.len() as u16).to_be_bytes()); - exts.extend_from_slice(&sni_ext); + push_extension(0x0000, &sni_ext); + + // Chrome-like profile keeps browser-like ordering and extension set. + if matches!(profile, TlsFetchProfile::ModernChromeLike) { + // ec_point_formats: uncompressed only. + push_extension(0x000b, &[0x01, 0x00]); + } // supported_groups let mut groups = profile_groups(profile).to_vec(); @@ -450,11 +487,16 @@ fn build_client_hello( let grease = grease_value(rng, deterministic, &format!("group:{sni}")); groups.insert(0, grease); } - exts.extend_from_slice(&0x000au16.to_be_bytes()); - exts.extend_from_slice(&((2 + groups.len() * 2) as u16).to_be_bytes()); - exts.extend_from_slice(&(groups.len() as u16 * 2).to_be_bytes()); + let mut groups_ext = Vec::with_capacity(2 + groups.len() * 2); + groups_ext.extend_from_slice(&(groups.len() as u16 * 2).to_be_bytes()); for g in groups { - exts.extend_from_slice(&g.to_be_bytes()); + groups_ext.extend_from_slice(&g.to_be_bytes()); + } + push_extension(0x000a, &groups_ext); + + if matches!(profile, TlsFetchProfile::ModernChromeLike) { + // session_ticket + push_extension(0x0023, &[]); } // signature_algorithms @@ -463,12 +505,12 @@ fn build_client_hello( let grease = grease_value(rng, deterministic, &format!("sigalg:{sni}")); sig_algs.insert(0, grease); } - exts.extend_from_slice(&0x000du16.to_be_bytes()); - exts.extend_from_slice(&((2 + sig_algs.len() * 2) as u16).to_be_bytes()); - exts.extend_from_slice(&(sig_algs.len() as u16 * 2).to_be_bytes()); + let mut sig_algs_ext = Vec::with_capacity(2 + sig_algs.len() * 2); + sig_algs_ext.extend_from_slice(&(sig_algs.len() as u16 * 2).to_be_bytes()); for a in sig_algs { - exts.extend_from_slice(&a.to_be_bytes()); + sig_algs_ext.extend_from_slice(&a.to_be_bytes()); } + push_extension(0x000d, &sig_algs_ext); // supported_versions let mut versions = profile_supported_versions(profile).to_vec(); @@ -476,30 +518,32 @@ fn build_client_hello( let grease = grease_value(rng, deterministic, &format!("version:{sni}")); versions.insert(0, grease); } - exts.extend_from_slice(&0x002bu16.to_be_bytes()); - exts.extend_from_slice(&((1 + versions.len() * 2) as u16).to_be_bytes()); - exts.push((versions.len() * 2) as u8); + let mut versions_ext = Vec::with_capacity(1 + versions.len() * 2); + versions_ext.push((versions.len() * 2) as u8); for v in versions { - exts.extend_from_slice(&v.to_be_bytes()); + versions_ext.extend_from_slice(&v.to_be_bytes()); + } + push_extension(0x002b, &versions_ext); + + if matches!(profile, TlsFetchProfile::ModernChromeLike) { + // psk_key_exchange_modes: psk_dhe_ke + push_extension(0x002d, &[0x01, 0x01]); } // key_share (x25519) - let key = if deterministic { - let det = deterministic_bytes(&format!("keyshare:{sni}"), 32); - let mut key = [0u8; 32]; - key.copy_from_slice(&det); - key - } else { - gen_key_share(rng) - }; + let key = gen_key_share( + rng, + deterministic, + &format!("tls-fetch-keyshare:{sni}:{}", profile.as_str()), + ); let mut keyshare = Vec::with_capacity(4 + key.len()); - keyshare.extend_from_slice(&0x001du16.to_be_bytes()); // group + keyshare.extend_from_slice(&0x001du16.to_be_bytes()); keyshare.extend_from_slice(&(key.len() as u16).to_be_bytes()); keyshare.extend_from_slice(&key); - exts.extend_from_slice(&0x0033u16.to_be_bytes()); - exts.extend_from_slice(&((2 + keyshare.len()) as u16).to_be_bytes()); - exts.extend_from_slice(&(keyshare.len() as u16).to_be_bytes()); - exts.extend_from_slice(&keyshare); + let mut keyshare_ext = Vec::with_capacity(2 + keyshare.len()); + keyshare_ext.extend_from_slice(&(keyshare.len() as u16).to_be_bytes()); + keyshare_ext.extend_from_slice(&keyshare); + push_extension(0x0033, &keyshare_ext); // ALPN let mut alpn_list = Vec::new(); @@ -508,16 +552,15 @@ fn build_client_hello( alpn_list.extend_from_slice(proto); } if !alpn_list.is_empty() { - exts.extend_from_slice(&0x0010u16.to_be_bytes()); - exts.extend_from_slice(&((2 + alpn_list.len()) as u16).to_be_bytes()); - exts.extend_from_slice(&(alpn_list.len() as u16).to_be_bytes()); - exts.extend_from_slice(&alpn_list); + let mut alpn_ext = Vec::with_capacity(2 + alpn_list.len()); + alpn_ext.extend_from_slice(&(alpn_list.len() as u16).to_be_bytes()); + alpn_ext.extend_from_slice(&alpn_list); + push_extension(0x0010, &alpn_ext); } if grease_enabled { let grease = grease_value(rng, deterministic, &format!("ext:{sni}")); - exts.extend_from_slice(&grease.to_be_bytes()); - exts.extend_from_slice(&0u16.to_be_bytes()); + push_extension(grease, &[]); } // padding to reduce recognizability and keep length ~500 bytes @@ -553,10 +596,14 @@ fn build_client_hello( record } -fn gen_key_share(rng: &SecureRandom) -> [u8; 32] { - let mut key = [0u8; 32]; - key.copy_from_slice(&rng.bytes(32)); - key +fn gen_key_share(rng: &SecureRandom, deterministic: bool, seed: &str) -> [u8; 32] { + let mut scalar = [0u8; 32]; + if deterministic { + scalar.copy_from_slice(&deterministic_bytes(seed, 32)); + } else { + scalar.copy_from_slice(&rng.bytes(32)); + } + x25519(scalar, X25519_BASEPOINT_BYTES) } async fn read_tls_record(stream: &mut S) -> Result<(u8, Vec)> @@ -1018,6 +1065,7 @@ async fn fetch_via_rustls_stream( host: &str, sni: &str, proxy_header: Option>, + alpn_protocols: &[&[u8]], ) -> Result where S: AsyncRead + AsyncWrite + Unpin, @@ -1028,7 +1076,7 @@ where stream.flush().await?; } - let config = build_client_config(); + let config = build_client_config(alpn_protocols); let connector = TlsConnector::from(config); let server_name = ServerName::try_from(sni.to_owned()) @@ -1113,6 +1161,7 @@ async fn fetch_via_rustls( proxy_protocol: u8, unix_sock: Option<&str>, strict_route: bool, + alpn_protocols: &[&[u8]], ) -> Result { #[cfg(unix)] if let Some(sock_path) = unix_sock { @@ -1124,7 +1173,8 @@ async fn fetch_via_rustls( "Rustls fetch using mask unix socket" ); let proxy_header = build_tls_fetch_proxy_header(proxy_protocol, None, None); - return fetch_via_rustls_stream(stream, host, sni, proxy_header).await; + return fetch_via_rustls_stream(stream, host, sni, proxy_header, alpn_protocols) + .await; } Ok(Err(e)) => { warn!( @@ -1152,7 +1202,7 @@ async fn fetch_via_rustls( .await?; let (src_addr, dst_addr) = socket_addrs_from_upstream_stream(&stream); let proxy_header = build_tls_fetch_proxy_header(proxy_protocol, src_addr, dst_addr); - fetch_via_rustls_stream(stream, host, sni, proxy_header).await + fetch_via_rustls_stream(stream, host, sni, proxy_header, alpn_protocols).await } /// Fetch real TLS metadata with an adaptive multi-profile strategy. @@ -1191,6 +1241,14 @@ pub async fn fetch_real_tls_with_strategy( break; } let timeout_for_attempt = attempt_timeout.min(total_budget - elapsed); + debug!( + sni = %sni, + profile = profile.as_str(), + alpn = ?profile_alpn_labels(profile), + grease_enabled = strategy.grease_enabled, + deterministic = strategy.deterministic, + "TLS fetch ClientHello params (raw)" + ); match fetch_via_raw_tls( host, @@ -1256,6 +1314,16 @@ pub async fn fetch_real_tls_with_strategy( } let rustls_timeout = attempt_timeout.min(total_budget - elapsed); + let rustls_profile = selected_profile.unwrap_or(TlsFetchProfile::ModernChromeLike); + let rustls_alpn_protocols = profile_alpn(rustls_profile); + debug!( + sni = %sni, + profile = rustls_profile.as_str(), + alpn = ?profile_alpn_labels(rustls_profile), + grease_enabled = strategy.grease_enabled, + deterministic = strategy.deterministic, + "TLS fetch ClientHello params (rustls)" + ); let rustls_result = fetch_via_rustls( host, port, @@ -1266,6 +1334,7 @@ pub async fn fetch_real_tls_with_strategy( proxy_protocol, unix_sock, strategy.strict_route, + rustls_alpn_protocols, ) .await; @@ -1327,8 +1396,8 @@ mod tests { use super::{ ProfileCacheValue, TlsFetchStrategy, build_client_hello, build_tls_fetch_proxy_header, - derive_behavior_profile, encode_tls13_certificate_message, order_profiles, profile_cache, - profile_cache_key, + derive_behavior_profile, encode_tls13_certificate_message, fetch_via_rustls_stream, + order_profiles, profile_alpn, profile_cache, profile_cache_key, }; use crate::config::TlsFetchProfile; use crate::crypto::SecureRandom; @@ -1336,11 +1405,116 @@ mod tests { TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; use crate::tls_front::types::TlsProfileSource; + use tokio::io::AsyncReadExt; + + struct ParsedClientHelloForTest { + session_id: Vec, + extensions: Vec<(u16, Vec)>, + } fn read_u24(bytes: &[u8]) -> usize { ((bytes[0] as usize) << 16) | ((bytes[1] as usize) << 8) | (bytes[2] as usize) } + fn parse_client_hello_for_test(record: &[u8]) -> ParsedClientHelloForTest { + assert!(record.len() >= 9, "record too short"); + assert_eq!(record[0], TLS_RECORD_HANDSHAKE, "not a handshake record"); + let record_len = u16::from_be_bytes([record[3], record[4]]) as usize; + assert_eq!(record.len(), 5 + record_len, "record length mismatch"); + + let handshake = &record[5..]; + assert_eq!(handshake[0], 0x01, "not a ClientHello handshake"); + let hello_len = read_u24(&handshake[1..4]); + assert_eq!(handshake.len(), 4 + hello_len, "handshake length mismatch"); + let hello = &handshake[4..]; + + let mut pos = 0usize; + pos += 2; + pos += 32; + + let session_len = hello[pos] as usize; + pos += 1; + let session_id = hello[pos..pos + session_len].to_vec(); + pos += session_len; + + let cipher_len = u16::from_be_bytes([hello[pos], hello[pos + 1]]) as usize; + pos += 2 + cipher_len; + + let compression_len = hello[pos] as usize; + pos += 1 + compression_len; + + let ext_len = u16::from_be_bytes([hello[pos], hello[pos + 1]]) as usize; + pos += 2; + let ext_end = pos + ext_len; + assert_eq!(ext_end, hello.len(), "extensions length mismatch"); + + let mut extensions = Vec::new(); + while pos + 4 <= ext_end { + let ext_type = u16::from_be_bytes([hello[pos], hello[pos + 1]]); + let data_len = u16::from_be_bytes([hello[pos + 2], hello[pos + 3]]) as usize; + pos += 4; + let data = hello[pos..pos + data_len].to_vec(); + pos += data_len; + extensions.push((ext_type, data)); + } + assert_eq!(pos, ext_end, "extension parse did not consume all bytes"); + + ParsedClientHelloForTest { + session_id, + extensions, + } + } + + fn parse_alpn_protocols(data: &[u8]) -> Vec> { + assert!(data.len() >= 2, "ALPN extension is too short"); + let protocols_len = u16::from_be_bytes([data[0], data[1]]) as usize; + assert_eq!(protocols_len + 2, data.len(), "ALPN list length mismatch"); + let mut pos = 2usize; + let mut out = Vec::new(); + while pos < data.len() { + let len = data[pos] as usize; + pos += 1; + out.push(data[pos..pos + len].to_vec()); + pos += len; + } + out + } + + async fn capture_rustls_client_hello_record(alpn_protocols: &'static [&'static [u8]]) -> Vec { + let (client, mut server) = tokio::io::duplex(32 * 1024); + let fetch_task = tokio::spawn(async move { + fetch_via_rustls_stream( + client, + "example.com", + "example.com", + None, + alpn_protocols, + ) + .await + }); + + let mut header = [0u8; 5]; + server + .read_exact(&mut header) + .await + .expect("must read client hello record header"); + let body_len = u16::from_be_bytes([header[3], header[4]]) as usize; + let mut body = vec![0u8; body_len]; + server + .read_exact(&mut body) + .await + .expect("must read client hello record body"); + drop(server); + + let result = fetch_task.await.expect("fetch task must join"); + assert!(result.is_err(), "capture task should end with handshake error"); + + let mut record = Vec::with_capacity(5 + body_len); + record.extend_from_slice(&header); + record.extend_from_slice(&body); + record + } + #[test] fn test_encode_tls13_certificate_message_single_cert() { let cert = vec![0x30, 0x03, 0x02, 0x01, 0x01]; @@ -1470,6 +1644,173 @@ mod tests { assert_eq!(first, second); } + #[test] + fn test_raw_client_hello_alpn_matches_profile() { + let rng = SecureRandom::new(); + for profile in [ + TlsFetchProfile::ModernChromeLike, + TlsFetchProfile::ModernFirefoxLike, + TlsFetchProfile::CompatTls12, + TlsFetchProfile::LegacyMinimal, + ] { + let hello = build_client_hello("alpn.example", &rng, profile, false, true); + let parsed = parse_client_hello_for_test(&hello); + let alpn_ext = parsed + .extensions + .iter() + .find(|(ext_type, _)| *ext_type == 0x0010) + .expect("ALPN extension must exist"); + let parsed_alpn = parse_alpn_protocols(&alpn_ext.1); + let expected_alpn = profile_alpn(profile) + .iter() + .map(|proto| proto.to_vec()) + .collect::>(); + assert_eq!( + parsed_alpn, + expected_alpn, + "ALPN mismatch for {}", + profile.as_str() + ); + } + } + + #[test] + fn test_modern_chrome_like_browser_extension_layout() { + let rng = SecureRandom::new(); + let hello = build_client_hello( + "chrome.example", + &rng, + TlsFetchProfile::ModernChromeLike, + false, + true, + ); + let parsed = parse_client_hello_for_test(&hello); + assert_eq!(parsed.session_id.len(), 32, "modern chrome must use non-empty session id"); + + let extension_ids = parsed + .extensions + .iter() + .map(|(ext_type, _)| *ext_type) + .collect::>(); + let expected_prefix = [0x0000, 0x000b, 0x000a, 0x0023, 0x000d, 0x002b, 0x002d, 0x0033, 0x0010]; + assert!( + extension_ids.as_slice().starts_with(&expected_prefix), + "unexpected extension order: {extension_ids:?}" + ); + assert!( + extension_ids.contains(&0x0015), + "modern chrome profile should include padding extension" + ); + + let key_share = parsed + .extensions + .iter() + .find(|(ext_type, _)| *ext_type == 0x0033) + .expect("key_share extension must exist"); + let key_share_data = &key_share.1; + assert!( + key_share_data.len() >= 2 + 4 + 32, + "key_share payload is too short" + ); + let entry_len = u16::from_be_bytes([key_share_data[0], key_share_data[1]]) as usize; + assert_eq!(entry_len, key_share_data.len() - 2, "key_share list length mismatch"); + let group = u16::from_be_bytes([key_share_data[2], key_share_data[3]]); + let key_len = u16::from_be_bytes([key_share_data[4], key_share_data[5]]) as usize; + let key = &key_share_data[6..6 + key_len]; + assert_eq!(group, 0x001d, "key_share group must be x25519"); + assert_eq!(key_len, 32, "x25519 key length must be 32"); + assert!(key.iter().any(|b| *b != 0), "x25519 key must not be all zero"); + } + + #[test] + fn test_fallback_profiles_keep_compat_extension_set() { + let rng = SecureRandom::new(); + for profile in [ + TlsFetchProfile::ModernFirefoxLike, + TlsFetchProfile::CompatTls12, + TlsFetchProfile::LegacyMinimal, + ] { + let hello = build_client_hello("fallback.example", &rng, profile, false, true); + let parsed = parse_client_hello_for_test(&hello); + let extension_ids = parsed + .extensions + .iter() + .map(|(ext_type, _)| *ext_type) + .collect::>(); + + assert!(extension_ids.contains(&0x0000), "SNI extension must exist"); + assert!( + extension_ids.contains(&0x000a), + "supported_groups extension must exist" + ); + assert!( + extension_ids.contains(&0x000d), + "signature_algorithms extension must exist" + ); + assert!( + extension_ids.contains(&0x002b), + "supported_versions extension must exist" + ); + assert!( + extension_ids.contains(&0x0033), + "key_share extension must exist" + ); + assert!(extension_ids.contains(&0x0010), "ALPN extension must exist"); + assert!( + !extension_ids.contains(&0x000b), + "ec_point_formats must stay chrome-only" + ); + assert!( + !extension_ids.contains(&0x0023), + "session_ticket must stay chrome-only" + ); + assert!( + !extension_ids.contains(&0x002d), + "psk_key_exchange_modes must stay chrome-only" + ); + + let expected_session_len = if matches!(profile, TlsFetchProfile::ModernFirefoxLike) { + 32 + } else { + 0 + }; + assert_eq!( + parsed.session_id.len(), + expected_session_len, + "unexpected session id length for {}", + profile.as_str() + ); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn test_rustls_client_hello_alpn_matches_selected_profile() { + for profile in [ + TlsFetchProfile::ModernChromeLike, + TlsFetchProfile::CompatTls12, + TlsFetchProfile::LegacyMinimal, + ] { + let record = capture_rustls_client_hello_record(profile_alpn(profile)).await; + let parsed = parse_client_hello_for_test(&record); + let alpn_ext = parsed + .extensions + .iter() + .find(|(ext_type, _)| *ext_type == 0x0010) + .expect("ALPN extension must exist"); + let parsed_alpn = parse_alpn_protocols(&alpn_ext.1); + let expected_alpn = profile_alpn(profile) + .iter() + .map(|proto| proto.to_vec()) + .collect::>(); + assert_eq!( + parsed_alpn, + expected_alpn, + "rustls ALPN mismatch for {}", + profile.as_str() + ); + } + } + #[test] fn test_build_tls_fetch_proxy_header_v2_with_tcp_addrs() { let src: SocketAddr = "198.51.100.10:42000".parse().expect("valid src"); From 8960fad8cd499935a858ede41bcf5c62eb6f5be5 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:55:37 +0300 Subject: [PATCH 5/5] =?UTF-8?q?=D0=A1lassified=20Bad=20Connections=20and?= =?UTF-8?q?=20Handshake=20Failures=20in=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/api/mod.rs | 16 +++++- src/api/model.rs | 10 ++++ src/api/runtime_stats.rs | 16 +++++- src/proxy/client.rs | 105 +++++++++++++++++++++------------------ src/stats/mod.rs | 61 +++++++++++++++++++++-- src/tls_front/fetcher.rs | 45 +++++++++++------ 8 files changed, 187 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e18868..8ede4c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2791,7 +2791,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "telemt" -version = "3.4.5" +version = "3.4.6" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 6146466..8983d48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.4.5" +version = "3.4.6" edition = "2024" [features] diff --git a/src/api/mod.rs b/src/api/mod.rs index 46bdc10..f33a89b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -41,7 +41,7 @@ use config_store::{current_revision, load_config_from_disk, parse_if_match}; use events::ApiEventStore; use http_utils::{error_response, read_json, read_optional_json, success_response}; use model::{ - ApiFailure, CreateUserRequest, DeleteUserResponse, HealthData, HealthReadyData, + ApiFailure, ClassCount, CreateUserRequest, DeleteUserResponse, HealthData, HealthReadyData, PatchUserRequest, RotateSecretRequest, SummaryData, UserActiveIps, }; use runtime_edge::{ @@ -334,10 +334,24 @@ async fn handle( } ("GET", "/v1/stats/summary") => { let revision = current_revision(&shared.config_path).await?; + let connections_bad_by_class = shared + .stats + .get_connects_bad_class_counts() + .into_iter() + .map(|(class, total)| ClassCount { class, total }) + .collect(); + let handshake_failures_by_class = shared + .stats + .get_handshake_failure_class_counts() + .into_iter() + .map(|(class, total)| ClassCount { class, total }) + .collect(); let data = SummaryData { uptime_seconds: shared.stats.uptime_secs(), connections_total: shared.stats.get_connects_all(), connections_bad_total: shared.stats.get_connects_bad(), + connections_bad_by_class, + handshake_failures_by_class, handshake_timeouts_total: shared.stats.get_handshake_timeouts(), configured_users: cfg.access.users.len(), }; diff --git a/src/api/model.rs b/src/api/model.rs index c6e24ea..fa1f063 100644 --- a/src/api/model.rs +++ b/src/api/model.rs @@ -71,11 +71,19 @@ pub(super) struct HealthReadyData { pub(super) total_upstreams: usize, } +#[derive(Serialize, Clone)] +pub(super) struct ClassCount { + pub(super) class: String, + pub(super) total: u64, +} + #[derive(Serialize)] pub(super) struct SummaryData { pub(super) uptime_seconds: f64, pub(super) connections_total: u64, pub(super) connections_bad_total: u64, + pub(super) connections_bad_by_class: Vec, + pub(super) handshake_failures_by_class: Vec, pub(super) handshake_timeouts_total: u64, pub(super) configured_users: usize, } @@ -91,6 +99,8 @@ pub(super) struct ZeroCoreData { pub(super) uptime_seconds: f64, pub(super) connections_total: u64, pub(super) connections_bad_total: u64, + pub(super) connections_bad_by_class: Vec, + pub(super) handshake_failures_by_class: Vec, pub(super) handshake_timeouts_total: u64, pub(super) accept_permit_timeout_total: u64, pub(super) configured_users: usize, diff --git a/src/api/runtime_stats.rs b/src/api/runtime_stats.rs index 131acef..6fd9bb6 100644 --- a/src/api/runtime_stats.rs +++ b/src/api/runtime_stats.rs @@ -7,8 +7,8 @@ use crate::transport::upstream::IpPreference; use super::ApiShared; use super::model::{ - DcEndpointWriters, DcStatus, DcStatusData, MeWriterStatus, MeWritersData, MeWritersSummary, - MinimalAllData, MinimalAllPayload, MinimalDcPathData, MinimalMeRuntimeData, + ClassCount, DcEndpointWriters, DcStatus, DcStatusData, MeWriterStatus, MeWritersData, + MeWritersSummary, MinimalAllData, MinimalAllPayload, MinimalDcPathData, MinimalMeRuntimeData, MinimalQuarantineData, UpstreamDcStatus, UpstreamStatus, UpstreamSummaryData, UpstreamsData, ZeroAllData, ZeroCodeCount, ZeroCoreData, ZeroDesyncData, ZeroMiddleProxyData, ZeroPoolData, ZeroUpstreamData, @@ -26,6 +26,16 @@ pub(crate) struct MinimalCacheEntry { pub(super) fn build_zero_all_data(stats: &Stats, configured_users: usize) -> ZeroAllData { let telemetry = stats.telemetry_policy(); + let bad_connection_classes = stats + .get_connects_bad_class_counts() + .into_iter() + .map(|(class, total)| ClassCount { class, total }) + .collect(); + let handshake_failure_classes = stats + .get_handshake_failure_class_counts() + .into_iter() + .map(|(class, total)| ClassCount { class, total }) + .collect(); let handshake_error_codes = stats .get_me_handshake_error_code_counts() .into_iter() @@ -38,6 +48,8 @@ pub(super) fn build_zero_all_data(stats: &Stats, configured_users: usize) -> Zer uptime_seconds: stats.uptime_secs(), connections_total: stats.get_connects_all(), connections_bad_total: stats.get_connects_bad(), + connections_bad_by_class: bad_connection_classes, + handshake_failures_by_class: handshake_failure_classes, handshake_timeouts_total: stats.get_handshake_timeouts(), accept_permit_timeout_total: stats.get_accept_permit_timeout_total(), configured_users, diff --git a/src/proxy/client.rs b/src/proxy/client.rs index 67fea54..2d4dd42 100644 --- a/src/proxy/client.rs +++ b/src/proxy/client.rs @@ -324,38 +324,38 @@ fn record_beobachten_class( beobachten.record(class, peer_ip, beobachten_ttl(config)); } +fn classify_expected_64_got_0(kind: std::io::ErrorKind) -> Option<&'static str> { + match kind { + std::io::ErrorKind::UnexpectedEof => Some("expected_64_got_0_unexpected_eof"), + std::io::ErrorKind::ConnectionReset => Some("expected_64_got_0_connection_reset"), + std::io::ErrorKind::ConnectionAborted => Some("expected_64_got_0_connection_aborted"), + std::io::ErrorKind::BrokenPipe => Some("expected_64_got_0_broken_pipe"), + std::io::ErrorKind::NotConnected => Some("expected_64_got_0_not_connected"), + _ => None, + } +} + +fn classify_handshake_failure_class(error: &ProxyError) -> &'static str { + match error { + ProxyError::Io(err) => classify_expected_64_got_0(err.kind()).unwrap_or("other"), + ProxyError::Stream(StreamError::UnexpectedEof) => "expected_64_got_0_unexpected_eof", + ProxyError::Stream(StreamError::Io(err)) => { + classify_expected_64_got_0(err.kind()).unwrap_or("other") + } + _ => "other", + } +} + fn record_handshake_failure_class( beobachten: &BeobachtenStore, config: &ProxyConfig, peer_ip: IpAddr, error: &ProxyError, ) { - let class = match error { - ProxyError::Io(err) - if matches!( - err.kind(), - std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::NotConnected - ) => - { - "expected_64_got_0" - } - ProxyError::Stream(StreamError::UnexpectedEof) => "expected_64_got_0", - ProxyError::Stream(StreamError::Io(err)) - if matches!( - err.kind(), - std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::NotConnected - ) => - { - "expected_64_got_0" - } + // Keep beobachten buckets stable while detailed per-kind classification + // is tracked in API counters. + let class = match classify_handshake_failure_class(error) { + value if value.starts_with("expected_64_got_0_") => "expected_64_got_0", _ => "other", }; record_beobachten_class(beobachten, config, peer_ip, class); @@ -364,7 +364,7 @@ fn record_handshake_failure_class( #[inline] fn increment_bad_on_unknown_tls_sni(stats: &Stats, error: &ProxyError) { if matches!(error, ProxyError::UnknownTlsSni) { - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("unknown_tls_sni"); } } @@ -465,7 +465,7 @@ where Ok(Ok(info)) => { if !is_trusted_proxy_source(peer.ip(), &config.server.proxy_protocol_trusted_cidrs) { - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("proxy_protocol_untrusted"); warn!( peer = %peer, trusted = ?config.server.proxy_protocol_trusted_cidrs, @@ -486,13 +486,13 @@ where } } Ok(Err(e)) => { - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("proxy_protocol_invalid_header"); warn!(peer = %peer, error = %e, "Invalid PROXY protocol header"); record_beobachten_class(&beobachten, &config, peer.ip(), "other"); return Err(e); } Err(_) => { - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("proxy_protocol_header_timeout"); warn!(peer = %peer, timeout_ms = proxy_header_timeout.as_millis(), "PROXY protocol header timeout"); record_beobachten_class(&beobachten, &config, peer.ip(), "other"); return Err(ProxyError::InvalidProxyProtocol); @@ -582,7 +582,7 @@ where // third-party clients or future Telegram versions. if !tls_clienthello_len_in_bounds(tls_len) { debug!(peer = %real_peer, tls_len = tls_len, max_tls_len = MAX_TLS_PLAINTEXT_SIZE, "TLS handshake length out of bounds"); - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("tls_clienthello_len_out_of_bounds"); maybe_apply_mask_reject_delay(&config).await; let (reader, writer) = tokio::io::split(stream); return Ok(masking_outcome( @@ -602,7 +602,7 @@ where Ok(n) => n, Err(e) => { debug!(peer = %real_peer, error = %e, tls_len = tls_len, "TLS ClientHello body read failed; engaging masking fallback"); - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("tls_clienthello_read_error"); maybe_apply_mask_reject_delay(&config).await; let initial_len = 5; let (reader, writer) = tokio::io::split(stream); @@ -620,7 +620,7 @@ where if body_read < tls_len { debug!(peer = %real_peer, got = body_read, expected = tls_len, "Truncated in-range TLS ClientHello; engaging masking fallback"); - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("tls_clienthello_truncated"); maybe_apply_mask_reject_delay(&config).await; let initial_len = 5 + body_read; let (reader, writer) = tokio::io::split(stream); @@ -644,7 +644,7 @@ where ).await { HandshakeResult::Success(result) => result, HandshakeResult::BadClient { reader, writer } => { - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("tls_handshake_bad_client"); return Ok(masking_outcome( reader, writer, @@ -684,7 +684,7 @@ where wrap_tls_application_record(&pending_plaintext) }; let reader = tokio::io::AsyncReadExt::chain(std::io::Cursor::new(pending_record), reader); - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("tls_mtproto_bad_client"); debug!( peer = %peer, "Authenticated TLS session failed MTProto validation; engaging masking fallback" @@ -714,7 +714,7 @@ where } else { if !config.general.modes.classic && !config.general.modes.secure { debug!(peer = %real_peer, "Non-TLS modes disabled"); - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("direct_modes_disabled"); maybe_apply_mask_reject_delay(&config).await; let (reader, writer) = tokio::io::split(stream); return Ok(masking_outcome( @@ -741,7 +741,7 @@ where ).await { HandshakeResult::Success(result) => result, HandshakeResult::BadClient { reader, writer } => { - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("direct_mtproto_bad_client"); return Ok(masking_outcome( reader, writer, @@ -778,6 +778,7 @@ where Ok(Ok(outcome)) => outcome, Ok(Err(e)) => { debug!(peer = %peer, error = %e, "Handshake failed"); + stats_for_timeout.increment_handshake_failure_class(classify_handshake_failure_class(&e)); record_handshake_failure_class( &beobachten_for_timeout, &config_for_timeout, @@ -788,6 +789,7 @@ where } Err(_) => { stats_for_timeout.increment_handshake_timeouts(); + stats_for_timeout.increment_handshake_failure_class("timeout"); debug!(peer = %peer, "Handshake timeout"); record_beobachten_class( &beobachten_for_timeout, @@ -977,7 +979,8 @@ impl RunningClientHandler { self.peer.ip(), &self.config.server.proxy_protocol_trusted_cidrs, ) { - self.stats.increment_connects_bad(); + self.stats + .increment_connects_bad_with_class("proxy_protocol_untrusted"); warn!( peer = %self.peer, trusted = ?self.config.server.proxy_protocol_trusted_cidrs, @@ -1007,7 +1010,8 @@ impl RunningClientHandler { } } Ok(Err(e)) => { - self.stats.increment_connects_bad(); + self.stats + .increment_connects_bad_with_class("proxy_protocol_invalid_header"); warn!(peer = %self.peer, error = %e, "Invalid PROXY protocol header"); record_beobachten_class( &self.beobachten, @@ -1018,7 +1022,8 @@ impl RunningClientHandler { return Err(e); } Err(_) => { - self.stats.increment_connects_bad(); + self.stats + .increment_connects_bad_with_class("proxy_protocol_header_timeout"); warn!( peer = %self.peer, timeout_ms = proxy_header_timeout.as_millis(), @@ -1116,6 +1121,7 @@ impl RunningClientHandler { Ok(Ok(outcome)) => outcome, Ok(Err(e)) => { debug!(peer = %peer_for_log, error = %e, "Handshake failed"); + stats.increment_handshake_failure_class(classify_handshake_failure_class(&e)); record_handshake_failure_class( &beobachten_for_timeout, &config_for_timeout, @@ -1126,6 +1132,7 @@ impl RunningClientHandler { } Err(_) => { stats.increment_handshake_timeouts(); + stats.increment_handshake_failure_class("timeout"); debug!(peer = %peer_for_log, "Handshake timeout"); record_beobachten_class( &beobachten_for_timeout, @@ -1161,7 +1168,8 @@ impl RunningClientHandler { // third-party clients or future Telegram versions. if !tls_clienthello_len_in_bounds(tls_len) { debug!(peer = %peer, tls_len = tls_len, max_tls_len = MAX_TLS_PLAINTEXT_SIZE, "TLS handshake length out of bounds"); - self.stats.increment_connects_bad(); + self.stats + .increment_connects_bad_with_class("tls_clienthello_len_out_of_bounds"); maybe_apply_mask_reject_delay(&self.config).await; let (reader, writer) = self.stream.into_split(); return Ok(masking_outcome( @@ -1181,7 +1189,8 @@ impl RunningClientHandler { Ok(n) => n, Err(e) => { debug!(peer = %peer, error = %e, tls_len = tls_len, "TLS ClientHello body read failed; engaging masking fallback"); - self.stats.increment_connects_bad(); + self.stats + .increment_connects_bad_with_class("tls_clienthello_read_error"); maybe_apply_mask_reject_delay(&self.config).await; let (reader, writer) = self.stream.into_split(); return Ok(masking_outcome( @@ -1198,7 +1207,8 @@ impl RunningClientHandler { if body_read < tls_len { debug!(peer = %peer, got = body_read, expected = tls_len, "Truncated in-range TLS ClientHello; engaging masking fallback"); - self.stats.increment_connects_bad(); + self.stats + .increment_connects_bad_with_class("tls_clienthello_truncated"); maybe_apply_mask_reject_delay(&self.config).await; let initial_len = 5 + body_read; let (reader, writer) = self.stream.into_split(); @@ -1235,7 +1245,7 @@ impl RunningClientHandler { { HandshakeResult::Success(result) => result, HandshakeResult::BadClient { reader, writer } => { - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("tls_handshake_bad_client"); return Ok(masking_outcome( reader, writer, @@ -1285,7 +1295,7 @@ impl RunningClientHandler { }; let reader = tokio::io::AsyncReadExt::chain(std::io::Cursor::new(pending_record), reader); - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("tls_mtproto_bad_client"); debug!( peer = %peer, "Authenticated TLS session failed MTProto validation; engaging masking fallback" @@ -1332,7 +1342,8 @@ impl RunningClientHandler { if !self.config.general.modes.classic && !self.config.general.modes.secure { debug!(peer = %peer, "Non-TLS modes disabled"); - self.stats.increment_connects_bad(); + self.stats + .increment_connects_bad_with_class("direct_modes_disabled"); maybe_apply_mask_reject_delay(&self.config).await; let (reader, writer) = self.stream.into_split(); return Ok(masking_outcome( @@ -1372,7 +1383,7 @@ impl RunningClientHandler { { HandshakeResult::Success(result) => result, HandshakeResult::BadClient { reader, writer } => { - stats.increment_connects_bad(); + stats.increment_connects_bad_with_class("direct_mtproto_bad_client"); return Ok(masking_outcome( reader, writer, diff --git a/src/stats/mod.rs b/src/stats/mod.rs index 9609e19..9678f2a 100644 --- a/src/stats/mod.rs +++ b/src/stats/mod.rs @@ -88,6 +88,8 @@ impl Drop for RouteConnectionLease { pub struct Stats { connects_all: AtomicU64, connects_bad: AtomicU64, + connects_bad_classes: DashMap<&'static str, AtomicU64>, + handshake_failure_classes: DashMap<&'static str, AtomicU64>, current_connections_direct: AtomicU64, current_connections_me: AtomicU64, handshake_timeouts: AtomicU64, @@ -518,10 +520,32 @@ impl Stats { self.connects_all.fetch_add(1, Ordering::Relaxed); } } - pub fn increment_connects_bad(&self) { - if self.telemetry_core_enabled() { - self.connects_bad.fetch_add(1, Ordering::Relaxed); + + pub fn increment_connects_bad_with_class(&self, class: &'static str) { + if !self.telemetry_core_enabled() { + return; } + self.connects_bad.fetch_add(1, Ordering::Relaxed); + let entry = self + .connects_bad_classes + .entry(class) + .or_insert_with(|| AtomicU64::new(0)); + entry.fetch_add(1, Ordering::Relaxed); + } + + pub fn increment_connects_bad(&self) { + self.increment_connects_bad_with_class("other"); + } + + pub fn increment_handshake_failure_class(&self, class: &'static str) { + if !self.telemetry_core_enabled() { + return; + } + let entry = self + .handshake_failure_classes + .entry(class) + .or_insert_with(|| AtomicU64::new(0)); + entry.fetch_add(1, Ordering::Relaxed); } pub fn increment_current_connections_direct(&self) { self.current_connections_direct @@ -1640,6 +1664,37 @@ impl Stats { pub fn get_connects_bad(&self) -> u64 { self.connects_bad.load(Ordering::Relaxed) } + + pub fn get_connects_bad_class_counts(&self) -> Vec<(String, u64)> { + let mut out: Vec<(String, u64)> = self + .connects_bad_classes + .iter() + .map(|entry| { + ( + entry.key().to_string(), + entry.value().load(Ordering::Relaxed), + ) + }) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } + + pub fn get_handshake_failure_class_counts(&self) -> Vec<(String, u64)> { + let mut out: Vec<(String, u64)> = self + .handshake_failure_classes + .iter() + .map(|entry| { + ( + entry.key().to_string(), + entry.value().load(Ordering::Relaxed), + ) + }) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } + pub fn get_accept_permit_timeout_total(&self) -> u64 { self.accept_permit_timeout_total.load(Ordering::Relaxed) } diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index 5a1ca91..53ec803 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -436,7 +436,10 @@ fn build_client_hello( let session_id = if session_id_len == 0 { Vec::new() } else if deterministic { - deterministic_bytes(&format!("tls-fetch-session:{sni}:{}", profile.as_str()), session_id_len) + deterministic_bytes( + &format!("tls-fetch-session:{sni}:{}", profile.as_str()), + session_id_len, + ) } else { rng.bytes(session_id_len) }; @@ -1480,17 +1483,13 @@ mod tests { out } - async fn capture_rustls_client_hello_record(alpn_protocols: &'static [&'static [u8]]) -> Vec { + async fn capture_rustls_client_hello_record( + alpn_protocols: &'static [&'static [u8]], + ) -> Vec { let (client, mut server) = tokio::io::duplex(32 * 1024); let fetch_task = tokio::spawn(async move { - fetch_via_rustls_stream( - client, - "example.com", - "example.com", - None, - alpn_protocols, - ) - .await + fetch_via_rustls_stream(client, "example.com", "example.com", None, alpn_protocols) + .await }); let mut header = [0u8; 5]; @@ -1507,7 +1506,10 @@ mod tests { drop(server); let result = fetch_task.await.expect("fetch task must join"); - assert!(result.is_err(), "capture task should end with handshake error"); + assert!( + result.is_err(), + "capture task should end with handshake error" + ); let mut record = Vec::with_capacity(5 + body_len); record.extend_from_slice(&header); @@ -1685,14 +1687,20 @@ mod tests { true, ); let parsed = parse_client_hello_for_test(&hello); - assert_eq!(parsed.session_id.len(), 32, "modern chrome must use non-empty session id"); + assert_eq!( + parsed.session_id.len(), + 32, + "modern chrome must use non-empty session id" + ); let extension_ids = parsed .extensions .iter() .map(|(ext_type, _)| *ext_type) .collect::>(); - let expected_prefix = [0x0000, 0x000b, 0x000a, 0x0023, 0x000d, 0x002b, 0x002d, 0x0033, 0x0010]; + let expected_prefix = [ + 0x0000, 0x000b, 0x000a, 0x0023, 0x000d, 0x002b, 0x002d, 0x0033, 0x0010, + ]; assert!( extension_ids.as_slice().starts_with(&expected_prefix), "unexpected extension order: {extension_ids:?}" @@ -1713,13 +1721,20 @@ mod tests { "key_share payload is too short" ); let entry_len = u16::from_be_bytes([key_share_data[0], key_share_data[1]]) as usize; - assert_eq!(entry_len, key_share_data.len() - 2, "key_share list length mismatch"); + assert_eq!( + entry_len, + key_share_data.len() - 2, + "key_share list length mismatch" + ); let group = u16::from_be_bytes([key_share_data[2], key_share_data[3]]); let key_len = u16::from_be_bytes([key_share_data[4], key_share_data[5]]) as usize; let key = &key_share_data[6..6 + key_len]; assert_eq!(group, 0x001d, "key_share group must be x25519"); assert_eq!(key_len, 32, "x25519 key length must be 32"); - assert!(key.iter().any(|b| *b != 0), "x25519 key must not be all zero"); + assert!( + key.iter().any(|b| *b != 0), + "x25519 key must not be all zero" + ); } #[test]