
🌐 Auch auf: English · Français · Español
Deep Dive aus der Reihe rund um die Ansible-Tour. Niveau: fortgeschrittene Linux-Administration. Die Tour erklärt die Rollen; die Deep Dives erklären die Technik darunter – wie sie funktioniert, warum sie hier ist, was genau dieses Setup damit macht, wie man sie im Alltag betreibt und was es sonst noch gegeben hätte.
fail2ban wirkt wie das einfachste Werkzeug auf diesen Servern: Log lesen, Fehlversuche zählen, Adresse sperren. Auf einem K3s-Host hat jeder Teil dieses Satzes einen Haken. Die Logs liegen in Container-Dateien, die bei jedem Neustart ihren Namen ändern; die Bans müssen in einer Firewall landen, in die auch andere Programme schreiben; und eine Adressfamilie, die niemand getestet hat, kann das Ganze zum Placebo machen. Dieser Beitrag verfolgt eine einzige böse Anfrage von der Logzeile bis zum Eintrag im Kernel, erklärt, was Teil 5 der Tour nur streift – und beschreibt drei Fälle, in denen fail2ban „Ban“ meldete und die Firewall die Adresse trotzdem hereinließ.
🧭 Wie fail2ban funktioniert
Jails, Filter, Aktionen
Ein Jail verbindet drei Dinge: ein Log, das beobachtet wird, einen Filter, der eine böse Zeile erkennt, und eine Aktion, die sperrt. Der Filter ist eine Menge regulärer Ausdrücke (failregex, mit ignoreregex für Ausnahmen), in denen <HOST> die Adresse markiert. Jeder Treffer ist ein Fehlversuch dieser Adresse. Sammelt eine Adresse maxretry Fehlversuche innerhalb von findtime, läuft die Aktion und sperrt sie für bantime.
[nginx-k3s-wp-login]
enabled = true
filter = nginx-k3s-wp-login
logpath = /var/log/pods/ingress-nginx_ingress-nginx-*/nginx-ingress/*.log
backend = polling
maxretry = 10
findtime = 600Backends: wie das Log gelesen wird
- systemd liest das Journal direkt (nach Unit gefiltert) – robust, keine Dateien, keine Rotation. Hier für
sshd. - polling prüft die Datei alle paar Sekunden auf neuen Inhalt. Einfach, funktioniert auf jedem Dateisystem – und die einzige Wahl, wenn der Pfad ein Glob ist, der sich ändert.
- pyinotify reagiert sofort auf Dateiereignisse; schneller, beobachtet aber die Dateien, die beim Start des Jails existierten.
Der Zeitstempel zählt so viel wie die Adresse: fail2ban liest das Datum jeder Zeile (datepattern) und ignoriert Zeilen, die älter als findtime sind. Ein Filter mit falschem Datumsformat trifft nichts – lautlos.
Die Datenbank und recidive
fail2ban speichert aktuelle Bans in einer SQLite-Datenbank (/var/lib/fail2ban/fail2ban.sqlite3). Nach einem Neustart setzt es jeden Ban wieder, der noch nicht abgelaufen ist. Das Jail recidive richtet fail2ban auf sich selbst: Sein Log ist fail2ban.log, sein Filter zählt „Ban“-Zeilen, und wer an einem Tag dreimal gesperrt wird, wird für 30 Tage auf allen Ports gesperrt.
🛡️ Warum es hier ist
- Günstiger Schutz gegen automatisierte Angriffe auf SSH, den WordPress-Login, den Nextcloud-Login und Grafana – und gegen Scanner, die Listen bekannter verwundbarer Pfade abklappern.
- Bans im Kernel, nicht in der Anwendung. Eine gesperrte Adresse erreicht gar nichts, nicht einmal den TLS-Handshake.
- Sichtbarkeit. Wer anklopft, wie oft und an welcher Tür, steht im Ban-Log – seit Oktober 2026 in Loki durchsuchbar.
🔧 Was dieses Repository macht
Sieben Jails, zwei Log-Quellen
| Jail | Log | Regel | Sperre |
|---|---|---|---|
| sshd | Journal (systemd-Backend) | 3 Fehlversuche | Standard 1 h |
| nginx-k3s-wp-login | Ingress-Log | 10 POSTs an /wp-login.php in 10 min | 1 h |
| nginx-k3s-nc-login | Ingress-Log | 10 POSTs an Nextclouds /login in 10 min | 1 h |
| nginx-k3s-grafana-login | Ingress-Log | 5 fehlgeschlagene Grafana-Logins (HTTP 401) in 10 min | 1 h |
| nginx-k3s-scanner | Ingress-Log | 20 Antworten mit 403 oder 404 in 1 min | 1 h |
| nginx-k3s-bad-paths | Ingress-Log | 2 Anfragen nach Pfaden, die es hier nie gibt (/.env, /.git/, /phpmyadmin, Webshells …) in 1 h | 24 h |
| recidive | fail2ban.log | an einem Tag 3-mal gesperrt | 30 Tage |
Alle HTTP-Jails lesen das Access-Log des Ingress-Controllers – die eine Stelle, die jede Anfrage passiert, für Blog und Nextcloud gleichermaßen. Die Filter erwarten das containerd-Präfix vor jeder Zeile (2026-06-19T14:24:11Z stdout F <client> - - [Zeit] "POST /wp-login.php …"); ein Filter für ein normales nginx-Log würde nie treffen.
Der Ban geht direkt in nftables
fail2ban bekommt keine eigene Tabelle und keine eigene Chain. Seine Aktion fügt die Adresse in ein Set ein, das die Firewall ganz oben in ihrer Input-Chain prüft – jedes Jail in sein eigenes Paar f2b_<jail>4/6 (warum, steht beim fünften Fehler unten):
[Definition]
actionban = nft add element inet filter <nftset> { <ip> timeout <bantime>s }
actionunban = nft delete element inet filter <nftset> { <ip> } 2>/dev/null; true
[Init]
setname = banned
nftset = <setname>4
[Init?family=inet6]
nftset = <setname>6
# jail.local - every jail names its own set pair
[sshd]
action = nftables-banned-sets[setname=f2b_sshd]Jedes Element trägt seine eigene Ablaufzeit, ein Ban läuft also auch dann im Kernel ab, wenn fail2ban inzwischen gestorben ist. Das Entsperren ist nur ein Aufräumen nach bestem Bemühen.
Die Logdatei, die wandert
Das Ingress-Log ist keine einzelne Datei. Es ist /var/log/pods/<namespace>_<pod>_<uid>/nginx-ingress/N.log, und sowohl das Pod-Verzeichnis als auch die Nummer ändern sich: Ein Container-Neustart schreibt 1.log nach 0.log, ein neuer Pod bekommt ein neues Verzeichnis. fail2ban expandiert den Glob in logpath einmal, beim Start des Jails. Ein kleiner Timer (fail2ban-ingress-logwatch, alle zwei Minuten) vergleicht die aktuelle Dateiliste mit der letzten und lädt die nginx-k3s-*-Jails neu, wenn sie sich unterscheidet. Ohne ihn: siehe den ersten Fehler unten.
🐛 Dreimal sagte fail2ban „Ban“, und nichts passierte
1. Tagelang eine tote Datei beobachtet
Am 23. September 2026 stellte sich heraus, dass die HTTP-Jails auf beiden Nextcloud-Servern seit Tagen nichts gesperrt hatten. Nach einem Neustart war fail2ban vor dem Ingress-Container gestartet, und nach einem Container-Neustart verfolgten die Jails weiter eine Datei, in die keine Zeilen mehr kamen. Alles sah gesund aus – der Dienst lief, die Jails standen in der Liste, nirgends ein Fehler. Der oben beschriebene Log-Watch-Timer ist die Lösung. Er ist auch einer der Gründe, warum Monit jetzt prüft, dass jedes erwartete Jail aktiv ist (die Geschichte des Wachhunds begann damit, dass fail2ban zwei Tage lang still ausgefallen war).
2. Ein Firewall-Neuladen, das die Sperr-Sets leerte
Die Firewall ersetzt bei jedem Neuladen ihre eigene Tabelle – und mit ihr die Sets, leer. fail2ban behielt seine Bans in der Datenbank und hielt sie für aktiv. Am 30. September 2026 existierten nach einem Playbook-Lauf auf dem einen Server 15 von 17 Bans nur noch in fail2ban, auf dem anderen 17 von 24. Der Firewall-Handler startet fail2ban jetzt direkt nach jedem Neuladen neu, und fail2ban setzt jeden nicht abgelaufenen Ban wieder.
3. IPv6-Bans, die nie möglich waren
Bis zum 14. Oktober 2026 übergab jedes Jail nftset = banned4 an die Aktion – auch für IPv6-Adressen. nft weigerte sich, eine IPv6-Adresse in ein IPv4-Set zu schreiben („Address family for hostname not supported“), fail2ban protokollierte den Ban trotzdem, und die Adresse wurde nie blockiert. Auf allen drei Servern. Der Abschnitt [Init?family=inet6] oben wählt das Set nach Adressfamilie; in der Woche bis zum 20. Oktober 2026 waren 15 von 444 Bans IPv6-Adressen – wenige, aber jede davon wäre vorher einfach durchspaziert.
Ein vierter, gefunden von Loki
Als die Logs am 19. Oktober 2026 nach Loki gingen, feuerte der neue Alarm für fail2ban-Fehler sofort. Die meisten Fehler waren harmlos (KeyError auf ein gerade entferntes Pod-Log – das Polling-Backend verliert eine Datei, der Jail läuft weiter). Aber am 13. und 14. Oktober gab es echte: Failed to execute ban jail 'nginx-k3s-bad-paths' action 'nftables…' – Bans, die nicht stattfanden, während das Neuladen der Firewall überarbeitet wurde. Niemand hatte sie gesehen, weil fail2ban seine eigenen Fehler nicht mailt. Der Alarm tut es jetzt, ohne das bekannte harmlose Muster.
Ein fünfter: ein Set für alle Jails
Bis zum 21. Oktober 2026 schrieben alle Jails in dasselbe Set-Paar banned4/6. Das wirkt aufgeräumt und hat einen Haken: Eine Adresse, die zwei Jails sperren, steht nur einmal im Set. Lief der kürzere Ban ab – etwa der des Scanner-Jails nach einem Tag –, verschwand die Adresse aus dem Set, obwohl recidive sie noch 30 Tage draußen halten wollte. fail2ban führte sie als gesperrt, der Kernel nicht mehr. Ein Abgleich an dem Tag fand ein bis fünf solcher Adressen pro Server. Seitdem hat jedes Jail sein eigenes Paar (f2b_sshd4, f2b_recidive4, …) und fügt nur dort ein und entfernt nur dort, und ein Monit-Check (f2b_sets) vergleicht regelmäßig fail2bans Liste mit den Sets.
📊 Was es sperrt
Eine Woche, 14. bis 20. Oktober 2026, alle drei Server:
| Jail | Blog | Nextcloud A | Nextcloud B |
|---|---|---|---|
| nginx-k3s-bad-paths | 107 | 74 | 74 |
| nginx-k3s-scanner | 63 | 50 | 47 |
| sshd | 9 | 2 | 11 |
| recidive | 4 | 2 | – |
| nginx-k3s-wp-login | 1 | – | – |
| IPv6-Anteil | 11 von 184 | 2 von 128 | 2 von 132 |
Das Bild ist eindeutig: Fast alle Bans kommen von Scannern, die nach .env-Dateien, Git-Verzeichnissen, Admin-Oberflächen und Webshells suchen. Echte Login-Angriffe sind selten – der WordPress-Login sah am Tag der Auswertung zwei POSTs in drei Stunden, und seit dem 19. Oktober 2026 antwortet /wp-login.php ohnehin nur Adressen aus der Länderliste. SSH-Bans sind wenige, weil Port 10022 nur deutsche Adressbereiche annimmt; was bleibt, sind einige Adressen aus diesem Bereich.
🧰 Betrieb im Alltag
# alle Jails, dann eines im Detail (aktuell gesperrt, insgesamt)
fail2ban-client status
fail2ban-client status nginx-k3s-bad-paths
# die Sicht der Firewall: jedes Jail hat sein eigenes Set-Paar f2b_<jail>4/6
nft list set inet filter f2b_bad_paths4
nft list set inet filter f2b_recidive4
fail2ban-client get sshd action nftables-banned-sets setname # -> f2b_sshd
# einen Ban aufheben (beides: fail2bans Eintrag und das Kernel-Set)
fail2ban-client set nginx-k3s-scanner unbanip 203.0.113.7
# trifft ein Filter echte Zeilen? (gegen das aktuelle Ingress-Log)
fail2ban-regex /var/log/pods/ingress-nginx_ingress-nginx-*/nginx-ingress/0.log \
/etc/fail2ban/filter.d/nginx-k3s-bad-paths.conf
# welche Logdatei beobachtet ein Jail gerade?
fail2ban-client get nginx-k3s-wp-login logpathWenn ein Jail nicht auslöst
- Beobachtet es die richtige Datei?
get <jail> logpathgegenls /var/log/pods/…– nach einem Neustart des Ingress-Controllers sollte der Log-Watch-Timer es innerhalb von zwei Minuten neu geladen haben. - Trifft der Filter überhaupt?
fail2ban-regexgegen die echte Datei. Ein geändertes Zeilenformat nach einem Ingress-Update zeigt sich hier. - Wird das Datum verstanden? Zeilen älter als
findtimewerden ignoriert; ein falschesdatepatternmacht jede Zeile „alt“. - Erreicht der Ban den Kernel?
statusmitnft list setvergleichen. Weichen sie ab, infail2ban.lognach „Failed to execute ban“ suchen – oder in Loki:{job="fail2ban"} |= "ERROR".
Nicht vom eigenen Jump-Host aus testen
Einmal /.env abzurufen, um zu sehen, ob das Bad-Paths-Jail funktioniert, ist verlockend. Zwei Anfragen innerhalb einer Stunde sperren die Adresse für 24 Stunden – den Jump-Host eingeschlossen und damit jede SSH-Sitzung zu diesem Server. Die privaten Bereiche stehen in ignoreip, die öffentliche Adresse des Admin-Netzes nicht. Testen mit fail2ban-regex gegen eine Logzeile, oder von einer Adresse, auf die man einen Tag verzichten kann.
🆘 Spickzettel: Symptom → Befehl
Die Kurzfassung dieses Artikels für den Moment, in dem etwas nicht stimmt. Auf dem Host als root ausführen; <…> sind Platzhalter.
| Symptom | Befehl |
|---|---|
| Ein Jail sperrt nichts | fail2ban-client status <jail> |
| Der Filter greift nicht | fail2ban-regex <logfile> /etc/fail2ban/filter.d/<filter>.conf |
| Gesperrt, kommt aber trotzdem durch | nft get element inet filter f2b_<jail>4 '{ <ip> }' # "No such file" = not in the set |
| In welches Set schreibt ein Jail? | fail2ban-client get <jail> action nftables-banned-sets setname |
| Einen Ban aufheben | fail2ban-client unban <ip> |
| Ist ein Ban fehlgeschlagen? | grep -E "ERROR|Failed to execute" /var/log/fail2ban.log | tail |
| fail2ban steht, oder ein Jail fehlt | systemctl status fail2ban |
| Sets leer nach einem Firewall-Reload | systemctl restart fail2ban # re-applies every active ban |
| Selbst ausgesperrt | # from another address or the provider console: |
🔭 Was als Nächstes kommt
- Bans in Grafana – erledigt. Seit dem 21. Oktober 2026 bringt das Repository ein Dashboard „fail2ban – Bans“ mit: Bans pro Jail und Stunde, die Summen für den gewählten Zeitraum und die letzten Ban-Zeilen, alles aus den Logzeilen, die Loki ohnehin hat.
- Eine Sperrliste für alle drei Server. Ein auf dem Blog gesperrter Scanner ist meist derselbe, der eine Stunde später die Nextcloud-Server versucht. Teilen bräuchte einen kleinen Abgleich zwischen den Hosts.
- Reputationslisten (im Stil von CrowdSec) als zusätzliche Schicht – zum Preis eines externen Dienstes.
⚖️ Alternativen – und warum fail2ban
| Option | Stärke | Warum nicht hier |
|---|---|---|
| CrowdSec | Gemeinsame Community-Sperrlisten, Szenarien, Bouncer | Mehr bewegliche Teile (Agent, lokale API, Bouncer) und Daten, die mit einem externen Dienst geteilt werden. |
| sshguard | Klein, schnell, auf SSH ausgerichtet | Deckt SSH gut ab, die HTTP-Jails weniger. |
| Nur Rate-Limits im Ingress | Schützt HTTP ohne zusätzlichen Daemon | Bremst Angreifer, sperrt sie aber nie, und tut nichts für SSH. Zusätzlich im Einsatz (Rate-Limit der Suche). |
| fail2ban | Funktioniert mit jedem Log, Aktionen skriptbar, ausgereift | Gewählt – mit den drei Lehren von oben eingebaut. |
fail2ban ist alt, einfach und tut genau, was es verspricht. Die Arbeit war nie das Werkzeug; sie bestand darin, sicherzustellen, dass jedes „Ban“ in seinem Log auch als Eintrag im Kernel landete. Seit Oktober 2026 wird das geprüft – vom Firewall-Handler, von Monit und von einem Log-Alarm – statt angenommen.


