mirror of
https://github.com/telemt/telemt.git
synced 2026-04-25 06:24:10 +03:00
Close Errors Classification + TLS 1.2/1.3 Correctness in Fronting + Full ServerHello + ALPN in TLS Fetcher: merge pull request #738 from telemt/flow
Close Errors Classification + TLS 1.2/1.3 Correctness in Fronting + Full ServerHello + ALPN in TLS Fetcher
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2791,7 +2791,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "telemt"
|
||||
version = "3.4.5"
|
||||
version = "3.4.6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "telemt"
|
||||
version = "3.4.5"
|
||||
version = "3.4.6"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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<ClassCount>,
|
||||
pub(super) handshake_failures_by_class: Vec<ClassCount>,
|
||||
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<ClassCount>,
|
||||
pub(super) handshake_failures_by_class: Vec<ClassCount>,
|
||||
pub(super) handshake_timeouts_total: u64,
|
||||
pub(super) accept_permit_timeout_total: u64,
|
||||
pub(super) configured_users: usize,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -231,7 +231,11 @@ fn print_help() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::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]
|
||||
fn resolve_runtime_config_path_anchors_relative_to_startup_cwd() {
|
||||
@@ -299,6 +303,81 @@ 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));
|
||||
}
|
||||
|
||||
#[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) {
|
||||
@@ -428,7 +507,63 @@ 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")
|
||||
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(
|
||||
|
||||
@@ -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,23 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -811,6 +811,122 @@ pub fn extract_alpn_from_client_hello(handshake: &[u8]) -> Vec<Vec<u8>> {
|
||||
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<ClientHelloTlsVersion> {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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 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 {
|
||||
|
||||
@@ -324,17 +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 err.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
"expected_64_got_0"
|
||||
}
|
||||
ProxyError::Stream(StreamError::UnexpectedEof) => "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);
|
||||
@@ -343,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,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,
|
||||
@@ -465,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);
|
||||
@@ -561,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(
|
||||
@@ -581,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);
|
||||
@@ -599,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);
|
||||
@@ -623,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,
|
||||
@@ -663,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"
|
||||
@@ -693,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(
|
||||
@@ -720,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,
|
||||
@@ -757,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,
|
||||
@@ -767,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,
|
||||
@@ -956,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,
|
||||
@@ -986,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,
|
||||
@@ -997,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(),
|
||||
@@ -1095,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,
|
||||
@@ -1105,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,
|
||||
@@ -1140,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(
|
||||
@@ -1160,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(
|
||||
@@ -1177,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();
|
||||
@@ -1214,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,
|
||||
@@ -1264,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"
|
||||
@@ -1311,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(
|
||||
@@ -1351,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
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,
|
||||
|
||||
@@ -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<ClientConfig> {
|
||||
fn build_client_config(alpn_protocols: &[&[u8]]) -> Arc<ClientConfig> {
|
||||
let root = rustls::RootCertStore::empty();
|
||||
|
||||
let provider = rustls::crypto::ring::default_provider();
|
||||
@@ -288,6 +289,7 @@ fn build_client_config() -> Arc<ClientConfig> {
|
||||
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,20 @@ 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 +463,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 +490,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 +508,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 +521,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 +555,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 +599,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<S>(stream: &mut S) -> Result<(u8, Vec<u8>)>
|
||||
@@ -1018,6 +1068,7 @@ async fn fetch_via_rustls_stream<S>(
|
||||
host: &str,
|
||||
sni: &str,
|
||||
proxy_header: Option<Vec<u8>>,
|
||||
alpn_protocols: &[&[u8]],
|
||||
) -> Result<TlsFetchResult>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
@@ -1028,7 +1079,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 +1164,7 @@ async fn fetch_via_rustls(
|
||||
proxy_protocol: u8,
|
||||
unix_sock: Option<&str>,
|
||||
strict_route: bool,
|
||||
alpn_protocols: &[&[u8]],
|
||||
) -> Result<TlsFetchResult> {
|
||||
#[cfg(unix)]
|
||||
if let Some(sock_path) = unix_sock {
|
||||
@@ -1124,7 +1176,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 +1205,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 +1244,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 +1317,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 +1337,7 @@ pub async fn fetch_real_tls_with_strategy(
|
||||
proxy_protocol,
|
||||
unix_sock,
|
||||
strategy.strict_route,
|
||||
rustls_alpn_protocols,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1327,8 +1399,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 +1408,115 @@ 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<u8>,
|
||||
extensions: Vec<(u16, Vec<u8>)>,
|
||||
}
|
||||
|
||||
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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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 +1646,186 @@ 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user