Synlimit V2

This commit is contained in:
Alexey
2026-06-28 12:48:43 +03:00
parent 1ee9a234d7
commit 558f352a57
11 changed files with 1268 additions and 34 deletions
+98
View File
@@ -0,0 +1,98 @@
use std::path::PathBuf;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
pub(super) async fn run_command(
binary: &str,
args: &[&str],
stdin: Option<String>,
) -> Result<(), String> {
let Some(command_path) = resolve_command(binary) else {
return Err(format!("{binary} is not available"));
};
let mut command = Command::new(command_path);
command.args(args);
if stdin.is_some() {
command.stdin(std::process::Stdio::piped());
}
command.stdout(std::process::Stdio::null());
command.stderr(std::process::Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| format!("spawn {binary} failed: {e}"))?;
if let Some(blob) = stdin
&& let Some(mut writer) = child.stdin.take()
{
writer
.write_all(blob.as_bytes())
.await
.map_err(|e| format!("stdin write {binary} failed: {e}"))?;
}
let output = child
.wait_with_output()
.await
.map_err(|e| format!("wait {binary} failed: {e}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("{binary} exited with status {}", output.status)
} else {
stderr
})
}
pub(super) async fn run_command_stdout(binary: &str, args: &[&str]) -> Result<String, String> {
let Some(command_path) = resolve_command(binary) else {
return Err(format!("{binary} is not available"));
};
let output = Command::new(command_path)
.args(args)
.output()
.await
.map_err(|e| format!("wait {binary} failed: {e}"))?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).to_string());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("{binary} exited with status {}", output.status)
} else {
stderr
})
}
fn resolve_command(binary: &str) -> Option<PathBuf> {
let mut dirs = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
.unwrap_or_default();
dirs.extend(["/usr/sbin", "/sbin", "/usr/bin", "/bin"].map(PathBuf::from));
dirs.into_iter()
.map(|dir| dir.join(binary))
.find(|candidate| candidate.exists() && candidate.is_file())
}
pub(super) fn has_cap_net_admin() -> bool {
#[cfg(target_os = "linux")]
{
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
return false;
};
for line in status.lines() {
if let Some(raw) = line.strip_prefix("CapEff:") {
let caps = raw.trim();
if let Ok(bits) = u64::from_str_radix(caps, 16) {
const CAP_NET_ADMIN_BIT: u64 = 12;
return (bits & (1u64 << CAP_NET_ADMIN_BIT)) != 0;
}
}
}
false
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
+311
View File
@@ -0,0 +1,311 @@
use std::net::IpAddr;
use super::command::run_command;
use super::model::{SynLimitRule, SynLimitTargets, synlimit_rate_arg};
const IPTABLES_CHAIN: &str = "TELEMT_SYNLIMIT";
const IPTABLES_HASHLIMIT_PREFIX: &str = "TMT-SYN";
const IPV4_IOS_PACKET_LENGTH: u16 = 64;
const IPV6_IOS_PACKET_LENGTH: u16 = 84;
const IOS_TTL_LIMIT: u8 = 65;
#[derive(Clone, Copy)]
enum IpTablesFamily {
V4,
V6,
}
impl IpTablesFamily {
fn ios_packet_length(self) -> u16 {
match self {
Self::V4 => IPV4_IOS_PACKET_LENGTH,
Self::V6 => IPV6_IOS_PACKET_LENGTH,
}
}
fn ttl_match(self) -> [&'static str; 3] {
match self {
Self::V4 => ["-m", "ttl", "--ttl-lt"],
Self::V6 => ["-m", "hl", "--hl-lt"],
}
}
fn hashlimit_tag(self) -> &'static str {
match self {
Self::V4 => "4",
Self::V6 => "6",
}
}
}
pub(super) async fn apply_synlimit_rules(targets: &SynLimitTargets) -> Result<(), String> {
apply_rules_for_binary("iptables", &targets.iptables_v4, IpTablesFamily::V4).await?;
apply_rules_for_binary("ip6tables", &targets.iptables_v6, IpTablesFamily::V6).await
}
async fn apply_rules_for_binary(
binary: &str,
targets: &[SynLimitRule],
family: IpTablesFamily,
) -> Result<(), String> {
if targets.is_empty() {
return Ok(());
}
let _ = run_command(binary, &["-t", "filter", "-N", IPTABLES_CHAIN], None).await;
run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await?;
if run_command(
binary,
&["-t", "filter", "-C", "INPUT", "-j", IPTABLES_CHAIN],
None,
)
.await
.is_err()
{
run_command(
binary,
&["-t", "filter", "-I", "INPUT", "1", "-j", IPTABLES_CHAIN],
None,
)
.await?;
}
for (idx, target) in targets.iter().enumerate() {
for rule in iptables_synfix_rule_args(target, idx, family) {
let refs: Vec<&str> = rule.iter().map(String::as_str).collect();
run_command(binary, &refs, None).await?;
}
}
run_command(
binary,
&["-t", "filter", "-A", IPTABLES_CHAIN, "-j", "RETURN"],
None,
)
.await?;
Ok(())
}
fn iptables_synfix_rule_args(
target: &SynLimitRule,
idx: usize,
family: IpTablesFamily,
) -> Vec<Vec<String>> {
vec![
iptables_ios_accept_rule_args(target, idx, family),
iptables_ios_reject_rule_args(target, family),
iptables_generic_accept_rule_args(target, idx, family),
iptables_generic_reject_rule_args(target),
]
}
fn iptables_ios_accept_rule_args(
target: &SynLimitRule,
idx: usize,
family: IpTablesFamily,
) -> Vec<String> {
let hashlimit_name = format!(
"{IPTABLES_HASHLIMIT_PREFIX}-I{}-{idx}",
family.hashlimit_tag()
);
let mut args = iptables_base_rule_args(target.ip, target.port);
args.extend(iptables_ios_match_args(family));
args.extend(iptables_hashlimit_args(
&hashlimit_name,
target.ios_seconds,
target.ios_hitcount,
target.ios_burst,
target.hashlimit_expire_ms,
target.hashlimit_size,
));
args.extend(["-j".to_string(), "ACCEPT".to_string()]);
args
}
fn iptables_ios_reject_rule_args(target: &SynLimitRule, family: IpTablesFamily) -> Vec<String> {
let mut args = iptables_base_rule_args(target.ip, target.port);
args.extend(iptables_ios_match_args(family));
args.extend(iptables_reject_args());
args
}
fn iptables_generic_accept_rule_args(
target: &SynLimitRule,
idx: usize,
family: IpTablesFamily,
) -> Vec<String> {
let hashlimit_name = format!(
"{IPTABLES_HASHLIMIT_PREFIX}-G{}-{idx}",
family.hashlimit_tag()
);
let mut args = iptables_base_rule_args(target.ip, target.port);
args.extend(iptables_hashlimit_args(
&hashlimit_name,
target.generic_seconds,
target.generic_hitcount,
target.generic_burst,
target.hashlimit_expire_ms,
target.hashlimit_size,
));
args.extend(["-j".to_string(), "ACCEPT".to_string()]);
args
}
fn iptables_generic_reject_rule_args(target: &SynLimitRule) -> Vec<String> {
let mut args = iptables_base_rule_args(target.ip, target.port);
args.extend(iptables_reject_args());
args
}
fn iptables_base_rule_args(ip: Option<IpAddr>, port: u16) -> Vec<String> {
let mut args = vec![
"-t".to_string(),
"filter".to_string(),
"-A".to_string(),
IPTABLES_CHAIN.to_string(),
"-p".to_string(),
"tcp".to_string(),
"--syn".to_string(),
"-m".to_string(),
"tcp".to_string(),
"--tcp-flags".to_string(),
"SYN".to_string(),
"SYN".to_string(),
];
if let Some(ip) = ip {
args.push("-d".to_string());
args.push(ip.to_string());
}
args.extend(["--dport".to_string(), port.to_string()]);
args
}
fn iptables_ios_match_args(family: IpTablesFamily) -> Vec<String> {
let mut args = vec![
"-m".to_string(),
"length".to_string(),
"--length".to_string(),
family.ios_packet_length().to_string(),
];
args.extend(family.ttl_match().map(str::to_string));
args.push(IOS_TTL_LIMIT.to_string());
args
}
fn iptables_hashlimit_args(
name: &str,
seconds: u32,
hitcount: u32,
burst: u32,
expire_ms: u32,
size: u32,
) -> Vec<String> {
vec![
"-m".to_string(),
"hashlimit".to_string(),
"--hashlimit-name".to_string(),
name.to_string(),
"--hashlimit-mode".to_string(),
"srcip".to_string(),
"--hashlimit-upto".to_string(),
synlimit_rate_arg(seconds, hitcount),
"--hashlimit-burst".to_string(),
burst.to_string(),
"--hashlimit-htable-expire".to_string(),
expire_ms.to_string(),
"--hashlimit-htable-size".to_string(),
size.to_string(),
]
}
fn iptables_reject_args() -> Vec<String> {
vec![
"-j".to_string(),
"REJECT".to_string(),
"--reject-with".to_string(),
"tcp-reset".to_string(),
]
}
pub(super) async fn clear_rules_for_binary(binary: &str) -> Result<(), String> {
let mut errors = Vec::new();
for _ in 0..8 {
match run_command(
binary,
&["-t", "filter", "-D", "INPUT", "-j", IPTABLES_CHAIN],
None,
)
.await
{
Ok(()) => {}
Err(error) if is_missing_command_or_iptables_rule(&error) => break,
Err(error) => {
errors.push(format!("{binary} delete INPUT jump failed: {error}"));
break;
}
}
}
if let Err(error) = run_command(binary, &["-t", "filter", "-F", IPTABLES_CHAIN], None).await
&& !is_missing_command_or_iptables_rule(&error)
{
errors.push(format!("{binary} flush chain failed: {error}"));
}
if let Err(error) = run_command(binary, &["-t", "filter", "-X", IPTABLES_CHAIN], None).await
&& !is_missing_command_or_iptables_rule(&error)
{
errors.push(format!("{binary} delete chain failed: {error}"));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join(", "))
}
}
fn is_missing_command_or_iptables_rule(error: &str) -> bool {
error.contains("is not available")
|| error.contains("No chain/target/match by that name")
|| error.contains("does not exist")
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use super::*;
use crate::synlimit_control::model::test_rule;
fn has_pair(args: &[String], key: &str, value: &str) -> bool {
args.windows(2)
.any(|pair| pair[0].as_str() == key && pair[1].as_str() == value)
}
#[test]
fn iptables_rules_use_synfix_order_and_rejects() {
let target = test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443);
let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V4);
assert_eq!(rules.len(), 4);
assert!(has_pair(&rules[0], "--length", "64"));
assert!(has_pair(&rules[0], "--ttl-lt", "65"));
assert!(has_pair(&rules[0], "--hashlimit-upto", "12/second"));
assert!(has_pair(&rules[0], "--hashlimit-burst", "24"));
assert!(has_pair(&rules[0], "--hashlimit-htable-expire", "60000"));
assert!(has_pair(&rules[0], "--hashlimit-htable-size", "32768"));
assert!(has_pair(&rules[0], "-j", "ACCEPT"));
assert!(has_pair(&rules[1], "-j", "REJECT"));
assert!(has_pair(&rules[1], "--reject-with", "tcp-reset"));
assert!(has_pair(&rules[2], "--hashlimit-upto", "48/minute"));
assert!(has_pair(&rules[3], "--reject-with", "tcp-reset"));
}
#[test]
fn ip6tables_rules_use_ipv6_hoplimit_classifier() {
let target = test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 443);
let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V6);
assert!(has_pair(&rules[0], "--length", "84"));
assert!(has_pair(&rules[0], "--hl-lt", "65"));
assert!(has_pair(&rules[0], "-d", "::1"));
}
}
+97
View File
@@ -0,0 +1,97 @@
use std::sync::Arc;
use tokio::sync::watch;
use tracing::warn;
use crate::config::{ProxyConfig, SynLimitMode};
mod command;
mod iptables;
mod model;
mod nftables;
use self::command::has_cap_net_admin;
use self::model::synlimit_targets;
pub(crate) fn spawn_synlimit_controller(config_rx: watch::Receiver<Arc<ProxyConfig>>) {
if !cfg!(target_os = "linux") {
if has_synlimit_config(&config_rx.borrow()) {
warn!("SYN limiter is configured but unsupported on this OS; skipping netfilter rules");
}
return;
}
tokio::spawn(async move {
wait_for_config_channel_close_and_reconcile(config_rx).await;
if let Err(error) = clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear SYN limiter rules after config channel close");
}
});
}
async fn wait_for_config_channel_close_and_reconcile(
mut config_rx: watch::Receiver<Arc<ProxyConfig>>,
) {
while config_rx.changed().await.is_ok() {
let cfg = config_rx.borrow_and_update().clone();
reconcile_synlimit_rules(&cfg).await;
}
}
pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) {
if let Err(error) = clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear existing SYN limiter rules before reconcile");
}
let targets = synlimit_targets(cfg);
if targets.is_empty() {
return;
}
if !has_cap_net_admin() {
warn!(
"SYN limiter configured but CAP_NET_ADMIN is not available; netfilter rules not applied"
);
return;
}
if targets.has_iptables_targets()
&& let Err(error) = iptables::apply_synlimit_rules(&targets).await
{
warn!(error = %error, "Failed to apply iptables SYN limiter rules");
}
if targets.has_nft_targets()
&& let Err(error) = nftables::apply_synlimit_rules(&targets).await
{
warn!(error = %error, "Failed to apply nftables SYN limiter rules");
}
}
pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<(), String> {
if !has_cap_net_admin() {
return Ok(());
}
let mut errors = Vec::new();
if let Err(error) = nftables::clear_rules_all_families().await {
errors.push(error);
}
if let Err(error) = iptables::clear_rules_for_binary("iptables").await {
errors.push(error);
}
if let Err(error) = iptables::clear_rules_for_binary("ip6tables").await {
errors.push(error);
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
fn has_synlimit_config(cfg: &ProxyConfig) -> bool {
cfg.server
.listeners
.iter()
.any(|listener| !matches!(listener.synlimit, SynLimitMode::Off))
}
+126
View File
@@ -0,0 +1,126 @@
use std::collections::BTreeSet;
use std::net::IpAddr;
use crate::config::{ProxyConfig, SynLimitMode};
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(super) struct SynLimitRule {
pub(super) ip: Option<IpAddr>,
pub(super) port: u16,
pub(super) generic_seconds: u32,
pub(super) generic_hitcount: u32,
pub(super) generic_burst: u32,
pub(super) ios_seconds: u32,
pub(super) ios_hitcount: u32,
pub(super) ios_burst: u32,
pub(super) hashlimit_expire_ms: u32,
pub(super) hashlimit_size: u32,
}
#[derive(Default)]
pub(super) struct SynLimitTargets {
pub(super) iptables_v4: Vec<SynLimitRule>,
pub(super) iptables_v6: Vec<SynLimitRule>,
pub(super) nft_v4: Vec<SynLimitRule>,
pub(super) nft_v6: Vec<SynLimitRule>,
}
impl SynLimitTargets {
pub(super) fn is_empty(&self) -> bool {
self.iptables_v4.is_empty()
&& self.iptables_v6.is_empty()
&& self.nft_v4.is_empty()
&& self.nft_v6.is_empty()
}
pub(super) fn has_iptables_targets(&self) -> bool {
!self.iptables_v4.is_empty() || !self.iptables_v6.is_empty()
}
pub(super) fn has_nft_targets(&self) -> bool {
!self.nft_v4.is_empty() || !self.nft_v6.is_empty()
}
}
pub(super) fn synlimit_targets(cfg: &ProxyConfig) -> SynLimitTargets {
let mut iptables_v4 = BTreeSet::new();
let mut iptables_v6 = BTreeSet::new();
let mut nft_v4 = BTreeSet::new();
let mut nft_v6 = BTreeSet::new();
for listener in &cfg.server.listeners {
let backend = listener.synlimit;
if matches!(backend, SynLimitMode::Off) {
continue;
}
let target = SynLimitRule {
ip: (!listener.ip.is_unspecified()).then_some(listener.ip),
port: listener.port.unwrap_or(cfg.server.port),
generic_seconds: listener.synlimit_seconds,
generic_hitcount: listener.synlimit_hitcount,
generic_burst: listener.synlimit_burst,
ios_seconds: listener.synlimit_ios_seconds,
ios_hitcount: listener.synlimit_ios_hitcount,
ios_burst: listener.synlimit_ios_burst,
hashlimit_expire_ms: listener.synlimit_hashlimit_expire_ms,
hashlimit_size: listener.synlimit_hashlimit_size,
};
match (backend, listener.ip.is_ipv4()) {
(SynLimitMode::Iptables, true) => {
iptables_v4.insert(target);
}
(SynLimitMode::Iptables, false) => {
iptables_v6.insert(target);
}
(SynLimitMode::Nftables, true) => {
nft_v4.insert(target);
}
(SynLimitMode::Nftables, false) => {
nft_v6.insert(target);
}
(SynLimitMode::Off, _) => {}
}
}
SynLimitTargets {
iptables_v4: iptables_v4.into_iter().collect(),
iptables_v6: iptables_v6.into_iter().collect(),
nft_v4: nft_v4.into_iter().collect(),
nft_v6: nft_v6.into_iter().collect(),
}
}
pub(super) fn synlimit_rate_arg(seconds: u32, hitcount: u32) -> String {
let seconds = u64::from(seconds.max(1));
let hitcount = u64::from(hitcount.max(1));
for (unit_seconds, unit_name) in [
(1_u64, "second"),
(60_u64, "minute"),
(3_600_u64, "hour"),
(86_400_u64, "day"),
] {
let amount = hitcount.saturating_mul(unit_seconds);
if amount >= seconds && amount % seconds == 0 {
return format!("{}/{}", amount / seconds, unit_name);
}
}
let amount = hitcount.saturating_mul(86_400).saturating_add(seconds - 1) / seconds;
format!("{}/day", amount.max(1))
}
#[cfg(test)]
pub(super) fn test_rule(ip: Option<IpAddr>, port: u16) -> SynLimitRule {
SynLimitRule {
ip,
port,
generic_seconds: 60,
generic_hitcount: 48,
generic_burst: 1,
ios_seconds: 1,
ios_hitcount: 12,
ios_burst: 24,
hashlimit_expire_ms: 60_000,
hashlimit_size: 32_768,
}
}
+256
View File
@@ -0,0 +1,256 @@
use super::command::{run_command, run_command_stdout};
use super::model::{SynLimitRule, SynLimitTargets, synlimit_rate_arg};
const NFT_TABLE: &str = "telemt_synlimit";
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) -> 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);
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<'_>) -> String {
let mut script = String::new();
script.push_str(&format!("table {} {NFT_TABLE} {{\n", plan.family.as_str()));
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() -> Result<(), String> {
let mut errors = Vec::new();
for family in [NftFamily::Inet, NftFamily::Ip, NftFamily::Ip6] {
if let Err(error) = run_command(
"nft",
&["delete", "table", family.as_str(), NFT_TABLE],
None,
)
.await
&& !is_missing_command_or_nft_table(&error)
{
errors.push(format!(
"nft delete table {} {NFT_TABLE} failed: {error}",
family.as_str()
));
}
}
if errors.is_empty() {
Ok(())
} 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;
#[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 script = nft_synlimit_script(NftApplyPlan {
family: NftFamily::Inet,
v4_targets: &[rule],
v6_targets: &[],
});
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 script = nft_synlimit_script(NftApplyPlan {
family: NftFamily::Inet,
v4_targets: &[],
v6_targets: &[rule],
});
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"));
}
}