mirror of
https://github.com/telemt/telemt.git
synced 2026-09-08 11:34:10 +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.
391 lines
12 KiB
Rust
391 lines
12 KiB
Rust
use std::net::IpAddr;
|
|
|
|
use super::command::run_command;
|
|
use super::model::{SynLimitNamespace, SynLimitRule, SynLimitTargets, synlimit_rate_arg};
|
|
|
|
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,
|
|
namespace: &SynLimitNamespace,
|
|
) -> Result<(), String> {
|
|
apply_rules_for_binary(
|
|
"iptables",
|
|
&targets.iptables_v4,
|
|
IpTablesFamily::V4,
|
|
namespace,
|
|
)
|
|
.await?;
|
|
apply_rules_for_binary(
|
|
"ip6tables",
|
|
&targets.iptables_v6,
|
|
IpTablesFamily::V6,
|
|
namespace,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn apply_rules_for_binary(
|
|
binary: &str,
|
|
targets: &[SynLimitRule],
|
|
family: IpTablesFamily,
|
|
namespace: &SynLimitNamespace,
|
|
) -> Result<(), String> {
|
|
if targets.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let chain = namespace.iptables_chain.as_str();
|
|
let _ = run_command(binary, &["-t", "filter", "-N", chain], None).await;
|
|
run_command(binary, &["-t", "filter", "-F", chain], None).await?;
|
|
if run_command(binary, &["-t", "filter", "-C", "INPUT", "-j", chain], None)
|
|
.await
|
|
.is_err()
|
|
{
|
|
run_command(
|
|
binary,
|
|
&["-t", "filter", "-I", "INPUT", "1", "-j", chain],
|
|
None,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
for (idx, target) in targets.iter().enumerate() {
|
|
for rule in iptables_synfix_rule_args(target, idx, family, namespace) {
|
|
let refs: Vec<&str> = rule.iter().map(String::as_str).collect();
|
|
run_command(binary, &refs, None).await?;
|
|
}
|
|
}
|
|
run_command(binary, &["-t", "filter", "-A", chain, "-j", "RETURN"], None).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn iptables_synfix_rule_args(
|
|
target: &SynLimitRule,
|
|
idx: usize,
|
|
family: IpTablesFamily,
|
|
namespace: &SynLimitNamespace,
|
|
) -> Vec<Vec<String>> {
|
|
vec![
|
|
iptables_ios_accept_rule_args(target, idx, family, namespace),
|
|
iptables_ios_reject_rule_args(target, family, namespace),
|
|
iptables_generic_accept_rule_args(target, idx, family, namespace),
|
|
iptables_generic_reject_rule_args(target, namespace),
|
|
]
|
|
}
|
|
|
|
fn iptables_ios_accept_rule_args(
|
|
target: &SynLimitRule,
|
|
idx: usize,
|
|
family: IpTablesFamily,
|
|
namespace: &SynLimitNamespace,
|
|
) -> Vec<String> {
|
|
let hashlimit_name = format!(
|
|
"{}-I{}-{idx}",
|
|
namespace.iptables_hashlimit_prefix,
|
|
family.hashlimit_tag()
|
|
);
|
|
let mut args =
|
|
iptables_base_rule_args(namespace.iptables_chain.as_str(), 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,
|
|
namespace: &SynLimitNamespace,
|
|
) -> Vec<String> {
|
|
let mut args =
|
|
iptables_base_rule_args(namespace.iptables_chain.as_str(), 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,
|
|
namespace: &SynLimitNamespace,
|
|
) -> Vec<String> {
|
|
let hashlimit_name = format!(
|
|
"{}-G{}-{idx}",
|
|
namespace.iptables_hashlimit_prefix,
|
|
family.hashlimit_tag()
|
|
);
|
|
let mut args =
|
|
iptables_base_rule_args(namespace.iptables_chain.as_str(), 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,
|
|
namespace: &SynLimitNamespace,
|
|
) -> Vec<String> {
|
|
let mut args =
|
|
iptables_base_rule_args(namespace.iptables_chain.as_str(), target.ip, target.port);
|
|
args.extend(iptables_reject_args());
|
|
args
|
|
}
|
|
|
|
fn iptables_base_rule_args(chain: &str, ip: Option<IpAddr>, port: u16) -> Vec<String> {
|
|
let mut args = vec![
|
|
"-t".to_string(),
|
|
"filter".to_string(),
|
|
"-A".to_string(),
|
|
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,
|
|
namespace: &SynLimitNamespace,
|
|
) -> Result<bool, String> {
|
|
let mut errors = Vec::new();
|
|
let mut removed = false;
|
|
let chain = namespace.iptables_chain.as_str();
|
|
for _ in 0..8 {
|
|
match run_command(binary, &["-t", "filter", "-D", "INPUT", "-j", chain], None).await {
|
|
Ok(()) => {
|
|
removed = true;
|
|
}
|
|
Err(error) if is_missing_command_or_iptables_rule(&error) => break,
|
|
Err(error) => {
|
|
errors.push(format!("{binary} delete INPUT jump failed: {error}"));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
match run_command(binary, &["-t", "filter", "-F", chain], None).await {
|
|
Ok(()) => {
|
|
removed = true;
|
|
}
|
|
Err(error) if is_missing_command_or_iptables_rule(&error) => {}
|
|
Err(error) => {
|
|
errors.push(format!("{binary} flush chain failed: {error}"));
|
|
}
|
|
}
|
|
match run_command(binary, &["-t", "filter", "-X", chain], None).await {
|
|
Ok(()) => {
|
|
removed = true;
|
|
}
|
|
Err(error) if is_missing_command_or_iptables_rule(&error) => {}
|
|
Err(error) => {
|
|
errors.push(format!("{binary} delete chain failed: {error}"));
|
|
}
|
|
}
|
|
|
|
if errors.is_empty() {
|
|
Ok(removed)
|
|
} 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")
|
|
|| error.contains("Couldn't load target")
|
|
}
|
|
|
|
#[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)
|
|
}
|
|
|
|
fn has_key(args: &[String], key: &str) -> bool {
|
|
args.iter().any(|arg| arg == key)
|
|
}
|
|
|
|
fn test_namespace() -> SynLimitNamespace {
|
|
SynLimitNamespace {
|
|
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(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn iptables_rules_use_synfix_order_and_rejects() {
|
|
let target = test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443);
|
|
let namespace = test_namespace();
|
|
let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V4, &namespace);
|
|
|
|
assert_eq!(rules.len(), 4);
|
|
assert!(has_pair(&rules[0], "-A", "TMT_SYN_TEST"));
|
|
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 namespace = test_namespace();
|
|
let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V6, &namespace);
|
|
|
|
assert!(has_pair(&rules[0], "--length", "84"));
|
|
assert!(has_pair(&rules[0], "--hl-lt", "65"));
|
|
assert!(has_pair(&rules[0], "-d", "::1"));
|
|
}
|
|
|
|
#[test]
|
|
fn iptables_missing_rule_errors_are_cleanup_benign() {
|
|
assert!(is_missing_command_or_iptables_rule(
|
|
"iptables is not available"
|
|
));
|
|
assert!(is_missing_command_or_iptables_rule(
|
|
"iptables: No chain/target/match by that name."
|
|
));
|
|
assert!(is_missing_command_or_iptables_rule(
|
|
"iptables: Chain TELEMT_SYNLIMIT does not exist."
|
|
));
|
|
assert!(is_missing_command_or_iptables_rule(
|
|
"Couldn't load target `TELEMT_SYNLIMIT': No such file or directory"
|
|
));
|
|
assert!(!is_missing_command_or_iptables_rule(
|
|
"iptables: Permission denied"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn iptables_wildcard_rule_omits_destination_match() {
|
|
let target = test_rule(None, 443);
|
|
let namespace = test_namespace();
|
|
let rules = iptables_synfix_rule_args(&target, 0, IpTablesFamily::V4, &namespace);
|
|
|
|
for rule in rules {
|
|
assert!(!has_key(&rule, "-d"));
|
|
assert!(has_pair(&rule, "--dport", "443"));
|
|
}
|
|
}
|
|
}
|