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
+11 -6
View File
@@ -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
+20
View File
@@ -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 [
+7 -3
View File
@@ -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<E>(self, value: bool) -> std::result::Result<Self::Value, E>
@@ -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\"",
)),
}
}
+3 -1
View File
@@ -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);
+6 -2
View File
@@ -74,7 +74,7 @@ fn resolve_command(binary: &str) -> Option<PathBuf> {
.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
}
+1
View File
@@ -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(),
}
}
+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::*;
+40
View File
@@ -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<SynLimitRule>,
pub(super) nft_v4: Vec<SynLimitRule>,
pub(super) nft_v6: Vec<SynLimitRule>,
pub(super) pf_v4: Vec<SynLimitRule>,
pub(super) pf_v6: Vec<SynLimitRule>,
}
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<SynLimitNa
write_namespace_rule_group(&mut hasher, b"iptables-v6", &targets.iptables_v6);
write_namespace_rule_group(&mut hasher, b"nft-v4", &targets.nft_v4);
write_namespace_rule_group(&mut hasher, b"nft-v6", &targets.nft_v6);
write_namespace_rule_group(&mut hasher, b"pf-v4", &targets.pf_v4);
write_namespace_rule_group(&mut hasher, b"pf-v6", &targets.pf_v6);
let suffix = format!("{:016x}", hasher.finish());
let iptables_suffix = &suffix[..12];
@@ -154,6 +175,7 @@ pub(super) fn synlimit_namespace(targets: &SynLimitTargets) -> Option<SynLimitNa
nft_table: format!("telemt_synlimit_{suffix}"),
iptables_chain: format!("TMT_SYN_{iptables_suffix}"),
iptables_hashlimit_prefix: format!("TMT{hashlimit_suffix}"),
pf_anchor: format!("telemt_synlimit/{suffix}"),
})
}
@@ -307,6 +329,16 @@ mod tests {
Some(444),
SynLimitMode::Nftables,
),
listener(
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 3)),
Some(445),
SynLimitMode::Pf,
),
listener(
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
Some(445),
SynLimitMode::Pf,
),
];
let targets = synlimit_targets(&cfg);
@@ -315,6 +347,8 @@ mod tests {
assert_eq!(targets.iptables_v6.len(), 1);
assert_eq!(targets.nft_v4.len(), 1);
assert_eq!(targets.nft_v6.len(), 1);
assert_eq!(targets.pf_v4.len(), 1);
assert_eq!(targets.pf_v6.len(), 1);
assert_eq!(
targets.iptables_v4[0].ip,
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)))
@@ -328,6 +362,11 @@ mod tests {
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 2)))
);
assert_eq!(targets.nft_v6[0].ip, None);
assert_eq!(
targets.pf_v4[0].ip,
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 3)))
);
assert_eq!(targets.pf_v6[0].ip, None);
}
#[test]
@@ -353,6 +392,7 @@ mod tests {
assert!(first.iptables_chain.starts_with("TMT_SYN_"));
assert!(first.iptables_chain.len() <= 28);
assert!(first.iptables_hashlimit_prefix.starts_with("TMT"));
assert!(first.pf_anchor.starts_with("telemt_synlimit/"));
}
#[test]
+1
View File
@@ -236,6 +236,7 @@ mod tests {
nft_table: table.to_string(),
iptables_chain: "TMT_SYN_TEST".to_string(),
iptables_hashlimit_prefix: "TMTTEST".to_string(),
pf_anchor: "telemt_synlimit/test".to_string(),
}
}
+124
View File
@@ -0,0 +1,124 @@
use std::net::IpAddr;
use super::command::{run_command, run_command_stdout};
use super::model::{SynLimitNamespace, SynLimitRule, SynLimitTargets};
const PF_ANCHOR_ROOT: &str = "telemt_synlimit";
pub(super) async fn apply_synlimit_rules(
targets: &SynLimitTargets,
namespace: &SynLimitNamespace,
) -> 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<bool, String> {
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<IpAddr>) -> String {
ip.map(|ip| ip.to_string())
.unwrap_or_else(|| "any".to_string())
}
pub(super) async fn clear_rules(namespace: &SynLimitNamespace) -> Result<bool, String> {
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"));
}
}