Compare commits

...

3 Commits
3.4.17 ... flow

Author SHA1 Message Date
Alexey
2d02fbe548 Bump 2026-06-12 15:06:14 +03:00
Alexey
2675779915 Fix SYN limiter lifecycle and default burst
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 14:40:26 +03:00
Alexey
c4954f745f Restore single-record TLS-F primary application flight
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
2026-06-12 12:44:22 +03:00
11 changed files with 235 additions and 120 deletions

2
Cargo.lock generated
View File

@@ -2938,7 +2938,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]] [[package]]
name = "telemt" name = "telemt"
version = "3.4.17" version = "3.4.18"
dependencies = [ dependencies = [
"aes", "aes",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "telemt" name = "telemt"
version = "3.4.17" version = "3.4.18"
edition = "2024" edition = "2024"
[features] [features]

View File

@@ -2219,10 +2219,10 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
| [`ip`](#ip) | `IpAddr` | — | `` | | [`ip`](#ip) | `IpAddr` | — | `` |
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `` | | [`port`](#port-serverlisteners) | `u16` | `server.port` | `` |
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` | | [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` |
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, or `"nftables"` | `false` | `` | | [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, or `"nftables"` | `false` | `` |
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `` | | [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `` | | [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `3` | `` | | [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `` |
| [`announce`](#announce) | `String` | — | `` | | [`announce`](#announce) | `String` | — | `` |
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` | | [`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) ## synlimit (server.listeners)
- **Constraints / validation**: `false`, `"iptables"`, or `"nftables"`. Omitted or `false` disables SYN limiting for this listener. - **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**: - **Example**:
```toml ```toml
@@ -2299,7 +2299,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
synlimit_hitcount = 1 synlimit_hitcount = 1
``` ```
## synlimit_burst (server.listeners) ## 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. - **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**: - **Example**:
@@ -2308,7 +2308,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche
ip = "0.0.0.0" ip = "0.0.0.0"
port = 443 port = 443
synlimit = "iptables" synlimit = "iptables"
synlimit_burst = 3 synlimit_burst = 2
``` ```
## announce ## announce
- **Constraints / validation**: `String` (optional). Must not be empty when set. - **Constraints / validation**: `String` (optional). Must not be empty when set.

View File

@@ -2225,10 +2225,10 @@
| [`ip`](#ip) | `IpAddr` | — | `` | | [`ip`](#ip) | `IpAddr` | — | `` |
| [`port`](#port-serverlisteners) | `u16` | `server.port` | `` | | [`port`](#port-serverlisteners) | `u16` | `server.port` | `` |
| [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` | | [`client_mss`](#client_mss-serverlisteners) | `String` | `[server].client_mss` | `` |
| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"` или `"nftables"` | `false` | `` | | [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"` или `"nftables"` | `false` | `` |
| [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `` | | [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `` | | [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `1` | `` |
| [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `3` | `` | | [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `2` | `` |
| [`announce`](#announce) | `String` | — | `` | | [`announce`](#announce) | `String` | — | `` |
| [`announce_ip`](#announce_ip) | `IpAddr` | — | `` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `` |
| [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `` |
@@ -2266,7 +2266,7 @@
``` ```
## synlimit (server.listeners) ## synlimit (server.listeners)
- **Ограничения / валидация**: `false`, `"iptables"` или `"nftables"`. Если параметр не задан или задан как `false`, SYN limiter для этого listenerа выключен. - **Ограничения / валидация**: `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 ```toml
@@ -2305,7 +2305,7 @@
synlimit_hitcount = 1 synlimit_hitcount = 1
``` ```
## synlimit_burst (server.listeners) ## 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`. - **Описание**: 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" ip = "0.0.0.0"
port = 443 port = 443
synlimit = "iptables" synlimit = "iptables"
synlimit_burst = 3 synlimit_burst = 2
``` ```
## announce ## announce
- **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан. - **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан.

View File

@@ -56,7 +56,7 @@ const DEFAULT_CONNTRACK_PRESSURE_LOW_WATERMARK_PCT: u8 = 70;
const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096; const DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC: u64 = 4096;
const DEFAULT_SYNLIMIT_SECONDS: u32 = 1; const DEFAULT_SYNLIMIT_SECONDS: u32 = 1;
const DEFAULT_SYNLIMIT_HITCOUNT: 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_CONNECT_RETRY_ATTEMPTS: u32 = 2;
const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5; const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5;
const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000; const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000;

View File

@@ -16,10 +16,12 @@
//! | `general` | `telemetry` / `me_*_policy` | Applied immediately | //! | `general` | `telemetry` / `me_*_policy` | Applied immediately |
//! | `network` | `dns_overrides` | Applied immediately | //! | `network` | `dns_overrides` | Applied immediately |
//! | `access` | All user/quota fields | Effective 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 //! Fields that require re-binding sockets (`server.listeners`, legacy
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not** //! `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. //! Non-hot changes are never mixed into the runtime config snapshot.
use std::collections::BTreeSet; use std::collections::BTreeSet;
@@ -34,7 +36,8 @@ use tracing::{error, info, warn};
use super::load::{LoadedConfig, ProxyConfig}; use super::load::{LoadedConfig, ProxyConfig};
use crate::config::{ 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); 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_global_each: usize,
pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode, pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode,
pub user_max_unique_ips_window_secs: u64, pub user_max_unique_ips_window_secs: u64,
pub listener_synlimit: Vec<ListenerSynLimitHotFields>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenerSynLimitHotFields {
pub ip: IpAddr,
pub port: Option<u16>,
pub synlimit: SynLimitMode,
pub synlimit_seconds: u32,
pub synlimit_hitcount: u32,
pub synlimit_burst: u32,
} }
impl HotFields { 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_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_mode: cfg.access.user_max_unique_ips_mode,
user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs, 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_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_mode = new.access.user_max_unique_ips_mode;
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs; 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() { if cfg.rebuild_runtime_user_auth().is_err() {
cfg.runtime_user_auth = None; cfg.runtime_user_auth = None;
@@ -574,6 +608,21 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg 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). /// Warn if any non-hot fields changed (require restart).
fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) { fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) {
let mut warned = false; 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 { if old_hot.desync_all_full != new_hot.desync_all_full {
info!( info!(
"config reload: desync_all_full: {} → {}", "config reload: desync_all_full: {} → {}",

View File

@@ -907,12 +907,12 @@ async fn run_telemt_core(
std::process::exit(1); 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). // On Unix, caller supplies privilege drop after bind (may require root for port < 1024).
drop_after_bind(); drop_after_bind();
synlimit_control::reconcile_synlimit_rules(&config).await;
synlimit_control::spawn_synlimit_controller(config_rx.clone());
runtime_tasks::apply_runtime_log_filter( runtime_tasks::apply_runtime_log_filter(
has_rust_log, has_rust_log,
&effective_log_level, &effective_log_level,

View File

@@ -103,7 +103,9 @@ async fn perform_shutdown(
let uptime_secs = process_started_at.elapsed().as_secs(); let uptime_secs = process_started_at.elapsed().as_secs();
info!("Uptime: {}", format_uptime(uptime_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 // Graceful ME pool shutdown
if let Some(pool) = &me_pool { if let Some(pool) = &me_pool {

View File

@@ -79,19 +79,26 @@ pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver<Arc<ProxyConf
} }
tokio::spawn(async move { tokio::spawn(async move {
wait_for_config_channel_close(config_rx).await; wait_for_config_channel_close_and_reconcile(config_rx).await;
clear_synlimit_rules_all_backends().await; if let Err(error) = clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear SYN limiter rules after config channel close");
}
}); });
} }
async fn wait_for_config_channel_close(mut config_rx: watch::Receiver<Arc<ProxyConfig>>) { async fn wait_for_config_channel_close_and_reconcile(
mut config_rx: watch::Receiver<Arc<ProxyConfig>>,
) {
while config_rx.changed().await.is_ok() { 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) { 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); let targets = synlimit_targets(cfg);
if targets.is_empty() { 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() { pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<(), String> {
clear_nft_synlimit_rules_all_families().await; let mut errors = Vec::new();
clear_iptables_synlimit_rules_for_binary("iptables").await; if let Err(error) = clear_nft_synlimit_rules_all_families().await {
clear_iptables_synlimit_rules_for_binary("ip6tables").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 { fn has_synlimit_config(cfg: &ProxyConfig) -> bool {
@@ -183,10 +203,6 @@ async fn apply_iptables_synlimit_rules_for_binary(
if targets.is_empty() { if targets.is_empty() {
return Ok(()); return Ok(());
} }
if !command_exists(binary) {
return Err(format!("{binary} is not available"));
}
let _ = run_command(binary, &["-t", "filter", "-N", IPTABLES_CHAIN], None).await; let _ = run_command(binary, &["-t", "filter", "-N", IPTABLES_CHAIN], None).await;
run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await?; run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await?;
if run_command( if run_command(
@@ -315,31 +331,43 @@ fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String {
format!("{}/day", amount.max(1)) format!("{}/day", amount.max(1))
} }
async fn clear_iptables_synlimit_rules_for_binary(binary: &str) { async fn clear_iptables_synlimit_rules_for_binary(binary: &str) -> Result<(), String> {
if !command_exists(binary) { let mut errors = Vec::new();
return;
}
for _ in 0..8 { for _ in 0..8 {
if run_command( match run_command(
binary, binary,
&["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN], &["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN],
None, None,
) )
.await .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; if let Err(error) = run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await
let _ = run_command(binary, &["-t", "filter", "-X", 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> { 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; let families = detect_nft_table_families().await;
for plan in nft_apply_plan(families, &targets.nft_v4, &targets.nft_v6) { for plan in nft_apply_plan(families, &targets.nft_v4, &targets.nft_v6) {
let script = nft_synlimit_script(plan); let script = nft_synlimit_script(plan);
@@ -447,25 +475,46 @@ fn nft_synlimit_script(plan: NftApplyPlan<'_>) -> String {
script script
} }
async fn clear_nft_synlimit_rules_all_families() { async fn clear_nft_synlimit_rules_all_families() -> Result<(), String> {
if !command_exists("nft") { let mut errors = Vec::new();
return;
}
for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] { for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] {
let _ = run_command( if let Err(error) = run_command(
"nft", "nft",
&["delete", "table", family.as_str(), NFT_TABLE], &["delete", "table", family.as_str(), NFT_TABLE],
None, 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<String>) -> Result<(), String> { async fn run_command(binary: &str, args: &[&str], stdin: Option<String>) -> Result<(), String> {
if !command_exists(binary) { let Some(command_path) = resolve_command(binary) else {
return Err(format!("{binary} is not available")); return Err(format!("{binary} is not available"));
} };
let mut command = Command::new(binary); let mut command = Command::new(command_path);
command.args(args); command.args(args);
if stdin.is_some() { if stdin.is_some() {
command.stdin(std::process::Stdio::piped()); command.stdin(std::process::Stdio::piped());
@@ -499,10 +548,10 @@ async fn run_command(binary: &str, args: &[&str], stdin: Option<String>) -> Resu
} }
async fn run_command_stdout(binary: &str, args: &[&str]) -> Result<String, String> { async fn run_command_stdout(binary: &str, args: &[&str]) -> Result<String, String> {
if !command_exists(binary) { let Some(command_path) = resolve_command(binary) else {
return Err(format!("{binary} is not available")); return Err(format!("{binary} is not available"));
} };
let output = Command::new(binary) let output = Command::new(command_path)
.args(args) .args(args)
.output() .output()
.await .await
@@ -518,14 +567,14 @@ async fn run_command_stdout(binary: &str, args: &[&str]) -> Result<String, Strin
}) })
} }
fn command_exists(binary: &str) -> bool { fn resolve_command(binary: &str) -> Option<PathBuf> {
let Some(path_var) = std::env::var_os("PATH") else { let mut dirs = std::env::var_os("PATH")
return false; .map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
}; .unwrap_or_default();
std::env::split_paths(&path_var).any(|dir| { dirs.extend(["/usr/sbin", "/sbin", "/usr/bin", "/bin"].map(PathBuf::from));
let candidate: PathBuf = dir.join(binary); dirs.into_iter()
candidate.exists() && candidate.is_file() .map(|dir| dir.join(binary))
}) .find(|candidate| candidate.exists() && candidate.is_file())
} }
fn has_cap_net_admin() -> bool { fn has_cap_net_admin() -> bool {

View File

@@ -155,57 +155,35 @@ fn push_fallback_size(sizes: &mut Vec<usize>, size: usize) {
} }
fn fallback_family_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> { fn fallback_family_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
if matches!(cached.behavior_profile.source, TlsProfileSource::Rustls) let mut sizes = Vec::with_capacity(1);
&& !cached.app_data_records_sizes.is_empty() let size = if matches!(cached.behavior_profile.source, TlsProfileSource::Rustls) {
{ cached
return cached.app_data_records_sizes.clone(); .app_data_records_sizes
} .first()
.copied()
let family = fallback_shape_family(cached); .unwrap_or_else(|| fallback_total_app_data_len(cached))
let mut remaining = fallback_total_app_data_len(cached); } else {
let preferred_chunk = match family { fallback_total_app_data_len(cached)
FallbackShapeFamily::NginxLike => 2896,
FallbackShapeFamily::BoringSslLike => 1369,
FallbackShapeFamily::RustlsLike => 2048,
}; };
let split_threshold = match family { push_fallback_size(&mut sizes, size);
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<usize> = 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);
}
sizes sizes
} }
fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> { fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
match cached.behavior_profile.source { match cached.behavior_profile.source {
TlsProfileSource::Raw | TlsProfileSource::Merged => { TlsProfileSource::Raw | TlsProfileSource::Merged => {
if !cached.behavior_profile.app_data_record_sizes.is_empty() { if let Some(size) = cached.behavior_profile.app_data_record_sizes.first() {
return cached.behavior_profile.app_data_record_sizes.clone(); return vec![(*size).clamp(MIN_APP_DATA, MAX_APP_DATA)];
} }
if !cached.app_data_records_sizes.is_empty() { if let Some(size) = cached.app_data_records_sizes.first() {
return cached.app_data_records_sizes.clone(); 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 => { TlsProfileSource::Default | TlsProfileSource::Rustls => {
return fallback_family_app_data_sizes(cached); return fallback_family_app_data_sizes(cached);
@@ -417,7 +395,7 @@ pub fn build_emulated_server_hello(
alpn: Option<Vec<u8>>, alpn: Option<Vec<u8>>,
new_session_tickets: u8, new_session_tickets: u8,
) -> Vec<u8> { ) -> Vec<u8> {
// --- ServerHello --- // ServerHello carries the authenticated digest bytes that the client verifies.
let extensions = build_profiled_server_hello_extensions(cached, server_key_share); let extensions = build_profiled_server_hello_extensions(cached, server_key_share);
let extensions_len = extensions.len() as u16; 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.len() as u16).to_be_bytes());
server_hello.extend_from_slice(&message); 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 change_cipher_spec_count = emulated_change_cipher_spec_count(cached);
let mut change_cipher_spec = Vec::with_capacity(change_cipher_spec_count * 6); let mut change_cipher_spec = Vec::with_capacity(change_cipher_spec_count * 6);
for _ in 0..change_cipher_spec_count { 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 mut sizes = {
let base_sizes = emulated_app_data_sizes(cached); let base_sizes = emulated_app_data_sizes(cached);
match cached.behavior_profile.source { match cached.behavior_profile.source {
@@ -550,8 +529,7 @@ pub fn build_emulated_server_hello(
app_data.extend_from_slice(&rec); app_data.extend_from_slice(&rec);
} }
// --- Combine --- // Optional NewSessionTicket mimic records are an explicit fingerprint opt-in.
// Optional NewSessionTicket mimic records (opaque ApplicationData for fingerprint).
let mut tickets = Vec::new(); let mut tickets = Vec::new();
for ticket_len in emulated_ticket_record_sizes(cached, new_session_tickets, rng) { for ticket_len in emulated_ticket_record_sizes(cached, new_session_tickets, rng) {
let mut rec = Vec::with_capacity(5 + ticket_len); 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(&app_data);
response.extend_from_slice(&tickets); 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()); let mut hmac_input = Vec::with_capacity(TLS_DIGEST_LEN + response.len());
hmac_input.extend_from_slice(client_digest); hmac_input.extend_from_slice(client_digest);
hmac_input.extend_from_slice(&response); hmac_input.extend_from_slice(&response);
@@ -1062,7 +1040,7 @@ mod tests {
app_lens.push(record_len); app_lens.push(record_len);
pos += 5 + 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()); assert_eq!(pos, response.len());
} }
} }

View File

@@ -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); 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] #[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); 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]);
} }