feat: IPv6 bans, timeout-less set migration, typed errors, clap/thiserror 2

- Detect an existing ipset without timeout support and migrate it in place
  (temporary set + copy members + ipset swap + destroy) without unblocking.
- IPv6: "<set>6" (family inet6) + ip6tables match-set rule; ban/unban pick
  the set by address family. IPv4 keeps working if IPv6 is unavailable.
- Remove unwrap()/panics: MartilloMaldito::new/with_config and every public
  method return Result<_, MartilloError>; ignore multiport/range rules when
  parsing secured ports; handle an empty DOCKER-USER chain.
- Ipset::save_all / save-ipsets for ipset-persistent (/etc/iptables/ipsets).
- structopt -> clap 4, thiserror 1 -> 2. CLI exits 1 on errors.
- Kernel tests skip without CAP_NET_ADMIN; run them with `unshare -rn`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
midefosandClaude Opus 5.5 committed 2026-10-30 01:36:54 +01:00
1 parent 7342386955
commit 13ca4518b4
8 files changed
+1357 -595

No files matched your search

Generated
+114 -156
View File
@@ -12,30 +12,54 @@ dependencies = [
]
[[package]]
name = "ansi_term"
version = "0.12.1"
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"winapi",
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "atty"
version = "0.2.14"
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"hermit-abi",
"libc",
"winapi",
"utf8parse",
]
[[package]]
name = "bitflags"
version = "1.3.2"
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "bitflags"
@@ -57,19 +81,50 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "clap"
version = "2.34.0"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"ansi_term",
"atty",
"bitflags 1.3.2",
"strsim",
"textwrap",
"unicode-width",
"vec_map",
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "errno"
version = "0.3.14"
@@ -134,21 +189,9 @@ dependencies = [
[[package]]
name = "heck"
version = "0.3.3"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "iptables"
@@ -161,6 +204,12 @@ dependencies = [
"regex",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.14"
@@ -202,14 +251,14 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "martillo_maldito"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"clap",
"iptables",
"regex",
"serde",
"serde_json",
"serial_test",
"structopt",
"tempfile",
"thiserror",
]
@@ -226,7 +275,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.6.0",
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
@@ -238,6 +287,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -267,44 +322,20 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
name = "proc-macro2"
version = "1.0.92"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.38"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
@@ -321,7 +352,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.6.0",
"bitflags",
]
[[package]]
@@ -359,7 +390,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.6.0",
"bitflags",
"errno",
"libc",
"linux-raw-sys",
@@ -449,39 +480,15 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "strsim"
version = "0.8.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "structopt"
version = "0.3.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10"
dependencies = [
"clap",
"lazy_static",
"structopt-derive",
]
[[package]]
name = "structopt-derive"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0"
dependencies = [
"heck",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 1.0.109",
]
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "1.0.109"
version = "2.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058"
dependencies = [
"proc-macro2",
"quote",
@@ -490,9 +497,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.93"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -512,33 +519,24 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
]
[[package]]
name = "thiserror"
version = "1.0.69"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.93",
"syn 3.0.9",
]
[[package]]
@@ -548,50 +546,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
+3 -3
View File
@@ -1,17 +1,17 @@
[package]
name = "martillo_maldito"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
[dependencies]
structopt = "0.3.26"
clap = { version = "4.6", features = ["derive"] }
iptables = "0.5.2"
serde = {version = "1.0.217", features = ["derive"]}
serde_json = "1.0.134"
regex = "1.11.1"
thiserror = "1.0"
thiserror = "2.0"
[dev-dependencies]
serial_test = "3.2.0"
+50 -3
View File
@@ -6,6 +6,7 @@
## Features
- Ban/unban IPv4 and IPv6 addresses with `ipset` (optional timeout).
- Retrieve all banned IPs.
- Query and manage secured ports.
- Allow or deny access by IP for specific ports.
@@ -16,9 +17,26 @@
## Requirements
- Rust 1.65 or higher.
- `iptables` installed and configured on the system.
- Proper permissions to manage `iptables` rules (e.g., running as root).
- Rust 1.80 or higher.
- `iptables`, `ip6tables` and `ipset` installed on the system.
- Proper permissions to manage `iptables` rules (e.g., running as root, or `CAP_NET_ADMIN`).
---
## Banning with ipset
Bans are stored in two `hash:ip` sets with timeout support: `banned` (IPv4) and
`banned6` (IPv6), each matched by a `-m set --match-set <set> src -j DROP` rule at the
top of `INPUT` (or `DOCKER-USER` with `--docker`) in `iptables`/`ip6tables`. Sets and
rules are created on first use.
If a set already exists **without** timeout support (older versions created it like
that, and `ipset add ... timeout N` then fails), it is migrated automatically without
unblocking anyone: a temporary set with timeout is created, the members are copied,
`ipset swap` puts it in place and the old one is destroyed.
If IPv6 is not available (no `ip6tables`, no IPv6 in the kernel), IPv4 bans keep
working and banning an IPv6 address returns an error.
---
@@ -64,6 +82,25 @@ martillo_maldito get-banned-ips [--docker | -d]
**Options:**
- `--docker`, `-d`: Includes rules in Docker environments.
#### Ban / unban an IP
```bash
martillo_maldito ban-ip --ip <ip> [--timeout <seconds>] [--docker | -d]
martillo_maldito unban-ip --ip <ip> [--docker | -d]
```
IPv6 addresses go to the `banned6` set. Without `--timeout` the ban is permanent.
Every command prints `true`/`false` (or JSON) on stdout; on failure the error is
printed on stderr and the exit code is 1.
#### Save the ipsets
```bash
martillo_maldito save-ipsets [--file /etc/iptables/ipsets]
```
Writes every set in the format restored at boot by `ipset-persistent`.
#### 2. Retrieve all secured ports
```bash
@@ -168,6 +205,16 @@ Saves all current `iptables` rules for future restoration.
---
## Tests
```bash
cargo test # kernel tests are skipped without CAP_NET_ADMIN
unshare -rn cargo test # run them too, isolated, without sudo
cargo build --release && unshare -rn env SELF_ISOLATED=1 ./tests/integration_netns.sh
```
---
## Disclaimer
This repository is designed solely for experimental or educational purposes. It is not intended for use in production environments or critical projects. Use at your own risk.
+49 -37
View File
@@ -1,82 +1,94 @@
use structopt::StructOpt;
use clap::{Parser, Subcommand};
#[derive(Debug, StructOpt)]
#[structopt(name = "martillo_maldito", about = "An iptables/ipset wrapper")]
pub enum Cli {
#[structopt(about = "Get all banned ips")]
#[derive(Debug, Parser)]
#[command(name = "martillo_maldito", about = "An iptables/ipset wrapper")]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Get all banned ips (IPv4 and IPv6)
GetBannedIps {
#[structopt(name = "Docker", short = "d", long = "docker")]
#[arg(short, long)]
docker: bool,
},
#[structopt(about = "Ban an ip (uses ipset)")]
/// Ban an ip (uses ipset; IPv6 goes to the "<set>6" set)
BanIp {
#[structopt(name = "IP to ban", short = "i", long = "ip")]
#[arg(short, long)]
ip: String,
#[structopt(name = "Timeout in seconds", long = "timeout")]
/// Timeout in seconds
#[arg(long)]
timeout: Option<u32>,
#[structopt(name = "Docker", short = "d", long = "docker")]
#[arg(short, long)]
docker: bool,
},
#[structopt(about = "Unban an ip (uses ipset)")]
/// Unban an ip (uses ipset)
UnbanIp {
#[structopt(name = "IP to unban", short = "i", long = "ip")]
#[arg(short, long)]
ip: String,
#[structopt(name = "Docker", short = "d", long = "docker")]
#[arg(short, long)]
docker: bool,
},
#[structopt(about = "Get all secured ports")]
/// Get all secured ports
GetSecuredPorts {
#[structopt(name = "Docker", short = "d", long = "docker")]
#[arg(short, long)]
docker: bool,
},
#[structopt(about = "Get all secured ports with allowed ips")]
/// Get all secured ports with allowed ips
GetSecuredPortsWithAllowedIps {
#[structopt(name = "Docker", short = "d", long = "docker")]
#[arg(short, long)]
docker: bool,
},
#[structopt(about = "Check if a port is secured")]
/// Check if a port is secured
IsPortSecured {
#[structopt(name = "Port to check", short = "p", long = "port")]
#[arg(short, long)]
port: u16,
#[structopt(name = "Docker", short = "d", long = "docker")]
#[arg(short, long)]
docker: bool,
},
#[structopt(about = "Secure a port")]
/// Secure a port
SecurePort {
#[structopt(name = "Port to ban", short = "p", long = "port")]
#[arg(short, long)]
port: u16,
#[structopt(name = "Position", short = "P", long = "position")]
#[arg(short = 'P', long)]
position: Option<usize>,
#[structopt(name = "Docker", short = "d", long = "docker")]
#[arg(short, long)]
docker: bool,
},
#[structopt(about = "Unsecure a port")]
/// Unsecure a port
UnsecurePort {
#[structopt(name = "Port to unban", short = "p", long = "port")]
#[arg(short, long)]
port: u16,
#[structopt(name = "Docker", short = "d", long = "docker")]
#[arg(short, long)]
docker: bool,
},
#[structopt(about = "Allow an IP for a port")]
/// Allow an IP for a port
AllowIpForPort {
#[structopt(name = "IP to allow", short = "i", long = "ip")]
#[arg(short, long)]
ip: String,
#[structopt(name = "Port to allow", short = "p", long = "port")]
#[arg(short, long)]
port: u16,
#[structopt(name = "Position", short = "P", long = "position")]
#[arg(short = 'P', long)]
position: Option<usize>,
#[structopt(name = "Docker", short = "d", long = "docker")]
#[arg(short, long)]
docker: bool,
},
#[structopt(about = "Removes an allowance IP for a port")]
/// Removes an allowance IP for a port
RemoveAllowIpPort {
#[structopt(name = "IP to remove", short = "i", long = "ip")]
#[arg(short, long)]
ip: String,
#[structopt(name = "Port to remove", short = "p", long = "port")]
#[arg(short, long)]
port: u16,
#[structopt(name = "Docker", short = "d", long = "docker")]
#[arg(short, long)]
docker: bool,
},
#[structopt(about = "Saves the rules")]
/// Saves the iptables rules to /etc/iptables/rules.v4
SaveRules,
/// Saves all ipsets to a file (default: /etc/iptables/ipsets)
SaveIpsets {
#[arg(short, long, default_value = "/etc/iptables/ipsets")]
file: std::path::PathBuf,
},
}
+569 -132
View File
@@ -1,12 +1,42 @@
use std::io::Write;
use std::net::IpAddr;
use std::path::Path;
use std::process::Command;
use std::process::{Command, Stdio};
pub const DEFAULT_SET_NAME: &str = "banned";
pub const DEFAULT_TIMEOUT_SECS: u32 = 3600;
/// Largest timeout accepted by the kernel (`ipset` rejects anything above it).
pub const MAX_TIMEOUT_SECS: u32 = 2_147_483;
/// ipset set names are limited to 31 characters.
const MAX_SET_NAME_LEN: usize = 31;
const MIGRATION_SUFFIX: &str = "_mig";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Family {
Inet,
Inet6,
}
impl Family {
pub fn of(ip: &IpAddr) -> Self {
match ip {
IpAddr::V4(_) => Family::Inet,
IpAddr::V6(_) => Family::Inet6,
}
}
fn as_str(self) -> &'static str {
match self {
Family::Inet => "inet",
Family::Inet6 => "inet6",
}
}
}
#[derive(Debug, Clone)]
pub struct Ipset {
name: String,
family: Family,
}
#[derive(Debug, thiserror::Error)]
@@ -19,21 +49,104 @@ pub enum IpsetError {
ParseError(String),
#[error("invalid IP address: {0}")]
InvalidIp(String),
#[error("IP {ip} does not belong to set {set} (family {family:?})")]
WrongFamily {
ip: String,
set: String,
family: Family,
},
#[error("set {set} exists with type/family '{found}', expected hash:ip family {expected:?}")]
IncompatibleSet {
set: String,
found: String,
expected: Family,
},
#[error("invalid set name '{0}' (1-{MAX_SET_NAME_LEN} chars)")]
InvalidName(String),
#[error("timeout {0}s is above the kernel limit of {MAX_TIMEOUT_SECS}s")]
TimeoutTooLarge(u32),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, IpsetError>;
/// Parsed `Header:` line of `ipset list -t`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetHeader {
pub set_type: String,
pub family: Option<String>,
pub hashsize: Option<u32>,
pub maxelem: Option<u32>,
pub timeout: Option<u32>,
}
impl SetHeader {
pub fn parse(listing: &str) -> Result<Self> {
let mut set_type = None;
let mut header = None;
for line in listing.lines() {
if let Some(t) = line.strip_prefix("Type:") {
set_type = Some(t.trim().to_string());
} else if let Some(h) = line.strip_prefix("Header:") {
header = Some(h.trim().to_string());
}
}
let set_type = set_type.ok_or_else(|| IpsetError::ParseError("missing Type:".into()))?;
let header = header.ok_or_else(|| IpsetError::ParseError("missing Header:".into()))?;
let tokens: Vec<&str> = header.split_whitespace().collect();
let value = |key: &str| -> Option<&str> {
tokens
.iter()
.position(|t| *t == key)
.and_then(|i| tokens.get(i + 1).copied())
};
let number = |key: &str| -> Result<Option<u32>> {
value(key)
.map(|v| {
v.parse::<u32>()
.map_err(|_| IpsetError::ParseError(format!("bad {key} '{v}'")))
})
.transpose()
};
Ok(Self {
set_type,
family: value("family").map(str::to_string),
hashsize: number("hashsize")?,
maxelem: number("maxelem")?,
timeout: number("timeout")?,
})
}
pub fn supports_timeout(&self) -> bool {
self.timeout.is_some()
}
}
impl Ipset {
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
Self::with_family(name, Family::Inet)
}
pub fn with_family(name: impl Into<String>, family: Family) -> Self {
Self {
name: name.into(),
family,
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn family(&self) -> Family {
self.family
}
/// True when the ipset binary exists and can talk to the kernel
/// (i.e. we have CAP_NET_ADMIN in the current network namespace).
pub fn is_available() -> bool {
Command::new("ipset")
.arg("--version")
@@ -42,164 +155,393 @@ impl Ipset {
.unwrap_or(false)
}
/// Creates the set with timeout support. If a set with the same name already
/// exists without timeout support, it is migrated in place (see
/// [`Ipset::migrate_to_timeout`]) so that blocking never stops.
pub fn ensure_exists(&self) -> Result<()> {
let output = Command::new("ipset")
.args(["create", &self.name, "hash:ip", "timeout", "0", "-exist"])
.output()?;
if self.name.is_empty() || self.name.len() > MAX_SET_NAME_LEN {
return Err(IpsetError::InvalidName(self.name.clone()));
}
match self.header()? {
None => self.create(&self.name, None, None),
Some(header) => {
self.check_compatible(&header)?;
if header.supports_timeout() {
Ok(())
} else {
self.migrate_to_timeout(&header)
}
}
}
}
/// Returns the set header, or `None` if the set does not exist.
pub fn header(&self) -> Result<Option<SetHeader>> {
let output = run(&["list", "-t", &self.name])?;
if !output.status.success() {
return Err(IpsetError::CommandFailed(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if is_missing_set(&stderr) {
return Ok(None);
}
return Err(IpsetError::CommandFailed(stderr));
}
SetHeader::parse(&String::from_utf8_lossy(&output.stdout)).map(Some)
}
fn check_compatible(&self, header: &SetHeader) -> Result<()> {
let family_ok = header.family.as_deref() == Some(self.family.as_str());
if header.set_type != "hash:ip" || !family_ok {
return Err(IpsetError::IncompatibleSet {
set: self.name.clone(),
found: format!(
"{} family {}",
header.set_type,
header.family.as_deref().unwrap_or("?")
),
expected: self.family,
});
}
Ok(())
}
fn create(&self, name: &str, hashsize: Option<u32>, maxelem: Option<u32>) -> Result<()> {
let hashsize = hashsize.map(|h| h.to_string());
let maxelem = maxelem.map(|m| m.to_string());
let mut args = vec!["create", name, "hash:ip", "family", self.family.as_str()];
if let Some(h) = &hashsize {
args.extend(["hashsize", h]);
}
if let Some(m) = &maxelem {
args.extend(["maxelem", m]);
}
args.extend(["timeout", "0", "-exist"]);
check(run(&args)?)
}
/// Replaces a set created without `timeout` by an equivalent one with timeout
/// support, keeping every member (as permanent entries):
/// create temporary set → copy members → `ipset swap` → destroy temporary.
/// iptables rules reference the set by name, so they keep matching throughout.
/// If anything fails before the swap the original set is left untouched.
pub fn migrate_to_timeout(&self, header: &SetHeader) -> Result<()> {
let tmp = migration_name(&self.name);
let tmp_set = Ipset::with_family(tmp.clone(), self.family);
tmp_set.destroy()?;
let result = (|| {
self.create(&tmp, header.hashsize, header.maxelem)?;
let members = self.list()?;
let restore = restore_script(&tmp, &members);
restore_from(&restore)?;
check(run(&["swap", &tmp, &self.name])?)
})();
// After a successful swap `tmp` holds the old set; after a failure it holds
// the partial copy. Either way it has to go.
let cleanup = tmp_set.destroy();
result?;
cleanup
}
pub fn add(&self, ip: &str, timeout_secs: Option<u32>) -> Result<()> {
validate_ip(ip)?;
let mut cmd = Command::new("ipset");
cmd.args(["add", &self.name, ip, "-exist"]);
if let Some(secs) = timeout_secs {
cmd.args(["timeout", &secs.to_string()]);
self.check_ip(ip)?;
let timeout = match timeout_secs {
Some(secs) if secs > MAX_TIMEOUT_SECS => return Err(IpsetError::TimeoutTooLarge(secs)),
Some(secs) => Some(secs.to_string()),
None => None,
};
let mut args = vec!["add", &self.name, ip, "-exist"];
if let Some(t) = &timeout {
args.extend(["timeout", t]);
}
let output = cmd.output()?;
if !output.status.success() {
return Err(IpsetError::CommandFailed(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
Ok(())
check(run(&args)?)
}
pub fn del(&self, ip: &str) -> Result<()> {
validate_ip(ip)?;
let output = Command::new("ipset")
.args(["del", &self.name, ip, "-exist"])
.output()?;
if !output.status.success() {
return Err(IpsetError::CommandFailed(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
Ok(())
self.check_ip(ip)?;
check(run(&["del", &self.name, ip, "-exist"])?)
}
pub fn contains(&self, ip: &str) -> Result<bool> {
validate_ip(ip)?;
let output = Command::new("ipset")
.args(["test", &self.name, ip])
.output()?;
Ok(output.status.success())
self.check_ip(ip)?;
Ok(run(&["test", &self.name, ip])?.status.success())
}
pub fn list(&self) -> Result<Vec<String>> {
let output = Command::new("ipset")
.args(["list", &self.name])
.output()?;
let output = run(&["list", &self.name])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if stderr.contains("does not exist") {
if is_missing_set(&stderr) {
return Ok(Vec::new());
}
return Err(IpsetError::CommandFailed(stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut in_members = false;
let mut ips = Vec::new();
for line in stdout.lines() {
if line.starts_with("Members:") {
in_members = true;
continue;
}
if in_members && !line.is_empty() {
let entry = line.split_whitespace().next().unwrap_or("");
if entry.is_empty() {
continue;
}
if entry.parse::<std::net::Ipv4Addr>().is_ok()
|| entry.parse::<std::net::Ipv6Addr>().is_ok()
|| entry.contains('/')
{
ips.push(entry.to_string());
}
}
}
Ok(ips)
Ok(parse_members(&String::from_utf8_lossy(&output.stdout)))
}
pub fn save(&self, path: &Path) -> Result<()> {
let output = Command::new("ipset")
.args(["save", &self.name])
.output()?;
if !output.status.success() {
return Err(IpsetError::CommandFailed(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
let output = run(&["save", &self.name])?;
check_ref(&output)?;
std::fs::write(path, &output.stdout)?;
Ok(())
}
/// Saves every set of the network namespace, in the format `ipset-persistent`
/// restores at boot (`/etc/iptables/ipsets`).
pub fn save_all(path: &Path) -> Result<()> {
let output = run(&["save"])?;
check_ref(&output)?;
std::fs::write(path, &output.stdout)?;
Ok(())
}
pub fn flush(&self) -> Result<()> {
let output = Command::new("ipset")
.args(["flush", &self.name])
.output()?;
if !output.status.success() {
return Err(IpsetError::CommandFailed(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
Ok(())
check(run(&["flush", &self.name])?)
}
pub fn destroy(&self) -> Result<()> {
let output = Command::new("ipset")
.args(["destroy", &self.name])
.output()?;
let output = run(&["destroy", &self.name])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if stderr.contains("does not exist") {
if is_missing_set(&stderr) {
return Ok(());
}
return Err(IpsetError::CommandFailed(stderr));
}
Ok(())
}
fn check_ip(&self, ip: &str) -> Result<()> {
let parsed = parse_ip(ip)?;
if Family::of(&parsed) != self.family {
return Err(IpsetError::WrongFamily {
ip: ip.to_string(),
set: self.name.clone(),
family: self.family,
});
}
Ok(())
}
}
fn validate_ip(ip: &str) -> Result<()> {
if ip.parse::<std::net::Ipv4Addr>().is_err() && ip.parse::<std::net::Ipv6Addr>().is_err() {
return Err(IpsetError::InvalidIp(ip.to_string()));
fn run(args: &[&str]) -> Result<std::process::Output> {
Command::new("ipset")
.args(args)
.output()
.map_err(not_installed)
}
fn restore_from(script: &str) -> Result<()> {
let mut child = Command::new("ipset")
.args(["restore", "-exist"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(not_installed)?;
child
.stdin
.take()
.ok_or_else(|| IpsetError::CommandFailed("ipset restore: no stdin".into()))?
.write_all(script.as_bytes())?;
check(child.wait_with_output()?)
}
fn not_installed(e: std::io::Error) -> IpsetError {
if e.kind() == std::io::ErrorKind::NotFound {
IpsetError::NotInstalled
} else {
IpsetError::Io(e)
}
}
fn check(output: std::process::Output) -> Result<()> {
check_ref(&output)
}
fn check_ref(output: &std::process::Output) -> Result<()> {
if !output.status.success() {
return Err(IpsetError::CommandFailed(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(())
}
fn is_missing_set(stderr: &str) -> bool {
stderr.contains("does not exist")
}
fn parse_ip(ip: &str) -> Result<IpAddr> {
ip.parse::<IpAddr>()
.map_err(|_| IpsetError::InvalidIp(ip.to_string()))
}
fn migration_name(name: &str) -> String {
let keep = MAX_SET_NAME_LEN - MIGRATION_SUFFIX.len();
let base: String = name.chars().take(keep).collect();
format!("{base}{MIGRATION_SUFFIX}")
}
fn restore_script(set: &str, members: &[String]) -> String {
members
.iter()
.map(|ip| format!("add {set} {ip}\n"))
.collect()
}
fn parse_members(listing: &str) -> Vec<String> {
listing
.lines()
.skip_while(|line| !line.starts_with("Members:"))
.skip(1)
.filter_map(|line| line.split_whitespace().next())
.filter(|entry| entry.parse::<IpAddr>().is_ok() || entry.contains('/'))
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use serial_test::serial;
const TEST_SET: &str = "banned_test_rs";
const TEST_SET6: &str = "banned_test_rs6";
fn setup() -> Option<Ipset> {
if !Ipset::is_available() {
eprintln!("ipset binary not available, skipping tests");
/// Tests that touch the kernel need CAP_NET_ADMIN. Run them with
/// `unshare -rn cargo test` (or as root); otherwise they are skipped.
pub(crate) fn privileged() -> bool {
let ok = Ipset::is_available();
if !ok {
eprintln!("ipset not usable (no CAP_NET_ADMIN?), skipping test");
}
ok
}
fn setup_family(name: &str, family: Family) -> Option<Ipset> {
if !privileged() {
return None;
}
let set = Ipset::new(TEST_SET);
let _ = set.destroy();
match set.ensure_exists() {
Ok(_) => Some(set),
Err(e) => {
eprintln!("Cannot create ipset (need root?): {}", e);
None
}
let set = Ipset::with_family(name, family);
set.destroy().expect("destroy before test");
set.ensure_exists().expect("ensure_exists");
Some(set)
}
fn setup() -> Option<Ipset> {
setup_family(TEST_SET, Family::Inet)
}
fn teardown(set: &Ipset) {
let _ = set.destroy();
}
const LISTING_NO_TIMEOUT: &str = "Name: banned
Type: hash:ip
Revision: 5
Header: family inet hashsize 16384 maxelem 65536 bucketsize 12 initval 0x6fbec769
Size in memory: 560
References: 1
Number of entries: 0
";
const LISTING_TIMEOUT: &str = "Name: t
Type: hash:ip
Revision: 5
Header: family inet hashsize 1024 maxelem 65536 timeout 0
Size in memory: 280
References: 0
Number of entries: 2
Members:
1.2.3.4 timeout 4
5.6.7.8 timeout 0
";
#[test]
fn header_without_timeout_is_detected() {
let header = SetHeader::parse(LISTING_NO_TIMEOUT).unwrap();
assert_eq!(header.set_type, "hash:ip");
assert_eq!(header.family.as_deref(), Some("inet"));
assert_eq!(header.hashsize, Some(16384));
assert_eq!(header.maxelem, Some(65536));
assert!(!header.supports_timeout());
}
#[test]
fn header_with_timeout_is_detected() {
let header = SetHeader::parse(LISTING_TIMEOUT).unwrap();
assert_eq!(header.timeout, Some(0));
assert!(header.supports_timeout());
}
#[test]
fn header_parse_errors_instead_of_panicking() {
assert!(SetHeader::parse("garbage").is_err());
assert!(SetHeader::parse("Type: hash:ip\nHeader: family inet hashsize x").is_err());
}
#[test]
fn members_are_parsed_without_timeouts() {
assert_eq!(parse_members(LISTING_TIMEOUT), vec!["1.2.3.4", "5.6.7.8"]);
assert!(parse_members(LISTING_NO_TIMEOUT).is_empty());
}
#[test]
fn migration_name_fits_ipset_limit() {
assert_eq!(migration_name("banned"), "banned_mig");
let long = "a".repeat(31);
assert_eq!(migration_name(&long).len(), MAX_SET_NAME_LEN);
}
#[test]
fn restore_script_targets_temporary_set() {
let script = restore_script("banned_mig", &["1.2.3.4".into(), "5.6.7.8".into()]);
assert_eq!(script, "add banned_mig 1.2.3.4\nadd banned_mig 5.6.7.8\n");
}
#[test]
fn invalid_ip_rejected() {
assert!(matches!(
parse_ip("not-an-ip"),
Err(IpsetError::InvalidIp(_))
));
assert!(parse_ip("1.2.3.4").is_ok());
assert!(parse_ip("::1").is_ok());
}
#[test]
fn wrong_family_rejected_without_kernel() {
let v4 = Ipset::new("whatever");
assert!(matches!(
v4.check_ip("2001:db8::1"),
Err(IpsetError::WrongFamily { .. })
));
let v6 = Ipset::with_family("whatever6", Family::Inet6);
assert!(matches!(
v6.check_ip("192.0.2.1"),
Err(IpsetError::WrongFamily { .. })
));
assert!(v6.check_ip("2001:db8::1").is_ok());
}
#[test]
fn oversized_timeout_rejected_without_kernel() {
let set = Ipset::new("whatever");
assert!(matches!(
set.add("192.0.2.1", Some(MAX_TIMEOUT_SECS + 1)),
Err(IpsetError::TimeoutTooLarge(_))
));
}
#[test]
fn invalid_name_rejected_without_kernel() {
assert!(matches!(
Ipset::new("x".repeat(32)).ensure_exists(),
Err(IpsetError::InvalidName(_))
));
}
#[test]
#[serial]
fn ensure_exists_idempotent() {
@@ -207,6 +549,7 @@ mod tests {
for _ in 0..5 {
set.ensure_exists().expect("ensure_exists failed");
}
assert!(set.header().unwrap().unwrap().supports_timeout());
teardown(&set);
}
@@ -225,7 +568,8 @@ mod tests {
fn add_twice_no_error() {
let Some(set) = setup() else { return };
set.add("192.0.2.2", None).expect("first add");
set.add("192.0.2.2", None).expect("second add should be idempotent");
set.add("192.0.2.2", None)
.expect("second add should be idempotent");
teardown(&set);
}
@@ -244,7 +588,8 @@ mod tests {
#[serial]
fn del_nonexistent_no_error() {
let Some(set) = setup() else { return };
set.del("192.0.2.4").expect("del nonexistent should not fail");
set.del("192.0.2.4")
.expect("del nonexistent should not fail");
teardown(&set);
}
@@ -262,22 +607,40 @@ mod tests {
#[test]
#[serial]
fn save_and_restore_roundtrip() {
use std::env;
let Some(set) = setup() else { return };
set.add("192.0.2.10", None).unwrap();
set.add("192.0.2.11", None).unwrap();
let tmp = env::temp_dir().join("martillo_ipset_test.restore");
set.save(&tmp).expect("save");
let tmp = tempfile::NamedTempFile::new().unwrap();
set.save(tmp.path()).expect("save");
let content = std::fs::read_to_string(&tmp).expect("read");
let content = std::fs::read_to_string(tmp.path()).expect("read");
assert!(content.contains("192.0.2.10"));
assert!(content.contains("192.0.2.11"));
let _ = std::fs::remove_file(&tmp);
teardown(&set);
}
#[test]
#[serial]
fn save_all_includes_every_set() {
let Some(set) = setup() else { return };
let Some(set6) = setup_family(TEST_SET6, Family::Inet6) else {
return;
};
set.add("192.0.2.12", Some(600)).unwrap();
set6.add("2001:db8::12", None).unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
Ipset::save_all(tmp.path()).expect("save_all");
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains(&format!("create {TEST_SET} hash:ip family inet")));
assert!(content.contains(&format!("create {TEST_SET6} hash:ip family inet6")));
assert!(content.contains("192.0.2.12 timeout"));
assert!(content.contains("2001:db8::12"));
teardown(&set);
teardown(&set6);
}
#[test]
#[serial]
fn list_parses_plain_format_correctly() {
@@ -302,33 +665,6 @@ mod tests {
teardown(&set);
}
#[test]
#[serial]
fn list_filters_non_ip_lines() {
let Some(set) = setup() else { return };
set.add("203.0.113.1", None).unwrap();
set.add("203.0.113.2", None).unwrap();
let ips = set.list().unwrap();
for ip in &ips {
assert!(
ip.parse::<std::net::Ipv4Addr>().is_ok()
|| ip.parse::<std::net::Ipv6Addr>().is_ok()
|| ip.contains('/'),
"got non-IP entry: {ip}"
);
}
assert_eq!(ips.len(), 2);
teardown(&set);
}
#[test]
fn invalid_ip_rejected() {
let result = validate_ip("not-an-ip");
assert!(matches!(result, Err(IpsetError::InvalidIp(_))));
assert!(validate_ip("1.2.3.4").is_ok());
assert!(validate_ip("::1").is_ok());
}
#[test]
#[serial]
fn flush_clears_all() {
@@ -348,16 +684,117 @@ mod tests {
set.add("203.0.113.10", Some(2)).expect("add with timeout");
assert!(set.contains("203.0.113.10").unwrap());
std::thread::sleep(std::time::Duration::from_secs(3));
assert!(!set.contains("203.0.113.10").unwrap(), "timeout should expire");
assert!(
!set.contains("203.0.113.10").unwrap(),
"timeout should expire"
);
teardown(&set);
}
#[test]
#[serial]
fn list_on_nonexistent_set_returns_empty() {
if !privileged() {
return;
}
let bogus = Ipset::new("nonexistent_set_zzz_999");
let result = bogus.list();
assert!(result.is_ok(), "list on nonexistent should not error");
assert_eq!(result.unwrap().len(), 0);
assert!(bogus.header().unwrap().is_none());
}
#[test]
#[serial]
fn ipv6_set_bans_ipv6() {
let Some(set) = setup_family(TEST_SET6, Family::Inet6) else {
return;
};
let header = set.header().unwrap().unwrap();
assert_eq!(header.family.as_deref(), Some("inet6"));
set.add("2001:db8::1", Some(600)).unwrap();
assert!(set.contains("2001:db8::1").unwrap());
assert_eq!(set.list().unwrap(), vec!["2001:db8::1"]);
teardown(&set);
}
#[test]
#[serial]
fn existing_set_with_other_family_is_an_error() {
let Some(set) = setup() else { return };
let as_v6 = Ipset::with_family(TEST_SET, Family::Inet6);
assert!(matches!(
as_v6.ensure_exists(),
Err(IpsetError::IncompatibleSet { .. })
));
teardown(&set);
}
#[test]
#[serial]
fn set_without_timeout_is_migrated_keeping_members_and_rule() {
if !privileged() {
return;
}
let set = Ipset::new(TEST_SET);
set.destroy().unwrap();
// Same shape as the set found in production (no timeout support).
check(
run(&[
"create", TEST_SET, "hash:ip", "family", "inet", "hashsize", "16384",
])
.unwrap(),
)
.unwrap();
for i in 1..=50 {
set.add(&format!("198.51.100.{i}"), None).unwrap();
}
assert!(
set.add("198.51.100.200", Some(60)).is_err(),
"a set without timeout must reject timeouts (that is the bug)"
);
// A rule referencing the set must keep working across the swap.
let rule = ["-m", "set", "--match-set", TEST_SET, "src", "-j", "DROP"];
let iptables_ok = Command::new("iptables")
.args(["-I", "INPUT", "1"])
.args(rule)
.status()
.map(|s| s.success())
.unwrap_or(false);
set.ensure_exists().expect("migration");
let header = set.header().unwrap().unwrap();
assert!(header.supports_timeout());
assert_eq!(header.hashsize, Some(16384));
let members = set.list().unwrap();
assert_eq!(members.len(), 50);
assert!(members.contains(&"198.51.100.1".to_string()));
set.add("198.51.100.200", Some(60))
.expect("timeout works after migration");
assert!(
Ipset::new(migration_name(TEST_SET))
.header()
.unwrap()
.is_none(),
"temporary set must be destroyed"
);
if iptables_ok {
let check_rule = Command::new("iptables")
.args(["-C", "INPUT"])
.args(rule)
.status()
.unwrap();
assert!(check_rule.success(), "match-set rule must survive the swap");
Command::new("iptables")
.args(["-D", "INPUT"])
.args(rule)
.status()
.unwrap();
}
teardown(&set);
}
#[test]
+67 -46
View File
@@ -1,71 +1,92 @@
mod cli;
use cli::Cli;
use martillo_maldito::martillo_maldito::MartilloMaldito;
use structopt::StructOpt;
use clap::Parser;
use cli::{Cli, Command};
use martillo_maldito::martillo_maldito::{MartilloMaldito, Result};
use std::process::ExitCode;
fn main() {
match Cli::from_args() {
Cli::GetBannedIps { docker } => {
let banned_ips = MartilloMaldito::ipv4(docker).get_banned_ips();
println!("{}", serde_json::to_string(&banned_ips).unwrap());
fn main() -> ExitCode {
match run(Cli::parse().command) {
Ok(output) => {
println!("{output}");
ExitCode::SUCCESS
}
Cli::BanIp { ip, timeout, docker } => {
let martillo = MartilloMaldito::ipv4(docker);
let result = match timeout {
Some(t) => martillo.ban_ip_with_timeout(&ip, t),
None => martillo.ban_ip(&ip),
};
println!("{}", result.is_ok())
Err(e) => {
// Keep the historical "false" on stdout for scripts, details on stderr.
println!("false");
eprintln!("error: {e}");
ExitCode::FAILURE
}
Cli::UnbanIp { ip, docker } => {
let martillo = MartilloMaldito::ipv4(docker);
println!("{}", martillo.unban_ip(&ip).is_ok())
}
Cli::GetSecuredPorts { docker } => {
let secured_ports = MartilloMaldito::ipv4(docker).get_secured_ports();
println!("{}", serde_json::to_string(&secured_ports).unwrap());
}
fn run(command: Command) -> Result<String> {
Ok(match command {
Command::GetBannedIps { docker } => json(&MartilloMaldito::new(docker)?.get_banned_ips()?),
Command::BanIp {
ip,
timeout,
docker,
} => {
let martillo = MartilloMaldito::new(docker)?;
match timeout {
Some(t) => martillo.ban_ip_with_timeout(&ip, t)?,
None => martillo.ban_ip(&ip)?,
}
Cli::GetSecuredPortsWithAllowedIps { docker } => {
let secured_ports_with_allowed_ips =
MartilloMaldito::ipv4(docker).get_secured_ports_with_allowed_ips();
println!(
"{}",
serde_json::to_string(&secured_ports_with_allowed_ips).unwrap()
);
"true".into()
}
Cli::IsPortSecured { port, docker } => {
let is_port_secured = MartilloMaldito::ipv4(docker).is_port_secured(port);
println!("{}", is_port_secured);
Command::UnbanIp { ip, docker } => {
MartilloMaldito::new(docker)?.unban_ip(&ip)?;
"true".into()
}
Cli::SecurePort {
Command::GetSecuredPorts { docker } => {
json(&MartilloMaldito::new(docker)?.get_secured_ports()?)
}
Command::GetSecuredPortsWithAllowedIps { docker } => {
json(&MartilloMaldito::new(docker)?.get_secured_ports_with_allowed_ips()?)
}
Command::IsPortSecured { port, docker } => {
// "false" here is an answer, not an error.
MartilloMaldito::new(docker)?
.is_port_secured(port)?
.to_string()
}
Command::SecurePort {
port,
position,
docker,
} => {
let port_secured = MartilloMaldito::ipv4(docker).secure_port(port, position);
println!("{}", port_secured.is_ok())
MartilloMaldito::new(docker)?.secure_port(port, position)?;
"true".into()
}
Cli::UnsecurePort { port, docker } => {
let port_unsecured = MartilloMaldito::ipv4(docker).unsecure_port(port);
println!("{}", port_unsecured.is_ok())
Command::UnsecurePort { port, docker } => {
MartilloMaldito::new(docker)?.unsecure_port(port)?;
"true".into()
}
Cli::AllowIpForPort {
Command::AllowIpForPort {
ip,
port,
position,
docker,
} => {
let allowed_ip = MartilloMaldito::ipv4(docker).allow_ip_for_port(&ip, port, position);
println!("{}", allowed_ip.is_ok())
MartilloMaldito::new(docker)?.allow_ip_for_port(&ip, port, position)?;
"true".into()
}
Cli::RemoveAllowIpPort { ip, port, docker } => {
let removed_allow_ip =
MartilloMaldito::ipv4(docker).remove_allow_ip_for_port(&ip, port);
println!("{}", removed_allow_ip.is_ok())
Command::RemoveAllowIpPort { ip, port, docker } => {
MartilloMaldito::new(docker)?.remove_allow_ip_for_port(&ip, port)?;
"true".into()
}
Cli::SaveRules => {
println!("{}", MartilloMaldito::save_rules().is_ok())
Command::SaveRules => {
MartilloMaldito::save_rules()?;
"true".into()
}
Command::SaveIpsets { file } => {
MartilloMaldito::new(false)?.save_ipsets(&file)?;
"true".into()
}
})
}
fn json<T: serde::Serialize>(value: &T) -> String {
serde_json::to_string(value).expect("Vec/HashMap of strings and ints always serialize")
}
+434 -197
View File
@@ -1,214 +1,236 @@
use crate::ipset::{Ipset, DEFAULT_SET_NAME, DEFAULT_TIMEOUT_SECS};
use crate::ipset::{Family, Ipset, IpsetError, DEFAULT_SET_NAME, DEFAULT_TIMEOUT_SECS};
use iptables::IPTables;
use regex::Regex;
use std::{collections::HashMap, process::Command};
use std::{collections::HashMap, net::IpAddr, path::Path, process::Command, sync::LazyLock};
const TABLE: &str = "filter";
#[derive(Debug, thiserror::Error)]
pub enum MartilloError {
#[error(transparent)]
Ipset(#[from] IpsetError),
#[error("iptables error: {0}")]
Iptables(String),
#[error("invalid IP address: {0}")]
InvalidIp(String),
#[error("IPv6 banning is not available: {0}")]
Ipv6Unavailable(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, MartilloError>;
fn ipt<T>(result: std::result::Result<T, Box<dyn std::error::Error>>) -> Result<T> {
result.map_err(|e| MartilloError::Iptables(e.to_string()))
}
pub struct MartilloMaldito {
iptables: iptables::IPTables,
iptables: IPTables,
chain: String,
ipset: Ipset,
/// IPv6 banning is best effort: hosts without ip6tables/IPv6 still get IPv4.
ipv6: std::result::Result<Ipset, String>,
ban_timeout: Option<u32>,
}
impl MartilloMaldito {
pub fn ipv4(docker: bool) -> MartilloMaldito {
Self::ipv4_with_config(docker, DEFAULT_SET_NAME, None)
pub fn new(docker: bool) -> Result<MartilloMaldito> {
Self::with_config(docker, DEFAULT_SET_NAME, None)
}
pub fn ipv4_with_config(
/// Prepares the IPv4 set `ipset_name` and the IPv6 set `{ipset_name}6`, each with
/// its `--match-set ... -j DROP` rule at the top of the chain. Sets created
/// without timeout support are migrated without unblocking anyone.
pub fn 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(),
) -> Result<MartilloMaldito> {
let chain = Self::get_chain(docker).to_string();
let iptables = ipt(iptables::new(false))?;
let ipset = Ipset::with_family(ipset_name, Family::Inet);
ipset.ensure_exists()?;
ensure_match_rule(&iptables, &chain, ipset.name())?;
let ipv6 = Self::setup_ipv6(&chain, &ipv6_set_name(ipset_name)).map_err(|e| e.to_string());
Ok(MartilloMaldito {
iptables,
chain,
ipset,
ipv6,
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 match_rule = format!("-m set --match-set {} src -j DROP", self.ipset.name());
let args: Vec<&str> = match_rule.split_whitespace().collect();
let check_args: Vec<&str> = std::iter::once("-C").chain(self.chain.split_whitespace()).chain(args.iter().copied()).collect();
let output = Command::new("iptables").args(&check_args).output()?;
if output.status.success() {
return Ok(());
}
let insert_args: Vec<&str> = std::iter::once("-I").chain(self.chain.split_whitespace()).chain(std::iter::once("1")).chain(args.iter().copied()).collect();
let output = Command::new("iptables").args(&insert_args).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()
}
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 {
let rules = self.get_rules();
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;
}
}
false
}
pub fn get_secured_ports(&self) -> Vec<u16> {
let rules = self.get_rules();
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> {
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 {
let ips = rules
.iter()
.filter(|r| {
r.contains(&format!("-A {} -s", self.chain))
&& r.contains(&format!("-p tcp -m tcp --dport {} -j ACCEPT", port))
})
.map(|r| extract_ip(&ip_regex, r).unwrap())
}
fn setup_ipv6(chain: &str, set_name: &str) -> Result<Ipset> {
let ip6tables = ipt(iptables::new(true))?;
let ipset = Ipset::with_family(set_name, Family::Inet6);
ipset.ensure_exists()?;
ensure_match_rule(&ip6tables, chain, ipset.name())?;
Ok(ipset)
}
/// `Err` with the reason when IPv6 addresses cannot be banned.
pub fn ipv6_status(&self) -> std::result::Result<(), &str> {
self.ipv6.as_ref().map(|_| ()).map_err(String::as_str)
}
pub fn ipset_names(&self) -> Vec<&str> {
let mut names = vec![self.ipset.name()];
if let Ok(v6) = &self.ipv6 {
names.push(v6.name());
}
names
}
pub fn save_rules() -> Result<()> {
let output = Command::new("iptables-save")
.args(["-f", "/etc/iptables/rules.v4"])
.output()?;
if !output.status.success() {
return Err(MartilloError::Iptables(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(())
}
/// Writes all ipsets to `path` in the format `ipset-persistent` restores at boot
/// (normally `/etc/iptables/ipsets`).
pub fn save_ipsets(&self, path: &Path) -> Result<()> {
Ipset::save_all(path)?;
Ok(())
}
pub fn is_port_secured(&self, port: u16) -> Result<bool> {
Ok(self.get_secured_ports()?.contains(&port))
}
pub fn get_secured_ports(&self) -> Result<Vec<u16>> {
Ok(self
.get_rules()?
.iter()
.filter_map(|r| parse_secured_port(r))
.collect())
}
pub fn get_banned_ips(&self) -> Result<Vec<String>> {
let mut ips = self.ipset.list()?;
if let Ok(v6) = &self.ipv6 {
ips.extend(v6.list()?);
}
Ok(ips)
}
pub fn get_secured_ports_with_allowed_ips(&self) -> Result<HashMap<u16, Vec<String>>> {
let rules = self.get_rules()?;
let mut result: HashMap<u16, Vec<String>> = rules
.iter()
.filter_map(|r| parse_secured_port(r))
.map(|port| (port, Vec::new()))
.collect();
result.insert(port, ips);
for (port, ip) in rules.iter().filter_map(|r| parse_allowed_ip(r)) {
if let Some(ips) = result.get_mut(&port) {
ips.push(ip);
}
result
}
Ok(result)
}
pub fn ban_ip(&self, ip: &str) -> Result<(), Box<dyn std::error::Error>> {
self.ipset.add(ip, self.ban_timeout)?;
pub fn ban_ip(&self, ip: &str) -> Result<()> {
self.set_for(ip)?.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))?;
pub fn ban_ip_with_timeout(&self, ip: &str, timeout_secs: u32) -> Result<()> {
self.set_for(ip)?.add(ip, Some(timeout_secs))?;
Ok(())
}
pub fn unban_ip(&self, ip: &str) -> Result<(), Box<dyn std::error::Error>> {
self.ipset.del(ip)?;
pub fn unban_ip(&self, ip: &str) -> Result<()> {
self.set_for(ip)?.del(ip)?;
Ok(())
}
pub fn secure_port(
&self,
port: u16,
position: Option<usize>,
) -> Result<(), Box<dyn std::error::Error>> {
let table = "filter";
pub fn is_banned(&self, ip: &str) -> Result<bool> {
Ok(self.set_for(ip)?.contains(ip)?)
}
fn set_for(&self, ip: &str) -> Result<&Ipset> {
let parsed: IpAddr = ip
.parse()
.map_err(|_| MartilloError::InvalidIp(ip.to_string()))?;
match parsed {
IpAddr::V4(_) => Ok(&self.ipset),
IpAddr::V6(_) => match &self.ipv6 {
Ok(v6) => Ok(v6),
Err(reason) => Err(MartilloError::Ipv6Unavailable(reason.clone())),
},
}
}
pub fn secure_port(&self, port: u16, position: Option<usize>) -> Result<()> {
let rule = secure_port_rule(port);
let position = if self.chain == "DOCKER-USER" && position.is_none() {
let all_docker_rules = self.get_rules()?;
Some(all_docker_rules.len() - 1)
} else {
position
let position = match position {
Some(position) => Some(position),
None if self.chain == "DOCKER-USER" => self.docker_user_return_position()?,
None => None,
};
if let Some(position) = position {
self.insert_unique(table, &rule, position)
self.insert_unique(&rule, position)
} else {
self.append_unique(table, &rule)
self.append_unique(&rule)
}
}
pub fn unsecure_port(&self, port: u16) -> Result<(), Box<dyn std::error::Error>> {
pub fn unsecure_port(&self, port: u16) -> Result<()> {
let rule = secure_port_rule(port);
self.iptables.delete("filter", &self.chain, &rule)
ipt(self.iptables.delete(TABLE, &self.chain, &rule))
}
pub fn allow_ip_for_port(
&self,
ip: &str,
port: u16,
position: Option<usize>,
) -> Result<(), Box<dyn std::error::Error>> {
let table = "filter";
pub fn allow_ip_for_port(&self, ip: &str, port: u16, position: Option<usize>) -> Result<()> {
let rule = allow_ip_for_port_rule(port, ip);
if let Some(position) = position {
self.insert_unique(table, &rule, position)
self.insert_unique(&rule, position)
} else {
self.append_unique(table, &rule)
self.append_unique(&rule)
}
}
pub fn remove_allow_ip_for_port(
&self,
ip: &str,
port: u16,
) -> Result<(), Box<dyn std::error::Error>> {
pub fn remove_allow_ip_for_port(&self, ip: &str, port: u16) -> Result<()> {
let rule = allow_ip_for_port_rule(port, ip);
self.iptables.delete("filter", &self.chain, &rule)
ipt(self.iptables.delete(TABLE, &self.chain, &rule))
}
fn get_rules(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
self.iptables.list("filter", &self.chain)
/// Older Docker versions end DOCKER-USER with `-j RETURN`; new rules must go
/// before it. Newer versions leave the chain empty, so we just append.
fn docker_user_return_position(&self) -> Result<Option<usize>> {
let rules = self.get_rules()?;
let ends_with_return = rules
.last()
.is_some_and(|r| r.trim() == format!("-A {} -j RETURN", self.chain));
// rules[0] is "-N CHAIN", so the index of the last rule is its 1-based position.
Ok(ends_with_return.then(|| rules.len() - 1))
}
fn append_unique(&self, table: &str, rule: &str) -> Result<(), Box<dyn std::error::Error>> {
self.iptables.append_unique(table, &self.chain, rule)
fn get_rules(&self) -> Result<Vec<String>> {
ipt(self.iptables.list(TABLE, &self.chain))
}
fn insert_unique(
&self,
table: &str,
rule: &str,
position: usize,
) -> Result<(), Box<dyn std::error::Error>> {
self.iptables
.insert_unique(table, &self.chain, rule, position as i32)
fn append_unique(&self, rule: &str) -> Result<()> {
ipt(self.iptables.append_unique(TABLE, &self.chain, rule))
}
fn insert_unique(&self, rule: &str, position: usize) -> Result<()> {
let position = i32::try_from(position)
.map_err(|_| MartilloError::Iptables(format!("invalid position {position}")))?;
ipt(self
.iptables
.insert_unique(TABLE, &self.chain, rule, position))
}
fn get_chain(docker: bool) -> &'static str {
@@ -220,6 +242,22 @@ impl MartilloMaldito {
}
}
pub fn ipv6_set_name(ipset_name: &str) -> String {
format!("{ipset_name}6")
}
fn match_rule(set: &str) -> String {
format!("-m set --match-set {set} src -j DROP")
}
fn ensure_match_rule(iptables: &IPTables, chain: &str, set: &str) -> Result<()> {
let rule = match_rule(set);
if ipt(iptables.exists(TABLE, chain, &rule))? {
return Ok(());
}
ipt(iptables.insert(TABLE, chain, &rule, 1))
}
fn secure_port_rule(port: u16) -> String {
format!("-p tcp --dport {} -j DROP", port)
}
@@ -228,24 +266,27 @@ fn allow_ip_for_port_rule(port: u16, ip: &str) -> String {
format!("-p tcp --dport {} -s {} -j ACCEPT", port, ip)
}
fn extract_ip(regex: &Regex, input: &str) -> Option<String> {
regex
.captures(input)
.and_then(|caps| caps.get(0).map(|m| m.as_str().to_string()))
static SECURED_PORT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^-A \S+ -p tcp -m tcp --dport (\d+) -j DROP$").expect("valid regex")
});
static ALLOWED_IP_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^-A \S+ -s (\S+?)(?:/32|/128)? -p tcp -m tcp --dport (\d+) -j ACCEPT$")
.expect("valid regex")
});
/// Port of a rule created by `secure_port` (`iptables -S` format). Anything else
/// (source-restricted, multiport, port ranges, match-set...) yields `None`.
fn parse_secured_port(rule: &str) -> Option<u16> {
let caps = SECURED_PORT_RE.captures(rule.trim())?;
caps.get(1)?.as_str().parse().ok()
}
fn extract_port(regex: &Regex, input: &str) -> Option<u16> {
regex
.captures(input)
.and_then(|caps| caps.get(1).map(|m| m.as_str().parse::<u16>().unwrap()))
}
fn iptables_regex_for_ip() -> Regex {
Regex::new(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b").unwrap()
}
fn iptables_regex_for_port() -> Regex {
Regex::new(r"--dport\s+(\d+)").unwrap()
/// `(port, ip)` of a rule created by `allow_ip_for_port`.
fn parse_allowed_ip(rule: &str) -> Option<(u16, String)> {
let caps = ALLOWED_IP_RE.captures(rule.trim())?;
let port = caps.get(2)?.as_str().parse().ok()?;
Some((port, caps.get(1)?.as_str().to_string()))
}
pub const fn default_ban_timeout_secs() -> u32 {
@@ -255,40 +296,236 @@ pub const fn default_ban_timeout_secs() -> u32 {
#[cfg(test)]
mod tests {
use super::*;
use crate::ipset::tests::privileged;
use serial_test::serial;
#[test]
fn correct_extract_ip() {
let regex = iptables_regex_for_ip();
let input = "-A INPUT -s 81.69.255.132/32 -j DROP";
assert_eq!(extract_ip(&regex, input), Some("81.69.255.132".to_string()));
}
#[test]
fn no_match_extract_ip() {
let regex = iptables_regex_for_ip();
let input = "-A INPUT -j DROP";
assert_eq!(extract_ip(&regex, input), None);
}
#[test]
fn docker_extract_ip() {
let regex = iptables_regex_for_ip();
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(&regex, 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";
fn secured_port_parsed() {
assert_eq!(
extract_port(&iptables_regex_for_port(), input),
parse_secured_port("-A INPUT -p tcp -m tcp --dport 2222 -j DROP"),
Some(2222)
);
assert!(!input.contains("match-set"));
assert_eq!(
parse_secured_port("-A DOCKER-USER -p tcp -m tcp --dport 3307 -j DROP"),
Some(3307)
);
}
#[test]
fn multiport_and_other_rules_do_not_panic() {
// Real rules from prohosting (docker-user-rules) and other shapes.
let rules = [
"-A INPUT -i eth0 -p tcp -m multiport --dports 8666,7640 -m comment --comment docker-user-rules -j DROP",
"-A INPUT -p tcp -m multiport --dports 80,443 -j DROP",
"-A INPUT -p tcp -m tcp --dport 1000:2000 -j DROP",
"-A INPUT -m set --match-set banned src -j DROP",
"-A INPUT -s 81.69.255.132/32 -j DROP",
"-A INPUT -s 10.0.0.5/32 -p tcp -m tcp --dport 22 -j DROP",
"-A INPUT -p tcp -m tcp --dport 99999 -j DROP",
"-N DOCKER-USER",
"-P INPUT ACCEPT",
"",
];
for rule in rules {
assert_eq!(parse_secured_port(rule), None, "{rule}");
assert_eq!(parse_allowed_ip(rule), None, "{rule}");
}
}
#[test]
fn allowed_ip_parsed() {
assert_eq!(
parse_allowed_ip("-A INPUT -s 10.0.0.5/32 -p tcp -m tcp --dport 9999 -j ACCEPT"),
Some((9999, "10.0.0.5".to_string()))
);
assert_eq!(
parse_allowed_ip("-A INPUT -s 10.0.0.0/24 -p tcp -m tcp --dport 9999 -j ACCEPT"),
Some((9999, "10.0.0.0/24".to_string()))
);
}
#[test]
fn ipv6_set_name_appends_six() {
assert_eq!(ipv6_set_name("banned"), "banned6");
}
#[test]
fn default_timeout_constant_is_one_hour() {
assert_eq!(default_ban_timeout_secs(), 3600);
}
const SET: &str = "martillo_test";
fn martillo() -> Option<MartilloMaldito> {
if !privileged() {
return None;
}
cleanup();
Some(MartilloMaldito::with_config(false, SET, Some(600)).expect("with_config"))
}
fn cleanup() {
for (cmd, set) in [
("iptables", SET.to_string()),
("ip6tables", ipv6_set_name(SET)),
] {
while Command::new(cmd)
.args([
"-D",
"INPUT",
"-m",
"set",
"--match-set",
&set,
"src",
"-j",
"DROP",
])
.output()
.is_ok_and(|o| o.status.success())
{}
}
Ipset::new(SET).destroy().unwrap();
Ipset::with_family(ipv6_set_name(SET), Family::Inet6)
.destroy()
.unwrap();
}
fn rules(cmd: &str) -> String {
String::from_utf8(
Command::new(cmd)
.args(["-S", "INPUT"])
.output()
.unwrap()
.stdout,
)
.unwrap()
}
#[test]
#[serial]
fn ban_routes_ip_to_set_by_family() {
let Some(m) = martillo() else { return };
m.ipv6_status().expect("ipv6 available in test netns");
m.ban_ip("192.0.2.50").unwrap();
m.ban_ip("2001:db8::50").unwrap();
assert!(Ipset::new(SET).contains("192.0.2.50").unwrap());
assert!(Ipset::with_family(ipv6_set_name(SET), Family::Inet6)
.contains("2001:db8::50")
.unwrap());
assert!(m.is_banned("2001:db8::50").unwrap());
let banned = m.get_banned_ips().unwrap();
assert!(banned.contains(&"192.0.2.50".to_string()));
assert!(banned.contains(&"2001:db8::50".to_string()));
m.unban_ip("2001:db8::50").unwrap();
assert!(!m.is_banned("2001:db8::50").unwrap());
assert!(matches!(m.ban_ip("nope"), Err(MartilloError::InvalidIp(_))));
cleanup();
}
#[test]
#[serial]
fn match_rules_installed_once_for_both_families() {
let Some(_m) = martillo() else { return };
let _again = MartilloMaldito::with_config(false, SET, None).unwrap();
let v4 = rules("iptables");
let v6 = rules("ip6tables");
assert_eq!(
v4.matches(&format!("--match-set {SET} src -j DROP"))
.count(),
1
);
assert_eq!(
v6.matches(&format!("--match-set {SET}6 src -j DROP"))
.count(),
1
);
cleanup();
}
#[test]
#[serial]
fn secured_ports_roundtrip() {
let Some(m) = martillo() else { return };
m.secure_port(9999, None).unwrap();
m.allow_ip_for_port("10.0.0.5", 9999, None).unwrap();
Command::new("iptables")
.args([
"-A",
"INPUT",
"-p",
"tcp",
"-m",
"multiport",
"--dports",
"80,443",
"-j",
"DROP",
])
.status()
.unwrap();
assert!(m.is_port_secured(9999).unwrap());
assert!(!m.is_port_secured(8888).unwrap());
assert_eq!(m.get_secured_ports().unwrap(), vec![9999]);
let allowed = m.get_secured_ports_with_allowed_ips().unwrap();
assert_eq!(allowed.get(&9999), Some(&vec!["10.0.0.5".to_string()]));
m.unsecure_port(9999).unwrap();
m.remove_allow_ip_for_port("10.0.0.5", 9999).unwrap();
Command::new("iptables")
.args([
"-D",
"INPUT",
"-p",
"tcp",
"-m",
"multiport",
"--dports",
"80,443",
"-j",
"DROP",
])
.status()
.unwrap();
assert!(!m.is_port_secured(9999).unwrap());
cleanup();
}
#[test]
#[serial]
fn docker_user_without_return_appends() {
if !privileged() {
return;
}
let _ = Command::new("iptables")
.args(["-N", "DOCKER-USER"])
.status();
let _ = Command::new("ip6tables")
.args(["-N", "DOCKER-USER"])
.status();
let m = MartilloMaldito::with_config(true, SET, None).unwrap();
m.secure_port(7777, None)
.expect("empty DOCKER-USER must not fail");
assert!(m.is_port_secured(7777).unwrap());
m.unsecure_port(7777).unwrap();
for cmd in ["iptables", "ip6tables"] {
Command::new(cmd)
.args(["-F", "DOCKER-USER"])
.status()
.unwrap();
Command::new(cmd)
.args(["-X", "DOCKER-USER"])
.status()
.unwrap();
}
Ipset::new(SET).destroy().unwrap();
Ipset::with_family(ipv6_set_name(SET), Family::Inet6)
.destroy()
.unwrap();
}
}
+71 -21
View File
@@ -1,9 +1,12 @@
#!/bin/bash
# Integration tests for martillo-maldito CLI using isolated network namespaces.
# Exercises iptables rule management. ipset is tested separately via cargo test.
# Integration tests for martillo-maldito CLI using an isolated network namespace.
# Exercises iptables/ip6tables rule management and ipset (sets are per-netns).
#
# Requires: iptables, sudo, cargo (already built binary at $BIN)
# Requires: iptables, ip6tables, ipset, jq, and either sudo or an already
# isolated namespace.
# Usage: BIN=./target/release/martillo_maldito ./tests/integration_netns.sh
# or, without sudo (user + network namespace):
# unshare -rn env SELF_ISOLATED=1 ./tests/integration_netns.sh
set -euo pipefail
@@ -16,18 +19,26 @@ if [[ ! -x "$BIN" ]]; then
exit 1
fi
if ! command -v sudo >/dev/null; then
if [[ -n "${SELF_ISOLATED:-}" ]]; then
if [[ $EUID -ne 0 ]]; then
echo "ERROR: SELF_ISOLATED needs to run as (namespace) root, e.g. unshare -rn"
exit 1
fi
in_ns() { "$@"; }
echo "==> Running in the current (already isolated) namespace"
else
if ! command -v sudo >/dev/null; then
echo "ERROR: sudo not installed"
exit 1
fi
cleanup() {
fi
cleanup() {
sudo ip netns del "$NS" 2>/dev/null || true
}
trap cleanup EXIT
echo "==> Creating namespace $NS"
sudo ip netns add "$NS"
}
trap cleanup EXIT
echo "==> Creating namespace $NS"
sudo ip netns add "$NS"
in_ns() { sudo ip netns exec "$NS" "$@"; }
fi
PASS=0
FAIL=0
@@ -44,15 +55,32 @@ assert_eq() {
}
run_cli() {
sudo ip netns exec "$NS" "$BIN" "$@"
in_ns "$BIN" "$@"
}
ipset_count() {
in_ns ipset list "$1" | awk '/^Members:/{p=1;next} p && NF{c++} END{print c+0}'
}
echo ""
echo "==> Test 0: existing set WITHOUT timeout is migrated (members and rule kept)"
in_ns ipset create banned hash:ip family inet hashsize 16384
in_ns ipset add banned 198.51.100.1
in_ns ipset add banned 198.51.100.2
in_ns iptables -I INPUT 1 -m set --match-set banned src -j DROP
output=$(run_cli ban-ip -i 192.0.2.30 --timeout 600)
assert_eq "ban with timeout works on migrated set" "true" "$output"
assert_eq "header now has timeout" "1" "$(in_ns ipset list -t banned | grep -c 'timeout 0')"
assert_eq "old members kept" "3" "$(ipset_count banned)"
assert_eq "match-set rule not duplicated" "1" "$(in_ns iptables -S INPUT | grep -c 'match-set banned src')"
assert_eq "temporary set removed" "false" "$(in_ns ipset list -n | grep -qx banned_mig && echo true || echo false)"
echo ""
echo "==> Test 1: ban_ip via CLI is idempotent"
run_cli ban-ip -i 192.0.2.10
# ipset is host-global, can't isolate per-netns. Just verify CLI returns ok.
run_cli ban-ip -i 192.0.2.10 >/dev/null
output=$(run_cli ban-ip -i 192.0.2.10)
assert_eq "ban-ip idempotent" "true" "$output"
assert_eq "192.0.2.10 in set" "0" "$(in_ns ipset test banned 192.0.2.10 2>/dev/null; echo $?)"
run_cli unban-ip -i 192.0.2.10 >/dev/null
echo ""
@@ -71,25 +99,47 @@ assert_eq "second unban returns ok" "true" "$output"
echo ""
echo "==> Test 4: secured ports with allowed IPs"
run_cli secure-port -p 9999
run_cli secure-port -p 9999 >/dev/null
assert_eq "port 9999 is secured" "true" "$(run_cli is-port-secured -p 9999)"
assert_eq "port 8888 is NOT secured" "false" "$(run_cli is-port-secured -p 8888)"
run_cli allow-ip-for-port -i 10.0.0.5 -p 9999
run_cli allow-ip-for-port -i 10.0.0.5 -p 9999 >/dev/null
in_ns iptables -A INPUT -p tcp -m multiport --dports 80,443 -j DROP
allowed=$(run_cli get-secured-ports-with-allowed-ips)
assert_eq "10.0.0.5 allowed for 9999" "10.0.0.5" "$(echo "$allowed" | jq -r '."9999"[]')"
run_cli unsecure-port -p 9999
run_cli remove-allow-ip-port -i 10.0.0.5 -p 9999
assert_eq "multiport rule ignored" "[9999]" "$(run_cli get-secured-ports)"
run_cli unsecure-port -p 9999 >/dev/null
run_cli remove-allow-ip-port -i 10.0.0.5 -p 9999 >/dev/null
assert_eq "port 9999 unsecured" "false" "$(run_cli is-port-secured -p 9999)"
echo ""
echo "==> Test 5: cli list matches ipset list directly"
cli_count=$(run_cli get-banned-ips | jq 'length')
assert_eq "cli reports same count as ipset" "$cli_count" "$cli_count"
direct=$(( $(ipset_count banned) + $(ipset_count banned6) ))
assert_eq "cli reports same count as ipset" "$direct" "$cli_count"
echo ""
echo "==> Test 6: error on invalid IP"
output=$(run_cli ban-ip -i "not-an-ip" 2>&1 || echo "failed")
output=$(run_cli ban-ip -i "not-an-ip" 2>/dev/null || true)
assert_eq "invalid IP rejected" "false" "$output"
assert_eq "invalid IP exits non-zero" "1" "$(run_cli ban-ip -i not-an-ip >/dev/null 2>&1; echo $?)"
echo ""
echo "==> Test 7: IPv6 goes to banned6 with an ip6tables rule"
output=$(run_cli ban-ip -i 2001:db8::7 --timeout 600)
assert_eq "ban IPv6" "true" "$output"
assert_eq "2001:db8::7 in banned6" "0" "$(in_ns ipset test banned6 2001:db8::7 2>/dev/null; echo $?)"
assert_eq "ip6tables match-set rule" "1" "$(in_ns ip6tables -S INPUT | grep -c 'match-set banned6 src -j DROP')"
assert_eq "IPv6 listed" "true" "$(run_cli get-banned-ips | jq 'index("2001:db8::7") != null')"
run_cli unban-ip -i 2001:db8::7 >/dev/null
assert_eq "IPv6 unbanned" "1" "$(in_ns ipset test banned6 2001:db8::7 2>/dev/null; echo $?)"
echo ""
echo "==> Test 8: save-ipsets writes a restorable file"
tmpfile=$(mktemp)
run_cli save-ipsets -f "$tmpfile" >/dev/null
assert_eq "saved banned" "1" "$(grep -c '^create banned hash:ip family inet ' "$tmpfile")"
assert_eq "saved banned6" "1" "$(grep -c '^create banned6 hash:ip family inet6 ' "$tmpfile")"
rm -f "$tmpfile"
echo ""
echo "================================================"