Der Postfix-Aufbau aus dem letzten Beitrag sind vier Pakete, zusammengedrahtet, mit einem Milter-Socket, einem SASL-Socket und einer Socketmap, die sie im Takt halten. Stalwart macht das Ganze in einem Binary. Alles davon.

Vier Pakete, zusammengedrahtet, gegen ein BinaryVIER PAKETE, ZUSAMMENGEDRAHTETEIN BINARYPostfix 3.10MTA, TLS, RelayDovecot 2.4SASL-LoginsRspamd 3.12, RedisSPF, DKIM, DMARCpostfix-tlspol 1.14DANE, MTA-STSSASL-SocketMilterSocketmapStalwart 0.16.27SMTP ein und ausIMAP und JMAPSPF, DKIM, DMARC, ARCDKIM-SignierungDANE und MTA-STSTLS-Reportingder SpamfilterSieve-Skriptingder Mailspeichernichts zusammenzudrahten
Vier Pakete und die Sockets dazwischen, gegen einen Prozess, der all das schon enthält

SMTP, IMAP und JMAP, der Mailspeicher, SPF, DKIM, DMARC, ARC, DKIM-Signierung, DANE, MTA-STS, TLS-Reporting, ein Spamfilter und eine Sieve-Engine, in einem Prozess, den du einmal startest. Kein Milter zu verdrahten, kein Policy-Daemon an einem Socket, kein zweites Paket, das sich mit dem ersten einig sein muss. Für jede Regel in dieser Reihe, die auf Postfix ein eigenes Werkzeug brauchte, hat Stalwart die Antwort schon in sich.

Das ist ein echter Vorteil, und dieser Beitrag tut nicht so, als wäre es anders. Aber „eingebaut“ und „sicher per Standard“ sind verschiedene Aussagen, und die Lücke zwischen ihnen ist der ganze Beitrag. Ich habe Stalwart 0.16.27 an genau denselben Hausregeln gemessen, die der Postfix-Aufbau erfüllt hat, im selben Labor, und jede Antwort festgehalten. Die Regeln gehen alle an. Sie anzubekommen ist eine andere Sache.

Die Einstellungen sind eine Datenbank, keine Datei

Fang mit dem an, was alle überrascht, denn es prägt alles Weitere. Postfix wird in main.cf konfiguriert, einer Textdatei, die du lesen, diffen und auf den nächsten Server kopieren kannst. Stalwart hat eine Konfigurationsdatei, und das ist sie ganz:

{"@type":"RocksDb","path":"/var/lib/stalwart/"}

Die benennt die Datenbank. Jede andere Einstellung, die Listener, die TLS-Policy, die Spamregeln, die Sieve-Skripte, liegt innerhalb dieser Datenbank, und du änderst sie über die JMAP-API oder mit stalwart-cli, gefolgt von einem Reload:

stalwart-cli apply --file house-policy.json
stalwart-cli create Action/ReloadSettings

Es gibt auch eine Weboberfläche.1 Die Konfiguration ist Zustand in einem laufenden Dienst, keine Datei auf der Platte, und das schneidet in beide Richtungen. Du bekommst eine Live-API und keinen Daemon, den du von Hand neu laden musst. Du verlierst, was dir eine Textdatei umsonst gibt: ein Diff, einen Kommentar, einen Commit, eine bekannt gute Kopie zum erneuten Ausrollen. Wer Stalwart deklarativ betreibt, hält am Ende die Plan-Datei, die apply einliest, unter Versionskontrolle, was der richtige Instinkt ist, und zugleich genau die dateibasierte Konfiguration, mit deren Fehlen er angefangen hat.

Was es im Auslieferungszustand akzeptiert

Bevor die Regeln angehen, hier Stalwart bei dem, was es ab Werk tut. Das ist die faire Ausgangslinie, und sie ist nicht beruhigend.

Im Auslieferungszustand akzeptiert Stalwart …
eine Klartext-Sitzung, kein STARTTLSakzeptiert
eine manipulierte signierte Rechnungakzeptiert
eine Nachricht, die SPF nicht bestehtakzeptiert
eine Fälschung der eigenen Domainakzeptiert
eine Absenderdomain, die nicht existiertakzeptiert
Zustellung an einen Server nur mit Klartextim Klartext gesendet
ein offenes Relay auf Port 25abgelehnt
ein Login mit falschem Envelope-Absenderabgelehnt
ein Login, das den From:-Header fälschtakzeptiert
ausgehend an ein ungültiges Zertifikat, beim erneuten Versuchakzeptiert

