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