De Postfix-build in het vorige stuk is vier pakketten aan elkaar geschroefd, met een milter-socket, een SASL-socket en een socketmap die ze in de pas houden. Stalwart doet het hele zaakje in één binary. Alles.
SMTP, IMAP en JMAP, de mailopslag, SPF, DKIM, DMARC, ARC, DKIM-ondertekening, DANE, MTA-STS, TLS-rapportage, een spamfilter en een Sieve-engine, in één proces dat je één keer start. Geen milter om aan te sluiten, geen policy-daemon op een socket, geen tweede pakket dat het met het eerste eens moet zijn. Voor elke regel in deze reeks die op Postfix een apart gereedschap vroeg, heeft Stalwart het antwoord al in zich.
Dat is een echt voordeel, en dit stuk doet niet alsof het anders is. Maar “ingebouwd” en “veilig standaard” zijn verschillende beweringen, en het gat ertussen is waar dit hele stuk over gaat. Ik hield Stalwart 0.16.27 aan precies de huisregels die de Postfix-build haalde, in hetzelfde lab, en legde elk antwoord vast. De regels gaan er allemaal op. Ze erop krijgen is een ander verhaal.
De Instellingen Zijn Een Database, Geen Bestand
Begin met het ding dat iedereen verrast, want het bepaalt al het andere. Postfix wordt geconfigureerd in main.cf, een tekstbestand dat je kunt lezen, diffen en naar de volgende server kopiëren. Stalwart heeft één configuratiebestand, en dit is het in zijn geheel:
{"@type":"RocksDb","path":"/var/lib/stalwart/"}
Dat benoemt de database. Elke andere instelling, de listeners, de TLS-policy, de spamregels, de Sieve-scripts, leeft binnen die database, en je wijzigt het via de JMAP-API of met stalwart-cli, gevolgd door een reload:
stalwart-cli apply --file house-policy.json
stalwart-cli create Action/ReloadSettings
Er is ook een webinterface.1 De configuratie is state in een draaiende service, geen bestand op schijf, en dat snijdt beide kanten op. Je krijgt een live API en geen daemon om met de hand te herladen. Je verliest wat een tekstbestand je gratis geeft: een diff, een commentaar, een commit, een bekend-goede kopie om opnieuw uit te rollen. Mensen die Stalwart declaratief draaien houden uiteindelijk het planbestand dat apply inleest onder versiebeheer, wat het juiste instinct is, en precies de bestandsgebaseerde configuratie die ze eerst niet hadden.
Wat Het Accepteert Zoals Geleverd
Voordat de regels erop gaan, hier is Stalwart zoals het uit de doos werkt. Dat is de eerlijke basislijn, en geruststellend is het niet.
| Zoals Stalwart geleverd wordt… | |
|---|---|
| een sessie in platte tekst, geen STARTTLS | geaccepteerd |
| een gewijzigde ondertekende factuur | geaccepteerd |
| een bericht dat SPF niet haalt | geaccepteerd |
| een vervalsing van het eigen domein | geaccepteerd |
| een afzenderdomein dat niet bestaat | geaccepteerd |
| aflevering aan een server die alleen platte tekst doet | in platte tekst verzonden |
| een open relay op poort 25 | geweigerd |
| een login met de verkeerde envelop-afzender | geweigerd |
een login die de From:-header vervalst | geaccepteerd |
| uitgaand naar een ongeldig certificaat, bij een nieuwe poging | geaccepteerd |
Twee van die weigeringen zijn het enige goede nieuws in de kolom, en een ingelogde gebruiker kon de zichtbare From: nog steeds vervalsen.
Niets daarvan is uniek voor Stalwart. Het is dezelfde set toegeeflijke standaardinstellingen waarmee bijna elke mailserver komt, om dezelfde twintig jaar oude reden, en het is het hele betoog van het openingsstuk in deze reeks: /nl/email/why-every-mail-setting-has-to-be-right/. Een server die alles doet is niet een server die alles veilig doet. Dat blijft aan jou om te configureren.
Vier Plekken Voor Een Regel
Hier wordt het één-binary-ontwerp echt anders om mee te werken. Op Postfix is bijna elke inkomende regel een smtpd_*_restrictions-regel of een milter. Op Stalwart landt dezelfde set regels in vier aparte mechanismen, en je hebt ze alle vier nodig.
Een expressietaal, een tag-naar-actie-map en een Sieve-script zijn drie verschillende syntaxen, en dat is nog voor de listener-instellingen. Eén binary, vier dingen om te leren. Maar met de policy toegepast lopen de captures regel voor regel gelijk met de Postfix-build:
| Regel | Stalwarts antwoord |
|---|---|
| Geen platte tekst inkomend | 550 5.7.1 Sender address not allowed. |
| Relay op poort 25 | 550 5.1.2 Relay not allowed. |
| Relay op 587 zonder login | 503 5.5.1 You must authenticate first. |
| Login die als een andere envelop-afzender verstuurt | 501 5.5.4 You are not allowed to send from this address. |
Login die de From:-header vervalst | 503 5.5.3 You may only send as your own address |
| Een ondertekend, intact, uitgelijnd bericht | geaccepteerd |
| Een gewijzigd ondertekend bericht | 503 5.5.3 DKIM signature does not verify |
| Een SPF-hardfail | 550 5.7.1 … excessive spam score. |
Elk antwoord komt van de draaiende labserver, over IPv4 en IPv6. De regels houden stand. Nu de drie plekken waar de weg erheen me iets leerde.
Een DKIM-fout Vraagt Om Een Sieve-script, Niet Om De Voor De Hand Liggende Instelling
Stalwart heeft een ingebouwd DKIM-resultaat en een spam-tag voor een mislukte handtekening, DKIM_REJECT, en het voor de hand liggende is om daarop af te wijzen. Het vangt het geval dat ertoe doet niet. Wanneer een ondertekend bericht onderweg zijn body gewijzigd krijgt, wat het factuurfraudegeval is waar de hele reeks op terugkomt, rapporteert de DKIM-verifier van Stalwart het resultaat als neutral, niet als fail. Lees je de mail-auth-broncode, dan belandt een body-hash-mismatch in de neutral-bak, dus de reject-tag gaat nooit af en het gewijzigde bericht wordt afgeleverd.2
Dus de DKIM-regel is in plaats daarvan een Sieve-script, dat rechtstreeks naar het verificatieresultaat kijkt en alles weigert wat geen schone pass of een echt ontbreken van handtekening is:
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;
}
Niet-ondertekende mail laat het resultaat leeg en wordt met rust gelaten, want niet-ondertekend is geen DKIM-fout; DMARC op p=reject handelt dat af. Met het script op zijn plek wordt de gewijzigde factuur bij DATA geweigerd:
B REFUSED (503, b'5.5.3 DKIM signature does not verify: the message was changed or forged')
Hetzelfde script draagt de From:-header-regel, want de header tegen de login vergelijken is ook een Sieve-klus. Twee regels, één script, en geen van beide is de instelling waar je het eerst naar grijpt.
De Controle Op Onbekende Afzenders Is Zwakker Dan Hij Lijkt
Op Postfix wordt een afzenderdomein dat niet bestaat geweigerd bij MAIL FROM, voordat er een byte van de body verstuurd is, met een schone 550 5.1.8 Domain not found. Stalwart komt bij dezelfde weigering uit, maar later en brozer.
De controle is een spam-tag, FROMHOST_NORES_A_OR_MX, die afgaat wanneer het domein van de afzender naar geen A- en geen MX-record resolvet. De weigering komt daarom pas nadat het hele bericht ontvangen is, als een afwijzing op spamscore:
550 5.7.1 Message rejected due to excessive spam score.
Dat is het juiste antwoord via een langere weg, en twee dingen eraan doen ertoe. Ten eerste, lees je de broncode van het spamfilter, dan draait de controle pas als het afzenderadres slaagt voor een “is valid”-test, waarbij geldig betekent dat het domein onder een bekende public suffix valt, dus een verzonnen topleveldomein slaat de A-en-MX-controle helemaal over. Ten tweede, en dit is degene die bijt: de lookup behandelt een DNS-fout anders dan een DNS-afwezigheid. Een domein dat SERVFAIL teruggeeft, in plaats van een schone “no such name”, krijgt geen tag, en het bericht wordt geaccepteerd. Postfix stelt het uit. Stalwart laat het binnen. Dat is een fail-open op precies het dubbelzinnige geval waar een beveiligingscontrole voor bestaat, en de enige plek in deze build waar Stalwart meetbaar minder veilig is dan het Postfix-equivalent.
IPv6 Is Een Fallback Tot Je Het Verandert
IPv6 is verplicht in deze reeks, in beide richtingen. Stalwart luistert zonder gedoe op beide families. Uitgaand is de valkuil: de meegeleverde route resolvet IPv4-eerst, dus een dual-stack-bestemming wordt over IPv4 bereikt en IPv6 is altijd slechts een fallback. In hetzelfde lab gebruikte Postfix IPv6. Stalwart gebruikte v4 tot de route werd omgezet om v6 te verkiezen:
"ipLookupStrategy": "v6ThenV4"
Daarna gaat aflevering aan een dual-stack-server over IPv6, bevestigd in de logs die het v6-adres bereiken. Het is één instelling. Het is ook niet de standaard, en op de IPv6-eis staat de standaard de verkeerde kant op.
MTA-STS Dwingt Af, En Bounced Te Snel
Stalwart publiceert zijn eigen MTA-STS-policy, wat een echt mooie toevoeging is, geen aparte webserver nodig zoals de Postfix-build nginx nodig heeft. Maar het komt in testing-modus, die niets afdwingt, en het wordt geserveerd onder het certificaat van de mailhost in plaats van een dat bij de policy-hostnaam past, dus een verifiërende fetch mislukt tot je allebei herstelt. Zet de modus op enforce en laad een certificaat voor de mta-sts-host, en de policy wordt netjes geserveerd en gecontroleerd:
mta-sts.policy-fetch strict = true
mta-sts.authorized
De verzendende kant dwingt ook af, en weigert een bestemming waarvan de policy zijn MX niet vermeldt. Maar het weigert op de verkeerde manier: een mismatch is een permanente fout bij de eerste poging, en het bericht bounced. RFC 8461 is er expliciet over dat dat niet zou moeten:
In all cases, MTAs SHOULD treat such failures as transient errors and retry delivery later.3
Stalwart haalt de policy wel opnieuw op voordat het faalt, dus de harde regel van de standaard, dat je eerst op een nieuwe policy controleert, wordt gehaald. De zachtere SHOULD, dat je de fout als tijdelijk behandelt en opnieuw probeert, niet. De prijs is een gebouncet bericht waar uitstel vriendelijker was geweest, op een fout die meestal van voorbijgaande aard is.
Uitgaande TLS: Verplicht, Dan Sterker Waar Het Kan
Net als bij de Postfix-build maakt de uitgaande kant encrypt de ondergrens en tilt elke bestemming daarboven zodra die het verdient. Stalwarts MtaTlsStrategy stelt per mechanisme een niveau in, en de huispolicy is:
| Mechanisme | Huisinstelling | Effect |
|---|---|---|
| STARTTLS | require | nooit in platte tekst versturen, naar niemand |
| DANE | optional | authenticeren waar een ondertekende TLSA is gepubliceerd |
| MTA-STS | optional | een afgedwongen policy respecteren waar die gepubliceerd is |
| Ongeldige certs | uit | een certificaat dat niet verifieert stelt de mail uit |
require op STARTTLS is de ondergrens die de meegeleverde standaard mist: uit de doos viel de uitgaande strategie terug op het accepteren van een ongeldig certificaat bij een nieuwe poging. Met de ondergrens gezet wordt een bestemming die geen bruikbare TLS biedt uitgesteld, niet gedegradeerd.
DANE Is Er, Maar Het Lab Kon Het Niet Bewijzen
De sterkste uitgaande controle, DANE, is net als de rest ingebouwd, en zijn uitgaande TLS faalt dicht zoals het hoort: gericht op een server waarvan het het certificaat niet kon verifiëren, weigerde Stalwart en hield het bericht vast in plaats van in platte tekst te versturen. Wat het afgesloten lab niet kon, is DANE laten slagen, en dat is een echte beperking die het vastleggen waard is.
Stalwart zet DANE alleen aan als het de DNS-rootsleutel zelf kan valideren, via een resolververbinding die geen plat UDP is. Een privé-labroot kan dat niet bieden, dus zet het DANE uit en logt een waarschuwing naar een pad dat standaard niet bestaat. Makkelijk te missen. Erger nog, voor een ondertekende zone rapporteert het de TLSA-records dan als “not DNSSEC signed” terwijl de echte oorzaak zijn eigen resolver is. De melding wijst het verkeerde ding aan.
Stalwart levert een alleen-lezen afleveringsdiagnose voor precies dit: een endpoint dat het hele uitgaande pad doorloopt en nooit een bericht verstuurt. Het streamt elke stap terwijl het bezig is:
GET /api/live/delivery/<domain>?timeout=20
| Stap | Wat het rapporteert |
|---|---|
| MX-lookup | de mailservers, en of de lookup valideerde |
| MTA-STS | de opgehaalde policy, en of de MX geautoriseerd is |
| TLS-RPT | het rapportageadres dat het domein publiceert |
| TLSA | de DANE-records, en of ze DNSSEC-ondertekend zijn |
| Connect, EHLO, STARTTLS | de sessie tot aan TLS, met de cipher |
| DANE-verificatie, QUIT | de certificaatmatch, dan een nette verbreking |
Geen MAIL FROM, geen RCPT TO, niets afgeleverd. Het is de controle om tegen een echt DANE-domein te draaien om het positieve geval te zien dat dit lab niet kan tonen.
Het positieve DANE-geval bewijzen betekent dat tegen echte DANE-publicerende domeinen draaien vanaf een met internet verbonden instance. Het is de ene test in deze reeks die een afgesloten lab niet kan doen, en de eerlijke grens van wat een lab je kan laten zien.
Is Ingebouwd Dan Beter?
Hier is het ding waar het hele stuk omheen cirkelt. Ingebouwd is minder om te installeren, minder om aan te sluiten, minder om tussen versies in de pas te houden, en er zit echte waarde in één proces waar het SPF-resultaat gewoon klaarligt voor de DKIM-controle en beide klaarliggen voor het Sieve-script, zonder socket ertussen. Voor een kleine deployment die één ding wil draaien en bijwerken is het een echt goed antwoord, en niets hierboven is een reden om het niet te gebruiken.
Maar elk moeilijk deel van deze reeks overleefde de verhuizing naar één binary. De standaardinstellingen komen nog steeds toegeeflijk. De regels moeten nog steeds gevonden en aangezet worden, over vier mechanismen in plaats van één. Afwijzen op een DKIM-fout vraagt nog steeds om een met de hand geschreven script. De controle op onbekende afzenders faalt nog steeds open bij een DNS-fout. MTA-STS bounced nog steeds wanneer het zou moeten uitstellen. Niets daarvan werd makkelijker doordat de code in één proces leeft. Dit zijn eigenschappen van het probleem, niet van de verpakking.
Zet de twee builds naast elkaar en het patroon is duidelijk:
| Postfix-build | Stalwart | |
|---|---|---|
| Onderdelen om te draaien | 4 pakketten, 3 sockets | 1 binary |
| Configuratie | main.cf, een tekstbestand | een database, via een API |
| SPF, DKIM, DMARC | Rspamd-milter | ingebouwd |
| DANE, MTA-STS uitgaand | postfix-tlspol | ingebouwd |
| MTA-STS publiceren | aparte nginx | ingebouwd |
| Afwijzen op DKIM-fout | Rspamd force action | een Sieve-script (ingebouwde tag mist het) |
| Onbekende afzender | 550 bij MAIL FROM | 550 na DATA, faalt open bij SERVFAIL |
| MTA-STS-mismatch uitgaand | stelt uit, probeert opnieuw | bounced bij de eerste poging |
| IPv6 uitgaand | standaard gebruikt | fallback tot omgezet naar v6-eerst |
| Standaardinstellingen zoals geleverd | toegeeflijk | toegeeflijk |
Wat de eerlijke conclusie is, en dezelfde die het Postfix-stuk via een andere weg bereikte. Wat mail veilig maakt is niet het aantal binaries. Het is iemand die besluit dat elke regel ertoe doet, uitzoekt waar hij leeft, hem aanzet, en het bericht verstuurt dat hij hoort tegen te houden om te controleren dat het gelukt is. Stalwart laat je dat allemaal doen, in één proces, over een nette API. Het zal ook, met rust gelaten, een sessie in platte tekst, een vervalste afzender en een gewijzigde factuur accepteren, precies zoals al het andere. De binary is één. De klus niet.
Stalwart-documentatie — configuratie via de JMAP-beheer-API en
stalwart-cli, met instellingen in de datastore in plaats van een bestand. Hier is versie 0.16.27 gebruikt. ↩︎mail-auth, de bibliotheek die Stalwart voor DKIM gebruikt — de DKIM-verifier rapporteert een body-hash-mismatch als een neutral resultaat in plaats van als een fout, en daarom vangt een afwijzing op alleen de fout-tag een gewijzigde body niet. ↩︎
RFC 8461 §5 — SMTP MTA Strict Transport Security: bij een mislukte policyvalidatie, “In all cases, MTAs SHOULD treat such failures as transient errors and retry delivery later”, en een bericht “MUST NOT be permanently failed” voordat de verzender op een bijgewerkte policy heeft gecontroleerd. ↩︎