Zwei dieser Ablehnungen sind die einzige gute Nachricht in der Spalte, und ein angemeldeter Nutzer könnte den sichtbaren From: trotzdem fälschen.

Nichts davon ist Stalwart-spezifisch. Es ist dieselbe Sammlung freizügiger Standardeinstellungen, mit denen fast jeder Mailserver ausgeliefert wird, aus demselben zwanzig Jahre alten Grund, und es ist das ganze Argument des Leitbeitrags dieser Reihe: /de/email/why-every-mail-setting-has-to-be-right/. Ein Server, der alles kann, ist kein Server, der alles sicher tut. Das zu konfigurieren bleibt deine Sache.

Vier Stellen für eine Regel

Hier wird das Ein-Binary-Design in der Arbeit wirklich anders. Auf Postfix ist fast jede eingehende Regel eine smtpd_*_restrictions-Zeile oder ein Milter. Auf Stalwart landet dieselbe Sammlung von Regeln in vier getrennten Mechanismen, und du brauchst alle vier.

Ein Binary, vier Stellen zum Konfigurieren einer RegelDER MECHANISMUSWAS ER DURCHSETZT, UND DIE ANTWORTNetzwerk-ListenerNetworkListenerSubmission auf 587 macht TLS; ein Login ist zum Relayen nötig.503 5.5.1 You must authenticate first.Ausdruck in der MAIL-StufeMtaStageMailKein TLS, unbekannter Absender oder einer, der dem Login nicht gehört.550 5.7.1 Sender address not allowed.Spam-Tags, auf Reject gesetztSpamTagSPF-Hard-Fail, DMARC-Reject, Absender-Host, der nicht auflöst.550 5.7.1 … excessive spam score.Sieve-SystemskriptSieveSystemScriptDKIM muss verifizieren; From-Header muss der Login sein.503 5.5.3 … does not verify / send as your own.
Ein Binary, aber eine Regel kann eine Listener-Einstellung sein, ein Ausdruck, ein Spam-Tag oder ein Sieve-Skript

Eine Ausdruckssprache, eine Tag-zu-Aktion-Zuordnung und ein Sieve-Skript sind drei verschiedene Syntaxen, und das noch vor den Listener-Einstellungen. Ein Binary, vier Dinge zu lernen. Aber mit angewendeter Policy decken sich die Mitschnitte Regel für Regel mit dem Postfix-Aufbau:

RegelStalwarts Antwort
Kein Klartext eingehend550 5.7.1 Sender address not allowed.
Relay auf Port 25550 5.1.2 Relay not allowed.
Relay auf 587 ohne Login503 5.5.1 You must authenticate first.
Login, das als anderer Envelope-Absender sendet501 5.5.4 You are not allowed to send from this address.
Login, das den From:-Header fälscht503 5.5.3 You may only send as your own address
Eine signierte, unveränderte, ausgerichtete Nachrichtakzeptiert
Eine manipulierte signierte Nachricht503 5.5.3 DKIM signature does not verify
Ein SPF-Hard-Fail550 5.7.1 … excessive spam score.

Jede Antwort stammt vom laufenden Laborserver, über IPv4 und IPv6. Die Regeln halten. Jetzt die drei Stellen, an denen der Weg dahin mir etwas beigebracht hat.

DKIM-Fehler braucht ein Sieve-Skript, nicht die naheliegende Einstellung

Stalwart hat ein eingebautes DKIM-Ergebnis und ein Spam-Tag für eine fehlgeschlagene Signatur, DKIM_REJECT, und das Naheliegende ist, darauf abzulehnen. Es fängt den Fall nicht, auf den es ankommt. Wenn bei einer signierten Nachricht der Body unterwegs verändert wird, also der Fall Rechnungsbetrug, auf den die ganze Reihe immer wieder zurückkommt, meldet Stalwarts DKIM-Prüfer das Ergebnis als neutral, nicht als fail. Liest man den mail-auth-Quelltext, landet eine nicht passende Body-Hash im Neutral-Fach, also feuert das Reject-Tag nie und die manipulierte Nachricht wird zugestellt.2

