Rate limiter mit Caddy und fail2ban

8 Minuten Lesezeit

Hier beschreibe ich, wie man das meist zur Abwehr unerlaubter Zugriffsversuche eingesetze Werkzeug fail2ban auch zur Begrenzung der Anzahl von erfolgreichen Zugriffen innerhalb eines definierten Zeitfensters verwenden kann. Dies nennt man “rate limiting” und soll denial of service Attacken vereiteln, bei denen mein Server überlastet wird.

Image: block drawing of how caddy interacts with fail2ban to rate limit accesses to my blog

Caddy-Logs einschalten

Zuerst müssen wir den Logger des Webservers einschalten. Bei Caddy geht das mit wenigen Zeilen Code, die ich meiner bestehenden Konfiguration hinzufüge:

# caddy2/config/Caddyfile

blog.schallbert.de {
# Define webserver
root * /www/blog
encode gzip
file_server

# Enable logging for fail2ban
log {
output file /log/blog/access.log
}

# [...]

Ab jetzt werden im angegebenen Pfad Logs angelegt, die ich mit einer logrotate-Konfiguration verwalte.

Fail2ban als Rate Limiter konfigurieren

Nochmal kurz zur Wiederholung: fail2ban greift auf die Paketfilter-Regeln der Maschine zu und modifiziert sozusagen ihre Firewall, um dynamische Angriffe abzuwehren. Damit dies funktioniert, müssen fail2ban Logdateien zur Verfügung gestellt werden, die eine Host-Applikation wie beispielsweise ein Webserver erzeugt und IP-Adressen der Client-Rechner enthalten.

Vorüberlegung: “http 200 OK” als Filter?

Auch normale, zulässige Zugriffe 200 OK müssen für meinen Rate Limiter zählen.

Um ein Gefühl zu bekommen wie mein Logger Zugriffe aufzeichnet, werfe ich einen Blick in die Logs. Ich lasse mir die Anzahl Einträge zurückgeben, die einen Status 200 OK enthalten. Dazu führe ich mit grep eine Suche durch und lasse mir die Anzahl Treffer zeilenweise per wc -l ausrechnen.

schallbert@machine:/var/log/caddy2/blog# grep -o '"status":200' access.log | wc -l
847

Gut, es hat heute also bereits 847 Zugriffe gegeben, die 200 OK zurückgemeldet bekommen haben. Nun rufe ich selbst mal die Landing page meiner Blogseite auf und lasse mir dann die Artikelübersicht anzeigen.

schallbert@machine:/var/log/caddy2/blog# grep -o '"status":200' access.log | wc -l
858 // after calling blog.schallbert.de
rschallbert@machine:/var/log/caddy2/blog# grep -o '"status":200' access.log | wc -l
882 // page loaded after clicking Posts" button

Whow, das waren 35 Einträge für zwei Klicks! Wenn ich mir das Log so ansehe, dann erklären sich die vielen Zeilen mit Assets - sprich Bildern und Logos, die geladen werden.

Damit ist leider klar, dass das Filterkriterium status:200 für meinen Rate Limiter nicht ohne Weiteres funktionieren kann. Denn die Anzahl Meldungen hängen maßgeblich von dem jeweiligen Artikel ab. Ich müsste also eine Grenze definieren, über der kein normaler Mensch Zugriffe auf mein Blog erzeugt.

Um nur “echte” Zugriffe auf meine Seiten zu zählen, muss ich die Assets in den Logs irgendwie ausschließen. Glücklicherweise gibt es dafür eine einfache Direktive in Caddy.

# caddy2/config/Caddyfile

 # Enable logging for fail2ban
   log_skip /assets*
   log {
    output file /log/blog/access.log
  }

log_skip sorgt nun dafür, dass alle Zugriffe auf Dateien im assets/-Ordner und darunter nicht mitgeloggt werden.
Das Verhalten von Fail2ban wird mittels zweier Konfigurationsdateien festgelegt:

Ordner filter.d

filter.d/<filter-name>.conf beinhaltet die Definition eines zu betrachtenden Ereignisses. So kann die failregex ein “Seite nicht gefunden” http 404 Statuscode ebenso zum Auslösen des Paketfilters verwendet werden wie der erfolgreiche Zugriff für den Aufbau meines Rate Limiter, welcher auf 200 OK reagiert. Abgeschrieben habe ich für die Regex bei Rafael Kassner.

# /opt/fail2ban/config/filter.d/caddy-ratelimit.conf

[Definition]
failregex   = "client_ip":"<HOST>"(.*)"status":200
datepattern = \d+
ignoreregex =

Außerdem werden in der Datei weitere Randbedingungen bestimmt, z.B. das Datumsformat datepattern an den Log-Output des zu schützenden Systems angepasst.

Datei jail.local

jail.local bestimmt die Bedingungen, bei deren Eintritt der Paketfilter für die Client-IP aktiv wird und weitere Zugriffsversuche blockt. Für den Einsatz als Rate Limiter benötige ich folgende Felder:

findtime - das Zeitfenster, in welchem Angriffe gezählt werden, maxretry ist die Anzahl zulässiger Versuche im Zeitfenster und bantime ist die Zeit, für die geblockt wird. ignoreip ist meist per Werkseinstellung auf die relevanten internen IP-Adressen vorkonfiguriert. In meinem Falle funkt act_runner zum Beispiel an intern gitea zur Abfrage, ob neue Automatisierungsaufgaben anliegen. Diese Zugriffe will ich auf keinen Fall behindern.

Das im Folgenden verwendete Beispiel für fail2ban zeigt den Rate Limiter auf meinem Blog. Am Ende sind Dateien und Log-Einträge für gitea im Wesentlichen gleich. Nur Filternamen und Parameter unterscheiden sich voneinander durch die unterschiedlichen Anforderungen an Webseite und DevOps-Platform.

# /opt/fail2ban/jail.local
# ...

[caddy-ratelimit]
enabled     = true
chain       = DOCKER-USER
action      = iptables-multiport
port        = http,https
filter      = caddy-ratelimit
logpath     = /var/log/caddy2/blog/access.log
findtime    = 10s
maxretry    = 10
bantime     = 6h

Sind diese beiden Dateien konfiguriert, kann man fail2ban neu starten und sich die Logs anschauen. Wenn man noch Fehler in den Dateien hat, gibt fail2ban folgendes aus:

ERROR Errors in jail 'caddy-ratelimit'. Skipping...

Tritt dies auf, kann z.B. das Jail nicht dem Filter (muss gleichnamig sein!) nicht zugeordnet werden, eine Feld-Definition wie maxretry ist falsch getippt oder ein Zahlenformat kann nicht gelesen werden. Wenn alles gut läuft, lautet der Eintrag:

Reading config files: /etc/fail2ban/filter.d/caddy-ratelimit.conf

Rate Limiter testen

Nun möchte ich prüfen, ob alles wie erwartet funktioniert.

Filter-Regex überprüfen

Als ersten Schritt schaue ich auf den Filterbegriff und prüfe, ob er in den Logs zuverlässig gefunden wird. Praktischerweise hat fail2ban hierfür ein passendes Werkzeug eingebaut: fail2ban-regex <logfile> <filter>. Ich kann also schlicht die Filterdatei und ein Test-Logfile eingeben und sehe dann, ob ich Treffer bekomme.

fail2ban-regex ./access.log /etc/fail2ban/filter.d/caddy-ratelimit.conf 

Running tests
=============

Use      filter file : caddy-ratelimit, basedir: /etc/fail2ban
Use      datepattern : \d+ : \d+
Use         log file : ./access.log.2
Use         encoding : UTF-8

Results
=======

Failregex: 2009 total
|-  #) [# of hits] regular expression
|   1) [2009] "client_ip":"<HOST>"(.*)"status":200
`-

Ignoreregex: 0 total

Date template hits:
|- [# of hits] date format
|  [2685] \d+
`-

