Add PF synlimit backend

Implement synlimit = "pf" for PF-based firewalls using per-listener source tracking rules in a Telemt-owned anchor.

PF evaluates max-src-conn-rate through source tracking when creating
tates for completed TCP connections. Telemt installs a rate-limited pass
rule followed by a reject fallback rule in the listener anchor:

  - under-limit new connections create PF state and pass;
  - over-limit new connections fall through to block return-rst;
  - access resumes automatically when the source rate drops below the configured window.

Keep Linux iptables/nftables behavior unchanged.
PF maps synlimit_hitcount / synlimit_seconds to max-src-conn-rate and intentionally has
no direct equivalent for Linux-only burst/hashlimit knobs.
This commit is contained in:
Vladimir Grebenshchikov
2026-07-19 18:37:05 +02:00
parent d851200e47
commit e2eec67a33
10 changed files with 247 additions and 55 deletions
+34 -43
View File
@@ -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<Option<SynLimitNamespace>> = Mutex::new(None);
@@ -36,27 +37,13 @@ pub(crate) fn spawn_synlimit_controller(
runtime_watch_rx: watch::Receiver<Option<RuntimeWatchState>>,
) -> 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<bool, String>
}
async fn clear_synlimit_rules_for_namespace(namespace: &SynLimitNamespace) -> Result<bool, String> {
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<SynLimitNamespace> {
}
}
fn has_synlimit_config(cfg: &ProxyConfig) -> bool {
cfg.server
.listeners
.iter()
.any(|listener| !matches!(listener.synlimit, SynLimitMode::Off))
}
#[cfg(test)]
mod tests {
use super::*;