Zwei identisch aussehende Dateien mit unterschiedlichem Inhalt, Container und Host, technische Illustration
Homelab 7 Min Read

Der Ordner bleibt gleich, die Datei nicht

Ich hatte gerade einen Zertifikats-Ausfall hinter mir und war dabei, alles wieder geradezuziehen. Zwanzig Dienste, frische Let's-Encrypt-Zertifikate aus dem Nginx Proxy Manager, alles grün. Bei Mailcow kam mir eine Frage,…

Audio-Guide KI-generierter Beitrag zum Thema

Ich hatte gerade einen Zertifikats-Ausfall hinter mir und war dabei, alles wieder geradezuziehen. Zwanzig Dienste, frische Let’s-Encrypt-Zertifikate aus dem Nginx Proxy Manager, alles grün. Bei Mailcow kam mir eine Frage, die so harmlos klingt, dass man sie sich normalerweise gar nicht erst stellt:

Bleibt der Ordner eigentlich gleich, wenn das Zertifikat erneuert wird?

Die Antwort ist ja. Und genau das war die Falle.

Zwei Wahrheiten über dasselbe Zertifikat

Auf dem Host lag live/npm-53/fullchain.pem, gültig bis zum 18. Dezember. Frisch geholt, korrekt abgelegt, richtiger Pfad. Mailcow band sich genau diese Datei ein, pro Dienst einmal:

- /var/lib/docker/volumes/nginx_proxy_manager/_data/live/npm-53/fullchain.pem:/etc/ssl/mail/cert.pem:ro
- /var/lib/docker/volumes/nginx_proxy_manager/_data/live/npm-53/privkey.pem:/etc/ssl/mail/key.pem:ro

Sieht vernünftig aus. Also müsste Mailcow dasselbe ausliefern, was auf dem Host liegt.

Tat er nicht. Ich habe ihn gefragt, was er tatsächlich an die Außenwelt gibt:

echo | openssl s_client -starttls smtp -connect 10.100.0.3:25 \
  -servername mail.marco-stankowitz.de 2>/dev/null \
  | openssl x509 -noout -serial -enddate

Heraus kam ein anderes Zertifikat: cert11, gültig bis zum 16. Dezember, während auf dem Host längst cert12 lag. Es war nicht so, dass eine Erneuerung fehlgeschlagen wäre. Der Host hatte das neue. Der Container lieferte das alte. Beide hielten sich für richtig.

Ein postfix reload änderte nichts. Auch nicht das zweite. Auch nicht das dritte, bei dem ich schon ahnte, dass ich das falsche Problem bearbeite.

Der Pfad ist nur ein Namensschild

Man stellt sich eine Datei als den Ort vor, an dem sie liegt. Das ist aber nicht, wie ein Linux-Dateisystem arbeitet.

Der Pfad ist nur ein Namensschild. Darunter liegt die eigentliche Datei mit einer internen Nummer, der Inode. Das Namensschild kann man abnehmen und an etwas anderes hängen, ohne dass sich an der Datei selbst irgendwas ändert — und umgekehrt kann jemand hinter dem Schild die ganze Datei austauschen.

Genau das passiert hier, und zwar von zwei Seiten gleichzeitig:

Docker löst einen Datei-Mount ein einziges Mal auf, nämlich beim Start des Containers. Danach merkt es sich nicht das Namensschild, sondern die Datei dahinter. Für immer.

Certbot überschreibt beim Erneuern die Datei nicht, sondern legt in archive/ eine komplett neue Generation an und hängt den Symlink in live/ um. Für den Host ist das sauber und sicher. Für den Container ist es tödlich, denn der hält ja an der alten Datei fest, die unter diesem Namen längst nicht mehr liegt.

Sichtbar machen kann man das, indem man beide Seiten nach der internen Nummer fragt:

Host       live/npm-53/fullchain.pem   → Inode 1795841   (aktuell)
Container  /etc/ssl/mail/cert.pem      → Inode 1837144   (eine ältere Generation)

Zwei verschiedene Zahlen, ein identischer Pfad. Und damit ist auch klar, warum jeder Reload wirkungslos war: Postfix hat brav neu eingelesen. Nur eben die Datei, an der der Container seit seinem letzten Start klebt.

Der Container hing übrigens nicht an der vorletzten, sondern an einer noch älteren Generation. Das lief also schon eine ganze Weile so.

Was daran wirklich gefährlich war

Ein veraltetes Zertifikat ist an sich kein Weltuntergang, solange es gültig ist. Es waren die zwei Dinge dahinter.

Das erste: Am 16. Dezember wäre es abgelaufen. Dann bricht die verschlüsselte Zustellung auf allen vier Mail-Ports gleichzeitig — während zwei Meter daneben, auf demselben Host, ein gültiges Zertifikat liegt. Man hätte sich zu Tode gesucht, weil der Pfad ja stimmt und die Datei ja aktuell ist.

Das zweite ist der Grund, warum ich seitdem vorsichtiger bin. Die sechs Mounts standen nicht in meiner docker-compose.override.yml, sondern direkt in Mailcows eigener docker-compose.yml. Und update.sh macht an drei Stellen ein git checkout -f. Beim nächsten regulären Mailcow-Update wären meine Zeilen weg gewesen, und Mailcow wäre auf sein selbstsigniertes Zertifikat vom März 2024 zurückgefallen. Das ist seit über einem Jahr abgelaufen.

