feat: migrate ban_ip/unban_ip to ipset
- New ipset.rs module wrapping ipset binary (hash:ip backend) - ban_ip() and unban_ip() use ipset.add/del instead of per-IP iptables rules - get_banned_ips() parses ipset list -json - Optional timeout via BAN_DURATION_SECS (default 3600s = 1h) - Auto-installs single iptables rule: -m set --match-set banned src -j DROP - New CLI subcommands: ban-ip, unban-ip (with --timeout) - 13 unit tests for ipset module + integration test script using netns - Update Woodpecker CI to install ipset
This commit is contained in:
+83
-39
@@ -1,30 +1,68 @@
|
||||
use crate::ipset::{Ipset, DEFAULT_SET_NAME, DEFAULT_TIMEOUT_SECS};
|
||||
use regex::Regex;
|
||||
use std::{collections::HashMap, process::Command};
|
||||
|
||||
pub struct MartilloMaldito {
|
||||
iptables: iptables::IPTables,
|
||||
chain: String,
|
||||
ipset: Ipset,
|
||||
ban_timeout: Option<u32>,
|
||||
}
|
||||
|
||||
impl MartilloMaldito {
|
||||
pub fn ipv4(docker: bool) -> MartilloMaldito {
|
||||
MartilloMaldito {
|
||||
iptables: iptables::new(false).unwrap(),
|
||||
chain: Self::get_chain(docker).to_string(),
|
||||
}
|
||||
Self::ipv4_with_config(docker, DEFAULT_SET_NAME, None)
|
||||
}
|
||||
|
||||
pub fn ipv6(docker: bool) -> MartilloMaldito {
|
||||
MartilloMaldito {
|
||||
iptables: iptables::new(true).unwrap(),
|
||||
chain: Self::get_chain(docker).to_string(),
|
||||
pub fn ipv4_with_config(
|
||||
docker: bool,
|
||||
ipset_name: &str,
|
||||
ban_timeout: Option<u32>,
|
||||
) -> MartilloMaldito {
|
||||
let ipset = Ipset::new(ipset_name);
|
||||
if let Err(e) = ipset.ensure_exists() {
|
||||
eprintln!("Warning: could not ensure ipset exists: {}", e);
|
||||
}
|
||||
let martillo = MartilloMaldito {
|
||||
iptables: iptables::new(false).unwrap(),
|
||||
chain: Self::get_chain(docker).to_string(),
|
||||
ipset,
|
||||
ban_timeout,
|
||||
};
|
||||
if let Err(e) = martillo.ensure_ipset_match_rule() {
|
||||
eprintln!("Warning: could not install ipset match rule: {}", e);
|
||||
}
|
||||
martillo
|
||||
}
|
||||
|
||||
fn ensure_ipset_match_rule(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let check_rule = format!("-m set --match-set {} src -j DROP", self.ipset.name());
|
||||
let output = Command::new("iptables")
|
||||
.args(["-C", &self.chain, &check_rule])
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let output = Command::new("iptables")
|
||||
.args(["-I", &self.chain, "1", &check_rule])
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"failed to install ipset match rule: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_rules() -> std::io::Result<std::process::Output> {
|
||||
Command::new("iptables-save")
|
||||
.args(["-f", "/etc/iptables/rules.v4"])
|
||||
.output()
|
||||
Command::new("iptables-save").args(["-f", "/etc/iptables/rules.v4"]).output()
|
||||
}
|
||||
|
||||
pub fn save_ipset(&self, path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.ipset.save(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_port_secured(&self, port: u16) -> bool {
|
||||
@@ -32,7 +70,6 @@ impl MartilloMaldito {
|
||||
if rules.is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
for rule in rules.unwrap() {
|
||||
if rule.contains(&format!("-p tcp -m tcp --dport {} -j DROP", port)) {
|
||||
return true;
|
||||
@@ -46,48 +83,30 @@ impl MartilloMaldito {
|
||||
if rules.is_err() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let port_regex = iptables_regex_for_port();
|
||||
rules
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r.contains("-p tcp -m tcp --dport") && r.contains("-j DROP"))
|
||||
.filter(|r| !r.contains("match-set"))
|
||||
.map(|r| extract_port(&port_regex, r).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_banned_ips(&self) -> Vec<String> {
|
||||
let rules = self.get_rules();
|
||||
if rules.is_err() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let ip_regex = iptables_regex_for_ip();
|
||||
rules
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.contains(&format!("-A {}", self.chain))
|
||||
&& r.contains("-j DROP")
|
||||
&& r.contains("-s")
|
||||
})
|
||||
.map(|r| extract_ip(&ip_regex, r).unwrap())
|
||||
.collect()
|
||||
self.ipset.list().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn get_secured_ports_with_allowed_ips(&self) -> HashMap<u16, Vec<String>> {
|
||||
let mut result: HashMap<u16, Vec<String>> = HashMap::new();
|
||||
|
||||
let secured_ports = self.get_secured_ports();
|
||||
if secured_ports.is_empty() {
|
||||
return result;
|
||||
}
|
||||
|
||||
let rules = self.get_rules();
|
||||
if rules.is_err() {
|
||||
return result;
|
||||
}
|
||||
|
||||
let rules = rules.unwrap();
|
||||
let ip_regex = iptables_regex_for_ip();
|
||||
for port in secured_ports {
|
||||
@@ -101,16 +120,26 @@ impl MartilloMaldito {
|
||||
.collect();
|
||||
result.insert(port, ips);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn ban_ip(&self, ip: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.append_unique("filter", &format!("-s {} -j DROP", ip))
|
||||
self.ipset.add(ip, self.ban_timeout)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ban_ip_with_timeout(
|
||||
&self,
|
||||
ip: &str,
|
||||
timeout_secs: u32,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.ipset.add(ip, Some(timeout_secs))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unban_ip(&self, ip: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.remove_unique("filter", &format!("-s {} -j DROP", ip))
|
||||
self.ipset.del(ip)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn secure_port(
|
||||
@@ -173,10 +202,6 @@ impl MartilloMaldito {
|
||||
self.iptables.append_unique(table, &self.chain, rule)
|
||||
}
|
||||
|
||||
fn remove_unique(&self, table: &str, rule: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.iptables.delete(table, &self.chain, rule)
|
||||
}
|
||||
|
||||
fn insert_unique(
|
||||
&self,
|
||||
table: &str,
|
||||
@@ -224,6 +249,10 @@ fn iptables_regex_for_port() -> Regex {
|
||||
Regex::new(r"--dport\s+(\d+)").unwrap()
|
||||
}
|
||||
|
||||
pub const fn default_ban_timeout_secs() -> u32 {
|
||||
DEFAULT_TIMEOUT_SECS
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -248,4 +277,19 @@ mod tests {
|
||||
let input = "-A DOCKER -d 172.18.0.2/32 ! -i br-127d33df48a4 -o br-127d33df48a4 -p tcp -m tcp --dport 8078 -j ACCEPT";
|
||||
assert_eq!(extract_ip(®ex, input), Some("172.18.0.2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secured_ports_excludes_ipset_match_rule() {
|
||||
let input = "-A INPUT -p tcp -m tcp --dport 2222 -j DROP";
|
||||
assert_eq!(
|
||||
extract_port(&iptables_regex_for_port(), input),
|
||||
Some(2222)
|
||||
);
|
||||
assert!(!input.contains("match-set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_timeout_constant_is_one_hour() {
|
||||
assert_eq!(default_ban_timeout_secs(), 3600);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user