Also ist die DKIM-Regel stattdessen ein Sieve-Skript, das direkt auf das Prüfergebnis schaut und alles ablehnt, was kein sauberer Pass oder eine echte fehlende Signatur ist:

if anyof (string :is "${env.dkim.result}" "neutral",
          string :is "${env.dkim.result}" "fail",
          string :is "${env.dkim.result}" "permerror") {
    ereject "DKIM signature does not verify: the message was changed or forged";
    stop;
}

Unsignierte Mail lässt das Ergebnis leer und wird in Ruhe gelassen, denn unsigniert ist kein DKIM-Fehler; DMARC mit p=reject erledigt das. Mit dem Skript wird die manipulierte Rechnung bei DATA abgelehnt:

B REFUSED (503, b'5.5.3 DKIM signature does not verify: the message was changed or forged')

Dasselbe Skript trägt die From:-Header-Regel, denn den Header gegen den Login zu vergleichen ist ebenfalls ein Sieve-Job. Zwei Regeln, ein Skript, und keine davon ist die Einstellung, zu der du zuerst greifen würdest.

Die Unbekannter-Absender-Prüfung ist schwächer, als sie aussieht

Auf Postfix wird eine Absenderdomain, die nicht existiert, bei MAIL FROM abgelehnt, bevor ein Byte des Bodys gesendet ist, mit einem sauberen 550 5.1.8 Domain not found. Stalwart kommt zur selben Ablehnung, aber später und fragiler.

Die Prüfung ist ein Spam-Tag, FROMHOST_NORES_A_OR_MX, das feuert, wenn die Absenderdomain weder einen A- noch einen MX-Eintrag auflöst. Die Ablehnung kommt daher, nachdem die ganze Nachricht empfangen wurde, als Ablehnung über den Spam-Score:

550 5.7.1 Message rejected due to excessive spam score.

Das ist die richtige Antwort auf einem längeren Weg, und zwei Dinge daran sind wichtig. Erstens, liest man den Quelltext des Spamfilters, läuft die Prüfung erst, wenn die Absenderadresse einen Test auf „gültig“ besteht, wobei gültig heißt, dass die Domain unter einem bekannten Public Suffix liegt, also überspringt eine erfundene Top-Level-Domain die A-und-MX-Prüfung ganz. Zweitens, und das ist das, was wehtut: die Abfrage behandelt einen DNS-Fehler anders als eine DNS-Abwesenheit. Eine Domain, die SERVFAIL zurückgibt statt eines sauberen „kein solcher Name“, bekommt kein Tag, und die Nachricht wird akzeptiert. Postfix stellt sie zurück. Stalwart lässt sie rein. Das ist ein Fail-Open genau im mehrdeutigen Fall, für den eine Sicherheitskontrolle da ist, und die eine Stelle in diesem Aufbau, an der Stalwart messbar weniger sicher ist als das Postfix-Äquivalent.

IPv6 ist ein Fallback, bis du es änderst

IPv6 ist in dieser Reihe Pflicht, in beide Richtungen. Stalwart lauscht ohne Aufhebens auf beiden Familien. Der Haken ist ausgehend: die ausgelieferte Route löst IPv4 zuerst auf, also wird ein Dual-Stack-Ziel über IPv4 erreicht und IPv6 ist immer nur ein Fallback. Im selben Labor hat Postfix IPv6 genutzt. Stalwart nutzte v4, bis die Route auf v6 zuerst umgestellt wurde:

"ipLookupStrategy": "v6ThenV4"

Danach läuft die Zustellung an einen Dual-Stack-Server über IPv6, in den Logs bestätigt, die die v6-Adresse erreichen. Es ist eine Einstellung. Es ist auch nicht der Standard, und beim IPv6-Gebot steht der Standard verkehrt herum.

MTA-STS setzt durch, und bounct zu früh

Stalwart veröffentlicht seine eigene MTA-STS-Policy, was wirklich eine schöne Sache ist, kein separater Webserver nötig, wie der Postfix-Aufbau nginx braucht. Aber sie wird im Modus testing ausgeliefert, der nichts durchsetzt, und sie wird unter dem Zertifikat des Mailhosts ausgeliefert statt unter einem, das zum Policy-Hostnamen passt, also scheitert ein prüfender Abruf, bis du beides korrigierst. Setze den Modus auf enforce und lade ein Zertifikat für den mta-sts-Host, und die Policy wird korrekt ausgeliefert und geprüft:

