From 52a1b66ad7b32e3d640f15a0b9bcad9ea0bf6c0e Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:12:52 +0300 Subject: [PATCH 1/8] Syntactic key shares for TLS-F Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/protocol/tls.rs | 17 +++++++++++------ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ec5f97..7cf05ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2938,7 +2938,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "telemt" -version = "3.4.16" +version = "3.4.17" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 1d09431..c03fdb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.4.16" +version = "3.4.17" edition = "2024" [features] diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index 5119805..e740b1a 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -638,14 +638,19 @@ fn build_server_hello_key_share_for_group( group: u16, rng: &SecureRandom, ) -> Option { + let expected_key_exchange_len = client_hello_key_share_group_len(group)?; + client_hello_key_share_group_entry(handshake, group, expected_key_exchange_len)?; + + // FakeTLS clients validate ServerHello shape and digest, not TLS traffic + // secrets, so the response must mirror the offered group without binding to + // the camouflage key bytes embedded in ClientHello. match group { - TLS_NAMED_GROUP_X25519MLKEM768 => { - let key_exchange = build_x25519mlkem768_server_key_share(handshake, rng)?; - Some(ServerHelloKeyShare::new(group, key_exchange)) - } + TLS_NAMED_GROUP_X25519MLKEM768 => Some(ServerHelloKeyShare::new( + group, + gen_fake_x25519mlkem768_server_key_share(rng), + )), TLS_NAMED_GROUP_X25519 => { - let key_exchange = build_x25519_server_key_share(handshake, rng)?; - Some(ServerHelloKeyShare::new(group, key_exchange)) + Some(ServerHelloKeyShare::new(group, gen_fake_x25519_key(rng).to_vec())) } _ => None, } From 26cd4734de2e2ea79692cdaa09a1095f01cff97b Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:29:10 +0300 Subject: [PATCH 2/8] Update tls.rs Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/protocol/tls.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index e740b1a..b71d28a 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -649,9 +649,10 @@ fn build_server_hello_key_share_for_group( group, gen_fake_x25519mlkem768_server_key_share(rng), )), - TLS_NAMED_GROUP_X25519 => { - Some(ServerHelloKeyShare::new(group, gen_fake_x25519_key(rng).to_vec())) - } + TLS_NAMED_GROUP_X25519 => Some(ServerHelloKeyShare::new( + group, + gen_fake_x25519_key(rng).to_vec(), + )), _ => None, } } From 1cbde70a143023b8806e53daec5b0e68876a7bb9 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:58:48 +0300 Subject: [PATCH 3/8] Add per-listener SYN limiter for Netfilter control Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/config/load.rs | 3 + src/config/types.rs | 74 ++++++ src/maestro/mod.rs | 4 + src/maestro/shutdown.rs | 3 + src/main.rs | 1 + src/synlimit_control.rs | 546 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 631 insertions(+) create mode 100644 src/synlimit_control.rs diff --git a/src/config/load.rs b/src/config/load.rs index b428079..85d830a 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -346,6 +346,7 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[ "ip", "port", "client_mss", + "synlimit", "announce", "announce_ip", "proxy_protocol", @@ -2186,6 +2187,7 @@ impl ProxyConfig { ip: ipv4, port: Some(config.server.port), client_mss: None, + synlimit: SynLimitMode::default(), announce: None, announce_ip: None, proxy_protocol: None, @@ -2199,6 +2201,7 @@ impl ProxyConfig { ip: ipv6, port: Some(config.server.port), client_mss: None, + synlimit: SynLimitMode::default(), announce: None, announce_ip: None, proxy_protocol: None, diff --git a/src/config/types.rs b/src/config/types.rs index d007428..27a5e71 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1369,6 +1369,77 @@ impl ConntrackPressureProfile { } } +/// Per-listener SYN limiter mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SynLimitMode { + /// Disable SYN limiting for this listener. + #[default] + Off, + /// Use iptables/ip6tables filter rules with the recent match. + Iptables, + /// Use nftables rules with timeout-backed dynamic sets. + Nftables, +} + +impl Serialize for SynLimitMode { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + match self { + Self::Off => serializer.serialize_bool(false), + Self::Iptables => serializer.serialize_str("iptables"), + Self::Nftables => serializer.serialize_str("nftables"), + } + } +} + +impl<'de> Deserialize<'de> for SynLimitMode { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct SynLimitModeVisitor; + + impl<'de> serde::de::Visitor<'de> for SynLimitModeVisitor { + type Value = SynLimitMode; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("false, iptables, or nftables") + } + + fn visit_bool(self, value: bool) -> std::result::Result + where + E: serde::de::Error, + { + if value { + Err(E::custom( + "synlimit=true is ambiguous; use \"iptables\" or \"nftables\"", + )) + } else { + Ok(SynLimitMode::Off) + } + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value.trim().to_ascii_lowercase().as_str() { + "false" | "off" | "disabled" | "none" => Ok(SynLimitMode::Off), + "iptables" => Ok(SynLimitMode::Iptables), + "nftables" => Ok(SynLimitMode::Nftables), + _ => Err(E::custom( + "synlimit must be false, \"iptables\", or \"nftables\"", + )), + } + } + } + + deserializer.deserialize_any(SynLimitModeVisitor) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConntrackControlConfig { /// Enables runtime conntrack-control worker for pressure mitigation. @@ -2102,6 +2173,9 @@ pub struct ListenerConfig { /// Empty string disables MSS shaping for this listener. #[serde(default)] pub client_mss: Option, + /// Per-listener SYN limiter mode. + #[serde(default)] + pub synlimit: SynLimitMode, /// IP address or hostname to announce in proxy links. /// Takes precedence over `announce_ip` if both are set. #[serde(default)] diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index 2d4fb54..590f421 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -45,6 +45,7 @@ use crate::stats::beobachten::BeobachtenStore; use crate::stats::telemetry::TelemetryPolicy; use crate::stats::{ReplayChecker, Stats}; use crate::stream::BufferPool; +use crate::synlimit_control; use crate::transport::UpstreamManager; use crate::transport::middle_proxy::MePool; use helpers::{ @@ -906,6 +907,9 @@ 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(); diff --git a/src/maestro/shutdown.rs b/src/maestro/shutdown.rs index 790b529..6677a5c 100644 --- a/src/maestro/shutdown.rs +++ b/src/maestro/shutdown.rs @@ -19,6 +19,7 @@ use tokio::signal::unix::{SignalKind, signal}; use tracing::{info, warn}; use crate::stats::Stats; +use crate::synlimit_control; use crate::transport::middle_proxy::MePool; use super::helpers::{format_uptime, unit_label}; @@ -102,6 +103,8 @@ 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; + // Graceful ME pool shutdown if let Some(pool) = &me_pool { match tokio::time::timeout(Duration::from_secs(2), pool.shutdown_send_close_conn_all()) diff --git a/src/main.rs b/src/main.rs index 7d0b377..98c9fd6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod service; mod startup; mod stats; mod stream; +mod synlimit_control; mod tls_front; mod transport; mod util; diff --git a/src/synlimit_control.rs b/src/synlimit_control.rs new file mode 100644 index 0000000..9d7c3b9 --- /dev/null +++ b/src/synlimit_control.rs @@ -0,0 +1,546 @@ +use std::collections::BTreeSet; +use std::net::IpAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tokio::sync::watch; +use tracing::{debug, warn}; + +use crate::config::{ProxyConfig, SynLimitMode}; + +const IPTABLES_CHAIN: &str = "TELEMT_SYNLIMIT"; +const IPTABLES_RECENT_NAME: &str = "telemt"; +const NFT_TABLE: &str = "telemt_synlimit"; +const NFT_CHAIN: &str = "input"; +const NFT_SET_V4: &str = "telemt_synlimit_v4"; +const NFT_SET_V6: &str = "telemt_synlimit_v6"; + +#[derive(Default)] +struct SynLimitTargets { + iptables_v4: Vec<(Option, u16)>, + iptables_v6: Vec<(Option, u16)>, + nft_v4: Vec<(Option, u16)>, + nft_v6: Vec<(Option, u16)>, +} + +#[derive(Clone, Copy)] +struct NftTableFamilies { + inet: bool, + ip: bool, + ip6: bool, +} + +#[derive(Clone, Copy)] +enum NftFamily { + Inet, + Ip, + Ip6, +} + +struct NftApplyPlan<'a> { + family: NftFamily, + v4_targets: &'a [(Option, u16)], + v6_targets: &'a [(Option, u16)], +} + +struct SynLimitRuleGuard; + +impl Drop for SynLimitRuleGuard { + fn drop(&mut self) { + clear_synlimit_rules_all_backends_sync(); + } +} + +impl SynLimitTargets { + fn is_empty(&self) -> bool { + self.iptables_v4.is_empty() + && self.iptables_v6.is_empty() + && self.nft_v4.is_empty() + && self.nft_v6.is_empty() + } + + fn has_iptables_targets(&self) -> bool { + !self.iptables_v4.is_empty() || !self.iptables_v6.is_empty() + } + + fn has_nft_targets(&self) -> bool { + !self.nft_v4.is_empty() || !self.nft_v6.is_empty() + } +} + +impl NftFamily { + fn as_str(self) -> &'static str { + match self { + Self::Inet => "inet", + Self::Ip => "ip", + Self::Ip6 => "ip6", + } + } +} + +pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver>) { + if !cfg!(target_os = "linux") { + if has_synlimit_config(&config_rx.borrow()) { + warn!("SYN limiter is configured but unsupported on this OS; skipping netfilter rules"); + } + return; + } + + tokio::spawn(async move { + let _guard = SynLimitRuleGuard; + wait_for_config_channel_close(config_rx).await; + clear_synlimit_rules_all_backends().await; + }); +} + +async fn wait_for_config_channel_close(mut config_rx: watch::Receiver>) { + while config_rx.changed().await.is_ok() { + config_rx.borrow_and_update(); + } +} + +pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) { + clear_synlimit_rules_all_backends().await; + + let targets = synlimit_targets(cfg); + if targets.is_empty() { + return; + } + if !has_cap_net_admin() { + warn!("SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied"); + return; + } + + if targets.has_iptables_targets() + && let Err(error) = apply_iptables_synlimit_rules(&targets).await + { + warn!(error = %error, "Failed to apply iptables SYN limiter rules"); + } + if targets.has_nft_targets() + && let Err(error) = apply_nft_synlimit_rules(&targets).await + { + warn!(error = %error, "Failed to apply nftables SYN limiter rules"); + } +} + +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; +} + +fn has_synlimit_config(cfg: &ProxyConfig) -> bool { + cfg.server + .listeners + .iter() + .any(|listener| !matches!(listener.synlimit, SynLimitMode::Off)) +} + +fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets { + let mut iptables_v4 = BTreeSet::new(); + let mut iptables_v6 = BTreeSet::new(); + let mut nft_v4 = BTreeSet::new(); + let mut nft_v6 = BTreeSet::new(); + + for listener in &cfg.server.listeners { + let backend = listener.synlimit; + if matches!(backend, SynLimitMode::Off) { + continue; + } + let port = listener.port.unwrap_or(cfg.server.port); + let ip = (!listener.ip.is_unspecified()).then_some(listener.ip); + + match (backend, listener.ip.is_ipv4()) { + (SynLimitMode::Iptables, true) => { + iptables_v4.insert((ip, port)); + } + (SynLimitMode::Iptables, false) => { + iptables_v6.insert((ip, port)); + } + (SynLimitMode::Nftables, true) => { + nft_v4.insert((ip, port)); + } + (SynLimitMode::Nftables, false) => { + nft_v6.insert((ip, port)); + } + (SynLimitMode::Off, _) => {} + } + } + + SynLimitTargets { + iptables_v4: iptables_v4.into_iter().collect(), + iptables_v6: iptables_v6.into_iter().collect(), + nft_v4: nft_v4.into_iter().collect(), + nft_v6: nft_v6.into_iter().collect(), + } +} + +async fn apply_iptables_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> { + apply_iptables_synlimit_rules_for_binary("iptables", &targets.iptables_v4).await?; + apply_iptables_synlimit_rules_for_binary("ip6tables", &targets.iptables_v6).await +} + +async fn apply_iptables_synlimit_rules_for_binary( + binary: &str, + targets: &[(Option, u16)], +) -> Result<(), String> { + if targets.is_empty() { + return Ok(()); + } + if !command_exists(binary) { + return Err(format!("{binary} is not available")); + } + + run_command(binary, &["-t", "filter", "-N", IPTABLES_CHAIN], None).await?; + run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await?; + if run_command( + binary, + &["-t", "filter", "-C", "INPUT", "-j", IPTABLES_CHAIN], + None, + ) + .await + .is_err() + { + run_command( + binary, + &["-t", "filter", "-A", "INPUT", "-j", IPTABLES_CHAIN], + None, + ) + .await?; + } + + for (ip, port) in targets { + let drop_args = iptables_synlimit_rule_args(ip, *port, "--rcheck", "DROP"); + let accept_args = iptables_synlimit_rule_args(ip, *port, "--set", "ACCEPT"); + let drop_refs: Vec<&str> = drop_args.iter().map(String::as_str).collect(); + let accept_refs: Vec<&str> = accept_args.iter().map(String::as_str).collect(); + run_command(binary, &drop_refs, None).await?; + run_command(binary, &accept_refs, None).await?; + } + + Ok(()) +} + +fn iptables_synlimit_rule_args( + ip: &Option, + port: u16, + recent_op: &str, + verdict: &str, +) -> Vec { + let mut args = vec![ + "-t".to_string(), + "filter".to_string(), + "-A".to_string(), + IPTABLES_CHAIN.to_string(), + "-p".to_string(), + "tcp".to_string(), + "--syn".to_string(), + ]; + if let Some(ip) = ip { + args.push("-d".to_string()); + args.push(ip.to_string()); + } + args.extend([ + "--dport".to_string(), + port.to_string(), + "-m".to_string(), + "recent".to_string(), + "--name".to_string(), + IPTABLES_RECENT_NAME.to_string(), + recent_op.to_string(), + ]); + if recent_op == "--rcheck" { + args.extend(["--seconds".to_string(), "1".to_string()]); + } + args.extend(["-j".to_string(), verdict.to_string()]); + args +} + +async fn clear_iptables_synlimit_rules_for_binary(binary: &str) { + if !command_exists(binary) { + return; + } + for _ in 0..8 { + if run_command( + binary, + &["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN], + None, + ) + .await + .is_err() + { + break; + } + } + let _ = run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await; + let _ = run_command(binary, &["-t", "filter", "-X", IPTABLES_CHAIN], None).await; +} + +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); + run_command("nft", &["-f", "-"], Some(script)).await?; + } + + Ok(()) +} + +async fn detect_nft_table_families() -> NftTableFamilies { + let Ok(output) = run_command_stdout("nft", &["list", "tables"]).await else { + return NftTableFamilies { + inet: false, + ip: false, + ip6: false, + }; + }; + + let mut families = NftTableFamilies { + inet: false, + ip: false, + ip6: false, + }; + for line in output.lines() { + let mut fields = line.split_whitespace(); + if fields.next() != Some("table") { + continue; + } + match fields.next() { + Some("inet") => families.inet = true, + Some("ip") => families.ip = true, + Some("ip6") => families.ip6 = true, + _ => {} + } + } + families +} + +fn nft_apply_plan<'a>( + families: NftTableFamilies, + v4_targets: &'a [(Option, u16)], + v6_targets: &'a [(Option, u16)], +) -> Vec> { + if !v4_targets.is_empty() && !v6_targets.is_empty() { + return vec![NftApplyPlan { + family: NftFamily::Inet, + v4_targets, + v6_targets, + }]; + } + if !v4_targets.is_empty() { + return vec![NftApplyPlan { + family: if families.inet || !families.ip { + NftFamily::Inet + } else { + NftFamily::Ip + }, + v4_targets, + v6_targets: &[], + }]; + } + if !v6_targets.is_empty() { + return vec![NftApplyPlan { + family: if families.inet || !families.ip6 { + NftFamily::Inet + } else { + NftFamily::Ip6 + }, + v4_targets: &[], + v6_targets, + }]; + } + Vec::new() +} + +fn nft_synlimit_script(plan: NftApplyPlan<'_>) -> String { + let mut script = String::new(); + script.push_str(&format!("table {} {NFT_TABLE} {{\n", plan.family.as_str())); + if !plan.v4_targets.is_empty() { + script.push_str(&format!(" set {NFT_SET_V4} {{\n")); + script.push_str(" type ipv4_addr\n"); + script.push_str(" flags timeout\n"); + script.push_str(" }\n"); + } + if !plan.v6_targets.is_empty() { + script.push_str(&format!(" set {NFT_SET_V6} {{\n")); + script.push_str(" type ipv6_addr\n"); + script.push_str(" flags timeout\n"); + script.push_str(" }\n"); + } + script.push_str(&format!(" chain {NFT_CHAIN} {{\n")); + script.push_str(" type filter hook input priority filter; policy accept;\n"); + for (ip, port) in plan.v4_targets { + let daddr = ip + .map(|ip| format!(" ip daddr {ip}")) + .unwrap_or_else(String::new); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} ip saddr @{NFT_SET_V4} drop\n" + )); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} add @{NFT_SET_V4} {{ ip saddr timeout 1s }} accept\n" + )); + } + for (ip, port) in plan.v6_targets { + let daddr = ip + .map(|ip| format!(" ip6 daddr {ip}")) + .unwrap_or_else(String::new); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} ip6 saddr @{NFT_SET_V6} drop\n" + )); + script.push_str(&format!( + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} add @{NFT_SET_V6} {{ ip6 saddr timeout 1s }} accept\n" + )); + } + script.push_str(" }\n"); + script.push_str("}\n"); + script +} + +async fn clear_nft_synlimit_rules_all_families() { + if !command_exists("nft") { + return; + } + for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] { + let _ = run_command( + "nft", + &["delete", "table", family.as_str(), NFT_TABLE], + None, + ) + .await; + } +} + +async fn run_command(binary: &str, args: &[&str], stdin: Option) -> Result<(), String> { + if !command_exists(binary) { + return Err(format!("{binary} is not available")); + } + let mut command = Command::new(binary); + command.args(args); + if stdin.is_some() { + command.stdin(std::process::Stdio::piped()); + } + command.stdout(std::process::Stdio::null()); + command.stderr(std::process::Stdio::piped()); + let mut child = command + .spawn() + .map_err(|e| format!("spawn {binary} failed: {e}"))?; + if let Some(blob) = stdin + && let Some(mut writer) = child.stdin.take() + { + writer + .write_all(blob.as_bytes()) + .await + .map_err(|e| format!("stdin write {binary} failed: {e}"))?; + } + let output = child + .wait_with_output() + .await + .map_err(|e| format!("wait {binary} failed: {e}"))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("{binary} exited with status {}", output.status) + } else { + stderr + }) +} + +async fn run_command_stdout(binary: &str, args: &[&str]) -> Result { + if !command_exists(binary) { + return Err(format!("{binary} is not available")); + } + let output = Command::new(binary) + .args(args) + .output() + .await + .map_err(|e| format!("wait {binary} failed: {e}"))?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).to_string()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("{binary} exited with status {}", output.status) + } else { + stderr + }) +} + +fn command_exists(binary: &str) -> 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 has_cap_net_admin() -> bool { + #[cfg(target_os = "linux")] + { + let Ok(status) = std::fs::read_to_string("/proc/self/status") else { + return false; + }; + for line in status.lines() { + if let Some(raw) = line.strip_prefix("CapEff:") { + let caps = raw.trim(); + if let Ok(bits) = u64::from_str_radix(caps, 16) { + const CAP_NET_ADMIN_BIT: u64 = 12; + return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0; + } + } + } + false + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + +fn clear_synlimit_rules_all_backends_sync() { + run_command_sync("nft", &["delete", "table", "inet", NFT_TABLE]); + run_command_sync("nft", &["delete", "table", "ip", NFT_TABLE]); + run_command_sync("nft", &["delete", "table", "ip6", NFT_TABLE]); + clear_iptables_synlimit_rules_for_binary_sync("iptables"); + clear_iptables_synlimit_rules_for_binary_sync("ip6tables"); +} + +fn clear_iptables_synlimit_rules_for_binary_sync(binary: &str) { + if !command_exists(binary) { + return; + } + for _ in 0..8 { + if !run_command_sync(binary, &["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN]) { + break; + } + } + run_command_sync(binary, &["-t", "filter", "-F", IPTABLES_CHAIN]); + run_command_sync(binary, &["-t", "filter", "-X", IPTABLES_CHAIN]); +} + +fn run_command_sync(binary: &str, args: &[&str]) -> bool { + if !command_exists(binary) { + return false; + } + match std::process::Command::new(binary) + .args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + { + Ok(status) => status.success(), + Err(error) => { + debug!(binary, error = %error, "SYN limiter cleanup command failed to spawn"); + false + } + } +} From c86dc2f65e62b740568d230ea5cbcfcde84e042e Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:59:47 +0300 Subject: [PATCH 4/8] Docs for SYN Limiter Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- docs/Config_params/CONFIG_PARAMS.en.md | 17 +++++++++++++++++ docs/Config_params/CONFIG_PARAMS.ru.md | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index 8fcb7d8..3268996 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -2219,6 +2219,7 @@ 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` | `✘` | | [`announce`](#announce) | `String` | — | `✘` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` | @@ -2254,6 +2255,22 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche port = 443 client_mss = "256" ``` +## 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 `recent` match name `telemt`. `"nftables"` uses nftables dynamic timeout sets and auto-detects whether the host already uses `inet`, `ip`, or `ip6` table families before creating Telemt-owned tables. 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. + - **Example**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + + [[server.listeners]] + ip = "::" + port = 443 + synlimit = "nftables" + ``` ## announce - **Constraints / validation**: `String` (optional). Must not be empty when set. - **Description**: Public IP/domain announced in proxy links for this listener. Takes precedence over `announce_ip`. diff --git a/docs/Config_params/CONFIG_PARAMS.ru.md b/docs/Config_params/CONFIG_PARAMS.ru.md index 7d473d8..02d336b 100644 --- a/docs/Config_params/CONFIG_PARAMS.ru.md +++ b/docs/Config_params/CONFIG_PARAMS.ru.md @@ -2225,6 +2225,7 @@ | [`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` | `✘` | | [`announce`](#announce) | `String` | — | `✘` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` | @@ -2260,6 +2261,22 @@ port = 443 client_mss = "256" ``` +## synlimit (server.listeners) + - **Ограничения / валидация**: `false`, `"iptables"` или `"nftables"`. Если параметр не задан или задан как `false`, SYN limiter для этого listener’а выключен. + - **Описание**: Устанавливает per-listener Linux netfilter SYN limiter rules для порта listener’а. `"iptables"` использует `iptables`/`ip6tables` filter rules с `recent` name `telemt`. `"nftables"` использует nftables dynamic timeout sets и автоматически определяет, какие table families уже используются на хосте (`inet`, `ip`, `ip6`), перед созданием Telemt-owned tables. Rules reconciled at runtime и удаляются при graceful shutdown Telemt; `SIGKILL` процессом не очищается. Требует CAP_NET_ADMIN и restart/rebind listener’а для изменений конфигурации. + - **Пример**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + + [[server.listeners]] + ip = "::" + port = 443 + synlimit = "nftables" + ``` ## announce - **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан. - **Описание**: Публичный IP-адрес или домен, объявляемый в proxy-ссылках для данного listener’а. Имеет приоритет над `announce_ip`. From eeff16c3fd9959528e80213c1597c061d47549b9 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:01:01 +0300 Subject: [PATCH 5/8] Rustfmt --- src/synlimit_control.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/synlimit_control.rs b/src/synlimit_control.rs index 9d7c3b9..2a393c1 100644 --- a/src/synlimit_control.rs +++ b/src/synlimit_control.rs @@ -109,7 +109,9 @@ pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) { return; } if !has_cap_net_admin() { - warn!("SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied"); + warn!( + "SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied" + ); return; } @@ -519,7 +521,10 @@ fn clear_iptables_synlimit_rules_for_binary_sync(binary: &str) { return; } for _ in 0..8 { - if !run_command_sync(binary, &["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN]) { + if !run_command_sync( + binary, + &["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN], + ) { break; } } From 942882f9dedd719a5dcb3d3ef761bfbd8b9f3904 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:29:23 +0300 Subject: [PATCH 6/8] SYN Limiter interval and hitcount in Config Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/config/defaults.rs | 10 +++++ src/config/load.rs | 16 ++++++++ src/config/types.rs | 6 +++ src/synlimit_control.rs | 82 +++++++++++------------------------------ 4 files changed, 53 insertions(+), 61 deletions(-) diff --git a/src/config/defaults.rs b/src/config/defaults.rs index da9e472..b2079b7 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -54,6 +54,8 @@ const DEFAULT_CONNTRACK_CONTROL_ENABLED: bool = true; const DEFAULT_CONNTRACK_PRESSURE_HIGH_WATERMARK_PCT: u8 = 85; 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_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2; const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5; const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000; @@ -243,6 +245,14 @@ pub(crate) fn default_conntrack_delete_budget_per_sec() -> u64 { DEFAULT_CONNTRACK_DELETE_BUDGET_PER_SEC } +pub(crate) fn default_synlimit_seconds() -> u32 { + DEFAULT_SYNLIMIT_SECONDS +} + +pub(crate) fn default_synlimit_hitcount() -> u32 { + DEFAULT_SYNLIMIT_HITCOUNT +} + pub(crate) fn default_prefer_4() -> u8 { 4 } diff --git a/src/config/load.rs b/src/config/load.rs index 85d830a..bd72fbf 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -347,6 +347,8 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[ "port", "client_mss", "synlimit", + "synlimit_seconds", + "synlimit_hitcount", "announce", "announce_ip", "proxy_protocol", @@ -1949,6 +1951,16 @@ impl ProxyConfig { ProxyError::Config(format!("server.listeners[{idx}].client_mss {error}")) })?; } + if listener.synlimit_seconds == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_seconds must be > 0" + ))); + } + if listener.synlimit_hitcount == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_hitcount must be > 0" + ))); + } } if config.server.accept_permit_timeout_ms > 60_000 { @@ -2188,6 +2200,8 @@ impl ProxyConfig { port: Some(config.server.port), client_mss: None, synlimit: SynLimitMode::default(), + synlimit_seconds: default_synlimit_seconds(), + synlimit_hitcount: default_synlimit_hitcount(), announce: None, announce_ip: None, proxy_protocol: None, @@ -2202,6 +2216,8 @@ impl ProxyConfig { port: Some(config.server.port), client_mss: None, synlimit: SynLimitMode::default(), + synlimit_seconds: default_synlimit_seconds(), + synlimit_hitcount: default_synlimit_hitcount(), announce: None, announce_ip: None, proxy_protocol: None, diff --git a/src/config/types.rs b/src/config/types.rs index 27a5e71..cc443bf 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -2176,6 +2176,12 @@ pub struct ListenerConfig { /// Per-listener SYN limiter mode. #[serde(default)] pub synlimit: SynLimitMode, + /// Iptables recent-match interval for the per-listener SYN limiter. + #[serde(default = "default_synlimit_seconds")] + pub synlimit_seconds: u32, + /// Iptables recent-match hit count for the per-listener SYN limiter. + #[serde(default = "default_synlimit_hitcount")] + pub synlimit_hitcount: u32, /// IP address or hostname to announce in proxy links. /// Takes precedence over `announce_ip` if both are set. #[serde(default)] diff --git a/src/synlimit_control.rs b/src/synlimit_control.rs index 2a393c1..f6fb0e2 100644 --- a/src/synlimit_control.rs +++ b/src/synlimit_control.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio::sync::watch; -use tracing::{debug, warn}; +use tracing::warn; use crate::config::{ProxyConfig, SynLimitMode}; @@ -19,8 +19,8 @@ const NFT_SET_V6: &str = "telemt_synlimit_v6"; #[derive(Default)] struct SynLimitTargets { - iptables_v4: Vec<(Option, u16)>, - iptables_v6: Vec<(Option, u16)>, + iptables_v4: Vec<(Option, u16, u32, u32)>, + iptables_v6: Vec<(Option, u16, u32, u32)>, nft_v4: Vec<(Option, u16)>, nft_v6: Vec<(Option, u16)>, } @@ -45,14 +45,6 @@ struct NftApplyPlan<'a> { v6_targets: &'a [(Option, u16)], } -struct SynLimitRuleGuard; - -impl Drop for SynLimitRuleGuard { - fn drop(&mut self) { - clear_synlimit_rules_all_backends_sync(); - } -} - impl SynLimitTargets { fn is_empty(&self) -> bool { self.iptables_v4.is_empty() @@ -89,7 +81,6 @@ pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver SynLimitTargets { } let port = listener.port.unwrap_or(cfg.server.port); let ip = (!listener.ip.is_unspecified()).then_some(listener.ip); + let seconds = listener.synlimit_seconds; + let hitcount = listener.synlimit_hitcount; match (backend, listener.ip.is_ipv4()) { (SynLimitMode::Iptables, true) => { - iptables_v4.insert((ip, port)); + iptables_v4.insert((ip, port, seconds, hitcount)); } (SynLimitMode::Iptables, false) => { - iptables_v6.insert((ip, port)); + iptables_v6.insert((ip, port, seconds, hitcount)); } (SynLimitMode::Nftables, true) => { nft_v4.insert((ip, port)); @@ -186,7 +179,7 @@ async fn apply_iptables_synlimit_rules(targets: &SynLimitTargets) -> Result<(), async fn apply_iptables_synlimit_rules_for_binary( binary: &str, - targets: &[(Option, u16)], + targets: &[(Option, u16, u32, u32)], ) -> Result<(), String> { if targets.is_empty() { return Ok(()); @@ -213,9 +206,11 @@ async fn apply_iptables_synlimit_rules_for_binary( .await?; } - for (ip, port) in targets { - let drop_args = iptables_synlimit_rule_args(ip, *port, "--rcheck", "DROP"); - let accept_args = iptables_synlimit_rule_args(ip, *port, "--set", "ACCEPT"); + for (ip, port, seconds, hitcount) in targets { + let drop_args = + iptables_synlimit_rule_args(ip, *port, *seconds, *hitcount, "--rcheck", "DROP"); + let accept_args = + iptables_synlimit_rule_args(ip, *port, *seconds, *hitcount, "--set", "ACCEPT"); let drop_refs: Vec<&str> = drop_args.iter().map(String::as_str).collect(); let accept_refs: Vec<&str> = accept_args.iter().map(String::as_str).collect(); run_command(binary, &drop_refs, None).await?; @@ -228,6 +223,8 @@ async fn apply_iptables_synlimit_rules_for_binary( fn iptables_synlimit_rule_args( ip: &Option, port: u16, + seconds: u32, + hitcount: u32, recent_op: &str, verdict: &str, ) -> Vec { @@ -254,7 +251,12 @@ fn iptables_synlimit_rule_args( recent_op.to_string(), ]); if recent_op == "--rcheck" { - args.extend(["--seconds".to_string(), "1".to_string()]); + args.extend([ + "--seconds".to_string(), + seconds.to_string(), + "--hitcount".to_string(), + hitcount.to_string(), + ]); } args.extend(["-j".to_string(), verdict.to_string()]); args @@ -507,45 +509,3 @@ fn has_cap_net_admin() -> bool { false } } - -fn clear_synlimit_rules_all_backends_sync() { - run_command_sync("nft", &["delete", "table", "inet", NFT_TABLE]); - run_command_sync("nft", &["delete", "table", "ip", NFT_TABLE]); - run_command_sync("nft", &["delete", "table", "ip6", NFT_TABLE]); - clear_iptables_synlimit_rules_for_binary_sync("iptables"); - clear_iptables_synlimit_rules_for_binary_sync("ip6tables"); -} - -fn clear_iptables_synlimit_rules_for_binary_sync(binary: &str) { - if !command_exists(binary) { - return; - } - for _ in 0..8 { - if !run_command_sync( - binary, - &["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN], - ) { - break; - } - } - run_command_sync(binary, &["-t", "filter", "-F", IPTABLES_CHAIN]); - run_command_sync(binary, &["-t", "filter", "-X", IPTABLES_CHAIN]); -} - -fn run_command_sync(binary: &str, args: &[&str]) -> bool { - if !command_exists(binary) { - return false; - } - match std::process::Command::new(binary) - .args(args) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - { - Ok(status) => status.success(), - Err(error) => { - debug!(binary, error = %error, "SYN limiter cleanup command failed to spawn"); - false - } - } -} From 9a3ff726b251e6a204d5a5202c8d5f20fbd19ebb Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:27:03 +0300 Subject: [PATCH 7/8] Use token-bucket SYN limiter backends Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- docs/Config_params/CONFIG_PARAMS.en.md | 41 ++++++- docs/Config_params/CONFIG_PARAMS.ru.md | 41 ++++++- src/config/defaults.rs | 5 + src/config/load.rs | 8 ++ src/config/types.rs | 11 +- src/synlimit_control.rs | 161 +++++++++++++++---------- 6 files changed, 200 insertions(+), 67 deletions(-) diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index 3268996..64e20fd 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -2220,6 +2220,9 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche | [`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` | `✘` | | [`announce`](#announce) | `String` | — | `✘` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` | @@ -2257,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 `recent` match name `telemt`. `"nftables"` uses nftables dynamic timeout sets and auto-detects whether the host already uses `inet`, `ip`, or `ip6` table families before creating Telemt-owned tables. 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 and listener restart/rebind for config changes. - **Example**: ```toml @@ -2271,6 +2274,42 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche port = 443 synlimit = "nftables" ``` +## synlimit_seconds (server.listeners) + - **Constraints / validation**: `u32`, must be `> 0`. Default is `1`. + - **Description**: Token-bucket interval for both SYN limiter backends. The rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`). + - **Example**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_seconds = 1 + ``` +## synlimit_hitcount (server.listeners) + - **Constraints / validation**: `u32`, must be `> 0`. Default is `1`. + - **Description**: Token-bucket rate amount for both SYN limiter backends. Together with `synlimit_seconds`, it defines the allowed source-IP SYN rate before excess SYN packets are dropped. + - **Example**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_hitcount = 1 + ``` +## synlimit_burst (server.listeners) + - **Constraints / validation**: `u32`, must be `> 0`. Default is `3`. + - **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**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_burst = 3 + ``` ## announce - **Constraints / validation**: `String` (optional). Must not be empty when set. - **Description**: Public IP/domain announced in proxy links for this listener. Takes precedence over `announce_ip`. diff --git a/docs/Config_params/CONFIG_PARAMS.ru.md b/docs/Config_params/CONFIG_PARAMS.ru.md index 02d336b..9062236 100644 --- a/docs/Config_params/CONFIG_PARAMS.ru.md +++ b/docs/Config_params/CONFIG_PARAMS.ru.md @@ -2226,6 +2226,9 @@ | [`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` | `✘` | | [`announce`](#announce) | `String` | — | `✘` | | [`announce_ip`](#announce_ip) | `IpAddr` | — | `✘` | | [`proxy_protocol`](#proxy_protocol) | `bool` | — | `✘` | @@ -2263,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 с `recent` name `telemt`. `"nftables"` использует nftables dynamic timeout sets и автоматически определяет, какие table families уже используются на хосте (`inet`, `ip`, `ip6`), перед созданием Telemt-owned tables. 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 и restart/rebind listener’а для изменений конфигурации. - **Пример**: ```toml @@ -2277,6 +2280,42 @@ port = 443 synlimit = "nftables" ``` +## synlimit_seconds (server.listeners) + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`. + - **Описание**: Token-bucket interval для обоих SYN limiter backends. Rate равен `synlimit_hitcount / synlimit_seconds` и рендерится в native netfilter rate units (`second`, `minute`, `hour` или `day`). + - **Пример**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_seconds = 1 + ``` +## synlimit_hitcount (server.listeners) + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `1`. + - **Описание**: Token-bucket rate amount для обоих SYN limiter backends. Вместе с `synlimit_seconds` задает разрешенный source-IP SYN rate до того, как excess SYN packets начнут drop’аться. + - **Пример**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_hitcount = 1 + ``` +## synlimit_burst (server.listeners) + - **Ограничения / валидация**: `u32`, должно быть `> 0`. Значение по умолчанию: `3`. + - **Описание**: Token-bucket burst size для обоих SYN limiter backends. Более высокие значения разрешают short connection bursts с одного source IP перед применением steady-state rate `synlimit_hitcount / synlimit_seconds`. + - **Пример**: + + ```toml + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "iptables" + synlimit_burst = 3 + ``` ## announce - **Ограничения / валидация**: `String` (необязательный параметр). Не должен быть пустым, если задан. - **Описание**: Публичный IP-адрес или домен, объявляемый в proxy-ссылках для данного listener’а. Имеет приоритет над `announce_ip`. diff --git a/src/config/defaults.rs b/src/config/defaults.rs index b2079b7..b0bafac 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -56,6 +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_UPSTREAM_CONNECT_RETRY_ATTEMPTS: u32 = 2; const DEFAULT_UPSTREAM_UNHEALTHY_FAIL_THRESHOLD: u32 = 5; const DEFAULT_UPSTREAM_CONNECT_BUDGET_MS: u64 = 3000; @@ -253,6 +254,10 @@ pub(crate) fn default_synlimit_hitcount() -> u32 { DEFAULT_SYNLIMIT_HITCOUNT } +pub(crate) fn default_synlimit_burst() -> u32 { + DEFAULT_SYNLIMIT_BURST +} + pub(crate) fn default_prefer_4() -> u8 { 4 } diff --git a/src/config/load.rs b/src/config/load.rs index bd72fbf..7b976c8 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -349,6 +349,7 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[ "synlimit", "synlimit_seconds", "synlimit_hitcount", + "synlimit_burst", "announce", "announce_ip", "proxy_protocol", @@ -1961,6 +1962,11 @@ impl ProxyConfig { "server.listeners[{idx}].synlimit_hitcount must be > 0" ))); } + if listener.synlimit_burst == 0 { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].synlimit_burst must be > 0" + ))); + } } if config.server.accept_permit_timeout_ms > 60_000 { @@ -2202,6 +2208,7 @@ impl ProxyConfig { synlimit: SynLimitMode::default(), synlimit_seconds: default_synlimit_seconds(), synlimit_hitcount: default_synlimit_hitcount(), + synlimit_burst: default_synlimit_burst(), announce: None, announce_ip: None, proxy_protocol: None, @@ -2218,6 +2225,7 @@ impl ProxyConfig { synlimit: SynLimitMode::default(), synlimit_seconds: default_synlimit_seconds(), synlimit_hitcount: default_synlimit_hitcount(), + synlimit_burst: default_synlimit_burst(), announce: None, announce_ip: None, proxy_protocol: None, diff --git a/src/config/types.rs b/src/config/types.rs index cc443bf..e0f7b04 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1375,9 +1375,9 @@ pub enum SynLimitMode { /// Disable SYN limiting for this listener. #[default] Off, - /// Use iptables/ip6tables filter rules with the recent match. + /// Use iptables/ip6tables filter rules with the hashlimit match. Iptables, - /// Use nftables rules with timeout-backed dynamic sets. + /// Use nftables rules with per-source token-bucket meters. Nftables, } @@ -2176,12 +2176,15 @@ pub struct ListenerConfig { /// Per-listener SYN limiter mode. #[serde(default)] pub synlimit: SynLimitMode, - /// Iptables recent-match interval for the per-listener SYN limiter. + /// Token-bucket rate interval for the per-listener SYN limiter. #[serde(default = "default_synlimit_seconds")] pub synlimit_seconds: u32, - /// Iptables recent-match hit count for the per-listener SYN limiter. + /// Token-bucket rate amount for the per-listener SYN limiter. #[serde(default = "default_synlimit_hitcount")] pub synlimit_hitcount: u32, + /// Token-bucket burst size for the per-listener SYN limiter. + #[serde(default = "default_synlimit_burst")] + pub synlimit_burst: u32, /// IP address or hostname to announce in proxy links. /// Takes precedence over `announce_ip` if both are set. #[serde(default)] diff --git a/src/synlimit_control.rs b/src/synlimit_control.rs index f6fb0e2..e39b73c 100644 --- a/src/synlimit_control.rs +++ b/src/synlimit_control.rs @@ -11,18 +11,17 @@ use tracing::warn; use crate::config::{ProxyConfig, SynLimitMode}; const IPTABLES_CHAIN: &str = "TELEMT_SYNLIMIT"; -const IPTABLES_RECENT_NAME: &str = "telemt"; +const IPTABLES_HASHLIMIT_NAME: &str = "TELEMT-BUMPER"; const NFT_TABLE: &str = "telemt_synlimit"; const NFT_CHAIN: &str = "input"; -const NFT_SET_V4: &str = "telemt_synlimit_v4"; -const NFT_SET_V6: &str = "telemt_synlimit_v6"; +type SynLimitTarget = (Option, u16, u32, u32, u32); #[derive(Default)] struct SynLimitTargets { - iptables_v4: Vec<(Option, u16, u32, u32)>, - iptables_v6: Vec<(Option, u16, u32, u32)>, - nft_v4: Vec<(Option, u16)>, - nft_v6: Vec<(Option, u16)>, + iptables_v4: Vec, + iptables_v6: Vec, + nft_v4: Vec, + nft_v6: Vec, } #[derive(Clone, Copy)] @@ -41,8 +40,8 @@ enum NftFamily { struct NftApplyPlan<'a> { family: NftFamily, - v4_targets: &'a [(Option, u16)], - v6_targets: &'a [(Option, u16)], + v4_targets: &'a [SynLimitTarget], + v6_targets: &'a [SynLimitTarget], } impl SynLimitTargets { @@ -61,7 +60,6 @@ impl SynLimitTargets { !self.nft_v4.is_empty() || !self.nft_v6.is_empty() } } - impl NftFamily { fn as_str(self) -> &'static str { match self { @@ -146,19 +144,20 @@ fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets { let ip = (!listener.ip.is_unspecified()).then_some(listener.ip); let seconds = listener.synlimit_seconds; let hitcount = listener.synlimit_hitcount; + let burst = listener.synlimit_burst; match (backend, listener.ip.is_ipv4()) { (SynLimitMode::Iptables, true) => { - iptables_v4.insert((ip, port, seconds, hitcount)); + iptables_v4.insert((ip, port, seconds, hitcount, burst)); } (SynLimitMode::Iptables, false) => { - iptables_v6.insert((ip, port, seconds, hitcount)); + iptables_v6.insert((ip, port, seconds, hitcount, burst)); } (SynLimitMode::Nftables, true) => { - nft_v4.insert((ip, port)); + nft_v4.insert((ip, port, seconds, hitcount, burst)); } (SynLimitMode::Nftables, false) => { - nft_v6.insert((ip, port)); + nft_v6.insert((ip, port, seconds, hitcount, burst)); } (SynLimitMode::Off, _) => {} } @@ -179,7 +178,7 @@ async fn apply_iptables_synlimit_rules(targets: &SynLimitTargets) -> Result<(), async fn apply_iptables_synlimit_rules_for_binary( binary: &str, - targets: &[(Option, u16, u32, u32)], + targets: &[SynLimitTarget], ) -> Result<(), String> { if targets.is_empty() { return Ok(()); @@ -188,7 +187,7 @@ async fn apply_iptables_synlimit_rules_for_binary( return Err(format!("{binary} is not available")); } - 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?; if run_command( binary, @@ -206,28 +205,71 @@ async fn apply_iptables_synlimit_rules_for_binary( .await?; } - for (ip, port, seconds, hitcount) in targets { - let drop_args = - iptables_synlimit_rule_args(ip, *port, *seconds, *hitcount, "--rcheck", "DROP"); - let accept_args = - iptables_synlimit_rule_args(ip, *port, *seconds, *hitcount, "--set", "ACCEPT"); + for (idx, (ip, port, seconds, hitcount, burst)) in targets.iter().enumerate() { + let hashlimit_name = format!("{IPTABLES_HASHLIMIT_NAME}-{idx}"); + let accept_args = iptables_hashlimit_accept_rule_args( + ip, + *port, + *seconds, + *hitcount, + *burst, + &hashlimit_name, + ); + let drop_args = iptables_synlimit_drop_rule_args(ip, *port); let drop_refs: Vec<&str> = drop_args.iter().map(String::as_str).collect(); let accept_refs: Vec<&str> = accept_args.iter().map(String::as_str).collect(); - run_command(binary, &drop_refs, None).await?; run_command(binary, &accept_refs, None).await?; + run_command(binary, &drop_refs, None).await?; } + run_command(binary, &["-t", "filter", "-A", IPTABLES_CHAIN, "-j", "RETURN"], None).await?; Ok(()) } -fn iptables_synlimit_rule_args( +fn iptables_hashlimit_accept_rule_args( ip: &Option, port: u16, seconds: u32, hitcount: u32, - recent_op: &str, - verdict: &str, + burst: u32, + hashlimit_name: &str, ) -> Vec { + let mut args = vec![ + "-t".to_string(), + "filter".to_string(), + "-A".to_string(), + IPTABLES_CHAIN.to_string(), + "-p".to_string(), + "tcp".to_string(), + "--syn".to_string(), + ]; + if let Some(ip) = ip { + args.push("-d".to_string()); + args.push(ip.to_string()); + } + let rate = synlimit_rate_arg(seconds, hitcount); + args.extend([ + "--dport".to_string(), + port.to_string(), + "-m".to_string(), + "hashlimit".to_string(), + "--hashlimit-name".to_string(), + hashlimit_name.to_string(), + "--hashlimit-mode".to_string(), + "srcip".to_string(), + "--hashlimit-upto".to_string(), + rate, + "--hashlimit-burst".to_string(), + burst.to_string(), + "--hashlimit-htable-expire".to_string(), + "15000".to_string(), + "-j".to_string(), + "ACCEPT".to_string(), + ]); + args +} + +fn iptables_synlimit_drop_rule_args(ip: &Option, port: u16) -> Vec { let mut args = vec![ "-t".to_string(), "filter".to_string(), @@ -244,24 +286,33 @@ fn iptables_synlimit_rule_args( args.extend([ "--dport".to_string(), port.to_string(), - "-m".to_string(), - "recent".to_string(), - "--name".to_string(), - IPTABLES_RECENT_NAME.to_string(), - recent_op.to_string(), + "-j".to_string(), + "DROP".to_string(), ]); - if recent_op == "--rcheck" { - args.extend([ - "--seconds".to_string(), - seconds.to_string(), - "--hitcount".to_string(), - hitcount.to_string(), - ]); - } - args.extend(["-j".to_string(), verdict.to_string()]); args } +fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String { + let seconds = u64::from(seconds.max(1)); + let hitcount = u64::from(hitcount.max(1)); + for (unit_seconds, unit_name) in [ + (1_u64, "second"), + (60_u64, "minute"), + (3_600_u64, "hour"), + (86_400_u64, "day"), + ] { + let amount = hitcount.saturating_mul(unit_seconds); + if amount >= seconds && amount % seconds == 0 { + return format!("{}/{}", amount / seconds, unit_name); + } + } + let amount = hitcount + .saturating_mul(86_400) + .saturating_add(seconds - 1) + / seconds; + format!("{}/day", amount.max(1)) +} + async fn clear_iptables_synlimit_rules_for_binary(binary: &str) { if !command_exists(binary) { return; @@ -324,11 +375,10 @@ async fn detect_nft_table_families() -> NftTableFamilies { } families } - fn nft_apply_plan<'a>( families: NftTableFamilies, - v4_targets: &'a [(Option, u16)], - v6_targets: &'a [(Option, u16)], + v4_targets: &'a [SynLimitTarget], + v6_targets: &'a [SynLimitTarget], ) -> Vec> { if !v4_targets.is_empty() && !v6_targets.is_empty() { return vec![NftApplyPlan { @@ -361,44 +411,33 @@ fn nft_apply_plan<'a>( } Vec::new() } - fn nft_synlimit_script(plan: NftApplyPlan<'_>) -> String { let mut script = String::new(); script.push_str(&format!("table {} {NFT_TABLE} {{\n", plan.family.as_str())); - if !plan.v4_targets.is_empty() { - script.push_str(&format!(" set {NFT_SET_V4} {{\n")); - script.push_str(" type ipv4_addr\n"); - script.push_str(" flags timeout\n"); - script.push_str(" }\n"); - } - if !plan.v6_targets.is_empty() { - script.push_str(&format!(" set {NFT_SET_V6} {{\n")); - script.push_str(" type ipv6_addr\n"); - script.push_str(" flags timeout\n"); - script.push_str(" }\n"); - } script.push_str(&format!(" chain {NFT_CHAIN} {{\n")); script.push_str(" type filter hook input priority filter; policy accept;\n"); - for (ip, port) in plan.v4_targets { + for (idx, (ip, port, seconds, hitcount, burst)) in plan.v4_targets.iter().enumerate() { let daddr = ip .map(|ip| format!(" ip daddr {ip}")) .unwrap_or_else(String::new); + let rate = synlimit_rate_arg(*seconds, *hitcount); script.push_str(&format!( - " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} ip saddr @{NFT_SET_V4} drop\n" + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v4_{idx} {{ ip saddr limit rate over {rate} burst {burst} packets }} drop\n" )); script.push_str(&format!( - " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} add @{NFT_SET_V4} {{ ip saddr timeout 1s }} accept\n" + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n" )); } - for (ip, port) in plan.v6_targets { + for (idx, (ip, port, seconds, hitcount, burst)) in plan.v6_targets.iter().enumerate() { let daddr = ip .map(|ip| format!(" ip6 daddr {ip}")) .unwrap_or_else(String::new); + let rate = synlimit_rate_arg(*seconds, *hitcount); script.push_str(&format!( - " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} ip6 saddr @{NFT_SET_V6} drop\n" + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synlimit_v6_{idx} {{ ip6 saddr limit rate over {rate} burst {burst} packets }} drop\n" )); script.push_str(&format!( - " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} add @{NFT_SET_V6} {{ ip6 saddr timeout 1s }} accept\n" + " tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n" )); } script.push_str(" }\n"); From 9904da737a2f1f307e44e59448bcc5d99959d8b3 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:28:41 +0300 Subject: [PATCH 8/8] Rustfmt --- src/synlimit_control.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/synlimit_control.rs b/src/synlimit_control.rs index e39b73c..466c16e 100644 --- a/src/synlimit_control.rs +++ b/src/synlimit_control.rs @@ -221,7 +221,12 @@ async fn apply_iptables_synlimit_rules_for_binary( run_command(binary, &accept_refs, None).await?; run_command(binary, &drop_refs, None).await?; } - run_command(binary, &["-t", "filter", "-A", IPTABLES_CHAIN, "-j", "RETURN"], None).await?; + run_command( + binary, + &["-t", "filter", "-A", IPTABLES_CHAIN, "-j", "RETURN"], + None, + ) + .await?; Ok(()) } @@ -306,10 +311,7 @@ fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String { return format!("{}/{}", amount / seconds, unit_name); } } - let amount = hitcount - .saturating_mul(86_400) - .saturating_add(seconds - 1) - / seconds; + let amount = hitcount.saturating_mul(86_400).saturating_add(seconds - 1) / seconds; format!("{}/day", amount.max(1)) }