commit d7dcd9d6b172593b4d99e4a0c2ffc917deb44d88 Author: SchiWaGoA Date: Sun Oct 25 20:41:08 2026 +0100 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 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