mta-sts.policy-fetch strict = true
mta-sts.authorized

Die Sendeseite setzt ebenfalls durch und lehnt ein Ziel ab, dessen Policy seinen MX nicht aufführt. Aber sie lehnt auf die falsche Art ab: eine Abweichung ist ein permanenter Fehler beim ersten Versuch, und die Nachricht bounct. RFC 8461 sagt ausdrücklich, dass es das nicht soll:

In all cases, MTAs SHOULD treat such failures as transient errors and retry delivery later.3

Stalwart holt die Policy vor dem Scheitern neu, also ist die harte Regel des Standards, dass du zuerst nach einer neuen Policy prüfst, erfüllt. Das weichere SHOULD, dass du den Fehler als vorübergehend behandelst und erneut zustellst, ist es nicht. Der Preis ist eine gebouncte Nachricht, wo eine Zurückstellung freundlicher gewesen wäre, bei einem Fehler, der meist vorübergehend ist.

Ausgehendes TLS: erforderlich, dann stärker, wo es geht

Wie beim Postfix-Aufbau macht die Sendeseite encrypt zur Untergrenze und hebt jedes Ziel darüber, wenn das Ziel es sich verdient. Stalwarts MtaTlsStrategy setzt für jeden Mechanismus eine Stufe, und die Hauspolicy ist:

MechanismusHauseinstellungWirkung
STARTTLSrequirenie im Klartext senden, an niemanden
DANEoptionalauthentifizieren, wo ein signiertes TLSA veröffentlicht ist
MTA-STSoptionaleine durchgesetzte Policy beachten, wo eine veröffentlicht ist
Ungültige Zertifikateausein Zertifikat, das nicht verifiziert, stellt die Mail zurück

require bei STARTTLS ist die Untergrenze, die dem ausgelieferten Standard fehlt: ab Werk fiel die Sendestrategie darauf zurück, beim erneuten Versuch ein ungültiges Zertifikat zu akzeptieren. Mit gesetzter Untergrenze wird ein Ziel, das kein brauchbares TLS bietet, zurückgestellt, nicht heruntergestuft.

DANE ist da, aber das Labor konnte es nicht beweisen

Die stärkste ausgehende Kontrolle, DANE, ist wie der Rest eingebaut, und ihr ausgehendes TLS versagt geschlossen, so wie es soll: auf einen Server gerichtet, dessen Zertifikat es nicht verifizieren konnte, lehnte Stalwart ab und hielt die Nachricht, statt im Klartext zu senden. Was das abgeschottete Labor nicht konnte, ist DANE beim Gelingen zu zeigen, und das ist eine echte Einschränkung, die festzuhalten ist.

Stalwart schaltet DANE nur dann ein, wenn es den DNS-Root-Schlüssel selbst validieren kann, über eine Resolver-Verbindung, die nicht einfaches UDP ist. Ein privater Labor-Root kann das nicht bieten, also deaktiviert es DANE und schreibt eine Warnung in einen Pfad, den es standardmäßig nicht gibt. Leicht zu übersehen. Schlimmer noch, für eine signierte Zone meldet es die TLSA-Einträge dann als „nicht DNSSEC-signiert“, obwohl die wahre Ursache sein eigener Resolver ist. Die Meldung beschuldigt das Falsche.

Stalwart liefert für genau das eine schreibgeschützte Zustelldiagnose mit: einen Endpunkt, der den ganzen ausgehenden Weg durchläuft und nie eine Nachricht sendet. Er streamt jeden Schritt, während er läuft:

GET /api/live/delivery/<domain>?timeout=20
SchrittWas er meldet
MX-Lookupdie Mailserver, und ob der Lookup validiert hat
MTA-STSdie abgerufene Policy, und ob der MX autorisiert ist
TLS-RPTdie Reporting-Adresse, die die Domain veröffentlicht
TLSAdie DANE-Einträge, und ob sie DNSSEC-signiert sind
Connect, EHLO, STARTTLSdie Sitzung bis zum TLS, mit der Cipher
DANE-Verify, QUITder Zertifikatsabgleich, dann ein sauberes Auflegen

Kein MAIL FROM, kein RCPT TO, nichts zugestellt. Das ist die Prüfung, die man gegen eine echte DANE-Domain laufen lässt, um den positiven Fall zu sehen, den dieses Labor nicht zeigen kann.

