From d7dcd9d6b172593b4d99e4a0c2ffc917deb44d88 Mon Sep 17 00:00:00 2001 From: SchiWaGoA Date: Sun, 25 Oct 2026 20:41:08 +0100 Subject: [PATCH] Initial version: npm-scanner image, filters, entrypoint, watcher, testbed - Image based on crazymax/fail2ban:1.1.1 with inotify-tools - Filters npm-scanner and npm-wplogin, tested with fail2ban-regex in the build - jail.local generated from .env, optional npm-wplogin and recidive - Watcher reloads the jails when NPM creates a new proxy host log - check command, docs, testbed for LXC Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 6 + .env.example | 42 +++++ .gitattributes | 2 + .gitignore | 5 + CHANGELOG.md | 15 ++ Dockerfile | 40 +++++ LICENSE | 21 +++ README.md | 169 +++++++++++++++++ SECURITY.md | 34 ++++ dev/testbed/.env.example | 16 ++ dev/testbed/check.sh | 99 ++++++++++ dev/testbed/docker-compose.yaml | 51 ++++++ dev/testbed/fake-npm-logs.sh | 53 ++++++ docker-compose.yaml | 44 +++++ docs/configuration.md | 96 ++++++++++ docs/operations.md | 107 +++++++++++ rootfs/etc/fail2ban/filter.d/npm-scanner.conf | 22 +++ rootfs/etc/fail2ban/filter.d/npm-wplogin.conf | 11 ++ .../etc/npm-scanner/jail.tpl/00-defaults.tpl | 6 + .../npm-scanner/jail.tpl/10-npm-scanner.tpl | 13 ++ .../npm-scanner/jail.tpl/11-npm-wplogin.tpl | 12 ++ .../etc/npm-scanner/jail.tpl/20-recidive.tpl | 10 ++ rootfs/usr/local/bin/npm-newlog-watch.sh | 70 ++++++++ rootfs/usr/local/bin/npm-scanner-init.sh | 170 ++++++++++++++++++ tests/filter-samples/npm-scanner.match.log | 10 ++ tests/filter-samples/npm-scanner.nomatch.log | 7 + tests/filter-samples/npm-wplogin.match.log | 4 + tests/filter-samples/npm-wplogin.nomatch.log | 3 + tests/run-filter-tests.sh | 25 +++ 29 files changed, 1163 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 dev/testbed/.env.example create mode 100644 dev/testbed/check.sh create mode 100644 dev/testbed/docker-compose.yaml create mode 100644 dev/testbed/fake-npm-logs.sh create mode 100644 docker-compose.yaml create mode 100644 docs/configuration.md create mode 100644 docs/operations.md create mode 100644 rootfs/etc/fail2ban/filter.d/npm-scanner.conf create mode 100644 rootfs/etc/fail2ban/filter.d/npm-wplogin.conf create mode 100644 rootfs/etc/npm-scanner/jail.tpl/00-defaults.tpl create mode 100644 rootfs/etc/npm-scanner/jail.tpl/10-npm-scanner.tpl create mode 100644 rootfs/etc/npm-scanner/jail.tpl/11-npm-wplogin.tpl create mode 100644 rootfs/etc/npm-scanner/jail.tpl/20-recidive.tpl create mode 100644 rootfs/usr/local/bin/npm-newlog-watch.sh create mode 100644 rootfs/usr/local/bin/npm-scanner-init.sh create mode 100644 tests/filter-samples/npm-scanner.match.log create mode 100644 tests/filter-samples/npm-scanner.nomatch.log create mode 100644 tests/filter-samples/npm-wplogin.match.log create mode 100644 tests/filter-samples/npm-wplogin.nomatch.log create mode 100644 tests/run-filter-tests.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fedb0a6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.env +data +docs +dev +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bf992f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# npm-scanner – copy to .env and adjust +# cp .env.example .env && nano .env + +# Image tag in the registry +NPM_SCANNER_VERSION=latest +TZ=Europe/Vienna +F2B_LOG_LEVEL=INFO + +# Log directories of NPM on the host +# jc21/nginx-proxy-manager: /logs +# jlesage/nginx-proxy-manager: /log and .../logs +# With only one directory, leave NPM_LOG_DIR2 empty or remove it. +NPM_LOG_DIR=/home/docker-projekte/nginx/data/log +NPM_LOG_DIR2=/home/docker-projekte/nginx/data/logs + +# Never ban (space separated): own public IP, LAN, VPN, monitoring +WHITELIST=203.0.113.10 192.168.1.0/24 + +# Firewall chain on the host (DOCKER-USER for NPM in Docker) +F2B_CHAIN=DOCKER-USER + +# npm-scanner: 3 hits in 1 h -> 24 h, increasing up to 4 weeks +SCANNER_MAXRETRY=3 +SCANNER_FINDTIME=1h +SCANNER_BANTIME=24h +SCANNER_MAXTIME=4w + +# npm-wplogin: WordPress login brute force +ENABLE_WPLOGIN=false +WPLOGIN_MAXRETRY=5 +WPLOGIN_FINDTIME=10m +WPLOGIN_BANTIME=24h +WPLOGIN_MAXTIME=4w + +# recidive: 3 bans in 1 week -> 4 weeks (INPUT + F2B_CHAIN) +ENABLE_RECIDIVE=false +RECIDIVE_MAXRETRY=3 +RECIDIVE_FINDTIME=1w +RECIDIVE_BANTIME=4w + +# Watch new NPM proxy hosts automatically +ENABLE_NEWLOG_WATCH=true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14faa21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +data/ +dev/testbed/data/ +dev/testbed/.env +dev/testbed/npm-logs/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c33200c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes are listed here. Versions follow [Semantic Versioning](https://semver.org). + +## [Unreleased] – 0.1.0 + +First version, generalised from a host-installed fail2ban setup. + +- Image based on `crazymax/fail2ban:1.1.1`, adds `inotify-tools` +- Filters `npm-scanner` and `npm-wplogin`, tested with `fail2ban-regex` during the image build +- `jail.local` generated from `.env` at every start (whitelist, chain, retries, ban times) +- Optional jails `npm-wplogin` and `recidive` +- Watcher reloads the jails when NPM creates a new proxy host log +- Second log directory for `jlesage/nginx-proxy-manager`; the same directory is never read twice +- `check` command: writes the configuration and runs `fail2ban-client -t` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6b6d48a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1 +# npm-scanner – fail2ban for Nginx Proxy Manager +# https://git.retronerd.at/schiwagoa/npm-scanner +# +# Build (multi-arch): +# docker buildx build --platform linux/amd64,linux/arm64 \ +# -t git.retronerd.at/schiwagoa/npm-scanner: --push . + +ARG FAIL2BAN_VERSION=1.1.1 + +FROM crazymax/fail2ban:${FAIL2BAN_VERSION} AS base + +RUN apk add --no-cache inotify-tools + +COPY rootfs / +RUN chmod 0755 /usr/local/bin/npm-scanner-init.sh /usr/local/bin/npm-newlog-watch.sh \ + && chmod 0644 /etc/fail2ban/filter.d/npm-scanner.conf /etc/fail2ban/filter.d/npm-wplogin.conf + +# Filter tests – the build fails if a filter no longer matches as expected. +FROM base AS test +COPY tests /tests +RUN sh /tests/run-filter-tests.sh && touch /tests/passed + +FROM base +ARG VERSION=dev +LABEL org.opencontainers.image.title="npm-scanner" \ + org.opencontainers.image.description="fail2ban for Nginx Proxy Manager: bans scanners in DOCKER-USER" \ + org.opencontainers.image.source="https://git.retronerd.at/schiwagoa/npm-scanner" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.version="${VERSION}" + +# Pulls in the test stage, so a plain `docker build` runs the tests too. +COPY --from=test /tests/passed /usr/share/npm-scanner/filter-tests.passed + +ENV TZ="Europe/Vienna" \ + F2B_LOG_TARGET="STDOUT" \ + F2B_DB_PURGE_AGE="30d" + +ENTRYPOINT [ "/usr/local/bin/npm-scanner-init.sh" ] +CMD [ "fail2ban-server", "-f", "-x", "-v", "start" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..32b8a0e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Thomas Werner Schwaighofer (retronerd.AT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c1b61b --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# npm-scanner + +fail2ban als Docker-Container für den **Nginx Proxy Manager (NPM)**: sperrt IPs, die nach +Schwachstellen scannen (`/.env`, `/.git/`, `phpinfo`, `/vendor/phpunit` …), direkt in der +Firewall des Hosts. + +- Liest die Access-Logs des NPM nur lesend – für `jc21/nginx-proxy-manager` und `jlesage/nginx-proxy-manager` +- Sperrt in der Kette **`DOCKER-USER`** auf allen Ports (Docker leitet veröffentlichte Ports an `INPUT` vorbei) +- **3 Treffer in 1 Stunde → 24 h**, bei Wiederholung steigend bis **4 Wochen** +- Optional: WordPress-Login-Schutz (`npm-wplogin`) und Wiederholungstäter (`recidive`) +- Neue Proxy-Hosts im NPM werden automatisch überwacht, ohne Neustart +- Alle Einstellungen in einer `.env`, Filter werden beim Image-Build getestet +- `amd64` und `arm64`, Basis [crazymax/fail2ban](https://github.com/crazy-max/docker-fail2ban) + +``` + Internet ──▶ Host-Firewall ──DOCKER-USER──▶ NPM (Docker) ──▶ Dienste + ▲ │ schreibt + │ sperrt ▼ + npm-scanner ◀──── liest ──── proxy-host-*_access.log + (fail2ban, network_mode: host) +``` + +Bewusst **nicht** gezählt: reine 404 (Nextcloud-/Matrix-Clients erzeugen legitim viele davon) +und Weiterleitungen am Default-Host ohne Scanner-Pfad (Suchmaschinen). + +## Quellen + +| | | +|---|---| +| Repository | https://git.retronerd.at/schiwagoa/npm-scanner | +| Image | `git.retronerd.at/schiwagoa/npm-scanner` | +| Basis-Image | https://github.com/crazy-max/docker-fail2ban · https://hub.docker.com/r/crazymax/fail2ban | +| fail2ban | https://github.com/fail2ban/fail2ban | +| Nginx Proxy Manager | https://nginxproxymanager.com · https://github.com/jlesage/docker-nginx-proxy-manager | + +## Voraussetzungen + +- Linux-Host mit Docker Engine und Compose-Plugin (`docker compose version`) +- NPM läuft auf **demselben Host** als Container +- **Kein** fail2ban direkt auf dem Host, das dieselben NPM-Logs liest – sonst wird doppelt gesperrt +- Im LXC-Container: auf dem Proxmox-Host `pct set --features nesting=1,keyctl=1` + +## Installation + +Alle Befehle als root. + +### 1. Log-Ordner des NPM ermitteln + +```bash +docker ps --format '{{.Names}}\t{{.Image}}' | grep -i proxy +``` + +Den Containernamen einsetzen: + +```bash +docker inspect --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}' +``` + +| Image | Container-Pfad | `NPM_LOG_DIR` | `NPM_LOG_DIR2` | +|---|---|---|---| +| `jc21/nginx-proxy-manager` | `/data` | `/logs` | leer | +| `jlesage/nginx-proxy-manager` | `/config` | `/log` | `/logs` | + +Bei jlesage schreiben neu gespeicherte Hosts nach `logs/`, ältere nach `log/` – deshalb beide. + +Kontrolle – es müssen `proxy-host-*_access.log` erscheinen: + +```bash +ls /home/docker-projekte/nginx/data/log | grep proxy-host | head +``` + +### 2. Projektordner anlegen + +```bash +mkdir -p /home/docker-projekte/npm-scanner +cd /home/docker-projekte/npm-scanner +curl -fsSLO https://git.retronerd.at/schiwagoa/npm-scanner/raw/branch/main/docker-compose.yaml +curl -fsSL -o .env https://git.retronerd.at/schiwagoa/npm-scanner/raw/branch/main/.env.example +chmod 600 .env +``` + +### 3. `.env` anpassen + +```bash +nano .env +``` + +Mindestens `NPM_LOG_DIR`, `NPM_LOG_DIR2` und **`WHITELIST`** (eigene öffentliche IP, LAN, VPN, +Monitoring) setzen. Alle Variablen: [docs/configuration.md](docs/configuration.md). + +### 4. Konfiguration prüfen (startet nichts) + +```bash +docker compose run --rm npm-scanner check +``` + +Erwartet: die erzeugte `jail.local` und am Ende `OK: configuration test is successful`. + +### 5. Starten + +```bash +docker compose up -d +docker compose ps +docker logs npm-scanner +``` + +Erwartet im Log: `[npm-scanner] log directories: …`, `[npm-newlog] watching …` und +`Jail 'npm-scanner' started`. Nach etwa 30 Sekunden steht der Container auf `healthy`. + +### 6. Prüfen + +```bash +docker exec npm-scanner fail2ban-client status npm-scanner +docker exec npm-scanner fail2ban-client get npm-scanner logpath +``` + +Nach der **ersten** Sperre hängt die Regel in `DOCKER-USER`: + +```bash +iptables -S DOCKER-USER +iptables -S f2b-npm-scan +``` + +## Betrieb + +| | | +|---|---| +| Gesperrte IPs | `docker exec npm-scanner fail2ban-client get npm-scanner banip --with-time` | +| Entsperren | `docker exec npm-scanner fail2ban-client set npm-scanner unbanip ` | +| Von Hand sperren | `docker exec npm-scanner fail2ban-client set npm-scanner banip ` | +| Letzte Sperren | `docker logs npm-scanner 2>&1 \| grep ' Ban ' \| tail -20` | +| Update | `docker compose pull && docker compose up -d` | + +Mehr – Backup, eigene Jails, Fehlersuche, Rückbau: [docs/operations.md](docs/operations.md). + +## Warum kein eigenes Docker-Netzwerk? + +fail2ban muss Regeln in der Firewall **des Hosts** setzen. Deshalb läuft der Container mit +`network_mode: host` und den Capabilities `NET_ADMIN` und `NET_RAW`. In einem Bridge-Netz +würde er nur die Firewall seines eigenen Netz-Namespaces ändern. Der Container öffnet keine +Ports und liest die NPM-Logs nur lesend. + +## Dokumentation + +| | | +|---|---| +| [Konfiguration](docs/configuration.md) | alle Variablen der `.env`, eigene Jails und Filter | +| [Betrieb](docs/operations.md) | Logs, Updates, Backup, Fehlerbehebung, Rückbau | +| [Sicherheit](SECURITY.md) | Rechte des Containers, Melden von Schwachstellen | + +## Lizenz + +[MIT](LICENSE). Basiert auf [crazymax/fail2ban](https://github.com/crazy-max/docker-fail2ban) (MIT) +und [fail2ban](https://github.com/fail2ban/fail2ban) (GPL-2.0). + +--- + +

