diff --git a/Cargo.lock b/Cargo.lock index 7cf05ef..9d451b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2938,7 +2938,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "telemt" -version = "3.4.17" +version = "3.4.18" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index c03fdb1..8b223ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.4.17" +version = "3.4.18" edition = "2024" [features] diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index 64e20fd..c508c87 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -2219,10 +2219,10 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche | [`ip`](#ip) | `IpAddr` | — | `✘` | | [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` | | [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` | -| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, or `"nftables"` | `false` | `✘` | -| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✘` | -| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✘` | -| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `3` | `✘` | +| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, or `"nftables"` | `false` | `✔` | +| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✔` | +| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✔` | +| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `✔` | | [`announce`](#announce) | `String` | — | `✘` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` | @@ -2260,7 +2260,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche ``` ## synlimit (server.listeners) - **Constraints / validation**: `false`, `"iptables"`, or `"nftables"`. Omitted or `false` disables SYN limiting for this listener. - - **Description**: Installs per-listener Linux netfilter SYN limiter rules for the listener port. `"iptables"` uses `iptables`/`ip6tables` filter rules with the `hashlimit` match as a per-source token bucket. `"nftables"` uses per-source `meter` rules with `limit rate over` and auto-detects whether the host already uses `inet`, `ip`, or `ip6` table families before creating Telemt-owned tables. The token-bucket rate is `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` controls the burst size. Rules are reconciled at runtime and removed during graceful Telemt shutdown; `SIGKILL` cannot be cleaned up by the process. Requires CAP_NET_ADMIN and listener restart/rebind for config changes. + - **Description**: Installs per-listener Linux netfilter SYN limiter rules for the listener port. `"iptables"` uses `iptables`/`ip6tables` filter rules with the `hashlimit` match as a per-source token bucket. `"nftables"` uses per-source `meter` rules with `limit rate over` and auto-detects whether the host already uses `inet`, `ip`, or `ip6` table families before creating Telemt-owned tables. The token-bucket rate is `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` controls the burst size. Rules are reconciled at runtime and removed during graceful Telemt shutdown; `SIGKILL` cannot be cleaned up by the process. Requires CAP_NET_ADMIN. `synlimit*` changes hot-reload for existing listener endpoints; changing listener `ip` or `port` still requires restart/rebind. - **Example**: ```toml @@ -2299,7 +2299,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche synlimit_hitcount = 1 ``` ## synlimit_burst (server.listeners) - - **Constraints / validation**: `u32`, must be `> 0`. Default is `3`. + - **Constraints / validation**: `u32`, must be `> 0`. Default is `2`. - **Description**: Token-bucket burst size for both SYN limiter backends. Higher values allow short connection bursts from the same source IP before the steady-state `synlimit_hitcount / synlimit_seconds` rate is enforced. - **Example**: @@ -2308,7 +2308,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche ip = "0.0.0.0" port = 443 synlimit = "iptables" - synlimit_burst = 3 + synlimit_burst = 2 ``` ## announce - **Constraints / validation**: `String` (optional). Must not be empty when set. diff --git a/docs/Config_params/CONFIG_PARAMS.ru.md b/docs/Config_params/CONFIG_PARAMS.ru.md index 9062236..0be80fe 100644 --- a/docs/Config_params/CONFIG_PARAMS.ru.md +++ b/docs/Config_params/CONFIG_PARAMS.ru.md @@ -2225,10 +2225,10 @@ | [`ip`](#ip) | `IpAddr` | — | `✘` | | [`port`](#port-serverlisteners) | `u16` | `server.port` | `✘` | | [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `✘` | -| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"` или `"nftables"` | `false` | `✘` | -| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✘` | -| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✘` | -| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `3` | `✘` | +| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"` или `"nftables"` | `false` | `✔` | +| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `✔` | +| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `✔` | +| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `✔` | | [`announce`](#announce) | `String` | — | `✘` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` | @@ -2266,7 +2266,7 @@ ``` ## synlimit (server.listeners) - **Ограничения / валидация**: `false`, `"iptables"` или `"nftables"`. Если параметр не задан или задан как `false`, SYN limiter для этого listener’а выключен. - - **Описание**: Устанавливает per-listener Linux netfilter SYN limiter rules для порта listener’а. `"iptables"` использует `iptables`/`ip6tables` filter rules с `hashlimit` match как per-source token bucket. `"nftables"` использует per-source `meter` rules с `limit rate over` и автоматически определяет, какие table families уже используются на хосте (`inet`, `ip`, `ip6`), перед созданием Telemt-owned tables. Token-bucket rate равен `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` управляет burst size. Rules reconciled at runtime и удаляются при graceful shutdown Telemt; `SIGKILL` процессом не очищается. Требует CAP_NET_ADMIN и restart/rebind listener’а для изменений конфигурации. + - **Описание**: Устанавливает per-listener Linux netfilter SYN limiter rules для порта listener’а. `"iptables"` использует `iptables`/`ip6tables` filter rules с `hashlimit` match как per-source token bucket. `"nftables"` использует per-source `meter` rules с `limit rate over` и автоматически определяет, какие table families уже используются на хосте (`inet`, `ip`, `ip6`), перед созданием Telemt-owned tables. Token-bucket rate равен `synlimit_hitcount / synlimit_seconds`; `synlimit_burst` управляет burst size. Rules reconciled at runtime и удаляются при graceful shutdown Telemt; `SIGKILL` процессом не очищается. Требует CAP_NET_ADMIN. Изменения `synlimit*` hot-reload’ятся для существующих listener endpoints; изменение listener `ip` или `port` по-прежнему требует restart/rebind. - **Пример**: ```toml @@ -2305,7 +2305,7 @@ synlimit_hitcount = 1 ``` ## synlimit_burst (server.listeners) - - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `3`. + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `2`. - **Описание**: Token-bucket burst size для обоих SYN limiter backends. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`. - **Пример**: @@ -2314,7 +2314,7 @@ ip = "0.0.0.0" port = 443 synlimit = "iptables" - synlimit_burst = 3 + synlimit_burst = 2 ``` ## announce - **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан. diff --git a/src/config/defaults.rs b/src/config/defaults.rs index b0bafac..b1af97c 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -56,7 +56,7 @@ const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70; const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096; const DEFAULT_SYNLIMIT_SECONDS: u32 = 1; const DEFAULT_SYNLIMIT_HITCOUNT: u32 = 1; -const DEFAULT_SYNLIMIT_BURST: u32 = 3; +const DEFAULT_SYNLIMIT_BURST: u32 = 2; const DEFAULT_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2; const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5; const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000; diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index 841bc1a..c911f8a 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -16,10 +16,12 @@ //! | `general` | `telemetry` / `me_*_policy` | Applied immediately | //! | `network` | `dns_overrides` | Applied immediately | //! | `access` | All user/quota fields | Effective immediately | +//! | `server.listeners` | `synlimit*` for existing endpoints | Netfilter rules reconciled immediately | //! //! Fields that require re-binding sockets (`server.listeners`, legacy //! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not** -//! applied; a warning is emitted. +//! applied, except for SYN limiter fields on unchanged listener endpoints; a +//! warning is emitted. //! Non-hot changes are never mixed into the runtime config snapshot. use std::collections::BTreeSet; @@ -34,7 +36,8 @@ use tracing::{error, info, warn}; use super::load::{LoadedConfig, ProxyConfig}; use crate::config::{ - LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel, MeWriterPickMode, + ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel, + MeWriterPickMode, SynLimitMode, }; const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50); @@ -131,6 +134,17 @@ pub struct HotFields { pub user_max_unique_ips_global_each: usize, pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode, pub user_max_unique_ips_window_secs: u64, + pub listener_synlimit: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListenerSynLimitHotFields { + pub ip: IpAddr, + pub port: Option, + pub synlimit: SynLimitMode, + pub synlimit_seconds: u32, + pub synlimit_hitcount: u32, + pub synlimit_burst: u32, } impl HotFields { @@ -260,6 +274,25 @@ impl HotFields { user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each, user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode, user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs, + listener_synlimit: cfg + .server + .listeners + .iter() + .map(ListenerSynLimitHotFields::from_listener) + .collect(), + } + } +} + +impl ListenerSynLimitHotFields { + fn from_listener(listener: &ListenerConfig) -> Self { + Self { + ip: listener.ip, + port: listener.port, + synlimit: listener.synlimit, + synlimit_seconds: listener.synlimit_seconds, + synlimit_hitcount: listener.synlimit_hitcount, + synlimit_burst: listener.synlimit_burst, } } } @@ -566,6 +599,7 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig { cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each; cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode; cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs; + overlay_listener_synlimit_fields(&mut cfg.server.listeners, &new.server.listeners); if cfg.rebuild_runtime_user_auth().is_err() { cfg.runtime_user_auth = None; @@ -574,6 +608,21 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig { cfg } +fn overlay_listener_synlimit_fields(old: &mut [ListenerConfig], new: &[ListenerConfig]) { + if old.len() != new.len() { + return; + } + for (old_listener, new_listener) in old.iter_mut().zip(new.iter()) { + if old_listener.ip != new_listener.ip || old_listener.port != new_listener.port { + continue; + } + old_listener.synlimit = new_listener.synlimit; + old_listener.synlimit_seconds = new_listener.synlimit_seconds; + old_listener.synlimit_hitcount = new_listener.synlimit_hitcount; + old_listener.synlimit_burst = new_listener.synlimit_burst; + } +} + /// Warn if any non-hot fields changed (require restart). fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) { let mut warned = false; @@ -850,6 +899,13 @@ fn log_changes( ); } + if old_hot.listener_synlimit != new_hot.listener_synlimit { + info!( + "config reload: server.listeners SYN limiter updated ({} listeners)", + new_hot.listener_synlimit.len() + ); + } + if old_hot.desync_all_full != new_hot.desync_all_full { info!( "config reload: desync_all_full: {} → {}", diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index 590f421..e5bb2f7 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -907,12 +907,12 @@ async fn run_telemt_core( std::process::exit(1); } - synlimit_control::reconcile_synlimit_rules(&config).await; - synlimit_control::spawn_synlimit_controller(config_rx.clone()); - // On Unix, caller supplies privilege drop after bind (may require root for port < 1024). drop_after_bind(); + synlimit_control::reconcile_synlimit_rules(&config).await; + synlimit_control::spawn_synlimit_controller(config_rx.clone()); + runtime_tasks::apply_runtime_log_filter( has_rust_log, &effective_log_level, diff --git a/src/maestro/shutdown.rs b/src/maestro/shutdown.rs index 6677a5c..4665337 100644 --- a/src/maestro/shutdown.rs +++ b/src/maestro/shutdown.rs @@ -103,7 +103,9 @@ async fn perform_shutdown( let uptime_secs = process_started_at.elapsed().as_secs(); info!("Uptime: {}", format_uptime(uptime_secs)); - synlimit_control::clear_synlimit_rules_all_backends().await; + if let Err(error) = synlimit_control::clear_synlimit_rules_all_backends().await { + warn!(error = %error, "Failed to clear SYN limiter rules during shutdown"); + } // Graceful ME pool shutdown if let Some(pool) = &me_pool { diff --git a/src/synlimit_control.rs b/src/synlimit_control.rs index 466c16e..e151d41 100644 --- a/src/synlimit_control.rs +++ b/src/synlimit_control.rs @@ -79,19 +79,26 @@ pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver>) { +async fn wait_for_config_channel_close_and_reconcile( + mut config_rx: watch::Receiver>, +) { while config_rx.changed().await.is_ok() { - config_rx.borrow_and_update(); + let cfg = config_rx.borrow_and_update().clone(); + reconcile_synlimit_rules(&cfg).await; } } pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) { - clear_synlimit_rules_all_backends().await; + if let Err(error) = clear_synlimit_rules_all_backends().await { + warn!(error = %error, "Failed to clear existing SYN limiter rules before reconcile"); + } let targets = synlimit_targets(cfg); if targets.is_empty() { @@ -116,10 +123,23 @@ pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) { } } -pub(crate) async fn clear_synlimit_rules_all_backends() { - clear_nft_synlimit_rules_all_families().await; - clear_iptables_synlimit_rules_for_binary("iptables").await; - clear_iptables_synlimit_rules_for_binary("ip6tables").await; +pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<(), String> { + let mut errors = Vec::new(); + if let Err(error) = clear_nft_synlimit_rules_all_families().await { + errors.push(error); + } + if let Err(error) = clear_iptables_synlimit_rules_for_binary("iptables").await { + errors.push(error); + } + if let Err(error) = clear_iptables_synlimit_rules_for_binary("ip6tables").await { + errors.push(error); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } } fn has_synlimit_config(cfg: &ProxyConfig) -> bool { @@ -183,10 +203,6 @@ async fn apply_iptables_synlimit_rules_for_binary( if targets.is_empty() { return Ok(()); } - if !command_exists(binary) { - return Err(format!("{binary} is not available")); - } - let _ = run_command(binary, &["-t", "filter", "-N", IPTABLES_CHAIN], None).await; run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await?; if run_command( @@ -315,31 +331,43 @@ fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String { format!("{}/day", amount.max(1)) } -async fn clear_iptables_synlimit_rules_for_binary(binary: &str) { - if !command_exists(binary) { - return; - } +async fn clear_iptables_synlimit_rules_for_binary(binary: &str) -> Result<(), String> { + let mut errors = Vec::new(); for _ in 0..8 { - if run_command( + match run_command( binary, &["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN], None, ) .await - .is_err() { - break; + Ok(()) => {} + Err(error) if is_missing_command_or_iptables_rule(&error) => break, + Err(error) => { + errors.push(format!("{binary} delete INPUT jump failed: {error}")); + break; + } } } - let _ = run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await; - let _ = run_command(binary, &["-t", "filter", "-X", IPTABLES_CHAIN], None).await; + if let Err(error) = run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await + && !is_missing_command_or_iptables_rule(&error) + { + errors.push(format!("{binary} flush chain failed: {error}")); + } + if let Err(error) = run_command(binary, &["-t", "filter", "-X", IPTABLES_CHAIN], None).await + && !is_missing_command_or_iptables_rule(&error) + { + errors.push(format!("{binary} delete chain failed: {error}")); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(", ")) + } } async fn apply_nft_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> { - if !command_exists("nft") { - return Err("nft is not available".to_string()); - } - let families = detect_nft_table_families().await; for plan in nft_apply_plan(families, &targets.nft_v4, &targets.nft_v6) { let script = nft_synlimit_script(plan); @@ -447,25 +475,46 @@ fn nft_synlimit_script(plan: NftApplyPlan<'_>) -> String { script } -async fn clear_nft_synlimit_rules_all_families() { - if !command_exists("nft") { - return; - } +async fn clear_nft_synlimit_rules_all_families() -> Result<(), String> { + let mut errors = Vec::new(); for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] { - let _ = run_command( + if let Err(error) = run_command( "nft", &["delete", "table", family.as_str(), NFT_TABLE], None, ) - .await; + .await + && !is_missing_command_or_nft_table(&error) + { + errors.push(format!( + "nft delete table {} {NFT_TABLE} failed: {error}", + family.as_str() + )); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(", ")) } } +fn is_missing_command_or_iptables_rule(error: &str) -> bool { + error.contains("is not available") + || error.contains("No chain/target/match by that name") + || error.contains("does not exist") +} + +fn is_missing_command_or_nft_table(error: &str) -> bool { + error.contains("is not available") || error.contains("No such file or directory") +} + async fn run_command(binary: &str, args: &[&str], stdin: Option) -> Result<(), String> { - if !command_exists(binary) { + let Some(command_path) = resolve_command(binary) else { return Err(format!("{binary} is not available")); - } - let mut command = Command::new(binary); + }; + let mut command = Command::new(command_path); command.args(args); if stdin.is_some() { command.stdin(std::process::Stdio::piped()); @@ -499,10 +548,10 @@ async fn run_command(binary: &str, args: &[&str], stdin: Option) -> Resu } async fn run_command_stdout(binary: &str, args: &[&str]) -> Result { - if !command_exists(binary) { + let Some(command_path) = resolve_command(binary) else { return Err(format!("{binary} is not available")); - } - let output = Command::new(binary) + }; + let output = Command::new(command_path) .args(args) .output() .await @@ -518,14 +567,14 @@ async fn run_command_stdout(binary: &str, args: &[&str]) -> Result bool { - let Some(path_var) = std::env::var_os("PATH") else { - return false; - }; - std::env::split_paths(&path_var).any(|dir| { - let candidate: PathBuf = dir.join(binary); - candidate.exists() && candidate.is_file() - }) +fn resolve_command(binary: &str) -> Option { + let mut dirs = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + dirs.extend(["/usr/sbin", "/sbin", "/usr/bin", "/bin"].map(PathBuf::from)); + dirs.into_iter() + .map(|dir| dir.join(binary)) + .find(|candidate| candidate.exists() && candidate.is_file()) } fn has_cap_net_admin() -> bool { diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index 9e39bef..4064135 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -155,57 +155,35 @@ fn push_fallback_size(sizes: &mut Vec, size: usize) { } fn fallback_family_app_data_sizes(cached: &CachedTlsData) -> Vec { - if matches!(cached.behavior_profile.source, TlsProfileSource::Rustls) - && !cached.app_data_records_sizes.is_empty() - { - return cached.app_data_records_sizes.clone(); - } - - let family = fallback_shape_family(cached); - let mut remaining = fallback_total_app_data_len(cached); - let preferred_chunk = match family { - FallbackShapeFamily::NginxLike => 2896, - FallbackShapeFamily::BoringSslLike => 1369, - FallbackShapeFamily::RustlsLike => 2048, + let mut sizes = Vec::with_capacity(1); + let size = if matches!(cached.behavior_profile.source, TlsProfileSource::Rustls) { + cached + .app_data_records_sizes + .first() + .copied() + .unwrap_or_else(|| fallback_total_app_data_len(cached)) + } else { + fallback_total_app_data_len(cached) }; - let split_threshold = match family { - FallbackShapeFamily::NginxLike => 4096, - FallbackShapeFamily::BoringSslLike => 1536, - FallbackShapeFamily::RustlsLike => 3072, - }; - - if remaining <= split_threshold { - return vec![remaining.clamp(MIN_APP_DATA, MAX_APP_DATA)]; - } - - let mut sizes: Vec = Vec::new(); - while remaining > 0 { - let chunk = remaining.min(preferred_chunk).min(MAX_APP_DATA); - if chunk < MIN_APP_DATA { - if let Some(last) = sizes.last_mut() { - *last = (*last).saturating_add(chunk).min(MAX_APP_DATA); - } else { - push_fallback_size(&mut sizes, chunk); - } - break; - } - push_fallback_size(&mut sizes, chunk); - remaining = remaining.saturating_sub(chunk); - } - + push_fallback_size(&mut sizes, size); sizes } fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec { match cached.behavior_profile.source { TlsProfileSource::Raw | TlsProfileSource::Merged => { - if !cached.behavior_profile.app_data_record_sizes.is_empty() { - return cached.behavior_profile.app_data_record_sizes.clone(); + if let Some(size) = cached.behavior_profile.app_data_record_sizes.first() { + return vec![(*size).clamp(MIN_APP_DATA, MAX_APP_DATA)]; } - if !cached.app_data_records_sizes.is_empty() { - return cached.app_data_records_sizes.clone(); + if let Some(size) = cached.app_data_records_sizes.first() { + return vec![(*size).clamp(MIN_APP_DATA, MAX_APP_DATA)]; } - return vec![cached.total_app_data_len.max(1024)]; + return vec![ + cached + .total_app_data_len + .max(1024) + .clamp(MIN_APP_DATA, MAX_APP_DATA), + ]; } TlsProfileSource::Default | TlsProfileSource::Rustls => { return fallback_family_app_data_sizes(cached); @@ -417,7 +395,7 @@ pub fn build_emulated_server_hello( alpn: Option>, new_session_tickets: u8, ) -> Vec { - // --- ServerHello --- + // ServerHello carries the authenticated digest bytes that the client verifies. let extensions = build_profiled_server_hello_extensions(cached, server_key_share); let extensions_len = extensions.len() as u16; @@ -449,7 +427,7 @@ pub fn build_emulated_server_hello( server_hello.extend_from_slice(&(message.len() as u16).to_be_bytes()); server_hello.extend_from_slice(&message); - // --- ChangeCipherSpec --- + // ChangeCipherSpec is part of the client-visible TLS shim prefix. let change_cipher_spec_count = emulated_change_cipher_spec_count(cached); let mut change_cipher_spec = Vec::with_capacity(change_cipher_spec_count * 6); for _ in 0..change_cipher_spec_count { @@ -463,7 +441,8 @@ pub fn build_emulated_server_hello( ]); } - // --- ApplicationData (fake encrypted records) --- + // Telegram clients authenticate the hello prefix and then expose any later + // ApplicationData bytes to the MTProto packet parser. let mut sizes = { let base_sizes = emulated_app_data_sizes(cached); match cached.behavior_profile.source { @@ -550,8 +529,7 @@ pub fn build_emulated_server_hello( app_data.extend_from_slice(&rec); } - // --- Combine --- - // Optional NewSessionTicket mimic records (opaque ApplicationData for fingerprint). + // Optional NewSessionTicket mimic records are an explicit fingerprint opt-in. let mut tickets = Vec::new(); for ticket_len in emulated_ticket_record_sizes(cached, new_session_tickets, rng) { let mut rec = Vec::with_capacity(5 + ticket_len); @@ -570,7 +548,7 @@ pub fn build_emulated_server_hello( response.extend_from_slice(&app_data); response.extend_from_slice(&tickets); - // --- HMAC --- + // The digest authenticates the server response bytes emitted by this builder. let mut hmac_input = Vec::with_capacity(TLS_DIGEST_LEN + response.len()); hmac_input.extend_from_slice(client_digest); hmac_input.extend_from_slice(&response); @@ -1062,7 +1040,7 @@ mod tests { app_lens.push(record_len); pos += 5 + record_len; } - assert_eq!(app_lens, vec![64, 3905, 537]); + assert_eq!(app_lens, vec![64]); assert_eq!(pos, response.len()); } } diff --git a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs index 50a78fc..6b3d3ee 100644 --- a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs +++ b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs @@ -106,7 +106,37 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() { ); let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION); - assert_eq!(app_records, vec![1200, 900]); + assert_eq!(app_records, vec![1200]); +} + +#[test] +fn emulated_server_hello_keeps_default_profile_primary_app_data_single() { + let mut cached = make_cached(); + cached.behavior_profile.source = TlsProfileSource::Default; + cached.behavior_profile.app_data_record_sizes.clear(); + cached.behavior_profile.ticket_record_sizes.clear(); + cached.app_data_records_sizes = vec![2048, 1024]; + cached.total_app_data_len = 5000; + let rng = SecureRandom::new(); + + let response = build_emulated_server_hello( + b"secret", + &[0x85; 32], + &[0x86; 16], + &cached, + false, + true, + ClientHelloTlsVersion::Tls13, + [0x13, 0x01], + &test_server_key_share(), + &rng, + None, + 0, + ); + + let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION); + assert_eq!(app_records.len(), 1); + assert!(app_records[0] >= 64); } #[test] @@ -130,5 +160,5 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() { ); let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION); - assert_eq!(app_records, vec![1200, 900, 220, 180]); + assert_eq!(app_records, vec![1200, 220, 180]); }