Lines: 2685 lines, 0 ignored, 2009 matched, 676 missed
[processed in 0.12 sec]

Quantitativ bekomme ich, was ich erwarte: das oben beschriebene Suchverfahren per grep liefert mir exakt dieselben Ergebnisse.

Jail überprüfen

Als nächsten Schritt muss ich testen, ob der Rate Limiter auch greift. Dafür simuliere ich mir Logeinträge. Die Jail-Regeln reguliere ich für diesen Test stark zurück, sonst muss ich zu viele Eingaben in kurzer Zeit erzeugen.

# /opt/fail2ban/jail.local TEST
# ...

[...]
findtime    = 10s
maxretry    = 2
bantime     = 100s

Nach einem Neustart von fail2ban erzeuge ich mir eine zweite Konsole, wo ich Log-Einträge simulieren kann. Dabei müssen mindestens Zeitpunkt, IP-Adresse des Aufrufenden und Status enthalten sein; ich gehe aber auf Nummer sicher und nehme vollständige Einträge.

echo '{"ts":1751246139,"remote_ip":"1.1.1.1","status":200,[superLongIrrelevantOtherStuffForFiltering]}' >> /var/log/caddy/blog/access.log

Dies gebe ich mehrfach ein, um über mein Limit von 2 retries zu kommen.