Den positiven DANE-Fall zu beweisen heißt, das gegen echte DANE-veröffentlichende Domains von einer mit dem Internet verbundenen Instanz laufen zu lassen. Es ist der eine Test in dieser Reihe, den ein abgeschottetes Labor nicht leisten kann, und die ehrliche Grenze dessen, was ein Labor dir zeigen kann.

Ist eingebaut also besser?

Hier ist das, worum der ganze Beitrag gekreist ist. Eingebaut ist weniger zu installieren, weniger zu verdrahten, weniger über Versionen hinweg im Takt zu halten, und es liegt echter Wert in einem einzigen Prozess, in dem das SPF-Ergebnis für die DKIM-Prüfung einfach da ist und beide für das Sieve-Skript da sind, kein Socket dazwischen. Für ein kleines Deployment, das eine Sache zum Laufen und Aktualisieren will, ist es eine wirklich gute Antwort, und nichts oben ist ein Grund, es nicht zu nutzen.

Aber jeder schwierige Teil dieser Reihe hat den Umzug in ein Binary überlebt. Die Standardeinstellungen werden weiter freizügig ausgeliefert. Die Regeln müssen weiter gefunden und angeschaltet werden, über vier Mechanismen statt einem. Die Ablehnung bei DKIM-Fehler braucht weiter ein handgeschriebenes Skript. Die Unbekannter-Absender-Prüfung versagt weiter offen bei einem DNS-Fehler. MTA-STS bounct weiter, wo es zurückstellen sollte. Nichts davon wurde einfacher, weil der Code in einem Prozess liegt. Das sind Eigenschaften des Problems, nicht der Verpackung.

Stell die beiden Aufbauten nebeneinander, und das Muster ist deutlich:

Postfix-AufbauStalwart
Teile zum Betrieb4 Pakete, 3 Sockets1 Binary
Konfigurationmain.cf, eine Textdateieine Datenbank, über eine API
SPF, DKIM, DMARCRspamd-Miltereingebaut
DANE, MTA-STS ausgehendpostfix-tlspoleingebaut
MTA-STS-Veröffentlichungseparates nginxeingebaut
Ablehnung bei DKIM-FehlerRspamd Force Actionein Sieve-Skript (eingebautes Tag verpasst ihn)
Unbekannter Absender550 bei MAIL FROM550 nach DATA, versagt offen bei SERVFAIL
MTA-STS-Abweichung ausgehendstellt zurück, versucht erneutbounct beim ersten Versuch
IPv6 ausgehendper Standard genutztFallback, bis auf v6 zuerst umgestellt
Standardeinstellungen ab Werkfreizügigfreizügig

Was das ehrliche Fazit ist, und dasselbe, zu dem der Postfix-Beitrag auf einem anderen Weg gekommen ist. Was Mail sicher macht, ist nicht die Zahl der Binaries. Es ist jemand, der entscheidet, dass jede Regel zählt, der findet, wo sie wohnt, sie anschaltet und die Nachricht sendet, die sie stoppen soll, um zu prüfen, dass sie es tat. Stalwart lässt dich all das tun, in einem Prozess, über eine aufgeräumte API. Es wird, sich selbst überlassen, auch eine Klartext-Sitzung, einen gefälschten Absender und eine veränderte Rechnung akzeptieren, genau wie alles andere. Das Binary ist eins. Die Aufgabe ist es nicht.


  1. Stalwart-Dokumentation — Konfiguration über die JMAP-Management-API und stalwart-cli, mit Einstellungen im Datenspeicher statt in einer Datei. Hier Version 0.16.27 verwendet. ↩︎

  2. mail-auth, die Bibliothek, die Stalwart für DKIM nutzt — sein DKIM-Prüfer meldet eine nicht passende Body-Hash als neutrales Ergebnis statt als Fehler, weshalb eine Ablehnung allein auf dem Fehler-Tag einen manipulierten Body nicht fängt. ↩︎

  3. RFC 8461 §5 — SMTP MTA Strict Transport Security: bei einem Fehler der Policy-Validierung, „In all cases, MTAs SHOULD treat such failures as transient errors and retry delivery later“, und eine Nachricht „MUST NOT be permanently failed“, bevor der Absender nach einer aktualisierten Policy geprüft hat. ↩︎