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:
commit
d7dcd9d6b1
29 files changed
+1163
No files matched your search
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.env
|
||||
data
|
||||
docs
|
||||
dev
|
||||
*.md
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
data/
|
||||
dev/testbed/data/
|
||||
dev/testbed/.env
|
||||
dev/testbed/npm-logs/
|
||||
@@ -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
@@ -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" ]
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 ]]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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`.
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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" "-"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user