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]] [[package]]
name = "ansi_term" name = "anstream"
version = "0.12.1" version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [ dependencies = [
"winapi", "anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
] ]
[[package]] [[package]]
name = "atty" name = "anstyle"
version = "0.2.14" version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"hermit-abi", "utf8parse",
"libc",
"winapi",
] ]
[[package]] [[package]]
name = "bitflags" name = "anstyle-query"
version = "1.3.2" version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "bitflags" name = "bitflags"
@@ -57,19 +81,50 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "clap" name = "clap"
version = "2.34.0" version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [ dependencies = [
"ansi_term", "clap_builder",
"atty", "clap_derive",
"bitflags 1.3.2",
"strsim",
"textwrap",
"unicode-width",
"vec_map",
] ]
[[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]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -134,21 +189,9 @@ dependencies = [
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.3.3" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
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",
]
[[package]] [[package]]
name = "iptables" name = "iptables"
@@ -161,6 +204,12 @@ dependencies = [
"regex", "regex",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.14" version = "1.0.14"
@@ -202,14 +251,14 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]] [[package]]
name = "martillo_maldito" name = "martillo_maldito"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"clap",
"iptables", "iptables",
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"serial_test", "serial_test",
"structopt",
"tempfile", "tempfile",
"thiserror", "thiserror",
] ]
@@ -226,7 +275,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [ dependencies = [
"bitflags 2.6.0", "bitflags",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"libc", "libc",
@@ -238,6 +287,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -267,44 +322,20 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.92" version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.38" version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -321,7 +352,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags 2.6.0", "bitflags",
] ]
[[package]] [[package]]
@@ -359,7 +390,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags 2.6.0", "bitflags",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
@@ -449,39 +480,15 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.8.0" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[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",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "2.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -490,9 +497,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.93" version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058" checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -512,33 +519,24 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "1.0.69" version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.93", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -548,50 +546,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
[[package]] [[package]]
name = "unicode-segmentation" name = "utf8parse"
version = "1.12.0" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[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"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
+3 -3
View File
@@ -1,17 +1,17 @@
[package] [package]
name = "martillo_maldito" name = "martillo_maldito"
version = "0.2.0" version = "0.3.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
structopt = "0.3.26" clap = { version = "4.6", features = ["derive"] }
iptables = "0.5.2" iptables = "0.5.2"
serde = {version = "1.0.217", features = ["derive"]} serde = {version = "1.0.217", features = ["derive"]}
serde_json = "1.0.134" serde_json = "1.0.134"
regex = "1.11.1" regex = "1.11.1"
thiserror = "1.0" thiserror = "2.0"
[dev-dependencies] [dev-dependencies]
serial_test = "3.2.0" serial_test = "3.2.0"
+50 -3
View File
@@ -6,6 +6,7 @@
## Features ## Features
- Ban/unban IPv4 and IPv6 addresses with `ipset` (optional timeout).
- Retrieve all banned IPs. - Retrieve all banned IPs.
- Query and manage secured ports. - Query and manage secured ports.
- Allow or deny access by IP for specific ports. - Allow or deny access by IP for specific ports.
@@ -16,9 +17,26 @@
## Requirements ## Requirements
- Rust 1.65 or higher. - Rust 1.80 or higher.
- `iptables` installed and configured on the system. - `iptables`, `ip6tables` and `ipset` installed on the system.
- Proper permissions to manage `iptables` rules (e.g., running as root). - 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:** **Options:**
- `--docker`, `-d`: Includes rules in Docker environments. - `--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 #### 2. Retrieve all secured ports
```bash ```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 ## 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. 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)] #[derive(Debug, Parser)]
#[structopt(name = "martillo_maldito", about = "An iptables/ipset wrapper")] #[command(name = "martillo_maldito", about = "An iptables/ipset wrapper")]
pub enum Cli { pub struct Cli {
#[structopt(about = "Get all banned ips")] #[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Get all banned ips (IPv4 and IPv6)
GetBannedIps { GetBannedIps {
#[structopt(name = "Docker", short = "d", long = "docker")] #[arg(short, long)]
docker: bool, docker: bool,
}, },
#[structopt(about = "Ban an ip (uses ipset)")] /// Ban an ip (uses ipset; IPv6 goes to the "<set>6" set)
BanIp { BanIp {
#[structopt(name = "IP to ban", short = "i", long = "ip")] #[arg(short, long)]
ip: String, ip: String,
#[structopt(name = "Timeout in seconds", long = "timeout")] /// Timeout in seconds
#[arg(long)]
timeout: Option<u32>, timeout: Option<u32>,
#[structopt(name = "Docker", short = "d", long = "docker")] #[arg(short, long)]
docker: bool, docker: bool,
}, },
#[structopt(about = "Unban an ip (uses ipset)")] /// Unban an ip (uses ipset)
UnbanIp { UnbanIp {
#[structopt(name = "IP to unban", short = "i", long = "ip")] #[arg(short, long)]
ip: String, ip: String,
#[structopt(name = "Docker", short = "d", long = "docker")] #[arg(short, long)]
docker: bool, docker: bool,
}, },
#[structopt(about = "Get all secured ports")] /// Get all secured ports
GetSecuredPorts { GetSecuredPorts {
#[structopt(name = "Docker", short = "d", long = "docker")] #[arg(short, long)]
docker: bool, docker: bool,
}, },
#[structopt(about = "Get all secured ports with allowed ips")] /// Get all secured ports with allowed ips
GetSecuredPortsWithAllowedIps { GetSecuredPortsWithAllowedIps {
#[structopt(name = "Docker", short = "d", long = "docker")] #[arg(short, long)]
docker: bool, docker: bool,
}, },
#[structopt(about = "Check if a port is secured")] /// Check if a port is secured
IsPortSecured { IsPortSecured {
#[structopt(name = "Port to check", short = "p", long = "port")] #[arg(short, long)]
port: u16, port: u16,
#[structopt(name = "Docker", short = "d", long = "docker")] #[arg(short, long)]
docker: bool, docker: bool,
}, },
#[structopt(about = "Secure a port")] /// Secure a port
SecurePort { SecurePort {
#[structopt(name = "Port to ban", short = "p", long = "port")] #[arg(short, long)]
port: u16, port: u16,
#[structopt(name = "Position", short = "P", long = "position")] #[arg(short = 'P', long)]
position: Option<usize>, position: Option<usize>,
#[structopt(name = "Docker", short = "d", long = "docker")] #[arg(short, long)]
docker: bool, docker: bool,
}, },
#[structopt(about = "Unsecure a port")] /// Unsecure a port
UnsecurePort { UnsecurePort {
#[structopt(name = "Port to unban", short = "p", long = "port")] #[arg(short, long)]
port: u16, port: u16,
#[structopt(name = "Docker", short = "d", long = "docker")] #[arg(short, long)]
docker: bool, docker: bool,
}, },
#[structopt(about = "Allow an IP for a port")] /// Allow an IP for a port
AllowIpForPort { AllowIpForPort {
#[structopt(name = "IP to allow", short = "i", long = "ip")] #[arg(short, long)]
ip: String, ip: String,
#[structopt(name = "Port to allow", short = "p", long = "port")] #[arg(short, long)]
port: u16, port: u16,
#[structopt(name = "Position", short = "P", long = "position")] #[arg(short = 'P', long)]
position: Option<usize>, position: Option<usize>,
#[structopt(name = "Docker", short = "d", long = "docker")] #[arg(short, long)]
docker: bool, docker: bool,
}, },
#[structopt(about = "Removes an allowance IP for a port")] /// Removes an allowance IP for a port
RemoveAllowIpPort { RemoveAllowIpPort {
#[structopt(name = "IP to remove", short = "i", long = "ip")] #[arg(short, long)]
ip: String, ip: String,
#[structopt(name = "Port to remove", short = "p", long = "port")] #[arg(short, long)]
port: u16, port: u16,
#[structopt(name = "Docker", short = "d", long = "docker")] #[arg(short, long)]
docker: bool, docker: bool,
}, },
#[structopt(about = "Saves the rules")] /// Saves the iptables rules to /etc/iptables/rules.v4
SaveRules, 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::path::Path;
use std::process::Command; use std::process::{Command, Stdio};
pub const DEFAULT_SET_NAME: &str = "banned"; pub const DEFAULT_SET_NAME: &str = "banned";
pub const DEFAULT_TIMEOUT_SECS: u32 = 3600; 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)] #[derive(Debug, Clone)]
pub struct Ipset { pub struct Ipset {
name: String, name: String,
family: Family,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -19,21 +49,104 @@ pub enum IpsetError {
ParseError(String), ParseError(String),
#[error("invalid IP address: {0}")] #[error("invalid IP address: {0}")]
InvalidIp(String), 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}")] #[error("io error: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
pub type Result<T> = std::result::Result<T, IpsetError>; 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 { impl Ipset {
pub fn new(name: impl Into<String>) -> Self { 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 { pub fn name(&self) -> &str {
&self.name &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 { pub fn is_available() -> bool {
Command::new("ipset") Command::new("ipset")
.arg("--version") .arg("--version")
@@ -42,164 +155,393 @@ impl Ipset {
.unwrap_or(false) .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<()> { pub fn ensure_exists(&self) -> Result<()> {
let output = Command::new("ipset") if self.name.is_empty() || self.name.len() > MAX_SET_NAME_LEN {
.args(["create", &self.name, "hash:ip", "timeout", "0", "-exist"]) return Err(IpsetError::InvalidName(self.name.clone()));
.output()?; }
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() { if !output.status.success() {
return Err(IpsetError::CommandFailed( let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
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(()) 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<()> { pub fn add(&self, ip: &str, timeout_secs: Option<u32>) -> Result<()> {
validate_ip(ip)?; self.check_ip(ip)?;
let mut cmd = Command::new("ipset"); let timeout = match timeout_secs {
cmd.args(["add", &self.name, ip, "-exist"]); Some(secs) if secs > MAX_TIMEOUT_SECS => return Err(IpsetError::TimeoutTooLarge(secs)),
if let Some(secs) = timeout_secs { Some(secs) => Some(secs.to_string()),
cmd.args(["timeout", &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()?; check(run(&args)?)
if !output.status.success() {
return Err(IpsetError::CommandFailed(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
Ok(())
} }
pub fn del(&self, ip: &str) -> Result<()> { pub fn del(&self, ip: &str) -> Result<()> {
validate_ip(ip)?; self.check_ip(ip)?;
let output = Command::new("ipset") check(run(&["del", &self.name, ip, "-exist"])?)
.args(["del", &self.name, ip, "-exist"])
.output()?;
if !output.status.success() {
return Err(IpsetError::CommandFailed(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
Ok(())
} }
pub fn contains(&self, ip: &str) -> Result<bool> { pub fn contains(&self, ip: &str) -> Result<bool> {
validate_ip(ip)?; self.check_ip(ip)?;
let output = Command::new("ipset") Ok(run(&["test", &self.name, ip])?.status.success())
.args(["test", &self.name, ip])
.output()?;
Ok(output.status.success())
} }
pub fn list(&self) -> Result<Vec<String>> { pub fn list(&self) -> Result<Vec<String>> {
let output = Command::new("ipset") let output = run(&["list", &self.name])?;
.args(["list", &self.name])
.output()?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); 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 Ok(Vec::new());
} }
return Err(IpsetError::CommandFailed(stderr)); return Err(IpsetError::CommandFailed(stderr));
} }
let stdout = String::from_utf8_lossy(&output.stdout); Ok(parse_members(&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)
} }
pub fn save(&self, path: &Path) -> Result<()> { pub fn save(&self, path: &Path) -> Result<()> {
let output = Command::new("ipset") let output = run(&["save", &self.name])?;
.args(["save", &self.name]) check_ref(&output)?;
.output()?; std::fs::write(path, &output.stdout)?;
if !output.status.success() { Ok(())
return Err(IpsetError::CommandFailed(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
} }
/// 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)?; std::fs::write(path, &output.stdout)?;
Ok(()) Ok(())
} }
pub fn flush(&self) -> Result<()> { pub fn flush(&self) -> Result<()> {
let output = Command::new("ipset") check(run(&["flush", &self.name])?)
.args(["flush", &self.name])
.output()?;
if !output.status.success() {
return Err(IpsetError::CommandFailed(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
Ok(())
} }
pub fn destroy(&self) -> Result<()> { pub fn destroy(&self) -> Result<()> {
let output = Command::new("ipset") let output = run(&["destroy", &self.name])?;
.args(["destroy", &self.name])
.output()?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if stderr.contains("does not exist") { if is_missing_set(&stderr) {
return Ok(()); return Ok(());
} }
return Err(IpsetError::CommandFailed(stderr)); return Err(IpsetError::CommandFailed(stderr));
} }
Ok(()) 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<()> { fn run(args: &[&str]) -> Result<std::process::Output> {
if ip.parse::<std::net::Ipv4Addr>().is_err() && ip.parse::<std::net::Ipv6Addr>().is_err() { Command::new("ipset")
return Err(IpsetError::InvalidIp(ip.to_string())); .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(()) 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)] #[cfg(test)]
mod tests { pub(crate) mod tests {
use super::*; use super::*;
use serial_test::serial; use serial_test::serial;
const TEST_SET: &str = "banned_test_rs"; const TEST_SET: &str = "banned_test_rs";
const TEST_SET6: &str = "banned_test_rs6";
fn setup() -> Option<Ipset> { /// Tests that touch the kernel need CAP_NET_ADMIN. Run them with
if !Ipset::is_available() { /// `unshare -rn cargo test` (or as root); otherwise they are skipped.
eprintln!("ipset binary not available, skipping tests"); 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; return None;
} }
let set = Ipset::new(TEST_SET); let set = Ipset::with_family(name, family);
let _ = set.destroy(); set.destroy().expect("destroy before test");
match set.ensure_exists() { set.ensure_exists().expect("ensure_exists");
Ok(_) => Some(set), Some(set)
Err(e) => {
eprintln!("Cannot create ipset (need root?): {}", e);
None
}
} }
fn setup() -> Option<Ipset> {
setup_family(TEST_SET, Family::Inet)
} }
fn teardown(set: &Ipset) { fn teardown(set: &Ipset) {
let _ = set.destroy(); 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] #[test]
#[serial] #[serial]
fn ensure_exists_idempotent() { fn ensure_exists_idempotent() {
@@ -207,6 +549,7 @@ mod tests {
for _ in 0..5 { for _ in 0..5 {
set.ensure_exists().expect("ensure_exists failed"); set.ensure_exists().expect("ensure_exists failed");
} }
assert!(set.header().unwrap().unwrap().supports_timeout());
teardown(&set); teardown(&set);
} }
@@ -225,7 +568,8 @@ mod tests {
fn add_twice_no_error() { fn add_twice_no_error() {
let Some(set) = setup() else { return }; let Some(set) = setup() else { return };
set.add("192.0.2.2", None).expect("first add"); 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); teardown(&set);
} }
@@ -244,7 +588,8 @@ mod tests {
#[serial] #[serial]
fn del_nonexistent_no_error() { fn del_nonexistent_no_error() {
let Some(set) = setup() else { return }; 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); teardown(&set);
} }
@@ -262,22 +607,40 @@ mod tests {
#[test] #[test]
#[serial] #[serial]
fn save_and_restore_roundtrip() { fn save_and_restore_roundtrip() {
use std::env;
let Some(set) = setup() else { return }; let Some(set) = setup() else { return };
set.add("192.0.2.10", None).unwrap(); set.add("192.0.2.10", None).unwrap();
set.add("192.0.2.11", None).unwrap(); set.add("192.0.2.11", None).unwrap();
let tmp = env::temp_dir().join("martillo_ipset_test.restore"); let tmp = tempfile::NamedTempFile::new().unwrap();
set.save(&tmp).expect("save"); 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.10"));
assert!(content.contains("192.0.2.11")); assert!(content.contains("192.0.2.11"));
let _ = std::fs::remove_file(&tmp);
teardown(&set); 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] #[test]
#[serial] #[serial]
fn list_parses_plain_format_correctly() { fn list_parses_plain_format_correctly() {
@@ -302,33 +665,6 @@ mod tests {
teardown(&set); 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] #[test]
#[serial] #[serial]
fn flush_clears_all() { fn flush_clears_all() {
@@ -348,16 +684,117 @@ mod tests {
set.add("203.0.113.10", Some(2)).expect("add with timeout"); set.add("203.0.113.10", Some(2)).expect("add with timeout");
assert!(set.contains("203.0.113.10").unwrap()); assert!(set.contains("203.0.113.10").unwrap());
std::thread::sleep(std::time::Duration::from_secs(3)); 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); teardown(&set);
} }
#[test] #[test]
#[serial]
fn list_on_nonexistent_set_returns_empty() { fn list_on_nonexistent_set_returns_empty() {
if !privileged() {
return;
}
let bogus = Ipset::new("nonexistent_set_zzz_999"); let bogus = Ipset::new("nonexistent_set_zzz_999");
let result = bogus.list(); let result = bogus.list();
assert!(result.is_ok(), "list on nonexistent should not error"); assert!(result.is_ok(), "list on nonexistent should not error");
assert_eq!(result.unwrap().len(), 0); 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] #[test]
+67 -46
View File
@@ -1,71 +1,92 @@
mod cli; mod cli;
use cli::Cli; use clap::Parser;
use martillo_maldito::martillo_maldito::MartilloMaldito; use cli::{Cli, Command};
use structopt::StructOpt; use martillo_maldito::martillo_maldito::{MartilloMaldito, Result};
use std::process::ExitCode;
fn main() { fn main() -> ExitCode {
match Cli::from_args() { match run(Cli::parse().command) {
Cli::GetBannedIps { docker } => { Ok(output) => {
let banned_ips = MartilloMaldito::ipv4(docker).get_banned_ips(); println!("{output}");
println!("{}", serde_json::to_string(&banned_ips).unwrap()); ExitCode::SUCCESS
} }
Cli::BanIp { ip, timeout, docker } => { Err(e) => {
let martillo = MartilloMaldito::ipv4(docker); // Keep the historical "false" on stdout for scripts, details on stderr.
let result = match timeout { println!("false");
Some(t) => martillo.ban_ip_with_timeout(&ip, t), eprintln!("error: {e}");
None => martillo.ban_ip(&ip), ExitCode::FAILURE
};
println!("{}", result.is_ok())
} }
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 } => { "true".into()
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()
);
} }
Cli::IsPortSecured { port, docker } => { Command::UnbanIp { ip, docker } => {
let is_port_secured = MartilloMaldito::ipv4(docker).is_port_secured(port); MartilloMaldito::new(docker)?.unban_ip(&ip)?;
println!("{}", is_port_secured); "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, port,
position, position,
docker, docker,
} => { } => {
let port_secured = MartilloMaldito::ipv4(docker).secure_port(port, position); MartilloMaldito::new(docker)?.secure_port(port, position)?;
println!("{}", port_secured.is_ok()) "true".into()
} }
Cli::UnsecurePort { port, docker } => { Command::UnsecurePort { port, docker } => {
let port_unsecured = MartilloMaldito::ipv4(docker).unsecure_port(port); MartilloMaldito::new(docker)?.unsecure_port(port)?;
println!("{}", port_unsecured.is_ok()) "true".into()
} }
Cli::AllowIpForPort { Command::AllowIpForPort {
ip, ip,
port, port,
position, position,
docker, docker,
} => { } => {
let allowed_ip = MartilloMaldito::ipv4(docker).allow_ip_for_port(&ip, port, position); MartilloMaldito::new(docker)?.allow_ip_for_port(&ip, port, position)?;
println!("{}", allowed_ip.is_ok()) "true".into()
} }
Cli::RemoveAllowIpPort { ip, port, docker } => { Command::RemoveAllowIpPort { ip, port, docker } => {
let removed_allow_ip = MartilloMaldito::new(docker)?.remove_allow_ip_for_port(&ip, port)?;
MartilloMaldito::ipv4(docker).remove_allow_ip_for_port(&ip, port); "true".into()
println!("{}", removed_allow_ip.is_ok())
} }
Cli::SaveRules => { Command::SaveRules => {
println!("{}", MartilloMaldito::save_rules().is_ok()) 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 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 { pub struct MartilloMaldito {
iptables: iptables::IPTables, iptables: IPTables,
chain: String, chain: String,
ipset: Ipset, ipset: Ipset,
/// IPv6 banning is best effort: hosts without ip6tables/IPv6 still get IPv4.
ipv6: std::result::Result<Ipset, String>,
ban_timeout: Option<u32>, ban_timeout: Option<u32>,
} }
impl MartilloMaldito { impl MartilloMaldito {
pub fn ipv4(docker: bool) -> MartilloMaldito { pub fn new(docker: bool) -> Result<MartilloMaldito> {
Self::ipv4_with_config(docker, DEFAULT_SET_NAME, None) 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, docker: bool,
ipset_name: &str, ipset_name: &str,
ban_timeout: Option<u32>, ban_timeout: Option<u32>,
) -> MartilloMaldito { ) -> Result<MartilloMaldito> {
let ipset = Ipset::new(ipset_name); let chain = Self::get_chain(docker).to_string();
if let Err(e) = ipset.ensure_exists() { let iptables = ipt(iptables::new(false))?;
eprintln!("Warning: could not ensure ipset exists: {}", e); let ipset = Ipset::with_family(ipset_name, Family::Inet);
} ipset.ensure_exists()?;
let martillo = MartilloMaldito { ensure_match_rule(&iptables, &chain, ipset.name())?;
iptables: iptables::new(false).unwrap(),
chain: Self::get_chain(docker).to_string(), let ipv6 = Self::setup_ipv6(&chain, &ipv6_set_name(ipset_name)).map_err(|e| e.to_string());
Ok(MartilloMaldito {
iptables,
chain,
ipset, ipset,
ipv6,
ban_timeout, 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(); .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>> { pub fn ban_ip(&self, ip: &str) -> Result<()> {
self.ipset.add(ip, self.ban_timeout)?; self.set_for(ip)?.add(ip, self.ban_timeout)?;
Ok(()) Ok(())
} }
pub fn ban_ip_with_timeout( pub fn ban_ip_with_timeout(&self, ip: &str, timeout_secs: u32) -> Result<()> {
&self, self.set_for(ip)?.add(ip, Some(timeout_secs))?;
ip: &str,
timeout_secs: u32,
) -> Result<(), Box<dyn std::error::Error>> {
self.ipset.add(ip, Some(timeout_secs))?;
Ok(()) Ok(())
} }
pub fn unban_ip(&self, ip: &str) -> Result<(), Box<dyn std::error::Error>> { pub fn unban_ip(&self, ip: &str) -> Result<()> {
self.ipset.del(ip)?; self.set_for(ip)?.del(ip)?;
Ok(()) Ok(())
} }
pub fn secure_port( pub fn is_banned(&self, ip: &str) -> Result<bool> {
&self, Ok(self.set_for(ip)?.contains(ip)?)
port: u16, }
position: Option<usize>,
) -> Result<(), Box<dyn std::error::Error>> { fn set_for(&self, ip: &str) -> Result<&Ipset> {
let table = "filter"; 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 rule = secure_port_rule(port);
let position = match position {
let position = if self.chain == "DOCKER-USER" && position.is_none() { Some(position) => Some(position),
let all_docker_rules = self.get_rules()?; None if self.chain == "DOCKER-USER" => self.docker_user_return_position()?,
Some(all_docker_rules.len() - 1) None => None,
} else {
position
}; };
if let Some(position) = position { if let Some(position) = position {
self.insert_unique(table, &rule, position) self.insert_unique(&rule, position)
} else { } 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); 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( pub fn allow_ip_for_port(&self, ip: &str, port: u16, position: Option<usize>) -> Result<()> {
&self,
ip: &str,
port: u16,
position: Option<usize>,
) -> Result<(), Box<dyn std::error::Error>> {
let table = "filter";
let rule = allow_ip_for_port_rule(port, ip); let rule = allow_ip_for_port_rule(port, ip);
if let Some(position) = position { if let Some(position) = position {
self.insert_unique(table, &rule, position) self.insert_unique(&rule, position)
} else { } else {
self.append_unique(table, &rule) self.append_unique(&rule)
} }
} }
pub fn remove_allow_ip_for_port( pub fn remove_allow_ip_for_port(&self, ip: &str, port: u16) -> Result<()> {
&self,
ip: &str,
port: u16,
) -> Result<(), Box<dyn std::error::Error>> {
let rule = allow_ip_for_port_rule(port, ip); 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>> { /// Older Docker versions end DOCKER-USER with `-j RETURN`; new rules must go
self.iptables.list("filter", &self.chain) /// 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>> { fn get_rules(&self) -> Result<Vec<String>> {
self.iptables.append_unique(table, &self.chain, rule) ipt(self.iptables.list(TABLE, &self.chain))
} }
fn insert_unique( fn append_unique(&self, rule: &str) -> Result<()> {
&self, ipt(self.iptables.append_unique(TABLE, &self.chain, rule))
table: &str, }
rule: &str,
position: usize, fn insert_unique(&self, rule: &str, position: usize) -> Result<()> {
) -> Result<(), Box<dyn std::error::Error>> { let position = i32::try_from(position)
self.iptables .map_err(|_| MartilloError::Iptables(format!("invalid position {position}")))?;
.insert_unique(table, &self.chain, rule, position as i32) ipt(self
.iptables
.insert_unique(TABLE, &self.chain, rule, position))
} }
fn get_chain(docker: bool) -> &'static str { 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 { fn secure_port_rule(port: u16) -> String {
format!("-p tcp --dport {} -j DROP", port) 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) format!("-p tcp --dport {} -s {} -j ACCEPT", port, ip)
} }
fn extract_ip(regex: &Regex, input: &str) -> Option<String> { static SECURED_PORT_RE: LazyLock<Regex> = LazyLock::new(|| {
regex Regex::new(r"^-A \S+ -p tcp -m tcp --dport (\d+) -j DROP$").expect("valid regex")
.captures(input) });
.and_then(|caps| caps.get(0).map(|m| m.as_str().to_string()))
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> { /// `(port, ip)` of a rule created by `allow_ip_for_port`.
regex fn parse_allowed_ip(rule: &str) -> Option<(u16, String)> {
.captures(input) let caps = ALLOWED_IP_RE.captures(rule.trim())?;
.and_then(|caps| caps.get(1).map(|m| m.as_str().parse::<u16>().unwrap())) let port = caps.get(2)?.as_str().parse().ok()?;
} Some((port, caps.get(1)?.as_str().to_string()))
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()
} }
pub const fn default_ban_timeout_secs() -> u32 { pub const fn default_ban_timeout_secs() -> u32 {
@@ -255,40 +296,236 @@ pub const fn default_ban_timeout_secs() -> u32 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::ipset::tests::privileged;
use serial_test::serial;
#[test] #[test]
fn correct_extract_ip() { fn secured_port_parsed() {
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";
assert_eq!( assert_eq!(
extract_port(&iptables_regex_for_port(), input), parse_secured_port("-A INPUT -p tcp -m tcp --dport 2222 -j DROP"),
Some(2222) 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] #[test]
fn default_timeout_constant_is_one_hour() { fn default_timeout_constant_is_one_hour() {
assert_eq!(default_ban_timeout_secs(), 3600); 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 #!/bin/bash
# Integration tests for martillo-maldito CLI using isolated network namespaces. # Integration tests for martillo-maldito CLI using an isolated network namespace.
# Exercises iptables rule management. ipset is tested separately via cargo test. # 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 # 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 set -euo pipefail
@@ -16,18 +19,26 @@ if [[ ! -x "$BIN" ]]; then
exit 1 exit 1
fi 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" echo "ERROR: sudo not installed"
exit 1 exit 1
fi fi
cleanup() {
cleanup() {
sudo ip netns del "$NS" 2>/dev/null || true sudo ip netns del "$NS" 2>/dev/null || true
} }
trap cleanup EXIT trap cleanup EXIT
echo "==> Creating namespace $NS"
echo "==> Creating namespace $NS" sudo ip netns add "$NS"
sudo ip netns add "$NS" in_ns() { sudo ip netns exec "$NS" "$@"; }
fi
PASS=0 PASS=0
FAIL=0 FAIL=0
@@ -44,15 +55,32 @@ assert_eq() {
} }
run_cli() { 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 ""
echo "==> Test 1: ban_ip via CLI is idempotent" echo "==> Test 1: ban_ip via CLI is idempotent"
run_cli ban-ip -i 192.0.2.10 run_cli ban-ip -i 192.0.2.10 >/dev/null
# ipset is host-global, can't isolate per-netns. Just verify CLI returns ok.
output=$(run_cli ban-ip -i 192.0.2.10) output=$(run_cli ban-ip -i 192.0.2.10)
assert_eq "ban-ip idempotent" "true" "$output" 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 run_cli unban-ip -i 192.0.2.10 >/dev/null
echo "" echo ""
@@ -71,25 +99,47 @@ assert_eq "second unban returns ok" "true" "$output"
echo "" echo ""
echo "==> Test 4: secured ports with allowed IPs" 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 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)" 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) 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"[]')" assert_eq "10.0.0.5 allowed for 9999" "10.0.0.5" "$(echo "$allowed" | jq -r '."9999"[]')"
run_cli unsecure-port -p 9999 assert_eq "multiport rule ignored" "[9999]" "$(run_cli get-secured-ports)"
run_cli remove-allow-ip-port -i 10.0.0.5 -p 9999 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)" assert_eq "port 9999 unsecured" "false" "$(run_cli is-port-secured -p 9999)"
echo "" echo ""
echo "==> Test 5: cli list matches ipset list directly" echo "==> Test 5: cli list matches ipset list directly"
cli_count=$(run_cli get-banned-ips | jq 'length') 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 ""
echo "==> Test 6: error on invalid IP" 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 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 ""
echo "================================================" echo "================================================"