Expose TLS Fetcher Profile Quality for ServerHello fidelity

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-06-11 14:53:21 +03:00
parent 3d0560d583
commit 409b0ef5ee
7 changed files with 238 additions and 11 deletions

View File

@@ -381,11 +381,32 @@ async fn render_tls_front_profile_health(
"# HELP telemt_tls_front_profile_info TLS front profile source and feature flags per configured domain"
);
let _ = writeln!(out, "# TYPE telemt_tls_front_profile_info gauge");
let _ = writeln!(
out,
"# HELP telemt_tls_front_profile_quality_info TLS front profile quality and key-share group per configured domain"
);
let _ = writeln!(out, "# TYPE telemt_tls_front_profile_quality_info gauge");
let _ = writeln!(
out,
"# HELP telemt_tls_front_profile_age_seconds Age of cached TLS front profile data per configured domain"
);
let _ = writeln!(out, "# TYPE telemt_tls_front_profile_age_seconds gauge");
let _ = writeln!(
out,
"# HELP telemt_tls_front_profile_server_hello_bytes TLS front cached ServerHello record body bytes per configured domain"
);
let _ = writeln!(
out,
"# TYPE telemt_tls_front_profile_server_hello_bytes gauge"
);
let _ = writeln!(
out,
"# HELP telemt_tls_front_profile_server_hello_extensions TLS front cached visible ServerHello extension count per configured domain"
);
let _ = writeln!(
out,
"# TYPE telemt_tls_front_profile_server_hello_extensions gauge"
);
let _ = writeln!(
out,
"# HELP telemt_tls_front_profile_app_data_records TLS front cached app-data record count per configured domain"
@@ -420,11 +441,26 @@ async fn render_tls_front_profile_health(
"telemt_tls_front_profile_info{{domain=\"{}\",source=\"{}\",is_default=\"{}\",has_cert_info=\"{}\",has_cert_payload=\"{}\"}} 1",
domain, item.source, item.is_default, item.has_cert_info, item.has_cert_payload
);
let _ = writeln!(
out,
"telemt_tls_front_profile_quality_info{{domain=\"{}\",quality=\"{}\",key_share_group=\"{}\"}} 1",
domain, item.quality, item.key_share_group
);
let _ = writeln!(
out,
"telemt_tls_front_profile_age_seconds{{domain=\"{}\"}} {}",
domain, item.age_seconds
);
let _ = writeln!(
out,
"telemt_tls_front_profile_server_hello_bytes{{domain=\"{}\"}} {}",
domain, item.server_hello_record_len
);
let _ = writeln!(
out,
"telemt_tls_front_profile_server_hello_extensions{{domain=\"{}\"}} {}",
domain, item.server_hello_extensions
);
let _ = writeln!(
out,
"telemt_tls_front_profile_app_data_records{{domain=\"{}\"}} {}",
@@ -3901,7 +3937,10 @@ mod tests {
session_id: Vec::new(),
cipher_suite: [0x13, 0x01],
compression: 0,
extensions: Vec::new(),
extensions: vec![crate::tls_front::types::TlsExtension {
ext_type: 0x0033,
data: vec![0x00, 0x1d, 0x00, 0x20],
}],
},
cert_info: None,
cert_payload: Some(TlsCertPayload {
@@ -3915,6 +3954,7 @@ mod tests {
app_data_record_sizes: vec![1024, 512],
ticket_record_sizes: vec![69],
source: TlsProfileSource::Merged,
..TlsBehaviorProfile::default()
},
fetched_at: SystemTime::now(),
domain: "primary.example".to_string(),
@@ -3933,6 +3973,22 @@ mod tests {
assert!(
output.contains("telemt_tls_front_profile_info{domain=\"fallback.example\",source=\"default\",is_default=\"true\",has_cert_info=\"false\",has_cert_payload=\"false\"} 1")
);
assert!(
output.contains("telemt_tls_front_profile_quality_info{domain=\"primary.example\",quality=\"raw_strict\",key_share_group=\"x25519\"} 1")
);
assert!(
output.contains("telemt_tls_front_profile_quality_info{domain=\"fallback.example\",quality=\"fallback\",key_share_group=\"none\"} 1")
);
assert!(
output.contains(
"telemt_tls_front_profile_server_hello_bytes{domain=\"primary.example\"} 52"
)
);
assert!(
output.contains(
"telemt_tls_front_profile_server_hello_extensions{domain=\"primary.example\"} 1"
)
);
assert!(
output.contains(
"telemt_tls_front_profile_app_data_records{domain=\"primary.example\"} 2"
@@ -4045,7 +4101,10 @@ mod tests {
);
assert!(output.contains("# TYPE telemt_tls_front_profile_domains gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_info gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_quality_info gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_age_seconds gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_server_hello_bytes gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_server_hello_extensions gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_app_data_records gauge"));
assert!(output.contains("# TYPE telemt_tls_front_profile_ticket_records gauge"));
assert!(

View File

@@ -1447,6 +1447,7 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() {
app_data_record_sizes: vec![1024],
ticket_record_sizes: Vec::new(),
source: TlsProfileSource::Default,
..TlsBehaviorProfile::default()
},
fetched_at: SystemTime::now(),
domain: "example.com".to_string(),

View File

@@ -12,7 +12,8 @@ use tokio::time::sleep;
use tracing::{debug, info, warn};
use crate::tls_front::types::{
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsFetchResult, TlsProfileSource,
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsFetchResult, TlsProfileQuality,
TlsProfileSource,
};
const FULL_CERT_SENT_SWEEP_INTERVAL_SECS: u64 = 30;
@@ -47,10 +48,14 @@ pub struct TlsFrontCache {
pub(crate) struct TlsFrontProfileHealth {
pub(crate) domain: String,
pub(crate) source: &'static str,
pub(crate) quality: &'static str,
pub(crate) key_share_group: &'static str,
pub(crate) age_seconds: u64,
pub(crate) is_default: bool,
pub(crate) has_cert_info: bool,
pub(crate) has_cert_payload: bool,
pub(crate) server_hello_record_len: usize,
pub(crate) server_hello_extensions: usize,
pub(crate) app_data_records: usize,
pub(crate) ticket_records: usize,
pub(crate) change_cipher_spec_count: u8,
@@ -66,6 +71,23 @@ fn profile_source_label(source: TlsProfileSource) -> &'static str {
}
}
fn profile_quality_label(quality: TlsProfileQuality) -> &'static str {
match quality {
TlsProfileQuality::Fallback => "fallback",
TlsProfileQuality::RawPartial => "raw_partial",
TlsProfileQuality::RawStrict => "raw_strict",
}
}
fn key_share_group_label(group: Option<u16>) -> &'static str {
match group {
Some(0x001d) => "x25519",
Some(0x11ec) => "x25519mlkem768",
Some(_) => "other",
None => "none",
}
}
#[allow(dead_code)]
impl TlsFrontCache {
pub fn new(domains: &[String], default_len: usize, disk_path: impl AsRef<Path>) -> Self {
@@ -137,7 +159,8 @@ impl TlsFrontCache {
.get(domain)
.cloned()
.unwrap_or_else(|| self.default.clone());
let behavior = &cached.behavior_profile;
let mut behavior = cached.behavior_profile.clone();
behavior.refresh_server_hello_summary(&cached.server_hello_template);
let age_seconds = now
.duration_since(cached.fetched_at)
.map(|duration| duration.as_secs())
@@ -146,10 +169,14 @@ impl TlsFrontCache {
snapshot.push(TlsFrontProfileHealth {
domain: domain.clone(),
source: profile_source_label(behavior.source),
quality: profile_quality_label(behavior.quality),
key_share_group: key_share_group_label(behavior.server_hello_key_share_group),
age_seconds,
is_default: cached.domain == "default",
has_cert_info: cached.cert_info.is_some(),
has_cert_payload: cached.cert_payload.is_some(),
server_hello_record_len: behavior.server_hello_record_len,
server_hello_extensions: behavior.server_hello_extension_types.len(),
app_data_records: cached
.app_data_records_sizes
.len()
@@ -378,20 +405,39 @@ impl TlsFrontCache {
/// Replace cached entry from a fetch result.
pub async fn update_from_fetch(&self, domain: &str, fetched: TlsFetchResult) {
let TlsFetchResult {
server_hello_parsed,
app_data_records_sizes,
total_app_data_len,
mut behavior_profile,
cert_info,
cert_payload,
} = fetched;
behavior_profile.refresh_server_hello_summary(&server_hello_parsed);
let quality = behavior_profile.quality;
let data = CachedTlsData {
server_hello_template: fetched.server_hello_parsed,
cert_info: fetched.cert_info,
cert_payload: fetched.cert_payload,
app_data_records_sizes: fetched.app_data_records_sizes.clone(),
total_app_data_len: fetched.total_app_data_len,
behavior_profile: fetched.behavior_profile,
server_hello_template: server_hello_parsed,
cert_info,
cert_payload,
app_data_records_sizes: app_data_records_sizes.clone(),
total_app_data_len,
behavior_profile,
fetched_at: SystemTime::now(),
domain: domain.to_string(),
};
self.set(domain, data.clone()).await;
self.persist(domain, &data).await;
debug!(domain = %domain, len = fetched.total_app_data_len, "TLS cache updated");
if quality == TlsProfileQuality::RawStrict {
debug!(domain = %domain, len = total_app_data_len, "TLS cache updated");
} else {
warn!(
domain = %domain,
quality = profile_quality_label(quality),
len = total_app_data_len,
"TLS cache updated with non-strict front profile"
);
}
}
pub fn default_entry(&self) -> Arc<CachedTlsData> {

View File

@@ -838,6 +838,7 @@ fn derive_behavior_profile(records: &[(u8, Vec<u8>)]) -> TlsBehaviorProfile {
app_data_record_sizes,
ticket_record_sizes,
source: TlsProfileSource::Raw,
..TlsBehaviorProfile::default()
}
}
@@ -1087,14 +1088,18 @@ where
}
let mut server_hello = None;
let mut server_hello_record_len = 0usize;
for (t, body) in &records {
if *t == TLS_RECORD_HANDSHAKE && server_hello.is_none() {
server_hello = parse_server_hello(body);
server_hello_record_len = body.len();
}
}
let parsed = server_hello.ok_or_else(|| anyhow!("ServerHello not received"))?;
let behavior_profile = derive_behavior_profile(&records);
let mut behavior_profile = derive_behavior_profile(&records);
behavior_profile.server_hello_record_len = server_hello_record_len;
behavior_profile.refresh_server_hello_summary(&parsed);
let mut app_sizes = behavior_profile.app_data_record_sizes.clone();
app_sizes.extend_from_slice(&behavior_profile.ticket_record_sizes);
let total_app_data_len = app_sizes.iter().sum::<usize>().max(1024);
@@ -1272,6 +1277,7 @@ where
app_data_record_sizes: app_data_records_sizes,
ticket_record_sizes: Vec::new(),
source: TlsProfileSource::Rustls,
..TlsBehaviorProfile::default()
},
cert_info,
cert_payload,
@@ -1471,6 +1477,7 @@ pub async fn fetch_real_tls_with_strategy(
raw.cert_info = rustls.cert_info;
raw.cert_payload = rustls.cert_payload;
raw.behavior_profile.source = TlsProfileSource::Merged;
raw.behavior_profile.refresh_quality();
debug!(sni = %sni, "Fetched TLS metadata via adaptive raw probe + rustls cert chain");
Ok(raw)
} else {

View File

@@ -31,6 +31,7 @@ fn make_cached() -> CachedTlsData {
app_data_record_sizes: vec![1200, 900],
ticket_record_sizes: vec![220, 180],
source: TlsProfileSource::Merged,
..TlsBehaviorProfile::default()
},
fetched_at: SystemTime::now(),
domain: "example.com".to_string(),

View File

@@ -31,6 +31,7 @@ fn make_cached(cert_payload: Option<crate::tls_front::types::TlsCertPayload>) ->
app_data_record_sizes: vec![64],
ticket_record_sizes: Vec::new(),
source: TlsProfileSource::Default,
..TlsBehaviorProfile::default()
},
fetched_at: SystemTime::now(),
domain: "example.com".to_string(),

View File

@@ -1,6 +1,10 @@
use serde::{Deserialize, Serialize};
use std::time::SystemTime;
const EXT_KEY_SHARE: u16 = 0x0033;
const TLS_NAMED_GROUP_X25519: u16 = 0x001d;
const TLS_NAMED_GROUP_X25519MLKEM768: u16 = 0x11ec;
/// Parsed representation of an unencrypted TLS ServerHello.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedServerHello {
@@ -19,6 +23,52 @@ pub struct TlsExtension {
pub data: Vec<u8>,
}
impl ParsedServerHello {
/// Return the TLS record body length that would contain this ServerHello.
pub(crate) fn record_body_len(&self) -> usize {
let extensions_len = self
.extensions
.iter()
.map(|extension| 4 + extension.data.len())
.sum::<usize>();
4 + 2 + 32 + 1 + self.session_id.len() + 2 + 1 + 2 + extensions_len
}
/// Return visible ServerHello extension types in wire order.
pub(crate) fn extension_types(&self) -> Vec<u16> {
self.extensions
.iter()
.map(|extension| extension.ext_type)
.collect()
}
/// Return a replay-safe ServerHello key_share group when the extension is well-formed.
pub(crate) fn key_share_group(&self) -> Option<u16> {
self.extensions
.iter()
.find(|extension| extension.ext_type == EXT_KEY_SHARE)
.and_then(|extension| parse_key_share_group(&extension.data))
}
}
fn parse_key_share_group(data: &[u8]) -> Option<u16> {
if data.len() < 4 {
return None;
}
let group = u16::from_be_bytes([data[0], data[1]]);
let key_exchange_len = u16::from_be_bytes([data[2], data[3]]) as usize;
if data.len() != 4 + key_exchange_len {
return None;
}
match group {
TLS_NAMED_GROUP_X25519 | TLS_NAMED_GROUP_X25519MLKEM768 => Some(group),
_ => None,
}
}
/// Basic certificate metadata (optional, informative).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedCertificateInfo {
@@ -54,6 +104,19 @@ pub enum TlsProfileSource {
Merged,
}
/// DPI-facing quality class of a cached TLS front profile.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TlsProfileQuality {
/// No raw origin ServerHello shape is available.
#[default]
Fallback,
/// Raw origin ServerHello was captured, but encrypted flight shape is incomplete.
RawPartial,
/// Raw origin ServerHello and encrypted flight record sizes were captured.
RawStrict,
}
/// Coarse-grained TLS response behavior captured per SNI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsBehaviorProfile {
@@ -69,6 +132,18 @@ pub struct TlsBehaviorProfile {
/// Source of this behavior profile.
#[serde(default)]
pub source: TlsProfileSource,
/// DPI-facing quality of this profile.
#[serde(default)]
pub quality: TlsProfileQuality,
/// Captured ServerHello TLS record body length.
#[serde(default)]
pub server_hello_record_len: usize,
/// Captured visible ServerHello extension types in wire order.
#[serde(default)]
pub server_hello_extension_types: Vec<u16>,
/// Captured ServerHello key_share group when replay-safe.
#[serde(default)]
pub server_hello_key_share_group: Option<u16>,
}
fn default_change_cipher_spec_count() -> u8 {
@@ -82,10 +157,46 @@ impl Default for TlsBehaviorProfile {
app_data_record_sizes: Vec::new(),
ticket_record_sizes: Vec::new(),
source: TlsProfileSource::Default,
quality: TlsProfileQuality::Fallback,
server_hello_record_len: 0,
server_hello_extension_types: Vec::new(),
server_hello_key_share_group: None,
}
}
}
impl TlsBehaviorProfile {
/// Refresh cached visible ServerHello summary fields and quality.
pub(crate) fn refresh_server_hello_summary(&mut self, server_hello: &ParsedServerHello) {
if matches!(self.source, TlsProfileSource::Raw | TlsProfileSource::Merged) {
if self.server_hello_record_len == 0 {
self.server_hello_record_len = server_hello.record_body_len();
}
self.server_hello_extension_types = server_hello.extension_types();
self.server_hello_key_share_group = server_hello.key_share_group();
} else {
self.server_hello_record_len = 0;
self.server_hello_extension_types.clear();
self.server_hello_key_share_group = None;
}
self.refresh_quality();
}
/// Recompute the profile quality from current source and record-size evidence.
pub(crate) fn refresh_quality(&mut self) {
let has_raw_server_hello = matches!(self.source, TlsProfileSource::Raw | TlsProfileSource::Merged)
&& self.server_hello_record_len > 0;
self.quality = if has_raw_server_hello && !self.app_data_record_sizes.is_empty() {
TlsProfileQuality::RawStrict
} else if has_raw_server_hello {
TlsProfileQuality::RawPartial
} else {
TlsProfileQuality::Fallback
};
}
}
/// Cached data per SNI used by the emulator.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedTlsData {
@@ -147,5 +258,6 @@ mod tests {
assert!(cached.behavior_profile.app_data_record_sizes.is_empty());
assert!(cached.behavior_profile.ticket_record_sizes.is_empty());
assert_eq!(cached.behavior_profile.source, TlsProfileSource::Default);
assert_eq!(cached.behavior_profile.quality, TlsProfileQuality::Fallback);
}
}