Zum Inhalt springen

Infrastruktur · · 6 Min. Lesezeit

Nur die Mail-Ports schwiegen

Von zu Hause liefen alle Mail-Ports in einen Timeout, während HTTPS auf demselben Host normal antwortete. Ursache war eine zweite [DEFAULT] ignoreip in jail.d, die FAIL2BAN_IGNOREIP von docker-mailserver ersetzt.

Die später gelesene Ebene verdeckt die frühere: Wo jail.d die Whitelist überschreibt, bleibt von den Mail-Ports nur ein offener übrig.

Ein Morgen ohne Mail. Thunderbird holte nichts mehr, das Handy ebenso wenig. Im Browser lief Webmail weiter und zeigte dieselben Postfächer wie am Vortag. Der Host war erreichbar.

Der Verdacht ging zuerst in die falsche Richtung. Eine Störung beim Provider, ein DNS-Eintrag, eine abgelaufene Firewall-Regel: alles plausibel, alles falsch. Die Ursache stand in einer Konfigurationszeile, die auf dem Host schlicht nicht mehr existierte.

Ein Hinweis zu den Adressen in diesem Text. Die tatsächlich freigegebenen IP-Adressen sind durch Dokumentationsadressen aus RFC 5737 und RFC 3849 ersetzt. Die Struktur stimmt, die Ziffern nicht.

Was die Ports von aussen zeigten

Die erste brauchbare Messung war eine Portprobe vom betroffenen Anschluss aus. Gemessen mit nc und einem kurzen Connect-Timeout, damit ein hängender Port nicht die ganze Probe blockiert.

nc -G 6 -w 6 gegen mail.squibble.me, vom Heimanschluss aus

 25  keine Antwort
143  keine Antwort
465  keine Antwort
587  keine Antwort
993  keine Antwort
995  keine Antwort

443  OPEN, HTTP 200
563  OPEN

Sechs Mail-Ports antworten nicht. HTTPS auf demselben Host liefert eine Seite aus, SSH auf Port 563 nimmt Verbindungen an. Damit sind DNS und die Firewall des Hosts bereits erledigt: Beide würden nicht zwischen einzelnen Ports desselben Rechners unterscheiden, und die Namensauflösung lieferte für alle Ports dieselbe Adresse.

Was ein Timeout über die Ursache verrät

Der wichtigste Teil dieser Tabelle steht nicht in den Portnummern, sondern in der Art des Scheiterns. Ein abgelehnter Verbindungsversuch kommt sofort zurück und bedeutet, dass niemand auf diesem Port zuhört. Ein Timeout bedeutet, dass jemand die Pakete stillschweigend wegwirft.

Genau das ist der Unterschied zwischen REJECT und DROP im Paketfilter des Kernels. Ein REJECT schickt eine Absage, ein DROP schweigt und lässt den Client in seinen eigenen Timeout laufen. Unser Mailserver läuft mit FAIL2BAN_BLOCKTYPE=drop.

Dazu kommt der Zuschnitt auf einzelne Ports. Fail2Ban legt seine Sperrregel innerhalb des Mailserver-Containers an, in dessen eigener netfilter-Tabelle. Dienste ausserhalb dieses Containers, also der Reverse Proxy auf 443 und der SSH-Daemon des Hosts, sehen davon nichts.

Ein Blick in den Container bestätigte das. Fail2Ban gruppiert seine Regeln in sogenannte Jails, eines pro überwachtem Dienst. Die eigene Adresse stand in dovecot, postfix und postfix-sasl.

Was fail2ban tatsächlich ignoriert

Damit war die Frage nur verschoben. Diese Adresse ist seit Langem freigegeben, und zwar über FAIL2BAN_IGNOREIP, die Umgebungsvariable von docker-mailserver für genau diesen Zweck. Eine freigegebene Adresse darf gar nicht erst gesperrt werden.

Also fragten wir nicht die Konfiguration, sondern den laufenden Prozess.

