diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index 7b7cf4d..651162e 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -2311,7 +2311,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` | `✔` | +| [`synlimit`](#synlimit-serverlisteners) | `false`, `"iptables"`, `"nftables"`, or `"pf"` | `false` | `✔` | | [`synlimit_seconds`](#synlimit_seconds-serverlisteners) | `u32` | `60` | `✔` | | [`synlimit_hitcount`](#synlimit_hitcount-serverlisteners) | `u32` | `48` | `✔` | | [`synlimit_burst`](#synlimit_burst-serverlisteners) | `u32` | `1` | `✔` | @@ -2356,8 +2356,8 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche 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 two-tier SYN-fix rules for the listener port. `"iptables"` uses `iptables`/`ip6tables` filter rules with the `hashlimit`, `length`, and TTL/hop-limit matches. `"nftables"` uses Telemt-owned tables with per-source `meter` rules and equivalent IPv4/IPv6 classifiers. Rules are inserted early in `INPUT`, accept under-limit SYN packets, and reject over-limit SYN packets with TCP RST so clients retry promptly instead of waiting for a silent DROP timeout. The generic bucket is controlled by `synlimit_seconds`, `synlimit_hitcount`, and `synlimit_burst`; the iOS-like TTL/length bucket is controlled by `synlimit_ios_*`. 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. + - **Constraints / validation**: `false`, `"iptables"`, `"nftables"`, or `"pf"`. Omitted or `false` disables SYN limiting for this listener. + - **Description**: Installs per-listener firewall rules for the listener port. `"iptables"` uses Linux `iptables`/`ip6tables` filter rules with the `hashlimit`, `length`, and TTL/hop-limit matches. `"nftables"` uses Linux Telemt-owned tables with per-source `meter` rules and equivalent IPv4/IPv6 classifiers. These Linux rules are inserted early in `INPUT`, accept under-limit SYN packets, and reject over-limit SYN packets with TCP RST so clients retry promptly instead of waiting for a silent DROP timeout. `"pf"` uses FreeBSD PF source tracking in a Telemt anchor with `max-src-conn-rate`; PF applies this rate after TCP three-way handshake completion, accepts under-limit connections, and rejects over-limit new connections until the source rate falls back below the configured window. The generic bucket is controlled by `synlimit_seconds`, `synlimit_hitcount`, and `synlimit_burst` on Linux; PF maps `synlimit_hitcount / synlimit_seconds` to `max-src-conn-rate` and has no direct equivalents for `synlimit_burst`, `synlimit_ios_*`, or `synlimit_hashlimit_*`. Rules are reconciled at runtime and removed during graceful Telemt shutdown; `SIGKILL` cannot be cleaned up by the process. Linux requires CAP_NET_ADMIN. FreeBSD requires root and a main PF ruleset hook such as `anchor "telemt_synlimit/*"`. `synlimit*` changes hot-reload for existing listener endpoints; changing listener `ip` or `port` still requires restart/rebind. - **Operator note**: Telemt does not persist rules with `iptables-persistent`, write `/etc/sysctl.d`, edit systemd limits, or modify `client_mss`. Apply host-level tuning manually if your deployment policy requires it. - **Example**: @@ -2371,10 +2371,15 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche ip = "::" port = 443 synlimit = "nftables" + + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "pf" ``` ## synlimit_seconds (server.listeners) - **Constraints / validation**: `u32`, must be `> 0`. Default is `60`. - - **Description**: Generic SYN-fix token-bucket interval. The rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`). This bucket handles SYN packets that do not match the iOS-like TTL/length classifier. + - **Description**: Generic SYN-fix token-bucket interval. For Linux backends, the rate is `synlimit_hitcount / synlimit_seconds` and is rendered to native netfilter rate units (`second`, `minute`, `hour`, or `day`). This bucket handles SYN packets that do not match the iOS-like TTL/length classifier. For PF, the same pair is rendered as `max-src-conn-rate hitcount/seconds`. - **Example**: ```toml @@ -2446,7 +2451,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche ``` ## synlimit_hashlimit_expire_ms (server.listeners) - **Constraints / validation**: `u32`, must be `> 0`. Default is `60000`. - - **Description**: Entry expiration in milliseconds for iptables/ip6tables hashlimit buckets. nftables meters use kernel-managed state and do not expose this exact knob. + - **Description**: Entry expiration in milliseconds for iptables/ip6tables hashlimit buckets. nftables meters and PF source tracking use kernel-managed state and do not expose this exact knob. - **Example**: ```toml @@ -2458,7 +2463,7 @@ Note: This section also accepts the legacy alias `[server.admin_api]` (same sche ``` ## synlimit_hashlimit_size (server.listeners) - **Constraints / validation**: `u32`, must be `> 0`. Default is `32768`. - - **Description**: Hash table size for iptables/ip6tables hashlimit buckets. nftables meters use kernel-managed state and do not expose this exact knob. + - **Description**: Hash table size for iptables/ip6tables hashlimit buckets. nftables meters and PF source tracking use kernel-managed state and do not expose this exact knob. - **Example**: ```toml diff --git a/src/config/tests/load_basic_tests.rs b/src/config/tests/load_basic_tests.rs index 26daf59..9da4714 100644 --- a/src/config/tests/load_basic_tests.rs +++ b/src/config/tests/load_basic_tests.rs @@ -62,6 +62,26 @@ fn synlimit_synfix_defaults_are_loaded_for_listener() { assert_eq!(listener.synlimit_hashlimit_size, 32_768); } +#[test] +fn synlimit_pf_mode_is_loaded_for_listener() { + let cfg = load_config_from_temp_toml( + r#" + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + + [[server.listeners]] + ip = "0.0.0.0" + port = 443 + synlimit = "pf" + "#, + ); + + assert_eq!(cfg.server.listeners[0].synlimit, SynLimitMode::Pf); +} + #[test] fn synlimit_synfix_zero_values_are_rejected() { for (field, expected) in [ diff --git a/src/config/types.rs b/src/config/types.rs index 970fde2..d72ce03 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1471,6 +1471,8 @@ pub enum SynLimitMode { Iptables, /// Use nftables two-tier SYN-fix rules with per-source token-bucket meters. Nftables, + /// Use FreeBSD PF source tracking with connection-rate state limits. + Pf, } impl Serialize for SynLimitMode { @@ -1482,6 +1484,7 @@ impl Serialize for SynLimitMode { Self::Off => serializer.serialize_bool(false), Self::Iptables => serializer.serialize_str("iptables"), Self::Nftables => serializer.serialize_str("nftables"), + Self::Pf => serializer.serialize_str("pf"), } } } @@ -1497,7 +1500,7 @@ impl<'de> Deserialize<'de> for SynLimitMode { type Value = SynLimitMode; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("false, iptables, or nftables") + formatter.write_str("false, iptables, nftables, or pf") } fn visit_bool(self, value: bool) -> std::result::Result @@ -1506,7 +1509,7 @@ impl<'de> Deserialize<'de> for SynLimitMode { { if value { Err(E::custom( - "synlimit=true is ambiguous; use \"iptables\" or \"nftables\"", + "synlimit=true is ambiguous; use \"iptables\", \"nftables\", or \"pf\"", )) } else { Ok(SynLimitMode::Off) @@ -1521,8 +1524,9 @@ impl<'de> Deserialize<'de> for SynLimitMode { "false" | "off" | "disabled" | "none" => Ok(SynLimitMode::Off), "iptables" => Ok(SynLimitMode::Iptables), "nftables" => Ok(SynLimitMode::Nftables), + "pf" => Ok(SynLimitMode::Pf), _ => Err(E::custom( - "synlimit must be false, \"iptables\", or \"nftables\"", + "synlimit must be false, \"iptables\", \"nftables\", or \"pf\"", )), } } diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index b00a7b7..e9cc100 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -987,7 +987,9 @@ async fn run_telemt_core( std::process::exit(1); } - // On Unix, caller supplies privilege drop after bind (may require root for port < 1024). + synlimit_control::reconcile_synlimit_rules(&config).await; + + // On Unix, caller supplies privilege drop after bind and privileged firewall setup. drop_after_bind(); let synlimit_controller = synlimit_control::spawn_synlimit_controller(runtime_watch_rx); diff --git a/src/synlimit_control/command.rs b/src/synlimit_control/command.rs index 863b86f..19355c0 100644 --- a/src/synlimit_control/command.rs +++ b/src/synlimit_control/command.rs @@ -74,7 +74,7 @@ fn resolve_command(binary: &str) -> Option { .find(|candidate| candidate.exists() && candidate.is_file()) } -pub(super) fn has_cap_net_admin() -> bool { +pub(super) fn has_firewall_privileges() -> bool { #[cfg(target_os = "linux")] { let Ok(status) = std::fs::read_to_string("/proc/self/status") else { @@ -91,7 +91,11 @@ pub(super) fn has_cap_net_admin() -> bool { } false } - #[cfg(not(target_os = "linux"))] + #[cfg(all(unix, not(target_os = "linux")))] + { + nix::unistd::Uid::effective().is_root() + } + #[cfg(not(unix))] { false } diff --git a/src/synlimit_control/iptables.rs b/src/synlimit_control/iptables.rs index bb37044..e951b08 100644 --- a/src/synlimit_control/iptables.rs +++ b/src/synlimit_control/iptables.rs @@ -321,6 +321,7 @@ mod tests { nft_table: "telemt_synlimit_test".to_string(), iptables_chain: "TMT_SYN_TEST".to_string(), iptables_hashlimit_prefix: "TMTTEST".to_string(), + pf_anchor: "telemt_synlimit/test".to_string(), } } diff --git a/src/synlimit_control/mod.rs b/src/synlimit_control/mod.rs index e6fdb34..91fa5d6 100644 --- a/src/synlimit_control/mod.rs +++ b/src/synlimit_control/mod.rs @@ -4,15 +4,16 @@ use tokio::sync::watch; use tokio_util::sync::CancellationToken; use tracing::warn; -use crate::config::{ProxyConfig, SynLimitMode}; +use crate::config::ProxyConfig; use crate::maestro::generation::RuntimeWatchState; mod command; mod iptables; mod model; mod nftables; +mod pf; -use self::command::has_cap_net_admin; +use self::command::has_firewall_privileges; use self::model::{SynLimitNamespace, synlimit_namespace, synlimit_targets}; static ACTIVE_SYNLIMIT_NAMESPACE: Mutex> = Mutex::new(None); @@ -36,27 +37,13 @@ pub(crate) fn spawn_synlimit_controller( runtime_watch_rx: watch::Receiver>, ) -> SynlimitController { let shutdown = CancellationToken::new(); - let join = if !cfg!(target_os = "linux") { - tokio::spawn(watch_active_runtime_configs( - runtime_watch_rx, - shutdown.clone(), - |_generation_id, cfg| async move { - if has_synlimit_config(&cfg) { - warn!( - "SYN limiter is configured but unsupported on this OS; skipping netfilter rules" - ); - } - }, - )) - } else { - tokio::spawn(watch_active_runtime_configs( - runtime_watch_rx, - shutdown.clone(), - |_generation_id, cfg| async move { - reconcile_synlimit_rules(&cfg).await; - }, - )) - }; + let join = tokio::spawn(watch_active_runtime_configs( + runtime_watch_rx, + shutdown.clone(), + |_generation_id, cfg| async move { + reconcile_synlimit_rules(&cfg).await; + }, + )); SynlimitController { shutdown, join } } @@ -176,10 +163,8 @@ pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) { let Some(namespace) = namespace else { return; }; - if !has_cap_net_admin() { - warn!( - "SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied" - ); + if !has_firewall_privileges() { + warn!("SYN limiter configured but firewall privileges are not available; rules not applied"); return; } @@ -193,15 +178,20 @@ pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) { } } - if targets.has_iptables_targets() - && let Err(error) = iptables::apply_synlimit_rules(&targets, &namespace).await - { - warn!(error = %error, "Failed to apply iptables SYN limiter rules"); + if targets.has_iptables_targets() { + if let Err(error) = iptables::apply_synlimit_rules(&targets, &namespace).await { + warn!(error = %error, "Failed to apply iptables SYN limiter rules"); + } } - if targets.has_nft_targets() - && let Err(error) = nftables::apply_synlimit_rules(&targets, &namespace).await - { - warn!(error = %error, "Failed to apply nftables SYN limiter rules"); + if targets.has_nft_targets() { + if let Err(error) = nftables::apply_synlimit_rules(&targets, &namespace).await { + warn!(error = %error, "Failed to apply nftables SYN limiter rules"); + } + } + if targets.has_pf_targets() { + if let Err(error) = pf::apply_synlimit_rules(&targets, &namespace).await { + warn!(error = %error, "Failed to apply PF SYN limiter rules"); + } } } @@ -213,7 +203,7 @@ pub(crate) async fn clear_synlimit_rules_all_backends() -> Result } async fn clear_synlimit_rules_for_namespace(namespace: &SynLimitNamespace) -> Result { - if !has_cap_net_admin() { + if !has_firewall_privileges() { return Ok(false); } @@ -243,6 +233,14 @@ async fn clear_synlimit_rules_for_namespace(namespace: &SynLimitNamespace) -> Re errors.push(error); } } + match pf::clear_rules(namespace).await { + Ok(value) => { + removed |= value; + } + Err(error) => { + errors.push(error); + } + } if errors.is_empty() { Ok(removed) @@ -277,13 +275,6 @@ fn take_active_synlimit_namespace() -> Option { } } -fn has_synlimit_config(cfg: &ProxyConfig) -> bool { - cfg.server - .listeners - .iter() - .any(|listener| !matches!(listener.synlimit, SynLimitMode::Off)) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/synlimit_control/model.rs b/src/synlimit_control/model.rs index 45c6bf3..c832281 100644 --- a/src/synlimit_control/model.rs +++ b/src/synlimit_control/model.rs @@ -22,6 +22,7 @@ pub(super) struct SynLimitNamespace { pub(super) nft_table: String, pub(super) iptables_chain: String, pub(super) iptables_hashlimit_prefix: String, + pub(super) pf_anchor: String, } #[derive(Default)] @@ -30,6 +31,8 @@ pub(super) struct SynLimitTargets { pub(super) iptables_v6: Vec, pub(super) nft_v4: Vec, pub(super) nft_v6: Vec, + pub(super) pf_v4: Vec, + pub(super) pf_v6: Vec, } impl SynLimitTargets { @@ -38,6 +41,8 @@ impl SynLimitTargets { && self.iptables_v6.is_empty() && self.nft_v4.is_empty() && self.nft_v6.is_empty() + && self.pf_v4.is_empty() + && self.pf_v6.is_empty() } pub(super) fn has_iptables_targets(&self) -> bool { @@ -47,6 +52,10 @@ impl SynLimitTargets { pub(super) fn has_nft_targets(&self) -> bool { !self.nft_v4.is_empty() || !self.nft_v6.is_empty() } + + pub(super) fn has_pf_targets(&self) -> bool { + !self.pf_v4.is_empty() || !self.pf_v6.is_empty() + } } struct SynLimitNamespaceHasher { @@ -92,6 +101,8 @@ pub(super) fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets { let mut iptables_v6 = BTreeSet::new(); let mut nft_v4 = BTreeSet::new(); let mut nft_v6 = BTreeSet::new(); + let mut pf_v4 = BTreeSet::new(); + let mut pf_v6 = BTreeSet::new(); for listener in &cfg.server.listeners { let backend = listener.synlimit; @@ -124,6 +135,12 @@ pub(super) fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets { (SynLimitMode::Nftables, false) => { nft_v6.insert(target); } + (SynLimitMode::Pf, true) => { + pf_v4.insert(target); + } + (SynLimitMode::Pf, false) => { + pf_v6.insert(target); + } (SynLimitMode::Off, _) => {} } } @@ -133,6 +150,8 @@ pub(super) fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets { iptables_v6: iptables_v6.into_iter().collect(), nft_v4: nft_v4.into_iter().collect(), nft_v6: nft_v6.into_iter().collect(), + pf_v4: pf_v4.into_iter().collect(), + pf_v6: pf_v6.into_iter().collect(), } } @@ -146,6 +165,8 @@ pub(super) fn synlimit_namespace(targets: &SynLimitTargets) -> Option Option Result<(), String> { + if !has_pf_anchor_hook().await? { + return Err(format!( + "PF anchor hook is not installed; add anchor \"{PF_ANCHOR_ROOT}/*\" to pf.conf" + )); + } + + let script = pf_synlimit_script(targets); + run_command("pfctl", &["-a", namespace.pf_anchor.as_str(), "-f", "-"], Some(script)).await +} + +async fn has_pf_anchor_hook() -> Result { + let rules = run_command_stdout("pfctl", &["-s", "rules"]).await?; + Ok(rules.lines().any(is_pf_anchor_hook_line)) +} + +fn is_pf_anchor_hook_line(line: &str) -> bool { + line.trim().contains("anchor \"telemt_synlimit/*\"") +} + +fn pf_synlimit_script(targets: &SynLimitTargets) -> String { + let mut script = String::new(); + for target in &targets.pf_v4 { + push_pf_rules(&mut script, target); + } + for target in &targets.pf_v6 { + push_pf_rules(&mut script, target); + } + script +} + +fn push_pf_rules(script: &mut String, target: &SynLimitRule) { + let destination = pf_destination(target.ip); + script.push_str(&format!( + "pass in quick proto tcp from any to {destination} port {port} flags S/SA keep state (max-src-conn-rate {rate}/{seconds})\n", + port = target.port, + rate = target.generic_hitcount, + seconds = target.generic_seconds, + )); + script.push_str(&format!( + "block return-rst in quick proto tcp from any to {destination} port {port}\n", + port = target.port, + )); +} + +fn pf_destination(ip: Option) -> String { + ip.map(|ip| ip.to_string()) + .unwrap_or_else(|| "any".to_string()) +} + +pub(super) async fn clear_rules(namespace: &SynLimitNamespace) -> Result { + match run_command( + "pfctl", + &["-a", namespace.pf_anchor.as_str(), "-F", "rules"], + None, + ) + .await + { + Ok(()) => Ok(true), + Err(error) if is_missing_command_or_pf_anchor(&error) => Ok(false), + Err(error) => return Err(format!("pfctl flush anchor rules failed: {error}")), + } +} + +fn is_missing_command_or_pf_anchor(error: &str) -> bool { + error.contains("pfctl is not available") || error.contains("Anchor does not exist") +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use super::*; + use crate::synlimit_control::model::test_rule; + + #[test] + fn pf_script_uses_rate_limited_pass_before_reject() { + let mut targets = SynLimitTargets::default(); + targets.pf_v4 = vec![test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443)]; + let script = pf_synlimit_script(&targets); + + assert!(script.contains( + "pass in quick proto tcp from any to 203.0.113.7 port 443 flags S/SA keep state (max-src-conn-rate 48/60)" + )); + assert!(script.contains( + "block return-rst in quick proto tcp from any to 203.0.113.7 port 443" + )); + let pass_idx = script + .find("pass in quick proto tcp from any to 203.0.113.7 port 443") + .expect("rate-limited pass rule must be rendered"); + let block_idx = script + .find("block return-rst in quick proto tcp from any to 203.0.113.7 port 443") + .expect("reject fallback rule must be rendered"); + assert!(pass_idx < block_idx); + } + + #[test] + fn pf_script_supports_wildcard_and_ipv6_destinations() { + let mut targets = SynLimitTargets::default(); + targets.pf_v4 = vec![test_rule(None, 443)]; + targets.pf_v6 = vec![test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 8443)]; + let script = pf_synlimit_script(&targets); + + assert!(script.contains("to any port 443")); + assert!(script.contains("to ::1 port 8443")); + } + + #[test] + fn pf_anchor_hook_detection_requires_wildcard_hook() { + assert!(is_pf_anchor_hook_line("anchor \"telemt_synlimit/*\" all")); + assert!(!is_pf_anchor_hook_line("anchor \"telemt_synlimit\" all")); + assert!(!is_pf_anchor_hook_line("anchor \"other\" all")); + } +}