+ 🏰 Website · + 🐘 Mastodon · + 🔒 Datenschutz · + 💬 Support +

+ +

+ +

+ +

© Thomas Werner Schwaighofer · retronerd.AT · MIT Lizenz

diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..97a3c52 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security + +## Reporting a vulnerability + +Please do **not** open a public issue. Send a description and, if possible, steps to +reproduce to the maintainer via a private message on +[git.retronerd.at](https://git.retronerd.at/schiwagoa). You will get an answer within a +few days; fixes are released as soon as possible and mentioned in the changelog. + +## Security model + +fail2ban has to change the firewall of the host, so this container is **not** isolated +like a normal service: + +| Right | Why | +|---|---| +| `network_mode: host` | rules must land in the host's `DOCKER-USER` / `INPUT` chains | +| `NET_ADMIN`, `NET_RAW` | needed by iptables / nftables | +| runs as root inside the container | required by fail2ban and iptables | + +Limits: + +- No ports are opened; the container only reads logs and changes firewall rules. +- NPM logs are mounted **read-only**. +- `.env` values end up in `jail.local`; the entrypoint rejects characters outside + IP/network/host-name syntax in `WHITELIST` and validates all numbers and durations. +- Anyone who can run `docker exec` on the host can ban or unban any IP – that is the same + as root on the host anyway. + +Recommendations: + +- Always set `WHITELIST` (own public IP, LAN, VPN, monitoring). +- Keep `.env` at mode `600`. +- Pull the image regularly – it follows the `crazymax/fail2ban` releases. diff --git a/dev/testbed/.env.example b/dev/testbed/.env.example new file mode 100644 index 0000000..664e0a7 --- /dev/null +++ b/dev/testbed/.env.example @@ -0,0 +1,16 @@ +# Testbed settings – copy to .env +TZ=Europe/Vienna +F2B_LOG_LEVEL=INFO + +# 192.0.2.10 is used by check.sh as "whitelisted" address. +# Do NOT add the workstation here if you want to run the manual block test. +WHITELIST=192.0.2.10 + +F2B_CHAIN=DOCKER-USER +ENABLE_WPLOGIN=true +ENABLE_RECIDIVE=false +ENABLE_NEWLOG_WATCH=true + +# Published port of the target container +TARGET_BIND=0.0.0.0 +TARGET_PORT=8081 diff --git a/dev/testbed/check.sh b/dev/testbed/check.sh new file mode 100644 index 0000000..008aa80 --- /dev/null +++ b/dev/testbed/check.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# npm-scanner – automated testbed checks (run as root next to docker-compose.yaml) +# +# ./check.sh prepare create the simulated NPM log directories (before `docker compose up`) +# ./check.sh run all checks against the running testbed +# ./check.sh cleanup unban the test addresses +# +# Test addresses are from the documentation ranges (RFC 5737) and never route anywhere. + +set -uo pipefail +cd "$(dirname "$0")" + +C=npm-scanner-testbed +F=./fake-npm-logs.sh +pass=0 fail=0 + +ok() { echo " ok $*"; pass=$((pass + 1)); } +bad() { echo " FAIL $*"; fail=$((fail + 1)); } +f2b() { docker exec "$C" fail2ban-client "$@"; } +banned() { f2b get "$1" banip 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; } + +wait_ban() { # jail ip [seconds] + for _ in $(seq 1 "${3:-15}"); do banned "$1" "$2" && return 0; sleep 1; done + return 1 +} + +if [[ "${1:-}" == "prepare" ]]; then + mkdir -p npm-logs/log npm-logs/logs data + touch npm-logs/log/proxy-host-1_access.log npm-logs/log/default-host_access.log + echo "prepared: npm-logs/log, npm-logs/logs, data" + exit 0 +fi + +if [[ "${1:-}" == "cleanup" ]]; then + for ip in 198.51.100.77 198.51.100.78 198.51.100.79 198.51.100.80 192.0.2.10 198.51.100.81 198.51.100.82; do + f2b unban "$ip" >/dev/null 2>&1 || true + done + echo "test addresses unbanned" + exit 0 +fi + +echo "== 1. container" +state=$(docker inspect -f '{{.State.Health.Status}}' "$C" 2>/dev/null) +[[ "$state" == healthy ]] && ok "health: $state" || bad "health: ${state:-not running}" +jails=$(f2b status | sed -n 's/.*Jail list:\s*//p') +[[ "$jails" == *npm-scanner* ]] && ok "jails: $jails" || bad "npm-scanner jail missing ($jails)" +[[ "$jails" == *npm-wplogin* ]] && ok "npm-wplogin active" || bad "npm-wplogin missing (ENABLE_WPLOGIN=true?)" +docker exec "$C" test -f /usr/share/npm-scanner/filter-tests.passed && ok "filter tests passed in build" || bad "filter test marker missing" + +echo "== 2. scanner ban (log/, host 1)" +$F scan 198.51.100.77 3 1 log >/dev/null +wait_ban npm-scanner 198.51.100.77 && ok "198.51.100.77 banned" || bad "198.51.100.77 not banned" +iptables -S DOCKER-USER | grep -q -- '-j f2b-npm-scan' && ok "DOCKER-USER jumps to f2b-npm-scan" || bad "no jump in DOCKER-USER" +iptables -S f2b-npm-scan 2>/dev/null | grep -q '198.51.100.77' && ok "rule for 198.51.100.77 on the host" || bad "no host rule for 198.51.100.77" + +echo "== 3. new host in second directory (logs/, host 40) -> watcher" +$F newhost 40 logs >/dev/null +sleep 6 +f2b get npm-scanner logpath | grep -q '/logs/npm2/proxy-host-40_access.log' && ok "host 40 watched after reload" || bad "host 40 not watched" +docker logs "$C" 2>&1 | grep -q 'proxy-host-40_access.log – npm-scanner reloaded' && ok "watcher logged the reload" || bad "no watcher message" +banned npm-scanner 198.51.100.77 && ok "ban survived the reload" || bad "ban lost after reload" +$F scan 198.51.100.78 3 40 logs >/dev/null +wait_ban npm-scanner 198.51.100.78 && ok "198.51.100.78 banned from host 40" || bad "198.51.100.78 not banned" + +echo "== 4. default host 444" +$F fallback 198.51.100.79 3 >/dev/null +wait_ban npm-scanner 198.51.100.79 && ok "198.51.100.79 banned (444)" || bad "198.51.100.79 not banned" + +echo "== 5. wp-login brute force" +$F wplogin 198.51.100.80 5 1 log >/dev/null +wait_ban npm-wplogin 198.51.100.80 && ok "198.51.100.80 banned by npm-wplogin" || bad "198.51.100.80 not banned" + +echo "== 6. must NOT ban" +$F scan 192.0.2.10 5 1 log >/dev/null +$F harmless 198.51.100.81 10 1 log >/dev/null +$F scan 198.51.100.82 2 1 log >/dev/null +sleep 5 +banned npm-scanner 192.0.2.10 && bad "whitelisted 192.0.2.10 banned" || ok "whitelisted 192.0.2.10 not banned" +banned npm-scanner 198.51.100.81 && bad "harmless 198.51.100.81 banned" || ok "harmless 404/200 not banned" +banned npm-scanner 198.51.100.82 && bad "198.51.100.82 banned after 2 hits" || ok "2 hits below maxretry" + +echo "== 7. restart keeps bans" +docker restart "$C" >/dev/null +for _ in $(seq 1 30); do f2b ping >/dev/null 2>&1 && break; sleep 1; done +sleep 3 +banned npm-scanner 198.51.100.77 && ok "198.51.100.77 restored after restart" || bad "bans lost after restart" +iptables -S f2b-npm-scan 2>/dev/null | grep -q '198.51.100.77' && ok "host rule restored" || bad "host rule missing after restart" + +echo "== 8. stop removes the rules" +docker stop "$C" >/dev/null +if iptables -S 2>/dev/null | grep -q 'f2b-npm-'; then bad "f2b-npm-* rules left after stop"; else ok "no f2b-npm-* rules after stop"; fi +docker start "$C" >/dev/null +for _ in $(seq 1 30); do f2b ping >/dev/null 2>&1 && break; sleep 1; done +sleep 3 +banned npm-scanner 198.51.100.77 && ok "bans back after start" || bad "bans missing after start" + +echo +echo "result: $pass ok, $fail failed" +[[ $fail -eq 0 ]] diff --git a/dev/testbed/docker-compose.yaml b/dev/testbed/docker-compose.yaml new file mode 100644 index 0000000..aa27021 --- /dev/null +++ b/dev/testbed/docker-compose.yaml @@ -0,0 +1,51 @@ +# npm-scanner – testbed +# +# npm-scanner from the local source plus a small web server as target. +# NPM itself is simulated: fake-npm-logs.sh writes log lines in NPM format into +# ./npm-logs/log and ./npm-logs/logs (layout of jlesage/nginx-proxy-manager). +# +# cp .env.example .env && nano .env +# docker compose up -d --build +# ./check.sh + +name: npm-scanner-testbed + +services: + npm-scanner: + build: ../.. + image: npm-scanner:testbed + container_name: npm-scanner-testbed + restart: "no" + init: true + network_mode: host + cap_add: + - NET_ADMIN + - NET_RAW + env_file: .env + environment: + NEWLOG_SETTLE_SECONDS: "2" + volumes: + - ./data:/data + - ./npm-logs/log:/logs/npm1:ro + - ./npm-logs/logs:/logs/npm2:ro + + # Target behind a published port – traffic to it passes DOCKER-USER. + target: + image: traefik/whoami:v1.12.0 + container_name: npm-scanner-target + restart: "no" + ports: + - "${TARGET_BIND:-0.0.0.0}:${TARGET_PORT:-8081}:80" + networks: + npm-scanner-target.dockernetwork.local: + ipv4_address: 172.16.52.10 + +networks: + npm-scanner-target.dockernetwork.local: + name: npm-scanner-target.dockernetwork.local + driver: bridge + ipam: + config: + - subnet: 172.16.52.0/24 + gateway: 172.16.52.1 + ip_range: 172.16.52.128/25 diff --git a/dev/testbed/fake-npm-logs.sh b/dev/testbed/fake-npm-logs.sh new file mode 100644 index 0000000..a48c1aa --- /dev/null +++ b/dev/testbed/fake-npm-logs.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Writes log lines in Nginx Proxy Manager format, like NPM would. +# +# ./fake-npm-logs.sh scan [count] [host-id] [log|logs] 4xx on scanner paths +# ./fake-npm-logs.sh wplogin [count] [host-id] [log|logs] POST /wp-login.php +# ./fake-npm-logs.sh harmless [count] [host-id] [log|logs] 404 /favicon.ico, 200 / +# ./fake-npm-logs.sh fallback [count] 444 on the default host +# ./fake-npm-logs.sh newhost [log|logs] empty log of a new proxy host + +set -euo pipefail +cd "$(dirname "$0")" + +cmd=${1:?command missing} +now() { date -u '+%d/%b/%Y:%H:%M:%S +0000'; } +proxy_line() { # status method path ip + printf '[%s] - %s %s - %s https test.example.com "%s" [Client %s] [Length 150] [Gzip -] [Sent-to 172.16.52.10] "curl/8.5.0" "-"\n' \ + "$(now)" "$1" "$1" "$2" "$3" "$4" +} + +case "$cmd" in + scan|wplogin|harmless) + ip=${2:?ip missing}; count=${3:-3}; host=${4:-1}; dir=npm-logs/${5:-log} + file="$dir/proxy-host-${host}_access.log" + mkdir -p "$dir" + paths=(/.env /.git/config /phpinfo.php /vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php /actuator/env) + for ((i = 0; i < count; i++)); do + case "$cmd" in + scan) proxy_line 404 GET "${paths[i % ${#paths[@]}]}" "$ip" ;; + wplogin) proxy_line 200 POST /wp-login.php "$ip" ;; + harmless) proxy_line 404 GET /favicon.ico "$ip"; proxy_line 200 GET / "$ip" ;; + esac >> "$file" + sleep 0.2 + done + echo "$cmd: $count × $ip -> $file" + ;; + fallback) + ip=${2:?ip missing}; count=${3:-3} + mkdir -p npm-logs/log + for ((i = 0; i < count; i++)); do + printf '%s - - [%s] "GET / HTTP/1.1" 444 0 "-" "curl/8.5.0"\n' "$ip" "$(now)" >> npm-logs/log/default-host_access.log + sleep 0.2 + done + echo "fallback: $count × $ip -> npm-logs/log/default-host_access.log" + ;; + newhost) + host=${2:?host-id missing}; dir=npm-logs/${3:-log} + mkdir -p "$dir" + touch "$dir/proxy-host-${host}_access.log" + echo "newhost: $dir/proxy-host-${host}_access.log" + ;; + *) + sed -n '2,9p' "$0"; exit 1 ;; +esac diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..4504170 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,44 @@ +# npm-scanner – fail2ban for Nginx Proxy Manager (Docker) +# Source: https://git.retronerd.at/schiwagoa/npm-scanner +# Base: https://github.com/crazy-max/docker-fail2ban +# +# No own bridge network on purpose: fail2ban has to set its rules in the +# firewall of the HOST (chain DOCKER-USER). In a bridge network it would only +# change the firewall of its own container. + +name: npm-scanner + +services: + npm-scanner: + image: git.retronerd.at/schiwagoa/npm-scanner:${NPM_SCANNER_VERSION:-latest} + # Build locally instead of pulling: + # build: . + container_name: npm-scanner + restart: unless-stopped + init: true # tini: reaps the watcher and tail processes + network_mode: host + cap_add: + - NET_ADMIN + - NET_RAW + env_file: .env + environment: + TZ: ${TZ:-Europe/Vienna} + F2B_LOG_LEVEL: ${F2B_LOG_LEVEL:-INFO} + F2B_DB_PURGE_AGE: ${F2B_DB_PURGE_AGE:-30d} # history for increasing ban times + volumes: + - ./data:/data # fail2ban DB + own additions + - ${NPM_LOG_DIR:?NPM_LOG_DIR missing in .env}:/logs/npm1:ro + - ${NPM_LOG_DIR2:-${NPM_LOG_DIR}}:/logs/npm2:ro # jlesage: second directory (logs/) + # Optional, for fail2ban-client on the host / fail2ban-webui: + # - /run/npm-scanner:/var/run/fail2ban + healthcheck: + test: ["CMD", "fail2ban-client", "ping"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..3b89d44 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,96 @@ +# Konfiguration + +Alle Einstellungen stehen in der `.env` neben der `docker-compose.yaml`. Nach einer Änderung: + +```bash +docker compose run --rm npm-scanner check +docker compose up -d +``` + +Die `jail.local` wird bei **jedem Start** neu aus der `.env` erzeugt. + +## Variablen + +### Allgemein + +| Variable | Standard | Bedeutung | +|---|---|---| +| `NPM_SCANNER_VERSION` | `latest` | Image-Tag | +| `TZ` | `Europe/Vienna` | Zeitzone für Log-Zeitstempel | +| `F2B_LOG_LEVEL` | `INFO` | `CRITICAL`, `ERROR`, `WARNING`, `NOTICE`, `INFO`, `DEBUG` | +| `F2B_DB_PURGE_AGE` | `30d` | So lange bleiben alte Sperren in der DB – Grundlage für die steigende Sperrdauer und `recidive` | +| `NPM_LOG_DIR` | – (Pflicht) | Log-Ordner des NPM auf dem Host | +| `NPM_LOG_DIR2` | = `NPM_LOG_DIR` | Zweiter Log-Ordner (jlesage: `logs/`). Ist es derselbe Ordner wie `NPM_LOG_DIR`, wird er nur einmal gelesen. | +| `WHITELIST` | leer | IPs/Netze, die nie gesperrt werden, mit Leerzeichen getrennt. `127.0.0.1/8` und `::1` sind immer dabei. | +| `F2B_CHAIN` | `DOCKER-USER` | iptables-Kette für die Sperren | +| `ENABLE_NEWLOG_WATCH` | `true` | Neue Proxy-Hosts automatisch überwachen | +| `NEWLOG_SETTLE_SECONDS` | `5` | Wartezeit nach dem Erscheinen einer neuen Logdatei | +| `IPTABLES_MODE` | `auto` | Vom Basis-Image: `auto`, `legacy` (erzwingt iptables-legacy) | + +Zeitangaben: Zahl mit Einheit `s`, `m`, `h`, `d`, `w` – zum Beispiel `10m`, `24h`, `4w`. + +### npm-scanner (immer aktiv) + +| Variable | Standard | Bedeutung | +|---|---|---| +| `SCANNER_MAXRETRY` | `3` | Treffer bis zur Sperre | +| `SCANNER_FINDTIME` | `1h` | Zeitfenster für die Treffer | +| `SCANNER_BANTIME` | `24h` | Erste Sperrdauer | +| `SCANNER_MAXTIME` | `4w` | Höchste Sperrdauer bei Wiederholung | + +Gelesen werden `proxy-host-*_access.log`, `default-host_access.log`, `fallback_access.log` und +`fallback_http_access.log` in beiden Log-Ordnern. + +### npm-wplogin (optional) + +Zählt `POST` auf `wp-login.php` und `xmlrpc.php` auf allen Hosts hinter dem NPM. + +| Variable | Standard | +|---|---| +| `ENABLE_WPLOGIN` | `false` | +| `WPLOGIN_MAXRETRY` | `5` | +| `WPLOGIN_FINDTIME` | `10m` | +| `WPLOGIN_BANTIME` | `24h` | +| `WPLOGIN_MAXTIME` | `4w` | + +### recidive (optional) + +Sperrt Wiederholungstäter über alle Jails in `INPUT` **und** `F2B_CHAIN`. + +| Variable | Standard | +|---|---| +| `ENABLE_RECIDIVE` | `false` | +| `RECIDIVE_MAXRETRY` | `3` | +| `RECIDIVE_FINDTIME` | `1w` | +| `RECIDIVE_BANTIME` | `4w` | + +Mit `recidive` schreibt fail2ban sein Log nach `./data/log/fail2ban.log` (recidive liest es). +`docker logs` zeigt es trotzdem weiter an. + +## Eigene Jails, Filter und Actions + +Der Ordner `./data` wird beim Start eingebunden: + +| Ordner | Wirkung | +|---|---| +| `./data/jail.d/*.local` | Wird **nach** der erzeugten `jail.local` gelesen – eigene Jails oder Überschreibungen | +| `./data/filter.d/*.conf` | Eigene Filter. Gleichnamige Filter des Images werden ersetzt. | +| `./data/action.d/*.conf` | Eigene Actions, zum Beispiel für ntfy | +| `./data/db/` | fail2ban-Datenbank – nicht von Hand ändern | + +Beispiel: Sperrdauer für npm-scanner auf 48 h, ohne die `.env` zu ändern: + +```bash +nano ./data/jail.d/50-eigene.local +``` + +```ini +[npm-scanner] +bantime = 48h +``` + +```bash +docker compose restart npm-scanner +``` + +Die NPM-Logs liegen im Container unter `/logs/npm1` und `/logs/npm2`. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..25de5af --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,107 @@ +# Betrieb + +Alle Befehle im Projektordner (zum Beispiel `/home/docker-projekte/npm-scanner`) als root. + +## Status und Logs + +```bash +docker compose ps +docker logs --tail 50 npm-scanner +docker logs -f npm-scanner +docker exec npm-scanner fail2ban-client status +docker exec npm-scanner fail2ban-client status npm-scanner +``` + +Überwachte Dateien: + +```bash +docker exec npm-scanner fail2ban-client get npm-scanner logpath +``` + +Meldungen des Watchers (neue Proxy-Hosts): + +```bash +docker logs npm-scanner 2>&1 | grep npm-newlog +``` + +## Sperren + +```bash +docker exec npm-scanner fail2ban-client get npm-scanner banip --with-time +docker exec npm-scanner fail2ban-client set npm-scanner unbanip 198.51.100.7 +docker exec npm-scanner fail2ban-client set npm-scanner banip 198.51.100.7 +docker exec npm-scanner fail2ban-client unban 198.51.100.7 # aus allen Jails +``` + +Regeln auf dem Host: + +```bash +iptables -S DOCKER-USER +iptables -S f2b-npm-scan +``` + +## Filter gegen echte Logs testen + +Ändert nichts, zeigt nur die Treffer: + +```bash +docker exec npm-scanner sh -c 'fail2ban-regex "$(ls -S /logs/npm1/proxy-host-*_access.log | head -1)" /etc/fail2ban/filter.d/npm-scanner.conf' | grep -E '^Lines:' +docker exec npm-scanner sh -c 'fail2ban-regex --print-all-matched "$(ls -S /logs/npm1/proxy-host-*_access.log | head -1)" /etc/fail2ban/filter.d/npm-scanner.conf' | grep -E '^\| \[' | head -20 +``` + +## Update + +```bash +docker compose pull +docker compose up -d +docker logs --tail 30 npm-scanner +``` + +Die Sperren bleiben erhalten (fail2ban-DB in `./data/db`). + +## Backup + +```bash +mkdir -p /backups/config_backups/$(hostname) +docker compose stop npm-scanner +tar -cpzf /backups/config_backups/$(hostname)/$(date +%F)_npm-scanner.tar.gz -C .. "$(basename "$PWD")" +docker compose start npm-scanner +``` + +## Fehlerbehebung + +| Meldung | Ursache | Lösung | +|---|---|---| +| `NPM_LOG_DIR missing in .env` | Variable fehlt | `.env` prüfen | +| `/logs/npm1 is missing` | Pfad in `NPM_LOG_DIR` existiert nicht | `ls ` auf dem Host | +| `no proxy-host-*_access.log found yet` | Falscher Ordner oder NPM hat noch keine Hosts | Schritt 1 der Installation wiederholen | +| `Have not found any log file for npm-scanner jail` | Keine einzige Logdatei gefunden | wie oben | +| `iptables chain DOCKER-USER does not exist` | Docker auf dem Host läuft nicht oder ohne iptables | `systemctl status docker`, `iptables -S DOCKER-USER` | +| `iptables-nft is not supported by the host, falling back to iptables-legacy` | Host nutzt iptables-legacy | nur Hinweis; prüfen, dass die Regeln mit `iptables -S` auf dem Host sichtbar sind | +| Regeln im Container, nicht auf dem Host | `network_mode: host` fehlt | `docker-compose.yaml` prüfen | +| Jede Zeile zählt doppelt | Derselbe Ordner zweimal über verschiedene Pfade eingebunden | `NPM_LOG_DIR2` leer lassen | +| Eigene IP gesperrt | Fehlt in `WHITELIST` | entsperren (siehe oben), `WHITELIST` ergänzen, `docker compose up -d` | +| Container startet nicht | Fehler in der Konfiguration | `docker compose run --rm npm-scanner check` | + +Notfall – alle Sperren sofort aufheben: + +```bash +docker compose stop npm-scanner +``` + +fail2ban entfernt beim Stoppen seine Regeln. Bleiben sie trotzdem hängen: + +```bash +iptables -D DOCKER-USER -j f2b-npm-scan +iptables -F f2b-npm-scan +iptables -X f2b-npm-scan +``` + +## Rückbau + +```bash +docker compose down +iptables -S | grep f2b- || echo "keine f2b-Regeln mehr" +``` + +Danach den Projektordner löschen, wenn die Sperr-Historie nicht mehr gebraucht wird. diff --git a/rootfs/etc/fail2ban/filter.d/npm-scanner.conf b/rootfs/etc/fail2ban/filter.d/npm-scanner.conf new file mode 100644 index 0000000..f662006 --- /dev/null +++ b/rootfs/etc/fail2ban/filter.d/npm-scanner.conf @@ -0,0 +1,22 @@ +# npm-scanner – fail2ban filter for Nginx Proxy Manager: scanners and probing +# https://git.retronerd.at/schiwagoa/npm-scanner +# +# Rule 1 (proxy-host-*_access.log, NPM "proxy" format): +# only 4xx answers to typical scanner paths +# [25/Oct/2026:12:13:14 +0000] - 404 404 - GET https example.com "/.env" [Client 198.51.100.7] ... +# Rule 2 (default-host / fallback log, combined format): status 444 or 400 +# (access by IP or unknown host name, broken requests) +# Rule 3 (default-host / fallback log): scanner paths regardless of status (often 301 there) +# +# Deliberately NOT counted: plain 404 (Nextcloud/Matrix clients produce many legitimately) +# and redirects on the default host without a scanner path (search engine crawlers). + +[Definition] + +_paths = (?:/\.env|/\.git/|/\.svn/|/\.aws/|/\.docker/|/\.ssh/|/\.s3cfg|/\.npmrc|/\.DS_Store|/\.vscode/|phpinfo|/info\.php|/_ignition/|/_profiler/|/wp-content/debug\.log|/phpmyadmin|/pma/|/cgi-bin/|/server-status|/actuator/|/vendor/phpunit|/boaform|/HNAP1|/secrets\.json|/credentials\.json|/docker-compose\.ya?ml|/\.gitlab-ci\.yml|/shell\.php|/eval-stdin\.php|\.(?:sql|bak|old|swp)(?:\.gz|\.zip)?(?=["?])) + +failregex = - (?:\d{3}|-) 4\d\d - [A-Z]+ https? \S+ "[^"]*%(_paths)s[^"]*" \[Client \] + ^ - \S+ .*?"[^"]*" (?:444|400) + ^ - \S+ .*?"[A-Z]+ [^"]*%(_paths)s[^"]*" \d{3} + +ignoreregex = diff --git a/rootfs/etc/fail2ban/filter.d/npm-wplogin.conf b/rootfs/etc/fail2ban/filter.d/npm-wplogin.conf new file mode 100644 index 0000000..52567a3 --- /dev/null +++ b/rootfs/etc/fail2ban/filter.d/npm-wplogin.conf @@ -0,0 +1,11 @@ +# npm-wplogin – fail2ban filter for Nginx Proxy Manager: WordPress login brute force +# https://git.retronerd.at/schiwagoa/npm-scanner +# +# Counts POST requests to wp-login.php and xmlrpc.php on every host behind this NPM. +# [25/Oct/2026:12:13:14 +0000] - 200 200 - POST https blog.example.com "/wp-login.php" [Client 198.51.100.7] ... + +[Definition] + +failregex = - (?:\d{3}|-) (?:200|302|403|404) - POST https? \S+ "/(?:wp-login\.php|xmlrpc\.php)[^"]*" \[Client \] + +ignoreregex = diff --git a/rootfs/etc/npm-scanner/jail.tpl/00-defaults.tpl b/rootfs/etc/npm-scanner/jail.tpl/00-defaults.tpl new file mode 100644 index 0000000..65571de --- /dev/null +++ b/rootfs/etc/npm-scanner/jail.tpl/00-defaults.tpl @@ -0,0 +1,6 @@ +# Generated by npm-scanner at container start – do not edit. +# Own additions and overrides: ./data/jail.d/*.local (read after this file). + +[DEFAULT] +ignoreip = 127.0.0.1/8 ::1 @WHITELIST@ +backend = pyinotify diff --git a/rootfs/etc/npm-scanner/jail.tpl/10-npm-scanner.tpl b/rootfs/etc/npm-scanner/jail.tpl/10-npm-scanner.tpl new file mode 100644 index 0000000..ee75cbd --- /dev/null +++ b/rootfs/etc/npm-scanner/jail.tpl/10-npm-scanner.tpl @@ -0,0 +1,13 @@ + +# Scanners and probing. Ban in @F2B_CHAIN@ on all ports +# (Docker routes published ports past INPUT). +[npm-scanner] +enabled = true +filter = npm-scanner +logpath = @LOGPATH_SCANNER@ +maxretry = @SCANNER_MAXRETRY@ +findtime = @SCANNER_FINDTIME@ +bantime = @SCANNER_BANTIME@ +bantime.increment = true +bantime.maxtime = @SCANNER_MAXTIME@ +action = iptables-allports[name=npm-scan, chain=@F2B_CHAIN@, protocol=all] diff --git a/rootfs/etc/npm-scanner/jail.tpl/11-npm-wplogin.tpl b/rootfs/etc/npm-scanner/jail.tpl/11-npm-wplogin.tpl new file mode 100644 index 0000000..f25e829 --- /dev/null +++ b/rootfs/etc/npm-scanner/jail.tpl/11-npm-wplogin.tpl @@ -0,0 +1,12 @@ + +# WordPress login brute force (POST wp-login.php / xmlrpc.php). +[npm-wplogin] +enabled = true +filter = npm-wplogin +logpath = @LOGPATH_PROXY@ +maxretry = @WPLOGIN_MAXRETRY@ +findtime = @WPLOGIN_FINDTIME@ +bantime = @WPLOGIN_BANTIME@ +bantime.increment = true +bantime.maxtime = @WPLOGIN_MAXTIME@ +action = iptables-allports[name=npm-wp, chain=@F2B_CHAIN@, protocol=all] diff --git a/rootfs/etc/npm-scanner/jail.tpl/20-recidive.tpl b/rootfs/etc/npm-scanner/jail.tpl/20-recidive.tpl new file mode 100644 index 0000000..b11f038 --- /dev/null +++ b/rootfs/etc/npm-scanner/jail.tpl/20-recidive.tpl @@ -0,0 +1,10 @@ + +# Repeat offenders across all jails: blocked in INPUT and @F2B_CHAIN@. +[recidive] +enabled = true +logpath = @F2B_LOGFILE@ +findtime = @RECIDIVE_FINDTIME@ +maxretry = @RECIDIVE_MAXRETRY@ +bantime = @RECIDIVE_BANTIME@ +action = iptables-allports[name=recidive-input, chain=INPUT, protocol=all] + iptables-allports[name=recidive-docker, chain=@F2B_CHAIN@, protocol=all] diff --git a/rootfs/usr/local/bin/npm-newlog-watch.sh b/rootfs/usr/local/bin/npm-newlog-watch.sh new file mode 100644 index 0000000..c66a10b --- /dev/null +++ b/rootfs/usr/local/bin/npm-newlog-watch.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# npm-scanner – watch the NPM log directories for new hosts +# https://git.retronerd.at/schiwagoa/npm-scanner +# +# fail2ban resolves logpath globs only when a jail starts. A proxy host created later +# in NPM would therefore not be watched. This script reloads the jails as soon as a +# log file appears that no jail watches yet. Bans survive the reload. +# +# npm-newlog-watch.sh [ …] +# NPM_SCANNER_JAILS="npm-scanner npm-wplogin" jails that read NPM logs + +set -uo pipefail + +JAILS=${NPM_SCANNER_JAILS:-npm-scanner} +SETTLE=${NEWLOG_SETTLE_SECONDS:-5} + +log() { echo "[npm-newlog] $*"; } + +[[ $# -gt 0 ]] || { log "no directories given"; exit 1; } + +# Wait until the fail2ban server answers (max. 5 minutes). +for _ in $(seq 1 300); do + fail2ban-client ping >/dev/null 2>&1 && break + sleep 1 +done +if ! fail2ban-client ping >/dev/null 2>&1; then + log "fail2ban does not answer – watcher stopped" + exit 1 +fi + +# Same mode the crazymax entrypoint chose (nft or legacy) is active by now. +chain=$(grep -m1 -oE 'chain=[A-Za-z0-9_-]+' /etc/fail2ban/jail.local | cut -d= -f2) +if [[ -n "$chain" ]] && ! iptables -n -L "$chain" >/dev/null 2>&1; then + log "WARNING: iptables chain $chain does not exist – bans will fail. Is Docker running on the host?" +fi + +watched() { + local file=$1 jail + for jail in $JAILS; do + fail2ban-client get "$jail" logpath 2>/dev/null | grep -qF -- "$file" && return 0 + done + return 1 +} + +declare -A tried=() + +log "watching $* (jails: $JAILS)" +inotifywait -m -q -e create -e moved_to --format '%w%f' "$@" | while read -r file; do + case "${file##*/}" in + proxy-host-*_access.log|default-host_access.log|fallback_access.log|fallback_http_access.log) ;; + *) continue ;; + esac + [[ -n "${tried[$file]:-}" ]] && continue + + # Give NPM time to finish writing the config, and debounce log rotation. + sleep "$SETTLE" + [[ -f "$file" ]] || continue + watched "$file" && continue + + tried[$file]=1 + for jail in $JAILS; do + # npm-wplogin only reads proxy-host logs. + [[ "$jail" == npm-wplogin && "${file##*/}" != proxy-host-* ]] && continue + if fail2ban-client reload "$jail" >/dev/null 2>&1; then + log "new log $file – $jail reloaded" + else + log "WARNING: reload of $jail failed for $file" + fi + done +done diff --git a/rootfs/usr/local/bin/npm-scanner-init.sh b/rootfs/usr/local/bin/npm-scanner-init.sh new file mode 100644 index 0000000..6fcf42b --- /dev/null +++ b/rootfs/usr/local/bin/npm-scanner-init.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# npm-scanner – container entrypoint +# https://git.retronerd.at/schiwagoa/npm-scanner +# +# Builds /etc/fail2ban/jail.local from the environment, starts the log watcher +# and hands over to the crazymax entrypoint (/entrypoint.sh), which starts fail2ban. +# +# npm-scanner-init.sh fail2ban-server … normal start (CMD of the image) +# npm-scanner-init.sh check write config, run `fail2ban-client -t`, print jail.local, exit + +set -euo pipefail + +TPL_DIR=/etc/npm-scanner/jail.tpl +JAIL_LOCAL=/etc/fail2ban/jail.local +LOG1=/logs/npm1 +LOG2=/logs/npm2 + +log() { echo "[npm-scanner] $*"; } +die() { echo "[npm-scanner] ERROR: $*" >&2; exit 1; } + +# --- Settings -------------------------------------------------------------- + +WHITELIST=${WHITELIST:-} +F2B_CHAIN=${F2B_CHAIN:-DOCKER-USER} + +SCANNER_MAXRETRY=${SCANNER_MAXRETRY:-3} +SCANNER_FINDTIME=${SCANNER_FINDTIME:-1h} +SCANNER_BANTIME=${SCANNER_BANTIME:-24h} +SCANNER_MAXTIME=${SCANNER_MAXTIME:-4w} + +ENABLE_WPLOGIN=${ENABLE_WPLOGIN:-false} +WPLOGIN_MAXRETRY=${WPLOGIN_MAXRETRY:-5} +WPLOGIN_FINDTIME=${WPLOGIN_FINDTIME:-10m} +WPLOGIN_BANTIME=${WPLOGIN_BANTIME:-24h} +WPLOGIN_MAXTIME=${WPLOGIN_MAXTIME:-4w} + +ENABLE_RECIDIVE=${ENABLE_RECIDIVE:-false} +RECIDIVE_MAXRETRY=${RECIDIVE_MAXRETRY:-3} +RECIDIVE_FINDTIME=${RECIDIVE_FINDTIME:-1w} +RECIDIVE_BANTIME=${RECIDIVE_BANTIME:-4w} + +ENABLE_NEWLOG_WATCH=${ENABLE_NEWLOG_WATCH:-true} + +is_true() { [[ "${1,,}" =~ ^(1|true|yes|on)$ ]]; } + +check_int() { [[ "$2" =~ ^[1-9][0-9]*$ ]] || die "$1 must be a positive number (got '$2')"; } +check_time() { [[ "$2" =~ ^[1-9][0-9]*[smhdw]?$ ]] || die "$1 must be a duration like 10m, 24h, 4w (got '$2')"; } + +check_int SCANNER_MAXRETRY "$SCANNER_MAXRETRY" +check_time SCANNER_FINDTIME "$SCANNER_FINDTIME" +check_time SCANNER_BANTIME "$SCANNER_BANTIME" +check_time SCANNER_MAXTIME "$SCANNER_MAXTIME" +check_int WPLOGIN_MAXRETRY "$WPLOGIN_MAXRETRY" +check_time WPLOGIN_FINDTIME "$WPLOGIN_FINDTIME" +check_time WPLOGIN_BANTIME "$WPLOGIN_BANTIME" +check_time WPLOGIN_MAXTIME "$WPLOGIN_MAXTIME" +check_int RECIDIVE_MAXRETRY "$RECIDIVE_MAXRETRY" +check_time RECIDIVE_FINDTIME "$RECIDIVE_FINDTIME" +check_time RECIDIVE_BANTIME "$RECIDIVE_BANTIME" + +[[ "$F2B_CHAIN" =~ ^[A-Za-z0-9_-]{1,28}$ ]] || die "F2B_CHAIN contains invalid characters (got '$F2B_CHAIN')" + +# Only IPs, networks and host names – no characters that could break jail.local. +for entry in $WHITELIST; do + [[ "$entry" =~ ^[0-9A-Za-z.:/_-]+$ ]] || die "invalid WHITELIST entry '$entry'" +done +[[ -n "$WHITELIST" ]] || log "WARNING: WHITELIST is empty – your own IP can be banned." + +# --- Log directories ------------------------------------------------------- + +[[ -d "$LOG1" ]] || die "$LOG1 is missing – check NPM_LOG_DIR in .env" + +# NPM_LOG_DIR2 defaults to NPM_LOG_DIR. Watching the same directory twice +# would count every line twice, so the second mount is only used if it differs. +LOG_DIRS=("$LOG1") +if [[ -d "$LOG2" ]] && [[ "$(stat -L -c '%d:%i' "$LOG1")" != "$(stat -L -c '%d:%i' "$LOG2")" ]]; then + LOG_DIRS+=("$LOG2") +fi +log "log directories: ${LOG_DIRS[*]}" + +if ! ls "$LOG1"/proxy-host-*_access.log >/dev/null 2>&1 \ + && { [[ ${#LOG_DIRS[@]} -lt 2 ]] || ! ls "$LOG2"/proxy-host-*_access.log >/dev/null 2>&1; }; then + log "WARNING: no proxy-host-*_access.log found yet – is NPM_LOG_DIR correct?" +fi + +# One logpath entry per line, continuation lines indented for fail2ban. +join_paths() { + local out="" p + for p in "$@"; do + if [[ -z "$out" ]]; then out="$p"; else out+=$'\n'" $p"; fi + done + printf '%s' "$out" +} + +proxy_paths=() scanner_paths=() +for d in "${LOG_DIRS[@]}"; do + proxy_paths+=("$d/proxy-host-*_access.log") +done +scanner_paths=("${proxy_paths[@]}") +for d in "${LOG_DIRS[@]}"; do + scanner_paths+=("$d/default-host_access.log" "$d/fallback_access.log" "$d/fallback_http_access.log") +done +LOGPATH_PROXY=$(join_paths "${proxy_paths[@]}") +LOGPATH_SCANNER=$(join_paths "${scanner_paths[@]}") + +# recidive reads the fail2ban log, so it needs a file instead of STDOUT. +# A tail process keeps `docker logs` working. +F2B_LOGFILE=/data/log/fail2ban.log +if is_true "$ENABLE_RECIDIVE"; then + mkdir -p /data/log + touch "$F2B_LOGFILE" + export F2B_LOG_TARGET="$F2B_LOGFILE" +fi + +# --- jail.local ------------------------------------------------------------ + +render() { + local tpl + tpl=$(<"$1") + tpl=${tpl//@WHITELIST@/$WHITELIST} + tpl=${tpl//@F2B_CHAIN@/$F2B_CHAIN} + tpl=${tpl//@F2B_LOGFILE@/$F2B_LOGFILE} + tpl=${tpl//@LOGPATH_PROXY@/$LOGPATH_PROXY} + tpl=${tpl//@LOGPATH_SCANNER@/$LOGPATH_SCANNER} + tpl=${tpl//@SCANNER_MAXRETRY@/$SCANNER_MAXRETRY} + tpl=${tpl//@SCANNER_FINDTIME@/$SCANNER_FINDTIME} + tpl=${tpl//@SCANNER_BANTIME@/$SCANNER_BANTIME} + tpl=${tpl//@SCANNER_MAXTIME@/$SCANNER_MAXTIME} + tpl=${tpl//@WPLOGIN_MAXRETRY@/$WPLOGIN_MAXRETRY} + tpl=${tpl//@WPLOGIN_FINDTIME@/$WPLOGIN_FINDTIME} + tpl=${tpl//@WPLOGIN_BANTIME@/$WPLOGIN_BANTIME} + tpl=${tpl//@WPLOGIN_MAXTIME@/$WPLOGIN_MAXTIME} + tpl=${tpl//@RECIDIVE_MAXRETRY@/$RECIDIVE_MAXRETRY} + tpl=${tpl//@RECIDIVE_FINDTIME@/$RECIDIVE_FINDTIME} + tpl=${tpl//@RECIDIVE_BANTIME@/$RECIDIVE_BANTIME} + printf '%s\n' "$tpl" +} + +{ + render "$TPL_DIR/00-defaults.tpl" + render "$TPL_DIR/10-npm-scanner.tpl" + is_true "$ENABLE_WPLOGIN" && render "$TPL_DIR/11-npm-wplogin.tpl" + is_true "$ENABLE_RECIDIVE" && render "$TPL_DIR/20-recidive.tpl" + true +} > "$JAIL_LOCAL" + +jails="npm-scanner" +is_true "$ENABLE_WPLOGIN" && jails+=" npm-wplogin" +is_true "$ENABLE_RECIDIVE" && jails+=" recidive" +log "jails: $jails" + +# --- Start ----------------------------------------------------------------- + +if [[ "${1:-}" == "check" ]]; then + # Same preparation as the crazymax entrypoint, without starting the server. + mkdir -p /data/db /data/action.d /data/filter.d /data/jail.d + ln -sfn /data/jail.d /etc/fail2ban/jail.d + cat "$JAIL_LOCAL" + exec fail2ban-client -t +fi + +if is_true "$ENABLE_RECIDIVE"; then + tail -F -n 0 "$F2B_LOGFILE" 2>/dev/null & +fi + +if is_true "$ENABLE_NEWLOG_WATCH"; then + NPM_SCANNER_JAILS="${jails/ recidive/}" /usr/local/bin/npm-newlog-watch.sh "${LOG_DIRS[@]}" & +fi + +exec /entrypoint.sh "$@" diff --git a/tests/filter-samples/npm-scanner.match.log b/tests/filter-samples/npm-scanner.match.log new file mode 100644 index 0000000..7b6f313 --- /dev/null +++ b/tests/filter-samples/npm-scanner.match.log @@ -0,0 +1,10 @@ +[25/Oct/2026:12:13:14 +0000] - 404 404 - GET https example.com "/.env" [Client 198.51.100.7] [Length 150] [Gzip -] [Sent-to 172.16.1.10] "Mozilla/5.0" "-" +[25/Oct/2026:12:13:15 +0000] - - 404 - GET https example.com "/.git/config" [Client 198.51.100.7] [Length 150] [Gzip -] [Sent-to 172.16.1.10] "Mozilla/5.0" "-" +[25/Oct/2026:12:13:16 +0000] - 403 403 - GET https example.com "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php" [Client 198.51.100.8] [Length 150] [Gzip -] [Sent-to 172.16.1.10] "curl/8.5.0" "-" +[25/Oct/2026:12:13:17 +0000] - 404 404 - POST http example.com "/cgi-bin/luci" [Client 198.51.100.9] [Length 150] [Gzip -] [Sent-to 172.16.1.10] "-" "-" +[25/Oct/2026:12:13:18 +0000] - 404 404 - GET https example.com "/backup.sql.gz" [Client 2001:db8::7] [Length 150] [Gzip -] [Sent-to 172.16.1.10] "Mozilla/5.0" "-" +[25/Oct/2026:12:13:19 +0000] - 401 401 - GET https example.com "/actuator/env" [Client 198.51.100.10] [Length 150] [Gzip -] [Sent-to 172.16.1.10] "Mozilla/5.0" "-" +198.51.100.11 - - [25/Oct/2026:12:14:01 +0000] "GET / HTTP/1.1" 444 0 "-" "curl/8.5.0" +198.51.100.12 - - [25/Oct/2026:12:14:02 +0000] "\x16\x03\x01\x02\x00\x01\x00" 400 157 "-" "-" +198.51.100.13 - - [25/Oct/2026:12:14:03 +0000] "GET /.env HTTP/1.1" 301 166 "-" "Mozilla/5.0" +198.51.100.14 - - [25/Oct/2026:12:14:04 +0000] "GET /phpmyadmin/index.php HTTP/1.1" 301 166 "-" "Mozilla/5.0" diff --git a/tests/filter-samples/npm-scanner.nomatch.log b/tests/filter-samples/npm-scanner.nomatch.log new file mode 100644 index 0000000..a2e761c --- /dev/null +++ b/tests/filter-samples/npm-scanner.nomatch.log @@ -0,0 +1,7 @@ +[25/Oct/2026:12:13:14 +0000] - 200 200 - GET https example.com "/.env" [Client 198.51.100.7] [Length 150] [Gzip -] [Sent-to 172.16.1.10] "Mozilla/5.0" "-" +[25/Oct/2026:12:13:15 +0000] - 404 404 - GET https example.com "/favicon.ico" [Client 198.51.100.7] [Length 150] [Gzip -] [Sent-to 172.16.1.10] "Mozilla/5.0" "-" +[25/Oct/2026:12:13:16 +0000] - 404 404 - GET https cloud.example.com "/index.php/apps/files/preview" [Client 198.51.100.8] [Length 150] [Gzip -] [Sent-to 172.16.1.10] "Nextcloud-android" "-" +[25/Oct/2026:12:13:17 +0000] - 304 304 - GET https example.com "/assets/app.js" [Client 198.51.100.9] [Length 0] [Gzip -] [Sent-to 172.16.1.10] "Mozilla/5.0" "-" +[25/Oct/2026:12:13:18 +0000] - 404 404 - GET https example.com "/environment-info" [Client 198.51.100.10] [Length 150] [Gzip -] [Sent-to 172.16.1.10] "Mozilla/5.0" "-" +198.51.100.11 - - [25/Oct/2026:12:14:01 +0000] "GET / HTTP/1.1" 301 166 "-" "Mozilla/5.0 (compatible; Googlebot/2.1)" +198.51.100.12 - - [25/Oct/2026:12:14:02 +0000] "GET /robots.txt HTTP/1.1" 301 166 "-" "Mozilla/5.0" diff --git a/tests/filter-samples/npm-wplogin.match.log b/tests/filter-samples/npm-wplogin.match.log new file mode 100644 index 0000000..451b8c0 --- /dev/null +++ b/tests/filter-samples/npm-wplogin.match.log @@ -0,0 +1,4 @@ +[25/Oct/2026:12:13:14 +0000] - 200 200 - POST https blog.example.com "/wp-login.php" [Client 198.51.100.7] [Length 4500] [Gzip -] [Sent-to 172.16.1.20] "Mozilla/5.0" "-" +[25/Oct/2026:12:13:15 +0000] - 302 302 - POST https blog.example.com "/wp-login.php?redirect_to=%2Fwp-admin%2F" [Client 198.51.100.7] [Length 0] [Gzip -] [Sent-to 172.16.1.20] "Mozilla/5.0" "-" +[25/Oct/2026:12:13:16 +0000] - 200 200 - POST https blog.example.com "/xmlrpc.php" [Client 198.51.100.8] [Length 300] [Gzip -] [Sent-to 172.16.1.20] "-" "-" +[25/Oct/2026:12:13:17 +0000] - - 403 - POST https blog.example.com "/wp-login.php" [Client 2001:db8::8] [Length 150] [Gzip -] [Sent-to 172.16.1.20] "Mozilla/5.0" "-" diff --git a/tests/filter-samples/npm-wplogin.nomatch.log b/tests/filter-samples/npm-wplogin.nomatch.log new file mode 100644 index 0000000..1954756 --- /dev/null +++ b/tests/filter-samples/npm-wplogin.nomatch.log @@ -0,0 +1,3 @@ +[25/Oct/2026:12:13:14 +0000] - 200 200 - GET https blog.example.com "/wp-login.php" [Client 198.51.100.7] [Length 4500] [Gzip -] [Sent-to 172.16.1.20] "Mozilla/5.0" "-" +[25/Oct/2026:12:13:15 +0000] - 200 200 - POST https blog.example.com "/wp-admin/admin-ajax.php" [Client 198.51.100.7] [Length 50] [Gzip -] [Sent-to 172.16.1.20] "Mozilla/5.0" "-" +[25/Oct/2026:12:13:16 +0000] - 200 200 - POST https blog.example.com "/wp-comments-post.php" [Client 198.51.100.8] [Length 50] [Gzip -] [Sent-to 172.16.1.20] "Mozilla/5.0" "-" diff --git a/tests/run-filter-tests.sh b/tests/run-filter-tests.sh new file mode 100644 index 0000000..8c99878 --- /dev/null +++ b/tests/run-filter-tests.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Runs fail2ban-regex against the sample logs: +# .match.log every line must match +# .nomatch.log no line may match + +set -u +cd "$(dirname "$0")/filter-samples" || exit 1 +rc=0 + +matched() { fail2ban-regex "$1" "/etc/fail2ban/filter.d/$2.conf" | sed -n 's/^Lines: .* \([0-9]*\) matched.*/\1/p'; } +lines() { grep -cv '^#' "$1"; } + +for f in *.match.log; do + filter=${f%.match.log} + want=$(lines "$f"); got=$(matched "$f" "$filter") + if [ "$got" = "$want" ]; then echo "ok $f ($got/$want)"; else echo "FAIL $f ($got/$want matched)"; rc=1; fi +done + +for f in *.nomatch.log; do + filter=${f%.nomatch.log} + got=$(matched "$f" "$filter") + if [ "$got" = "0" ]; then echo "ok $f (0 matched)"; else echo "FAIL $f ($got matched, expected 0)"; rc=1; fi +done + +exit $rc