In der ersten Konsoleninstanz prüfe ich anschließend den fail2ban-Status für den entsprechenden Filter.

fail2ban-client status caddy-ratelimit
Status for the jail: caddy-ratelimit
|- Filter
|  |- Currently failed:	0
|  |- Total failed:	0
|  `- File list:	/var/log/caddy2/blog/access.log
`- Actions
   |- Currently banned:	0
   |- Total banned:	0
   `- Banned IP list:	

Das war nichts. Warum offenbart die fail2ban-Logdatei:

# /fail2ban/config/log/fail2ban/fail2ban.log
WARN [caddy-ratelimit] Ignoring all log entries older than 20s; # probably messages generated within a fail2ban restart period

Also nochmal mit passenden Zeitstempeln. Diese muss ich aus der aktuellen Systemzeit ($(date +%s.%N)) ziehen, wenn ich sie ständig anpassen muss. Daher nehme ich eine vollständige Logzeile her, modifiziere sie auf die IP-Adresse 1.1.1.1 und füge passende Zeitstempel ein:

 echo "{\"level\":\"info\",\"ts\":$(date +%s.%N),\"logger\":\"http.log.access.log0\",\"msg\":\"handled request\",\"request\":{\"remote_ip\":\"1.1.1.1\",\"remote_port\":\"38229\",\"client_ip\":\"1.1.1.1\",\"proto\":\"HTTP/1.1\",\"method\":\"GET\",\"host\":\"blog.schallbert.de\",\"uri\":\"/vacuum-clamping/\",\"headers\":{\"Accept-Encoding\":[\"gzip, deflate, br\"],\"Connection\":[\"keep-alive\"],\"User-Agent\":[\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0\"],\"Accept\":[\"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\"],\"Accept-Language\":[\"en-US,en;q=0.5\"]},\"tls\":{\"resumed\":false,\"version\":772,\"cipher_suite\":4865,\"proto\":\"http/1.1\",\"server_name\":\"blog.schallbert.de\"}},\"bytes_read\":0,\"user_id\":\"\",\"duration\":0.001207354,\"size\":12290,\"status\":200,\"resp_headers\":{\"Last-Modified\":[\"Tue, 10 Jun 2025 19:33:01 GMT\"],\"Content-Encoding\":[\"gzip\"],\"Server\":[\"Caddy\"],\"Alt-Svc\":[\"h3=\\\":443\\\"; ma=2592000\"],\"Vary\":[\"Accept-Encoding\"],\"Etag\":[\"\\\"gzip\\\"\"],\"Content-Type\":[\"text/html; charset=utf-8\"]}}" >> access.log

Nach mehrmaligem Aufruf dieses gebastelten Kommandos erhalte ich nun

fail2ban-client status caddy-ratelimit
Status for the jail: caddy-ratelimit
|- Filter
|  |- Currently failed:	1
|  |- Total failed:	11
|  `- File list:	/var/log/caddy2/blog/access.log
`- Actions
   |- Currently banned:	1
   |- Total banned:	4
   `- Banned IP list:	1.1.1.1

Das sieht super aus. Auf zum letzten Schritt.

Paketfilter überprüfen