Ein Update, das man macht, weil es die richtige Sache ist, hätte den Mailverkehr gestoppt.

Die Lösung ist langweilig, und das ist gut

Drei Schritte, keiner davon spektakulär.

Verzeichnis statt Datei einbinden. Das ist der Kern. Bindet man ein Verzeichnis ein, sieht der Container jede Änderung darin sofort — es gibt keine gemerkte Inode mehr, an der er hängen bleiben könnte. Die sechs Datei-Mounts aus dovecot, postfix und nginx-mailcow sind raus, Mailcow benutzt wieder seinen normalen Verzeichnis-Mount data/assets/ssl nach /etc/ssl/mail.

Stündlich abgleichen. /root/mailcow-cert-sync.sh vergleicht die Seriennummer in Mailcows SSL-Verzeichnis mit der auf dem Host. Weichen sie ab, kopiert es und löst ein Reload aus. Kein Container-Neustart, nur ein Reload — das reicht jetzt, weil der Container die Änderung tatsächlich mitbekommt.

Und die eine Zeile, auf die es ankommt: Kopiert wird mit cp, nicht mit mv. cp schreibt in die bestehende Datei hinein, die interne Nummer bleibt, alles gut. mv würde die Datei ersetzen — und damit exakt das Problem wiederherstellen, das ich gerade beseitigt habe. Dasselbe gilt für cp --remove-destination. Das ist die Art Detail, die man in einem halben Jahr beim Aufräumen entfernt, weil es so aussieht, als sei es egal.

Damit git checkout -f beim nächsten Mailcow-Update meine Änderung nicht wieder wegräumt, ist sie lokal als Commit festgehalten.

Der Beinahe-Unfall daneben

Beim Aufräumen bin ich über etwas gestolpert, das mich mehr erschreckt hat als das eigentliche Problem.

Meine Zone ist DNSSEC-signiert, auf den Mail-Ports liegen TLSA-Records — DANE. Vereinfacht gesagt steht im DNS ein Fingerabdruck meines öffentlichen Schlüssels, signiert und für jeden nachprüfbar. Große Anbieter — web.de, GMX, Posteo, viele Behörden — schauen dort nach, bevor sie zustellen. Passt der Fingerabdruck nicht zu dem, was mein Server vorzeigt, nehmen sie die Mail nicht an. Nicht mit Fehlermeldung an mich. Sie nehmen sie einfach nicht.

Dass das über Monate funktioniert hat, lag an einer Einstellung, die ich vor langer Zeit gesetzt und danach nie wieder angesehen habe: reuse_key = True. Das Zertifikat wird bei jeder Erneuerung neu, der Schlüssel bleibt identisch — und damit bleibt der TLSA-Record gültig, ohne dass ihn jemand anfassen muss. Nachweisbar an den echten Daten:

privkey11   07389C3D...   (17.09.)
privkey12   07389C3D...   (19.09.)   identisch

Nun existierten auf dem Host zwei Verzeichnisstrukturen für npm-53, mit zwei verschiedenen Schlüsseln. Die aktive (07389C3D…) passte zum TLSA-Record. Certbots eigenes archive_dir enthielt einen fremden (89A14AFA…). Ein ganz normaler Erneuerungslauf hätte per reuse_key mit einiger Wahrscheinlichkeit den falschen erwischt.

Das Ergebnis wäre ein Mailserver gewesen, der einwandfrei läuft, ein tadelloses Zertifikat vorzeigt, und von dem trotzdem ein guter Teil aller Mails nie ankommt. Lautlos.

Ich habe den aktiven Schlüssel deshalb als Generation 11 in die Struktur übernommen, die certbot tatsächlich benutzt. Und update-tlsa-inwx.py prüft alle dreißig Minuten, ob Fingerabdruck und DNS noch zueinander passen, und trägt bei INWX notfalls nach.

Eingerichtet ist nicht getestet

Der Teil, den ich früher übersprungen hätte: Ich habe den Mechanismus nicht nur gebaut, sondern ihn absichtlich kaputtgemacht. Ein veraltetes Zertifikat in Mailcows SSL-Verzeichnis gelegt, gewartet, geschaut, ob mailcow-cert-sync.sh greift.

Er griff. Seriendifferenz erkannt, Inode-erhaltend kopiert, Reload, Mailcow liefert wieder das richtige.

Das klingt nach Kleinkram, ist aber der Unterschied zwischen einer Sicherung und dem Gefühl, eine zu haben. Der ursprüngliche Aufbau sah ja auch vollkommen vernünftig aus. Er war sogar präziser als nötig — jemand hatte sich die Mühe gemacht, die exakten Dateien einzubinden statt pauschal einen Ordner. Nur war genau diese Präzision der Fehler.

Die Regel, die bleibt

Datei-Einbindungen sind für Dateien, die sich nie ändern. Eine Konfiguration, die man einmal schreibt, ein Zertifikat einer internen Stelle mit zehn Jahren Laufzeit.

Alles, was von außen regelmäßig ersetzt wird — und ein Let’s-Encrypt-Zertifikat mit neunzig Tagen Laufzeit wird zwangsläufig ersetzt — gehört als Verzeichnis eingebunden. Nicht als Datei.

Und wenn ein Dienst nach einem Reload immer noch den alten Stand zeigt, obwohl die Datei nachweislich neu ist, dann liest er nicht falsch. Dann schaut er auf etwas anderes als du.