fail2ban-client get dovecot ignoreip, im laufenden Container

These IP addresses/networks are ignored:
| - 127.0.0.1/8
| - ::1
| - 10.0.0.0/8
`- 172.16.0.0/12

Das sind die internen Netze und sonst nichts. Keine der öffentlichen Adressen aus FAIL2BAN_IGNOREIP war dabei. ignoreip ist die Liste der Adressen, die ein Jail niemals sperren darf, und diese Liste kannte unseren Anschluss nicht.

Die Whitelist war nicht veraltet, sondern abwesend.

jail.local und jail.d in der Lesereihenfolge

docker-mailserver schreibt den Inhalt von FAIL2BAN_IGNOREIP beim Start in /etc/fail2ban/jail.local. Das funktioniert und ist der vorgesehene Weg.

Unsere Ansible-Rolle liefert zusätzlich eine eigene Datei aus, die im Container als /etc/fail2ban/jail.d/user-jail.local landet. Sie kam durch einen späteren Ausbau der Rolle dazu, aus einem anderen Anlass: Roundcube meldet jeden Login über eine einzige Adresse im Swarm-Overlay an, dem internen Netz zwischen den Containern. Nach genug Tippfehlern der Nutzer sperrte das Webmail sich selbst aus. Die Datei nahm deshalb die internen Netze in ignoreip auf.

Beide Dateien sind für sich genommen richtig. Entscheidend ist, in welcher Reihenfolge Fail2Ban sie liest.

Lesereihenfolge von Fail2Ban

/etc/fail2ban/jail.conf
/etc/fail2ban/jail.d/*.conf
/etc/fail2ban/jail.local          <- FAIL2BAN_IGNOREIP landet hier
/etc/fail2ban/jail.d/*.local      <- unsere Rolle landet hier, zuletzt

jail.d ist das Verzeichnis für ergänzende Konfiguration, und .local gewinnt darin gegen .conf. Unsere Datei wird also als Letztes gelesen. Sie bringt einen eigenen [DEFAULT]-Abschnitt mit, und der enthält seinerseits ignoreip.

Ab diesem Zeitpunkt war FAIL2BAN_IGNOREIP wirkungslos. Die Variable wurde weiterhin gelesen, weiterhin auf die Platte geschrieben und danach überschrieben. Genau das macht diesen Fehler so zäh: Wer die Konfiguration prüft, findet die Adressen dort, wo er sie erwartet.

Was die Prüfung nach dem Deploy abgefragt hat

Der Ausbau, der die zweite Datei eingeführt hat, war nicht ungeprüft. Er endete mit einer Verifikation, und diese Verifikation fragte sogar das richtige Kommando ab, nämlich fail2ban-client get dovecot ignoreip.

Sie meldete Erfolg. Die internen Netze standen in der Ausgabe, denn sie waren gerade hinzugefügt worden. Was in der Ausgabe fehlte, prüfte niemand.

Das ist die eigentliche Lehre, und sie ist unbequemer als der Konfigurationsfehler. Eine Prüfung, die nur bestätigen kann, dass etwas hinzugekommen ist, kann niemals bemerken, dass etwas verschwunden ist. Der Befehl, der den Defekt sichtbar gemacht hätte, wurde ausgeführt und war grün.

Dazu kommt eine zweite, ältere Schwäche. Wir hatten uns schon einmal an derselben Stelle überschrieben, damals über user-patches.sh. Die Regel, die wir daraus mitnahmen, hiess: kein Fail2Ban-Setup über user-patches.sh. Der spätere Ausbau hielt sich daran und erzeugte denselben Effekt über einen anderen Pfad. Wir hatten den Vorfall aufgeschrieben, aber nicht seine Form.

Was der Plan falsch hatte

Beim Zusammenstellen der neuen Liste standen zwei IPv6-Einträge zur Debatte, 2001:db8:271:1889::2 und das zugehörige /64. Weder mail.squibble.me noch der Host selbst haben einen AAAA-Eintrag im DNS. Die Einträge sahen nach Resten einer alten Migration aus, und der erste Entwurf sah vor, sie zu streichen.

Auf dem Host sagte ip -6 addr show scope global etwas anderes. Die Adresse ist konfiguriert und aktiv, und ein dort laufender Dienst benutzt sie als Absender für seine eigenen Verbindungen zum Mailserver. Ohne den Eintrag hätte sich der nächste Dienst selbst ausgesperrt.

DNS beschreibt, wie ein Host gefunden wird. Über die Adresse, mit der derselbe Host nach aussen spricht, sagt es nichts. Wir hätten beinahe einen funktionierenden Eintrag gelöscht, während wir einen gelöschten reparierten.

Reihenfolge beim Aufräumen

Der Fix macht die ausgelieferte Datei zur einzigen Quelle für ignoreip und nimmt die internen Netze und die öffentlichen Freigaben in denselben [DEFAULT]-Abschnitt auf. FAIL2BAN_IGNOREIP ist jetzt bewusst leer, mit einem Kommentar daneben, der erklärt, warum die Variable nicht zurückkommen darf. Acht Einträge müssen nach dem Deploy in jedem Jail sichtbar sein.

Beim Aufräumen der bestehenden Sperren kommt es auf die Reihenfolge an. ignoreip verhindert neue Sperren und löst keine bestehenden auf. Die Sperrliste liegt als SQLite-Datenbank auf dem State-Volume und übersteht Neustarts und Redeployments. Wer zuerst aufräumt und danach die Whitelist ausrollt, sperrt legitimen Verkehr einfach erneut.

Also erst die Freigabe ausrollen, in allen drei Jails prüfen, und dann leeren. Gemessen mit fail2ban-client status <jail> unmittelbar vor und unmittelbar nach dem Leeren:

Currently banned, vor und nach dem Flush

dovecot         451  ->  0
postfix         468  ->  0
postfix-sasl    446  ->  0
custom            0  ->  0

Diese Zahlen füllen sich wieder, und das ist richtig so. Was dort einläuft, sind echte Brute-Force-Versuche gegen einen öffentlich erreichbaren Mailserver. Der Unterschied ist, dass die freigegebenen Adressen nicht mehr darunter sein können.

Fazit

Der konkrete Fehler gehört zu docker-mailserver und zu einer Rolle, die dem Container eine eigene Datei mitgibt. Wer nur die Umgebungsvariable benutzt, trifft ihn nie. Wer eine eigene Datei nach jail.d legt, sollte darin jede Zeile führen, die er behalten will.

Der allgemeine Teil ist die Form dahinter. Zwei Mechanismen konfigurieren dieselbe Einstellung, jeder für sich korrekt, und der spätere gewinnt vollständig. Solange beide Werte gleich aussehen, fällt das niemandem auf. Diese Form gibt es überall, wo eine Umgebungsvariable und eine Konfigurationsdatei dasselbe Feld beschreiben.

Wir haben diese Konfigurationszeile inzwischen mehrfach aufgeschrieben, jedes Mal korrekt, und jede folgende Änderung hat die vorherige Antwort entfernt. Dokumentation ist an dieser Stelle offensichtlich nicht das wirksame Mittel. Das wirksame Mittel wäre eine Prüfung nach dem Deploy, die rot wird, wenn die erwarteten Einträge im laufenden Jail fehlen. Diese Prüfung steht bei uns noch aus.

Eine Freigabe, die niemand nachliest, ist eine Vermutung.

Patrick Lehmann

Architecture & Governance Lead

Squibble GmbH

Bringt seit über zwanzig Jahren Struktur in gewachsene IT-Landschaften – als Architekt, Entwickler und Betreiber. Schreibt hier über die Systeme, die bei Squibble tatsächlich laufen, und über die Entscheide dahinter.