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 <noreply@anthropic.com>
This commit is contained in:
schiwagoaandClaude Opus 5.5 committed 2026-10-25 20:41:08 +01:00
commit d7dcd9d6b1
29 files changed
+1163

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
.git
.env
data
docs
dev
*.md
+42
View File
@@ -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: <source of /data>/logs
# jlesage/nginx-proxy-manager: <source of /config>/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
+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary
+5
View File
@@ -0,0 +1,5 @@
.env
data/
dev/testbed/data/
dev/testbed/.env
dev/testbed/npm-logs/
+15
View File
@@ -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`
+40
View File
@@ -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:<version> --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" ]
+21
View File
@@ -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.
+169
View File
@@ -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 <id> --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 <npm-container> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'
```
| Image | Container-Pfad | `NPM_LOG_DIR` | `NPM_LOG_DIR2` |
|---|---|---|---|
| `jc21/nginx-proxy-manager` | `/data` | `<Quelle von /data>/logs` | leer |
| `jlesage/nginx-proxy-manager` | `/config` | `<Quelle von /config>/log` | `<Quelle von /config>/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 <ip>` |
| Von Hand sperren | `docker exec npm-scanner fail2ban-client set npm-scanner banip <ip>` |
| 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).
---
<p align="center">
🏰 <a href="https://retronerd.at">Website</a> ·
🐘 <a href="https://social.retronerd.at/@schiwagoa">Mastodon</a> ·
🔒 <a href="https://retronerd.at/datenschutzerklaerung/">Datenschutz</a> ·
💬 <a href="https://helpdesk.retronerd.at/open.php">Support</a>
</p>
<p align="center">
<img src="https://dl.retronerd.at/Bilder/cat-footer.png" alt="" width="100%">
</p>
<p align="center">© Thomas Werner Schwaighofer · retronerd.AT · <a href="LICENSE">MIT Lizenz</a></p>
+34
View File
@@ -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.
+16
View File
@@ -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
+99
View File
@@ -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 ]]
+51
View File
@@ -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
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Writes log lines in Nginx Proxy Manager format, like NPM would.
#
# ./fake-npm-logs.sh scan <ip> [count] [host-id] [log|logs] 4xx on scanner paths
# ./fake-npm-logs.sh wplogin <ip> [count] [host-id] [log|logs] POST /wp-login.php
# ./fake-npm-logs.sh harmless <ip> [count] [host-id] [log|logs] 404 /favicon.ico, 200 /
# ./fake-npm-logs.sh fallback <ip> [count] 444 on the default host
# ./fake-npm-logs.sh newhost <host-id> [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
+44
View File
@@ -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"
+96
View File
@@ -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`.
+107
View File
@@ -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 <pfad>` 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.
@@ -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 <HOST>\]
^<HOST> - \S+ .*?"[^"]*" (?:444|400)
^<HOST> - \S+ .*?"[A-Z]+ [^"]*%(_paths)s[^"]*" \d{3}
ignoreregex =
@@ -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 <HOST>\]
ignoreregex =
@@ -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
@@ -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]
@@ -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]
@@ -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]
+70
View File
@@ -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 <dir> [<dir> …]
# 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
+170
View File
@@ -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 "$@"
@@ -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"
@@ -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"
@@ -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" "-"
@@ -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" "-"
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Runs fail2ban-regex against the sample logs:
# <filter>.match.log every line must match
# <filter>.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