From 13ca4518b40a83d3be1583722e17beb68b9d7a62 Mon Sep 17 00:00:00 2001 From: midefos Date: Fri, 30 Oct 2026 01:36:54 +0100 Subject: [PATCH] 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: "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 --- Cargo.lock | 270 ++++++-------- Cargo.toml | 6 +- README.md | 53 ++- src/cli.rs | 86 +++-- src/ipset.rs | 705 ++++++++++++++++++++++++++++++------- src/main.rs | 115 +++--- src/martillo_maldito.rs | 609 ++++++++++++++++++++++---------- tests/integration_netns.sh | 98 ++++-- 8 files changed, 1352 insertions(+), 590 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 759c7fd..6030449 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 2f8c8c2..dc1e74e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 548c4f6..6eea054 100644 --- a/README.md +++ b/README.md @@ -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 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 [--timeout ] [--docker | -d] +martillo_maldito unban-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. diff --git a/src/cli.rs b/src/cli.rs index ddf2c53..e6d452d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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 "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, - #[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, - #[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, - #[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, + }, } diff --git a/src/ipset.rs b/src/ipset.rs index b0093f7..7ebe5d7 100644 --- a/src/ipset.rs +++ b/src/ipset.rs @@ -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 = std::result::Result; +/// Parsed `Header:` line of `ipset list -t`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetHeader { + pub set_type: String, + pub family: Option, + pub hashsize: Option, + pub maxelem: Option, + pub timeout: Option, +} + +impl SetHeader { + pub fn parse(listing: &str) -> Result { + 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> { + value(key) + .map(|v| { + v.parse::() + .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) -> Self { - Self { name: name.into() } + Self::with_family(name, Family::Inet) + } + + pub fn with_family(name: impl Into, 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> { + 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, maxelem: Option) -> 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) -> 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 { - 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> { - 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::().is_ok() - || entry.parse::().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::().is_err() && ip.parse::().is_err() { - return Err(IpsetError::InvalidIp(ip.to_string())); +fn run(args: &[&str]) -> Result { + 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 { + ip.parse::() + .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 { + listing + .lines() + .skip_while(|line| !line.starts_with("Members:")) + .skip(1) + .filter_map(|line| line.split_whitespace().next()) + .filter(|entry| entry.parse::().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 { - 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 { + 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 { + 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::().is_ok() - || ip.parse::().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] diff --git a/src/main.rs b/src/main.rs index 9e58ee6..3885c4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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()) + } +} + +fn run(command: Command) -> Result { + 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)?, + } + "true".into() } - Cli::GetSecuredPorts { docker } => { - let secured_ports = MartilloMaldito::ipv4(docker).get_secured_ports(); - println!("{}", serde_json::to_string(&secured_ports).unwrap()); + Command::UnbanIp { ip, docker } => { + MartilloMaldito::new(docker)?.unban_ip(&ip)?; + "true".into() } - 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() - ); + Command::GetSecuredPorts { docker } => { + json(&MartilloMaldito::new(docker)?.get_secured_ports()?) } - Cli::IsPortSecured { port, docker } => { - let is_port_secured = MartilloMaldito::ipv4(docker).is_port_secured(port); - println!("{}", is_port_secured); + Command::GetSecuredPortsWithAllowedIps { docker } => { + json(&MartilloMaldito::new(docker)?.get_secured_ports_with_allowed_ips()?) } - Cli::SecurePort { + 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(value: &T) -> String { + serde_json::to_string(value).expect("Vec/HashMap of strings and ints always serialize") } diff --git a/src/martillo_maldito.rs b/src/martillo_maldito.rs index 4ff23c5..1d3c1bb 100644 --- a/src/martillo_maldito.rs +++ b/src/martillo_maldito.rs @@ -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 = std::result::Result; + +fn ipt(result: std::result::Result>) -> Result { + 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, ban_timeout: Option, } impl MartilloMaldito { - pub fn ipv4(docker: bool) -> MartilloMaldito { - Self::ipv4_with_config(docker, DEFAULT_SET_NAME, None) + pub fn new(docker: bool) -> Result { + 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, - ) -> 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 { + 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> { - 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(()); + fn setup_ipv6(chain: &str, set_name: &str) -> Result { + 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()); } - 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()?; + 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(format!( - "failed to install ipset match rule: {}", - String::from_utf8_lossy(&output.stderr) - ) - .into()); + return Err(MartilloError::Iptables( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); } Ok(()) } - pub fn save_rules() -> std::io::Result { - Command::new("iptables-save").args(["-f", "/etc/iptables/rules.v4"]).output() - } - - pub fn save_ipset(&self, path: &std::path::Path) -> Result<(), Box> { - self.ipset.save(path)?; + /// 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) -> bool { - let rules = self.get_rules(); - if rules.is_err() { - return false; + pub fn is_port_secured(&self, port: u16) -> Result { + Ok(self.get_secured_ports()?.contains(&port)) + } + + pub fn get_secured_ports(&self) -> Result> { + Ok(self + .get_rules()? + .iter() + .filter_map(|r| parse_secured_port(r)) + .collect()) + } + + pub fn get_banned_ips(&self) -> Result> { + let mut ips = self.ipset.list()?; + if let Ok(v6) = &self.ipv6 { + ips.extend(v6.list()?); } - for rule in rules.unwrap() { - if rule.contains(&format!("-p tcp -m tcp --dport {} -j DROP", port)) { - return true; + Ok(ips) + } + + pub fn get_secured_ports_with_allowed_ips(&self) -> Result>> { + let rules = self.get_rules()?; + let mut result: HashMap> = rules + .iter() + .filter_map(|r| parse_secured_port(r)) + .map(|port| (port, Vec::new())) + .collect(); + for (port, ip) in rules.iter().filter_map(|r| parse_allowed_ip(r)) { + if let Some(ips) = result.get_mut(&port) { + ips.push(ip); } } - false + Ok(result) } - pub fn get_secured_ports(&self) -> Vec { - 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 { - self.ipset.list().unwrap_or_default() - } - - pub fn get_secured_ports_with_allowed_ips(&self) -> HashMap> { - let mut result: HashMap> = 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()) - .collect(); - result.insert(port, ips); - } - result - } - - pub fn ban_ip(&self, ip: &str) -> Result<(), Box> { - 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> { - 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> { - 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, - ) -> Result<(), Box> { - let table = "filter"; + pub fn is_banned(&self, ip: &str) -> Result { + 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) -> 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> { + 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, - ) -> Result<(), Box> { - let table = "filter"; + pub fn allow_ip_for_port(&self, ip: &str, port: u16, position: Option) -> 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> { + 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, Box> { - 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> { + 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> { - self.iptables.append_unique(table, &self.chain, rule) + fn get_rules(&self) -> Result> { + ipt(self.iptables.list(TABLE, &self.chain)) } - fn insert_unique( - &self, - table: &str, - rule: &str, - position: usize, - ) -> Result<(), Box> { - 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 { - regex - .captures(input) - .and_then(|caps| caps.get(0).map(|m| m.as_str().to_string())) +static SECURED_PORT_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^-A \S+ -p tcp -m tcp --dport (\d+) -j DROP$").expect("valid regex") +}); + +static ALLOWED_IP_RE: LazyLock = 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 { + let caps = SECURED_PORT_RE.captures(rule.trim())?; + caps.get(1)?.as_str().parse().ok() } -fn extract_port(regex: &Regex, input: &str) -> Option { - regex - .captures(input) - .and_then(|caps| caps.get(1).map(|m| m.as_str().parse::().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(®ex, 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(®ex, 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(®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"; + 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 { + 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(); + } } diff --git a/tests/integration_netns.sh b/tests/integration_netns.sh index f47eed6..a426cbf 100755 --- a/tests/integration_netns.sh +++ b/tests/integration_netns.sh @@ -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,19 +19,27 @@ if [[ ! -x "$BIN" ]]; then exit 1 fi -if ! command -v sudo >/dev/null; then - echo "ERROR: sudo not installed" - exit 1 +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() { + sudo ip netns del "$NS" 2>/dev/null || true + } + trap cleanup EXIT + echo "==> Creating namespace $NS" + sudo ip netns add "$NS" + in_ns() { sudo ip netns exec "$NS" "$@"; } fi -cleanup() { - sudo ip netns del "$NS" 2>/dev/null || true -} -trap cleanup EXIT - -echo "==> Creating namespace $NS" -sudo ip netns add "$NS" - 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 "================================================" @@ -100,4 +150,4 @@ if [[ $FAIL -gt 0 ]]; then exit 1 fi -echo "All integration tests passed." \ No newline at end of file +echo "All integration tests passed."