mirror of
https://github.com/telemt/telemt.git
synced 2026-06-11 21:41:43 +03:00
Expose TLS Fetcher Profile Quality for ServerHello fidelity
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
@@ -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!(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user