mirror of
https://github.com/telemt/telemt.git
synced 2026-09-07 19:16:14 +03:00
e2eec67a33
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.
318 lines
11 KiB
Rust
318 lines
11 KiB
Rust
use super::command::{run_command, run_command_stdout};
|
|
use super::model::{SynLimitNamespace, SynLimitRule, SynLimitTargets, synlimit_rate_arg};
|
|
|
|
const NFT_CHAIN: &str = "input";
|
|
const NFT_INPUT_PRIORITY: i16 = -5;
|
|
const IPV4_IOS_PACKET_LENGTH: u16 = 64;
|
|
const IPV6_IOS_PACKET_LENGTH: u16 = 84;
|
|
const IOS_TTL_LIMIT: u8 = 65;
|
|
|
|
#[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 [SynLimitRule],
|
|
v6_targets: &'a [SynLimitRule],
|
|
}
|
|
|
|
impl NftFamily {
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Inet => "inet",
|
|
Self::Ip => "ip",
|
|
Self::Ip6 => "ip6",
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn apply_synlimit_rules(
|
|
targets: &SynLimitTargets,
|
|
namespace: &SynLimitNamespace,
|
|
) -> Result<(), 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, namespace);
|
|
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 [SynLimitRule],
|
|
v6_targets: &'a [SynLimitRule],
|
|
) -> Vec<NftApplyPlan<'a>> {
|
|
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<'_>, namespace: &SynLimitNamespace) -> String {
|
|
let mut script = String::new();
|
|
script.push_str(&format!(
|
|
"table {} {} {{\n",
|
|
plan.family.as_str(),
|
|
namespace.nft_table
|
|
));
|
|
script.push_str(&format!(" chain {NFT_CHAIN} {{\n"));
|
|
script.push_str(&format!(
|
|
" type filter hook input priority {NFT_INPUT_PRIORITY}; policy accept;\n"
|
|
));
|
|
for (idx, target) in plan.v4_targets.iter().enumerate() {
|
|
push_nft_v4_rules(&mut script, target, idx);
|
|
}
|
|
for (idx, target) in plan.v6_targets.iter().enumerate() {
|
|
push_nft_v6_rules(&mut script, target, idx);
|
|
}
|
|
script.push_str(" }\n");
|
|
script.push_str("}\n");
|
|
script
|
|
}
|
|
|
|
fn push_nft_v4_rules(script: &mut String, target: &SynLimitRule, idx: usize) {
|
|
let daddr = target
|
|
.ip
|
|
.map(|ip| format!(" ip daddr {ip}"))
|
|
.unwrap_or_default();
|
|
let ios_rate = synlimit_rate_arg(target.ios_seconds, target.ios_hitcount);
|
|
let generic_rate = synlimit_rate_arg(target.generic_seconds, target.generic_hitcount);
|
|
script.push_str(&format!(
|
|
" tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV4_IOS_PACKET_LENGTH} ip ttl < {IOS_TTL_LIMIT} tcp dport {port} meter telemt_synfix_ios_v4_{idx} {{ ip saddr limit rate over {ios_rate} burst {ios_burst} packets }} reject with tcp reset\n",
|
|
port = target.port,
|
|
ios_burst = target.ios_burst,
|
|
));
|
|
script.push_str(&format!(
|
|
" tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV4_IOS_PACKET_LENGTH} ip ttl < {IOS_TTL_LIMIT} tcp dport {port} accept\n",
|
|
port = target.port,
|
|
));
|
|
script.push_str(&format!(
|
|
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synfix_v4_{idx} {{ ip saddr limit rate over {generic_rate} burst {generic_burst} packets }} reject with tcp reset\n",
|
|
port = target.port,
|
|
generic_burst = target.generic_burst,
|
|
));
|
|
script.push_str(&format!(
|
|
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n",
|
|
port = target.port,
|
|
));
|
|
}
|
|
|
|
fn push_nft_v6_rules(script: &mut String, target: &SynLimitRule, idx: usize) {
|
|
let daddr = target
|
|
.ip
|
|
.map(|ip| format!(" ip6 daddr {ip}"))
|
|
.unwrap_or_default();
|
|
let ios_rate = synlimit_rate_arg(target.ios_seconds, target.ios_hitcount);
|
|
let generic_rate = synlimit_rate_arg(target.generic_seconds, target.generic_hitcount);
|
|
script.push_str(&format!(
|
|
" tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV6_IOS_PACKET_LENGTH} ip6 hoplimit < {IOS_TTL_LIMIT} tcp dport {port} meter telemt_synfix_ios_v6_{idx} {{ ip6 saddr limit rate over {ios_rate} burst {ios_burst} packets }} reject with tcp reset\n",
|
|
port = target.port,
|
|
ios_burst = target.ios_burst,
|
|
));
|
|
script.push_str(&format!(
|
|
" tcp flags & (fin|syn|rst|ack) == syn{daddr} meta length {IPV6_IOS_PACKET_LENGTH} ip6 hoplimit < {IOS_TTL_LIMIT} tcp dport {port} accept\n",
|
|
port = target.port,
|
|
));
|
|
script.push_str(&format!(
|
|
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} meter telemt_synfix_v6_{idx} {{ ip6 saddr limit rate over {generic_rate} burst {generic_burst} packets }} reject with tcp reset\n",
|
|
port = target.port,
|
|
generic_burst = target.generic_burst,
|
|
));
|
|
script.push_str(&format!(
|
|
" tcp flags & (fin|syn|rst|ack) == syn{daddr} tcp dport {port} accept\n",
|
|
port = target.port,
|
|
));
|
|
}
|
|
|
|
pub(super) async fn clear_rules_all_families(
|
|
namespace: &SynLimitNamespace,
|
|
) -> Result<bool, String> {
|
|
let mut errors = Vec::new();
|
|
let mut removed = false;
|
|
let table = namespace.nft_table.as_str();
|
|
for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] {
|
|
match run_command("nft", &["delete", "table", family.as_str(), table], None).await {
|
|
Ok(()) => {
|
|
removed = true;
|
|
}
|
|
Err(error) if is_missing_command_or_nft_table(&error) => {}
|
|
Err(error) => {
|
|
errors.push(format!(
|
|
"nft delete table {} {table} failed: {error}",
|
|
family.as_str(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
if errors.is_empty() {
|
|
Ok(removed)
|
|
} else {
|
|
Err(errors.join(", "))
|
|
}
|
|
}
|
|
|
|
fn is_missing_command_or_nft_table(error: &str) -> bool {
|
|
error.contains("is not available") || error.contains("No such file or directory")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
|
|
|
use super::*;
|
|
use crate::synlimit_control::model::test_rule;
|
|
|
|
fn test_namespace(table: &str) -> SynLimitNamespace {
|
|
SynLimitNamespace {
|
|
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(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nft_script_uses_synfix_v4_rules_and_early_priority() {
|
|
let rule = test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443);
|
|
let namespace = test_namespace("telemt_synlimit_test_a");
|
|
let script = nft_synlimit_script(
|
|
NftApplyPlan {
|
|
family: NftFamily::Inet,
|
|
v4_targets: &[rule],
|
|
v6_targets: &[],
|
|
},
|
|
&namespace,
|
|
);
|
|
|
|
assert!(script.contains("table inet telemt_synlimit_test_a"));
|
|
assert!(script.contains("type filter hook input priority -5; policy accept;"));
|
|
assert!(script.contains("ip daddr 203.0.113.7"));
|
|
assert!(script.contains("meta length 64 ip ttl < 65"));
|
|
assert!(script.contains("limit rate over 12/second burst 24 packets"));
|
|
assert!(script.contains("limit rate over 48/minute burst 1 packets"));
|
|
assert!(script.contains("reject with tcp reset"));
|
|
}
|
|
|
|
#[test]
|
|
fn nft_script_uses_ipv6_hoplimit_classifier() {
|
|
let rule = test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 443);
|
|
let namespace = test_namespace("telemt_synlimit_test_b");
|
|
let script = nft_synlimit_script(
|
|
NftApplyPlan {
|
|
family: NftFamily::Inet,
|
|
v4_targets: &[],
|
|
v6_targets: &[rule],
|
|
},
|
|
&namespace,
|
|
);
|
|
|
|
assert!(script.contains("table inet telemt_synlimit_test_b"));
|
|
assert!(script.contains("ip6 daddr ::1"));
|
|
assert!(script.contains("meta length 84 ip6 hoplimit < 65"));
|
|
assert!(script.contains("ip6 saddr limit rate over 12/second burst 24 packets"));
|
|
assert!(script.contains("ip6 saddr limit rate over 48/minute burst 1 packets"));
|
|
}
|
|
|
|
#[test]
|
|
fn nft_missing_table_errors_are_cleanup_benign() {
|
|
assert!(is_missing_command_or_nft_table("nft is not available"));
|
|
assert!(is_missing_command_or_nft_table(
|
|
"Error: No such file or directory"
|
|
));
|
|
assert!(!is_missing_command_or_nft_table(
|
|
"Error: Operation not permitted"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn nft_apply_plan_keeps_dual_stack_rules_in_inet_table() {
|
|
let v4_rule = test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443);
|
|
let v6_rule = test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 443);
|
|
let v4_rules = [v4_rule];
|
|
let v6_rules = [v6_rule];
|
|
let plans = nft_apply_plan(
|
|
NftTableFamilies {
|
|
inet: false,
|
|
ip: false,
|
|
ip6: false,
|
|
},
|
|
&v4_rules,
|
|
&v6_rules,
|
|
);
|
|
|
|
assert_eq!(plans.len(), 1);
|
|
assert_eq!(plans[0].family.as_str(), "inet");
|
|
assert_eq!(plans[0].v4_targets, v4_rules.as_slice());
|
|
assert_eq!(plans[0].v6_targets, v6_rules.as_slice());
|
|
}
|
|
}
|