Wenn wir nun einmal in die Logs schauen, müsste unser [caddy-ratelimit] Jail nun bei jeder “normalen” Anfrage triggern.

schallbert@machine: nano fail2ban/config/log/fail2ban/fail2ban.log

 <timestamp> <id> INFO  [caddy-ratelimit] Found <IP a>
 <timestamp> <id> INFO  [caddy-status] Found <IP b>
 <timestamp> <id> INFO  [caddy-ratelimit] Ignore 172.18.0.1 by ip
 <timestamp> <id> INFO  [caddy-status] Ignore 172.18.0.1 by ip
 <timestamp> <id> INFO  [caddy-ratelimit] Found <IP c>
 <timestamp> <id> INFO  [caddy-ratelimit] Found <IP c>
 <timestamp> <id> INFO  [caddy-status] Found <IP c>
 <timestamp> <id> INFO  [caddy-status] Found <IP c>
 <timestamp> <id> INFO  [caddy-ratelimit] Found <IP d>
 <timestamp> <id> INFO  [caddy-status] Found <IP d>
 <timestamp> <id> INFO  [caddy-status] Found <IP e>
 <timestamp> <id> INFO  [caddy-ratelimit] Found <IP e>
 <timestamp> <id> INFO  [caddy-ratelimit] Found <IP f>

Um die ganze Kette getestet zu haben müssen wir jetzt noch prüfen, ob die entsprechende IP-Adresse auch wirklich in der Hardware geblockt ist. Dafür gebe ich ein:

schallbert@machine:~# iptables -n -L | grep "1.1.1.1"
REJECT     all  --  1.1.1.1              0.0.0.0/0            reject-with icmp-port-unreachable

Scheint ja alles in Ordnung zu sein. Für einen letzten Test von einer externen IP-Adresse rufe ich im Browser einen Link Checker auf, der meine Webseite crawlen und damit den Rate Limiter auslösen soll. Obwohl dieser 44 Links in kürzester Zeit durchläuft, wird der Test als “bestanden” angezeigt. Sehr merkwürdig, [caddy-ratelimit] hätte anschlagen müssen!

iptable-chain INPUT statt FORWARD

Verwirrt schaue ich mir mal die ganze iptable an. Parallel suche ich mir die IP-Adresse des Link Checker heraus. Sie taucht in den iptables auf. Und trotzdem wird offensichtlich nicht geblockt. Woran das liegt? Ein genauer Blick zeigt:

Chain INPUT (policy ACCEPT)
target     prot opt source               destination         
f2b-caddy-ratelimit  tcp  --  0.0.0.0/0            0.0.0.0/0            multiport dports 22

Problem hier ist, dass mein Jail auf der INPUT-Chain liegt. Die Anfragen gehen aber doch nicht direkt an meine Server-Hardware, sondern werden über Docker an Caddyserver weitergeleitet. Um zu funktionieren, muss ich auf der FORWARD-Chain landen, wo DOCKER-USER bereits ist. Sehr merkwürdig, hatte ich doch in der jail.local extra chain = DOCKER-USER angegeben. Irgend etwas muss diese Definition überschreiben.

Ein unscheinbarer Forenpost zusammen mit dem Kommentar multiport im iptables-Print bringt mich auf die Lösung: Die action = iptables-multiport Direktive überschreibt mein chain = DOCKER-USER Statement, denn in der zugehörigen Konfigurationsdatei iptables.conf wird folgendes gesetzt: chain = INPUT.

Also lösche ich den action Eintrag einfach, sodass fail2ban auf den Default für DOCKER-USER zurückfällt: multiport dports 80,443. Nun taucht der Jail-Name auch korrekt in iptables auf.

schallbert@machine:~# iptables -n -L
Chain DOCKER-USER (1 references)
target     prot opt source               destination         
f2b-caddy-ratelimit  tcp  --  0.0.0.0/0            0.0.0.0/0            multiport dports 80,443

Erfolg genießen

Endlich zeigt ein weiterer Link Check, dass mein Rate Limiter funktioniert.

Image: deadlinkchecker view for blog.schallbert.de hits configured rate limit and gets blocked subsequentially. Thus it returns a Timeout