mirror of
https://github.com/telemt/telemt.git
synced 2026-09-05 18:16:06 +03:00
Merge pull request #884 from vgrebenschikov/feature/synlimit-pf-support
Add PF synlimit backend
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user