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> { 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 { 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()); } }