12 Commits
Author SHA1 Message Date
Pierre Dubouilh 27e78fe349 tighter checks for ipset install 2026-08-28 00:34:23 +02:00
Pierre Dubouilh 2df9519224 rename json to caddy
it was misleading
2026-08-28 00:34:23 +02:00
Pierre Dubouilh e8437d33fb tighten CLF 2026-08-28 00:34:23 +02:00
Pierre Dubouilh e63e926ccf drop sshd support 2026-08-28 00:34:23 +02:00
Pierre Dubouilh ac17c11158 multiple config per target 2026-08-28 00:34:23 +02:00
Pierre Dubouilh 43392ace03 fixup readme & bump rust release 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 9d0bc470cc use clap to avoid CLI settings conflicts 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 956d56716e bump serde & rework status parsing 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 9d677a13df gracefully introduce ipsets 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 38a374858e tighten clf regex 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 02f5bea4c0 fixup CLI flags 2026-05-27 18:10:50 +02:00
Pierre Dubouilh 1b9385e415 whitelist => blacklist 2026-05-27 18:10:50 +02:00
13 changed files with 314 additions and 300 deletions
+6 -1
View File
@@ -8,7 +8,12 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - name: Checkout
uses: actions/checkout@v4
- name: Format
run: rustup component add rustfmt clippy
- name: Run CI - name: Run CI
run: make ci run: make ci
+4 -1
View File
@@ -12,6 +12,9 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Format
run: rustup component add rustfmt clippy
- name: Run tests - name: Run tests
run: make ci run: make ci
@@ -29,4 +32,4 @@ jobs:
allowUpdates: true allowUpdates: true
artifacts: "builds/*" artifacts: "builds/*"
bodyFile: "builds/buildout" bodyFile: "builds/buildout"
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
Generated
+38 -32
View File
@@ -170,12 +170,6 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "dtoa"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0"
[[package]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.17" version = "0.2.17"
@@ -256,9 +250,9 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "0.3.4" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8324a32baf01e2ae060e9de58ed0bc2320c9a2833491ee36cd3b4c414de4db8c" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "kqueue" name = "kqueue"
@@ -348,24 +342,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "num-traits"
version = "0.1.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e5113e9fd4cc14ded8e499429f396a20f98c772a47cc8622a736e1ec843c31"
dependencies = [
"num-traits 0.2.19",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "num_cpus" name = "num_cpus"
version = "1.13.1" version = "1.13.1"
@@ -449,20 +425,44 @@ dependencies = [
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.185" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be9b6f69f1dfd54c3b568ffa45c310d6973a5e5148fd40cf515acaf38cf5bc31" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.96",
]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.1" version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c62115693d0a9ed8c32d1c760f0fdbe7d4b05cb13c135b9b54137ac0d59fccb" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [ dependencies = [
"dtoa",
"itoa", "itoa",
"num-traits 0.1.43", "memchr",
"serde", "serde",
"serde_core",
"zmij",
] ]
[[package]] [[package]]
@@ -703,3 +703,9 @@ name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+1 -1
View File
@@ -12,5 +12,5 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
lazy_static = "1.4.0" lazy_static = "1.4.0"
regex = "1.5.4" regex = "1.5.4"
anyhow = "1.0.44" anyhow = "1.0.44"
serde_json = "=1.0.1" serde_json = "1"
clap = { version = "4.5.27", features = ["derive"] } clap = { version = "4.5.27", features = ["derive"] }
+14 -16
View File
@@ -1,19 +1,18 @@
BLOCKFAST_VERS := $(shell date '+%Y-%m-%d') / $(shell git rev-parse --short HEAD) BLOCKFAST_VERS := $(shell git show -s --format=%cd --date=format:%Y-%m-%d HEAD) / $(shell git rev-parse --short HEAD)
export BLOCKFAST_VERS export BLOCKFAST_VERS
build:: build::
echo $(BLOCKFAST_VERS) @echo $(BLOCKFAST_VERS)
cargo build cargo build
cargo clippy --all cargo clippy --all
cargo fmt --all cargo fmt --all
run:: run::
touch /tmp/sshdtest
touch /tmp/clftest touch /tmp/clftest
touch /tmp/jsontest touch /tmp/caddytest
touch /tmp/generictest touch /tmp/generictest
cargo build cargo build
sudo target/debug/blockfast -v -s=/tmp/sshdtest -c=/tmp/clftest -j=/tmp/jsontest --generic-logpath=/tmp/generictest --generic-ip='from ([0-9a-fA-F:.]+) port' --generic-positive='Failed password' sudo target/debug/blockfast -v --clf-logpath=/tmp/clftest --caddy-logpath=/tmp/caddytest --generic-logpath=/tmp/generictest --generic-ip='from ([0-9a-fA-F:.]+) port' --generic-positive='Failed password'
ci:: test ci:: test
cargo fmt --all -- --check cargo fmt --all -- --check
@@ -21,14 +20,19 @@ ci:: test
build-all:: build-all::
mkdir -p builds mkdir -p builds
rustc --version > builds/buildout
cross build --release --target x86_64-unknown-linux-musl cross build --release --target x86_64-unknown-linux-musl
cross build --release --target aarch64-unknown-linux-musl cross build --release --target aarch64-unknown-linux-musl
cross build --release --target armv7-unknown-linux-musleabihf cross build --release --target armv7-unknown-linux-musleabihf
cp target/x86_64-unknown-linux-musl/release/blockfast builds/blockfast-x86_64-linux cp target/x86_64-unknown-linux-musl/release/blockfast builds/blockfast-x86_64-linux
cp target/aarch64-unknown-linux-musl/release/blockfast builds/blockfast-aarch64-linux cp target/aarch64-unknown-linux-musl/release/blockfast builds/blockfast-aarch64-linux
cp target/armv7-unknown-linux-musleabihf/release/blockfast builds/blockfast-arm7-linux cp target/armv7-unknown-linux-musleabihf/release/blockfast builds/blockfast-arm7-linux
chmod +x builds/blockfast-*
echo '```' > builds/buildout
echo $(BLOCKFAST_VERS) >> builds/buildout
rustc --version >> builds/buildout
sha256sum builds/* >> builds/buildout sha256sum builds/* >> builds/buildout
echo '```' >> builds/buildout
cat builds/buildout
watch:: watch::
ls src/*.rs | entr -rc -- make run ls src/*.rs | entr -rc -- make run
@@ -39,12 +43,6 @@ test::
release:: release::
cargo build --target x86_64-unknown-linux-musl --release cargo build --target x86_64-unknown-linux-musl --release
hit-sshd::
echo "Sep 26 06:25:32 livecompute sshd[23254]: Invalid user neal from 9.124.36.195" >> /tmp/sshdtest
ok-sshd::
echo "Sep 26 06:25:19 livecompute sshd[23246]: successful login 8.124.36.195 port 41883 ssh2" >> /tmp/sshdtest
hit-generic:: hit-generic::
echo "Sep 26 06:25:19 livecompute sshd[23246]: Failed password for root from 179.124.36.195 port 41883 ssh2" >> /tmp/generictest echo "Sep 26 06:25:19 livecompute sshd[23246]: Failed password for root from 179.124.36.195 port 41883 ssh2" >> /tmp/generictest
@@ -57,9 +55,9 @@ hit-clf::
ok-clf:: ok-clf::
echo "2.124.36.195 - p [25/Sep/2021:13:49:56 +0200] \"POST /some/rpc HTTP/2.0\" 200 23012" >> /tmp/clftest echo "2.124.36.195 - p [25/Sep/2021:13:49:56 +0200] \"POST /some/rpc HTTP/2.0\" 200 23012" >> /tmp/clftest
hit-json:: hit-caddy::
echo "{\"request\":{\"remote_ip\":\"1.124.36.19\"}, \"status\": 400}" >> /tmp/jsontest echo "{\"request\":{\"remote_ip\":\"1.124.36.19\"}, \"status\": 400}" >> /tmp/caddytest
ok-json:: ok-caddy::
echo "{\"request\":{\"remote_ip\":\"2.124.36.19\"}, \"status\": 200}" >> /tmp/jsontest echo "{\"request\":{\"remote_ip\":\"2.124.36.19\"}, \"status\": 200}" >> /tmp/caddytest
+20 -25
View File
@@ -3,9 +3,8 @@
Block internets scanners fast 🍶 Block internets scanners fast 🍶
Features: Features:
- SSH log parser - Common Log Format parser (apache, nginx logs, etc...)
- Common Log Format parser (apache logs, etc...) - Caddy JSON log parser
- JSON log parser (caddy logs)
- Generic log parser - Generic log parser
- Sane defaults - Sane defaults
- Fast ip ban with `ipset` - Fast ip ban with `ipset`
@@ -14,16 +13,15 @@ Features:
## example ## example
```txt ```txt
$ ./blockfast -s=/var/log/auth.log -j=/caddy/logs $ ./blockfast --caddy-logpath=/caddy/logs
1737927469 - starting with sshd parsing at "/tmp/sshdtest" 1737927469 - starting with caddy parsing at "/tmp/caddytest"
1737927469 - starting with json parsing at "/tmp/jsontest"
1737927469 - jail setup, allowance 5, time 21600s 1737927469 - jail setup, allowance 5, time 21600s
1737927477 - sshd logged offence for 9.124.36.195 1737927477 - caddy logged offence for 9.124.36.195
1737927478 - sshd logged offence for 9.124.36.195 1737927478 - caddy logged offence for 9.124.36.195
1737927479 - sshd logged offence for 9.124.36.195 1737927479 - caddy logged offence for 9.124.36.195
1737927479 - sshd logged offence for 9.124.36.195 1737927479 - caddy logged offence for 9.124.36.195
1737927480 - sshd logged offence for 9.124.36.195 1737927480 - caddy logged offence for 9.124.36.195
1737927480 - sshd jailtime for 9.124.36.195 1737927480 - caddy jailtime for 9.124.36.195
``` ```
## build ## build
@@ -36,17 +34,16 @@ Blockfast - block internets scanners fast 🍶
Author: pierre dubouilh <pldubouilh@gmail.com> Author: pierre dubouilh <pldubouilh@gmail.com>
Blockfast reads logs from various sources and blocks the offending IPs using iptables and ipset. Blockfast reads logs from various sources and blocks the offending IPs using iptables and ipset.
It supports logs from sshd, Common-Log-Format (Apache, etc..), JSON (Caddy) and a generic logs parser. It supports logs in Common-Log-Format (Apache, nginx, etc..), Caddy JSON and a generic logs parser.
Example: Example:
# block invalid sshd attempts & invalid http statuses from caddy # block invalid http statuses from caddy
./blockfast -s=/var/log/auth.log -j=/caddy/logs ./blockfast --caddy-logpath=/caddy/logs
# generic log parser example with a log text to flag, and a regex to parse the offending IP. # generic log parser example with a log text to flag, and a regex to parse the offending IP.
./blockfast --generic-logpath=/tmp/generictest --generic-positive='Failed password' --generic-ip='from ([0-9a-fA-F:.]+) port' ./blockfast --generic-logpath=/tmp/generictest --generic-positive='Failed password' --generic-ip='from ([0-9a-fA-F:.]+) port'
Usage: blockfast [OPTIONS] Usage: blockfast [OPTIONS]
Usage: blockfast [OPTIONS]
Options: Options:
--jailtime <JAILTIME> --jailtime <JAILTIME>
@@ -55,22 +52,20 @@ Options:
how many offences allowed (max 255) [default: 5] how many offences allowed (max 255) [default: 5]
-v, --verbose -v, --verbose
log all offences log all offences
-s, --sshd-logpath <SSHD_LOGPATH> --clf-logpath <CLF_LOGPATH>
path of sshd logfile path of Common-Log-Format logfile (Apache, nginx, etc..), can be repeated
-c, --clf-logpath <CLF_LOGPATH> --caddy-logpath <CADDY_LOGPATH>
path of Common-Log-Format logfile (Apache, etc..) path of Caddy JSON logfile, can be repeated
-j, --json-logpath <JSON_LOGPATH>
path of JSON HTTP logfile (Caddy)
--generic-logpath <GENERIC_LOGPATH> --generic-logpath <GENERIC_LOGPATH>
generic parser log file path generic parser log file path, can be repeated
--generic-ip <GENERIC_IP> --generic-ip <GENERIC_IP>
generic parser ip regex generic parser ip regex
--generic-positive <GENERIC_POSITIVE> --generic-positive <GENERIC_POSITIVE>
generic parser positive - if a logline contains this, it is considered bad, the rest is good generic parser positive - if a logline contains this, it is considered bad, the rest is good
--generic-negative <GENERIC_NEGATIVE> --generic-negative <GENERIC_NEGATIVE>
generic parser negative - if a logline contains this, it is considered good, the rest is bad generic parser negative - if a logline contains this, it is considered good, the rest is bad
--valid-http-statuses <VALID_HTTP_STATUSES> --invalid-http-statuses <INVALID_HTTP_STATUSES>
valid http statuses (for CLF and JSON logs) [default: 200 101] invalid http statuses (for CLF and Caddy logs). Coma separated list, accepts ranges with XX [default: 400,401,402,403]
-h, --help -h, --help
Print help Print help
-V, --version -V, --version
+1 -1
View File
@@ -1,2 +1,2 @@
[toolchain] [toolchain]
channel = "1.81.0" channel = "1.95.0"
+6 -6
View File
@@ -2,7 +2,7 @@ use crate::utils::ParsingStatus;
use anyhow::*; use anyhow::*;
use std::{net::IpAddr, str::FromStr}; use std::{net::IpAddr, str::FromStr};
pub fn parse(line: &str, valid_statuses: &[u32]) -> Result<ParsingStatus> { pub fn parse(line: &str, invalid_statuses: &[u32]) -> Result<ParsingStatus> {
let json: serde_json::Value = serde_json::from_str(line)?; let json: serde_json::Value = serde_json::from_str(line)?;
let remote_ip = json let remote_ip = json
@@ -17,8 +17,8 @@ pub fn parse(line: &str, valid_statuses: &[u32]) -> Result<ParsingStatus> {
.and_then(|r| r.as_u64()) .and_then(|r| r.as_u64())
.ok_or_else(|| anyhow!("cant parse json line - status"))?; .ok_or_else(|| anyhow!("cant parse json line - status"))?;
let is_good_status = valid_statuses.iter().any(|s| s == &(status as u32)); let is_bad_status = invalid_statuses.iter().any(|s| s == &(status as u32));
if !is_good_status { if is_bad_status {
return Ok(ParsingStatus::BadEntry(remote_ip)); return Ok(ParsingStatus::BadEntry(remote_ip));
} }
@@ -37,7 +37,7 @@ mod tests {
]; ];
vectors.iter().for_each(|e| { vectors.iter().for_each(|e| {
let ret = parse(*e, &vec![200, 404]).unwrap(); let ret = parse(*e, &vec![429, 401]).unwrap();
match ret { match ret {
ParsingStatus::BadEntry(_) => {} ParsingStatus::BadEntry(_) => {}
_ => panic!("bad parsing"), _ => panic!("bad parsing"),
@@ -53,7 +53,7 @@ mod tests {
]; ];
vectors.iter().for_each(|e| { vectors.iter().for_each(|e| {
let ret = parse(*e, &vec![200, 404]).unwrap(); let ret = parse(*e, &vec![429, 401]).unwrap();
match ret { match ret {
ParsingStatus::OkEntry => {} ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"), _ => panic!("bad parsing"),
@@ -68,7 +68,7 @@ mod tests {
]; ];
vectors.iter().for_each(|e| { vectors.iter().for_each(|e| {
let ret = parse(*e, &vec![200, 404]); let ret = parse(*e, &vec![429, 401]);
assert!(ret.is_err()); assert!(ret.is_err());
}) })
} }
+67 -18
View File
@@ -5,28 +5,33 @@ use regex::Regex;
use std::{net::IpAddr, str::FromStr}; use std::{net::IpAddr, str::FromStr};
lazy_static! { lazy_static! {
static ref RE_IP: Regex = Regex::new(r"^(\S+)\s").unwrap(); // anchored on the left side of the line: host, ident, authuser, [date],
static ref RE_STATUS: Regex = Regex::new(r"(\d+)\s(\w+)$").unwrap(); // the quoted request (honouring backslash escapes, so a quote injected in
// the URL cannot shift the match), then the status right after it. this
// covers plain CLF and the combined format - the trailing attacker
// controlled "referer" "user-agent" fields are never scanned
static ref RE_CLF: Regex =
Regex::new(r#"^(\S+)\s+\S+\s+\S+\s+\[[^\]]*\]\s+"(?:[^"\\]|\\.)*"\s+(\d{3})(?:\s|$)"#)
.unwrap();
} }
#[allow(clippy::bind_instead_of_map)] pub fn parse(line: &str, invalid_statuses: &[u32]) -> Result<ParsingStatus> {
pub fn parse(line: &str, valid_statuses: &[u32]) -> Result<ParsingStatus> { let caps = RE_CLF
let ip = RE_IP
.captures(line) .captures(line)
.and_then(|c| c.get(1)) .ok_or_else(|| anyhow!("cant parse clf line"))?;
.and_then(|g| Some(g.as_str()))
.and_then(|e| IpAddr::from_str(e).ok()) let ip = caps
.get(1)
.and_then(|g| IpAddr::from_str(g.as_str()).ok())
.ok_or_else(|| anyhow!("cant parse clf line - ip"))?; .ok_or_else(|| anyhow!("cant parse clf line - ip"))?;
let status = RE_STATUS let status = caps
.captures(line) .get(2)
.and_then(|c| c.get(1)) .and_then(|g| g.as_str().parse::<u32>().ok())
.and_then(|g| Some(g.as_str()))
.and_then(|e| e.parse::<u32>().ok())
.ok_or_else(|| anyhow!("cant parse clf line - status"))?; .ok_or_else(|| anyhow!("cant parse clf line - status"))?;
let is_good_status = valid_statuses.iter().any(|s| s == &status); let is_bad_status = invalid_statuses.iter().any(|s| s == &status);
if !is_good_status { if is_bad_status {
return Ok(ParsingStatus::BadEntry(ip)); return Ok(ParsingStatus::BadEntry(ip));
} }
@@ -45,7 +50,7 @@ mod tests {
]; ];
vectors.iter().for_each(|e| { vectors.iter().for_each(|e| {
let ret = parse(*e, &vec![200, 404]).unwrap(); let ret = parse(*e, &vec![401, 429]).unwrap();
match ret { match ret {
ParsingStatus::BadEntry(_) => {} ParsingStatus::BadEntry(_) => {}
_ => panic!("bad parsing"), _ => panic!("bad parsing"),
@@ -61,7 +66,7 @@ mod tests {
]; ];
vectors.iter().for_each(|e| { vectors.iter().for_each(|e| {
let ret = parse(*e, &vec![200, 404]).unwrap(); let ret = parse(*e, &vec![401, 429]).unwrap();
match ret { match ret {
ParsingStatus::OkEntry => {} ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"), _ => panic!("bad parsing"),
@@ -69,6 +74,50 @@ mod tests {
}) })
} }
#[test]
fn combined_format() {
// combined log format appends "referer" "user-agent" - the old parser
// read digits out of the user-agent as the status and silently missed these
let bad = r#"8.8.8.8 - - [25/Sep/2021:13:49:56 +0200] "GET /admin HTTP/1.1" 401 923 "https://example.com/" "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0""#;
match parse(bad, &vec![401, 429]).unwrap() {
ParsingStatus::BadEntry(_) => {}
_ => panic!("bad parsing"),
}
let ok = r#"8.8.8.8 - - [25/Sep/2021:13:49:56 +0200] "GET / HTTP/1.1" 200 923 "https://example.com/" "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0""#;
match parse(ok, &vec![401, 429]).unwrap() {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
}
#[test]
fn quote_injection() {
// servers escape quotes in the logged request - an escaped `\" 401 `
// inside the URL must not be mistaken for the end of the request field
let ok = r#"8.8.8.8 - - [25/Sep/2021:13:49:56 +0200] "GET /x?a=\" 401 - HTTP/1.1" 200 923"#;
match parse(ok, &vec![401, 429]).unwrap() {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
}
#[test]
fn bodyless() {
// CLF uses `-` for absent body bytes, both branches must still parse
let bad = "8.8.8.8 - p [25/Sep/2021:13:49:56 +0200] \"GET / HTTP/2.0\" 401 -";
match parse(bad, &vec![401, 429]).unwrap() {
ParsingStatus::BadEntry(_) => {}
_ => panic!("bad parsing"),
}
let ok = "8.8.8.8 - p [25/Sep/2021:13:49:56 +0200] \"GET / HTTP/2.0\" 304 -";
match parse(ok, &vec![401, 429]).unwrap() {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
}
#[test] #[test]
fn malformed() { fn malformed() {
let vectors = [ let vectors = [
@@ -77,7 +126,7 @@ mod tests {
]; ];
vectors.iter().for_each(|e| { vectors.iter().for_each(|e| {
let ret = parse(*e, &vec![200, 404]); let ret = parse(*e, &vec![429, 401]);
assert!(ret.is_err()); assert!(ret.is_err());
}) })
} }
+31 -9
View File
@@ -1,6 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::net::IpAddr; use std::net::IpAddr;
use std::process::Command; use std::process::Command;
use std::result::Result::Ok;
use std::sync::Mutex; use std::sync::Mutex;
use anyhow::*; use anyhow::*;
@@ -11,7 +12,7 @@ pub struct Jail {
name: String, name: String,
allowance: u8, allowance: u8,
jailtime: u32, jailtime: u32,
remand: Mutex<HashMap<IpAddr, (u8, u64)>>, remand: Mutex<HashMap<IpAddr, (u8, u64)>>, // ip -> (hits, timestamp)
} }
fn exec(program: &str, cmd: &str, err: &str) -> Result<(), Error> { fn exec(program: &str, cmd: &str, err: &str) -> Result<(), Error> {
@@ -22,22 +23,43 @@ fn exec(program: &str, cmd: &str, err: &str) -> Result<(), Error> {
Ok(()) Ok(())
} }
fn check_installed(program: &str) -> Result<()> {
match Command::new(program).arg("--version").output() {
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
bail!("`{}` not found, please install it first", program)
}
Err(e) => Err(e).context(format!("cant execute `{}`", program)),
}
}
fn exec_ok(program: &str, cmd: &str) -> Result<bool> {
let parts: Vec<&str> = cmd.split_whitespace().collect();
let out = Command::new(program).args(parts).output()?;
Ok(out.status.code() == Some(0))
}
impl Jail { impl Jail {
pub fn new(allowance: u8, jailtime: u32) -> Result<Jail> { pub fn new(allowance: u8, jailtime: u32) -> Result<Jail> {
const ERR_MSG: &str = "error using ipset/iptables, maybe it's not installed, or this program isn't running as root ?"; const ERR_MSG: &str =
"error using ipset/iptables, maybe this program isn't running as root ?";
check_installed("ipset")?;
check_installed("iptables")?;
let n = format!("blockfast_jail_{}", jailtime); let n = format!("blockfast_jail_{}", jailtime);
// create // create
let cmd = format!("create -exist {} hash:ip timeout {}", n, jailtime); let cmd = format!("create -exist {} hash:ip timeout {}", n, jailtime);
exec("ipset", &cmd, ERR_MSG)?; exec("ipset", &cmd, ERR_MSG)?;
// setup input // install drop rule on INPUT and FORWARD, but only if not already present
let cmd = format!("-I INPUT 1 -m set -j DROP --match-set {} src", n); let rule_spec = format!("-m set -j DROP --match-set {} src", n);
exec("iptables", &cmd, ERR_MSG)?; for chain in &["INPUT", "FORWARD"] {
let check = format!("-C {} {}", chain, rule_spec);
// setup fwd if !exec_ok("iptables", &check)? {
let cmd = format!("-I FORWARD 1 -m set -j DROP --match-set {} src", n); let install = format!("-I {} 1 {}", chain, rule_spec);
exec("iptables", &cmd, ERR_MSG)?; exec("iptables", &install, ERR_MSG)?;
}
}
log!("jail setup, allowance {}, time {}s", allowance, jailtime); log!("jail setup, allowance {}, time {}s", allowance, jailtime);
Ok(Jail { Ok(Jail {
+32 -49
View File
@@ -4,10 +4,9 @@ use anyhow::*;
use clap::Parser; use clap::Parser;
use linemux::{Line, MuxedLines}; use linemux::{Line, MuxedLines};
mod caddy;
mod clf; mod clf;
mod generic; mod generic;
mod json;
mod sshd;
mod utils; mod utils;
mod jail; mod jail;
@@ -19,54 +18,35 @@ async fn run() -> Result<()> {
let mut ml = MuxedLines::new()?; let mut ml = MuxedLines::new()?;
// HTTP statuses // HTTP statuses
let ok_statuses = args.valid_http_statuses.clone(); let invalid_statuses = args.invalid_http_statuses.clone();
let ok_statuses_parsed = parse_statuses(&ok_statuses)?; let invalid_statuses_parsed = parse_statuses(&invalid_statuses)?;
let ok_statuses_ref = ok_statuses_parsed.as_ref(); let invalid_statuses_ref = invalid_statuses_parsed.as_ref();
// generic parser // generic parser
let generic_path = args.generic_logpath.as_ref(); let generic_paths = &args.generic_logpath;
let generic_ip_re = args.generic_ip.as_ref(); let generic_ip_re = args.generic_ip.as_ref();
let generic_positive = args.generic_positive.as_ref(); let generic_positive = args.generic_positive.as_ref();
let generic_negative = args.generic_negative.as_ref(); let generic_negative = args.generic_negative.as_ref();
if args.generic_ip.is_some() for p in generic_paths {
|| args.generic_logpath.is_some()
|| args.generic_positive.is_some()
|| args.generic_negative.is_some()
{
if args.generic_ip.is_none() || args.generic_logpath.is_none() {
bail!("generic parser needs both ip regex and log file path");
}
if !(args.generic_positive.is_some() ^ args.generic_negative.is_some()) {
bail!("generic parser requires either a positive or a negative regex");
}
if let Some(p) = generic_path.as_ref() {
ml.add_file(&p).await?;
log!("starting with generic parsing at {:?}", &p);
}
}
// sshd
let sshd_logpath = args.sshd_logpath.as_ref();
if let Some(p) = sshd_logpath {
ml.add_file(&p).await?; ml.add_file(&p).await?;
log!("starting with sshd parsing at {:?}", &p); log!("starting with generic parsing at {:?}", &p);
} }
// common log format // common log format
let clf_logpath = args.clf_logpath.as_ref(); let clf_logpaths = &args.clf_logpath;
if let Some(p) = clf_logpath { for p in clf_logpaths {
ml.add_file(&p).await?; ml.add_file(&p).await?;
log!("starting with clf parsing at {:?}", &p); log!("starting with clf parsing at {:?}", &p);
} }
// json // caddy json
let json_logpath = args.json_logpath.as_ref(); let caddy_logpaths = &args.caddy_logpath;
if let Some(p) = json_logpath { for p in caddy_logpaths {
ml.add_file(&p).await?; ml.add_file(&p).await?;
log!("starting with json parsing at {:?}", &p); log!("starting with caddy parsing at {:?}", &p);
} }
if json_logpath.is_none() && clf_logpath.is_none() && sshd_logpath.is_none() { if caddy_logpaths.is_empty() && clf_logpaths.is_empty() && generic_paths.is_empty() {
bail!("no log files to parse, see --help"); bail!("no log files to parse, see --help");
} }
@@ -78,13 +58,11 @@ async fn run() -> Result<()> {
let path_buf = Some(line.source().to_path_buf()); let path_buf = Some(line.source().to_path_buf());
let path = path_buf.as_ref(); let path = path_buf.as_ref();
let (target, ret) = if path == sshd_logpath { let (target, ret) = if path.is_some_and(|p| clf_logpaths.contains(p)) {
("sshd", sshd::parse(payload)?) ("clf", clf::parse(payload, invalid_statuses_ref)?)
} else if path == clf_logpath { } else if path.is_some_and(|p| caddy_logpaths.contains(p)) {
("clf", clf::parse(payload, ok_statuses_ref)?) ("caddy", caddy::parse(payload, invalid_statuses_ref)?)
} else if path == json_logpath { } else if path.is_some_and(|p| generic_paths.contains(p)) {
("json", json::parse(payload, ok_statuses_ref)?)
} else if path == generic_path {
( (
"generic", "generic",
generic::parse(payload, generic_ip_re, generic_positive, generic_negative)?, generic::parse(payload, generic_ip_re, generic_positive, generic_negative)?,
@@ -106,18 +84,23 @@ async fn run() -> Result<()> {
Ok(()) Ok(())
}; };
while let Ok(Some(line)) = ml.next_line().await { loop {
if let Err(e) = assess_line(line) { match ml.next_line().await {
log!("ERR: {:?}", e); Ok(Some(line)) => {
if let Err(e) = assess_line(line) {
log!("ERR: {:?}", e);
}
}
Ok(None) => {
log!("log stream ended, exiting");
return Ok(());
}
Err(e) => return Err(e.into()),
} }
} }
Ok(())
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
run().await?; run().await
eprintln!("\n");
Ok(())
} }
-96
View File
@@ -1,96 +0,0 @@
use anyhow::*;
use lazy_static::lazy_static;
use regex::Regex;
use std::{net::IpAddr, str::FromStr};
use crate::utils::ParsingStatus;
struct Rule {
matcher: String,
extractor: Regex,
}
lazy_static! {
static ref SSHD_BAD: [Rule; 3] = [
Rule {
matcher: "Failed password".to_string(),
extractor: Regex::new(r"(from.)(\S+)").unwrap(),
},
Rule {
matcher: "Invalid user ".to_string(),
extractor: Regex::new(r"(from.)(\S+)").unwrap(),
},
Rule {
matcher: "authentication failure".to_string(),
extractor: Regex::new(r"(rhost=)(\S+)").unwrap()
},
];
}
pub fn parse(line: &str) -> Result<ParsingStatus> {
let hits = SSHD_BAD
.iter()
.find(|rule| line.contains(&rule.matcher))
.and_then(|r| r.extractor.captures(line));
if hits.is_none() {
return Ok(ParsingStatus::OkEntry);
}
let ip = hits
.and_then(|c| c.get(2))
.and_then(|m| IpAddr::from_str(m.as_str()).ok())
.ok_or_else(|| anyhow!("cant parse sshd line"))?;
Ok(ParsingStatus::BadEntry(ip))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn positive() {
let vectors = [
"Sep 26 06:25:19 livecompute sshd[23246]: Failed password for root from 179.124.36.195 port 41883 ssh2",
"Sep 26 06:26:14 livecompute sshd[23292]: pam_unix(sshd:auth): authentication failure; logname= u =0 tty=ssh ruser= rhost=5.101.107.190",
"Sep 26 06:25:32 livecompute sshd[23254]: Invalid user neal from 35.184.211.144"
];
vectors.iter().for_each(|e| {
let ret = parse(*e).unwrap();
match ret {
ParsingStatus::BadEntry(_) => {}
_ => panic!("bad parsing"),
}
})
}
#[test]
fn negative() {
let vectors = [
"Sep 26 06:25:19 livecompute sshd[23246]: successful login 179.124.36.195 port 41883 ssh2",
"Sep 26 06:26:14 livecompute sshd[23292]: pam_unix(sshd:auth): authentication total success; logname= u =0 tty=ssh ruser= rhost=5.101.107.190",
"Sep 26 06:25:32 livecompute sshd[23254]: very good user neal from 35.184.211.144"
];
vectors.iter().for_each(|e| {
let ret = parse(*e).unwrap();
match ret {
ParsingStatus::OkEntry => {}
_ => panic!("bad parsing"),
}
})
}
#[test]
fn malformed() {
let vectors = [
"Sep 26 06:25:19 livecompute sshd[23246]: Failed password for root from 179.124.36.195.232 port 41883 ssh2",
];
vectors.iter().for_each(|e| {
parse(*e).expect_err("");
})
}
}
+94 -45
View File
@@ -1,5 +1,5 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use clap::Parser; use clap::{ArgGroup, Parser};
use regex::Regex; use regex::Regex;
use std::{ use std::{
net::IpAddr, net::IpAddr,
@@ -43,28 +43,30 @@ pub fn parse_regex(a: &str) -> Result<Regex> {
Ok(r) Ok(r)
} }
/// Parse a comma-separated list of HTTP statuses with trailing-`x` wildcards.
///
/// Accepted forms per token: `NNN` (literal), `NNx` (10-wide range), `Nxx` (100-wide range).
/// All expanded codes must fall in the valid HTTP range 100..=599.
pub fn parse_statuses(a: &str) -> Result<Vec<u32>> { pub fn parse_statuses(a: &str) -> Result<Vec<u32>> {
if a.trim().is_empty() {
bail!("invalid_http_statuses: empty input");
}
let mut statuses = vec![]; let mut statuses = vec![];
for s in a.split(',') { for raw in a.split(',') {
if s.contains("xx") { let s = raw.trim();
let range = s.replace("xx", ""); let xs = s.chars().rev().take_while(|c| *c == 'x').count();
let range = range.parse::<u32>().context("invalid range")?; let digits = &s[..s.len() - xs];
let range = range * 100; if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
for i in 0..100 { bail!("invalid http status `{}`", s);
let status = range + i; }
statuses.push(status); let base: u32 = digits.parse().context("invalid status")?;
let span = 10u32.pow(xs as u32);
let from = base * span;
for code in from..(from + span) {
if !(100..=599).contains(&code) {
bail!("status {} out of valid HTTP range (100..=599)", code);
} }
} else if s.contains("x") { statuses.push(code);
let range = s.replace("x", "");
let range = range.parse::<u32>().context("invalid range")?;
let range = range * 10;
for i in 0..10 {
let status = range + i;
statuses.push(status);
}
} else {
let status = s.parse::<u32>().context("invalid status")?;
statuses.push(status);
} }
} }
Ok(statuses) Ok(statuses)
@@ -84,17 +86,17 @@ Blockfast - block internets scanners fast 🍶
Author: pierre dubouilh <pldubouilh@gmail.com> Author: pierre dubouilh <pldubouilh@gmail.com>
Blockfast reads logs from various sources and blocks the offending IPs using iptables and ipset. Blockfast reads logs from various sources and blocks the offending IPs using iptables and ipset.
It supports logs from sshd, Common-Log-Format (Apache, etc..), JSON (Caddy) and a generic logs parser. It supports logs in Common-Log-Format (Apache, nginx, etc..), Caddy JSON and a generic logs parser.
Example: Example:
# block invalid sshd attempts & invalid http statuses from caddy # block invalid http statuses from caddy
./blockfast -s=/var/log/auth.log -j=/caddy/logs ./blockfast --caddy-logpath=/caddy/logs
# generic log parser example with a log text to flag, and a regex to parse the offending IP. # generic log parser example with a log text to flag, and a regex to parse the offending IP.
./blockfast --generic-logpath=/tmp/generictest --generic-positive='Failed password' --generic-ip='from ([0-9a-fA-F:.]+) port'", ./blockfast --generic-logpath=/tmp/generictest --generic-positive='Failed password' --generic-ip='from ([0-9a-fA-F:.]+) port'",
verbatim_doc_comment, verbatim_doc_comment,
group(ArgGroup::new("generic_match").args(["generic_positive", "generic_negative"])),
)] )]
pub struct Args { pub struct Args {
/// jail time (seconds) /// jail time (seconds)
#[clap(long, default_value = "21600")] #[clap(long, default_value = "21600")]
@@ -108,35 +110,82 @@ pub struct Args {
#[clap(short, long)] #[clap(short, long)]
pub verbose: bool, pub verbose: bool,
/// path of sshd logfile /// path of Common-Log-Format logfile (Apache, nginx, etc..), can be repeated
#[clap(short, long, value_parser = resolve_path)]
pub sshd_logpath: Option<PathBuf>,
/// path of Common-Log-Format logfile (Apache, etc..)
#[clap(short, long, value_parser = resolve_path)]
pub clf_logpath: Option<PathBuf>,
/// path of JSON logfile (works with Caddy)
#[clap(short, long, value_parser = resolve_path)]
pub json_logpath: Option<PathBuf>,
/// generic parser log file path
#[clap(long, value_parser = resolve_path)] #[clap(long, value_parser = resolve_path)]
pub generic_logpath: Option<PathBuf>, pub clf_logpath: Vec<PathBuf>,
/// path of Caddy JSON logfile, can be repeated
#[clap(long, value_parser = resolve_path)]
pub caddy_logpath: Vec<PathBuf>,
/// generic parser log file path, can be repeated
#[clap(long, value_parser = resolve_path, requires_all = ["generic_ip", "generic_match"])]
pub generic_logpath: Vec<PathBuf>,
/// generic parser ip regex /// generic parser ip regex
#[clap(long , value_parser = parse_regex)] #[clap(long, value_parser = parse_regex, requires = "generic_logpath")]
pub generic_ip: Option<Regex>, pub generic_ip: Option<Regex>,
/// generic parser positive - if a logline contains this, it is considered bad, the rest is good /// generic parser positive - if a logline contains this, it is considered bad, the rest is good
#[clap(long)] #[clap(long, requires = "generic_logpath")]
pub generic_positive: Option<String>, pub generic_positive: Option<String>,
/// generic parser negative - if a logline contains this, it is considered good, the rest is bad /// generic parser negative - if a logline contains this, it is considered good, the rest is bad
#[clap(long)] #[clap(long, requires = "generic_logpath")]
pub generic_negative: Option<String>, pub generic_negative: Option<String>,
/// valid http statuses (for CLF and JSON logs). Coma separated list, accepts ranges with XX. /// invalid http statuses (for CLF and Caddy logs). Coma separated list, accepts ranges with XX.
#[clap(long, default_value = "10x,20x,30x,404,408")] #[clap(long, default_value = "400,401,402,403")]
pub valid_http_statuses: String, pub invalid_http_statuses: String,
}
#[cfg(test)]
mod tests {
use super::parse_statuses;
#[test]
fn literal() {
assert_eq!(parse_statuses("401").unwrap(), vec![401]);
assert_eq!(parse_statuses("401,404,429").unwrap(), vec![401, 404, 429]);
}
#[test]
fn ten_range() {
assert_eq!(
parse_statuses("40x").unwrap(),
(400..410).collect::<Vec<_>>()
);
}
#[test]
fn hundred_range() {
assert_eq!(
parse_statuses("4xx").unwrap(),
(400..500).collect::<Vec<_>>()
);
}
#[test]
fn mixed() {
let got = parse_statuses("401, 40x, 5xx").unwrap();
assert_eq!(got.len(), 1 + 10 + 100);
assert_eq!(got[0], 401);
assert!(got.contains(&500));
assert!(got.contains(&599));
}
#[test]
fn rejects_out_of_range() {
// 5x → 50..60, none of which are valid HTTP
assert!(parse_statuses("5x").is_err());
}
#[test]
fn rejects_garbage() {
assert!(parse_statuses("").is_err());
assert!(parse_statuses("xxx").is_err());
assert!(parse_statuses("1x2").is_err());
assert!(parse_statuses("abc").is_err());
assert!(parse_statuses("401,,402").is_err());
}
} }