Le montage Postfix du dernier article, c’est quatre paquets câblés ensemble, avec un socket milter, un socket SASL et un socketmap qui les tiennent en phase. Stalwart fait tout cela dans un seul binaire. Absolument tout.

Quatre paquets câblés ensemble, face à un seul binaireQUATRE PAQUETS, CÂBLÉS ENSEMBLEUN SEUL BINAIREPostfix 3.10MTA, TLS, relaisDovecot 2.4connexions SASLRspamd 3.12, RedisSPF, DKIM, DMARCpostfix-tlspol 1.14DANE, MTA-STSsocket saslmiltersocketmapStalwart 0.16.27SMTP entrant et sortantIMAP et JMAPSPF, DKIM, DMARC, ARCsignature DKIMDANE et MTA-STSrapports TLSle filtre antispamscripts Sievele magasin de courrierrien à câbler ensemble
Quatre paquets et les sockets entre eux, face à un seul processus qui contient déjà tout cela

SMTP, IMAP et JMAP, le magasin de courrier, SPF, DKIM, DMARC, ARC, la signature DKIM, DANE, MTA-STS, les rapports TLS, un filtre antispam et un moteur Sieve, dans un seul processus que vous démarrez une fois. Aucun milter à câbler, aucun démon de politique sur un socket, aucun second paquet qui doive s’accorder avec le premier. Pour chaque règle de cette série qui réclamait un outil séparé sous Postfix, Stalwart a déjà la réponse en lui.

C’est un vrai avantage, et cet article ne prétend pas le contraire. Mais « intégré » et « sûr par défaut » sont deux affirmations différentes, et tout l’article tient dans l’écart entre les deux. J’ai soumis Stalwart 0.16.27 exactement aux mêmes règles maison que celles satisfaites par le montage Postfix, dans le même labo, et j’ai capturé chaque réponse. Toutes les règles finissent par s’activer. Les activer est une autre affaire.

Les réglages sont une base de données, pas un fichier

Commençons par ce qui surprend tout le monde, parce que cela conditionne tout le reste. Postfix se configure dans main.cf, un fichier texte que vous pouvez lire, comparer et copier sur le serveur suivant. Stalwart a un seul fichier de configuration, et le voici en entier :

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

Celui-ci désigne la base de données. Tous les autres réglages, les listeners, la politique TLS, les règles antispam, les scripts Sieve, vivent dans cette base de données, et vous les modifiez via l’API JMAP ou avec stalwart-cli, suivi d’un rechargement :

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

Il y a aussi une interface web.1 La configuration est un état dans un service en cours d’exécution, pas un fichier sur disque, et cela coupe dans les deux sens. Vous gagnez une API en direct et aucun démon à recharger à la main. Vous perdez ce qu’un fichier texte offre gratuitement : un diff, un commentaire, un commit, une copie connue comme bonne à redéployer. Les gens qui exploitent Stalwart de façon déclarative finissent par garder sous gestion de versions le fichier de plan que apply consomme, ce qui est le bon réflexe, et aussi précisément la configuration en fichier qu’ils avaient commencé par ne pas avoir.

Ce qu’il accepte tel que livré

Avant d’activer les règles, voici Stalwart faisant ce qu’il fait tel quel. C’est la référence honnête, et elle n’est pas rassurante.

Tel que livré, Stalwart…
une session en clair, sans STARTTLSacceptée
une facture signée falsifiéeacceptée
un message qui échoue à SPFaccepté
une usurpation de son propre domaineacceptée
un domaine expéditeur qui n’existe pasaccepté
une remise vers un serveur en clair uniquementenvoyée en clair
un relais ouvert sur le port 25refusé
une connexion avec le mauvais expéditeur d’envelopperefusée
une connexion qui falsifie l’en-tête From:acceptée
un envoi sortant vers un certificat invalide, à la repriseaccepté

Ces deux refus sont la seule bonne nouvelle de la colonne, et un utilisateur connecté pourrait encore falsifier le From: visible.

Rien de tout cela n’est propre à Stalwart. C’est le même jeu de réglages permissifs par défaut que livre presque chaque serveur de courrier, pour la même raison vieille de vingt ans, et c’est tout l’argument de l’article d’ouverture de cette série : /fr/email/why-every-mail-setting-has-to-be-right/. Un serveur qui fait tout n’est pas un serveur qui fait tout en sécurité. Cela reste à vous de le configurer.

Quatre endroits où placer une règle

C’est ici que la conception en un seul binaire devient vraiment différente à l’usage. Sous Postfix, presque chaque règle entrante est une ligne smtpd_*_restrictions ou un milter. Sous Stalwart, le même jeu de règles atterrit dans quatre mécanismes distincts, et il vous faut les quatre.

Un seul binaire, quatre endroits où configurer une règleLE MÉCANISMECE QU'IL APPLIQUE, ET LA RÉPONSEListener réseauNetworkListenerSoumission sur 587 en TLS ; connexion requise pour relayer.503 5.5.1 You must authenticate first.Expression à l'étape MAILMtaStageMailPas de TLS, expéditeur inconnu, ou non possédé par la connexion.550 5.7.1 Sender address not allowed.Étiquettes antispam, réglées sur rejetSpamTagÉchec dur SPF, rejet DMARC, hôte expéditeur sans résolution.550 5.7.1 … excessive spam score.Script système SieveSieveSystemScriptDKIM doit se vérifier ; l'en-tête From doit être la connexion.503 5.5.3 … does not verify / send as your own.
Un seul binaire, mais une règle peut être un réglage de listener, une expression, une étiquette antispam ou un script Sieve

Un langage d’expressions, une table étiquette-vers-action et un script Sieve, cela fait trois syntaxes différentes, et c’est avant les réglages du listener. Un seul binaire, quatre choses à apprendre. Mais une fois la politique appliquée, les captures correspondent règle pour règle au montage Postfix :

RègleRéponse de Stalwart
Pas de clair en entrée550 5.7.1 Sender address not allowed.
Relais sur le port 25550 5.1.2 Relay not allowed.
Relais sur 587 sans connexion503 5.5.1 You must authenticate first.
Connexion envoyant sous un autre expéditeur d’enveloppe501 5.5.4 You are not allowed to send from this address.
Connexion falsifiant l’en-tête From:503 5.5.3 You may only send as your own address
Un message signé, intact, alignéaccepté
Un message signé falsifié503 5.5.3 DKIM signature does not verify
Un échec dur de SPF550 5.7.1 … excessive spam score.

Chaque réponse provient du serveur de labo en fonctionnement, en IPv4 et en IPv6. Les règles tiennent. Voici maintenant les trois endroits où y parvenir m’a appris quelque chose.

L’échec DKIM demande un script Sieve, pas le réglage évident

Stalwart dispose d’un résultat DKIM intégré et d’une étiquette antispam pour une signature en échec, DKIM_REJECT, et la chose évidente est de rejeter dessus. Elle ne rattrape pas le cas qui compte. Quand un message signé voit son corps modifié en transit, c’est-à-dire le cas de la fraude à la facture sur lequel toute la série revient sans cesse, le vérificateur DKIM de Stalwart signale le résultat comme neutral, pas fail. À la lecture du code de mail-auth, un désaccord de hachage de corps atterrit dans le panier neutre, si bien que l’étiquette de rejet ne se déclenche jamais et que le message falsifié est remis.2

La règle DKIM est donc plutôt un script Sieve, qui regarde directement le résultat de vérification et refuse tout ce qui n’est pas une réussite nette ou une véritable absence de signature :

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;
}

Le courrier non signé laisse le résultat vide et n’est pas touché, car non signé n’est pas un échec DKIM ; DMARC en p=reject s’en charge. Le script en place, la facture falsifiée est refusée à DATA :

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

Le même script porte la règle de l’en-tête From:, puisque comparer l’en-tête à la connexion est aussi un travail pour Sieve. Deux règles, un seul script, et ni l’une ni l’autre n’est le réglage vers lequel vous iriez d’abord.

Le contrôle de l’expéditeur inconnu est plus faible qu’il n’y paraît

Sous Postfix, un domaine expéditeur qui n’existe pas est refusé à MAIL FROM, avant qu’un seul octet du corps ne soit envoyé, avec un propre 550 5.1.8 Domain not found. Stalwart parvient au même refus, mais plus tard et de façon plus fragile.

Le contrôle est une étiquette antispam, FROMHOST_NORES_A_OR_MX, qui se déclenche quand le domaine de l’expéditeur ne résout vers aucun enregistrement A ni MX. Le refus arrive donc après la réception du message entier, sous forme de rejet au score de spam :

550 5.7.1 Message rejected due to excessive spam score.

C’est la bonne réponse par un chemin plus long, et deux choses à son sujet comptent. D’abord, à la lecture du code du filtre antispam, le contrôle ne s’exécute qu’une fois que l’adresse de l’expéditeur passe un test de « validité », où valide signifie que le domaine se trouve sous un suffixe public connu, si bien qu’un domaine de premier niveau inventé saute entièrement le contrôle A-et-MX. Ensuite, et c’est celle qui fait mal : la recherche traite une erreur DNS différemment d’une absence DNS. Un domaine qui renvoie SERVFAIL, plutôt qu’un propre « nom inexistant », ne reçoit aucune étiquette, et le message est accepté. Postfix le diffère. Stalwart le laisse entrer. C’est un fail-open sur exactement le cas ambigu pour lequel existe un contrôle de sécurité, et le seul endroit de ce montage où Stalwart est mesurablement moins sûr que l’équivalent Postfix.

IPv6 n’est qu’un repli tant que vous ne le changez pas

IPv6 est obligatoire dans cette série, dans les deux sens. Stalwart écoute sur les deux familles sans histoires. Le sortant est le piège : la route livrée résout en IPv4 d’abord, si bien qu’une destination à double pile est atteinte en IPv4 et qu’IPv6 n’est jamais qu’un repli. Dans le même labo, Postfix a utilisé IPv6. Stalwart a utilisé la v4 jusqu’à ce que la route soit basculée pour préférer la v6 :

"ipLookupStrategy": "v6ThenV4"

Après cela, la remise vers un serveur à double pile passe en IPv6, confirmé dans les journaux atteignant l’adresse v6. C’est un seul réglage. Ce n’est pas non plus le réglage par défaut, et sur l’exigence IPv6 le défaut est à l’envers.

MTA-STS applique sa politique, et rejette trop tôt

Stalwart publie sa propre politique MTA-STS, ce qui est une vraie bonne idée, sans serveur web séparé comme le montage Postfix a besoin de nginx. Mais il est livré en mode testing, qui n’applique rien, et il est servi sous le certificat de l’hôte de courrier plutôt que sous un certificat correspondant au nom d’hôte de la politique, si bien qu’une récupération vérifiante échoue tant que vous ne corrigez pas les deux. Réglez le mode sur enforce et chargez un certificat pour l’hôte mta-sts, et la politique est servie et vérifiée correctement :

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

Le côté émetteur applique lui aussi la politique, et refuse une destination dont la politique ne liste pas son MX. Mais il refuse de la mauvaise manière : un désaccord est un échec permanent dès la première tentative, et le message rebondit. La RFC 8461 est explicite : il ne le devrait pas :

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

Stalwart récupère bien à nouveau la politique avant d’échouer, si bien que la règle dure de la norme, vérifier d’abord s’il existe une nouvelle politique, est respectée. Le SHOULD plus souple, traiter l’échec comme temporaire et réessayer, ne l’est pas. Le coût, c’est un message rejeté là où un report aurait été plus clément, sur un échec qui est d’ordinaire transitoire.

TLS sortant : exigé, puis renforcé là où c’est possible

Comme le montage Postfix, le côté sortant fait de encrypt le plancher et élève chaque destination au-dessus quand la destination le mérite. Le MtaTlsStrategy de Stalwart fixe un niveau pour chaque mécanisme, et la politique maison est la suivante :

MécanismeRéglage maisonEffet
STARTTLSrequirene jamais envoyer en clair, à personne
DANEoptionalauthentifier là où un TLSA signé est publié
MTA-STSoptionalhonorer une politique appliquée là où il en existe une
Certificats invalidesdésactivéun certificat qui ne se vérifie pas diffère le courrier

require sur STARTTLS est le plancher qui manque au réglage livré par défaut : tel quel, la stratégie sortante se rabattait sur l’acceptation d’un certificat invalide à la reprise. Le plancher posé, une destination qui n’offre aucun TLS utilisable est différée, pas rétrogradée.

DANE est présent, mais le labo n’a pas pu le prouver

Le contrôle sortant le plus fort, DANE, est intégré comme le reste, et son TLS sortant échoue en se fermant comme il se doit : pointé vers un serveur dont il ne pouvait vérifier le certificat, Stalwart a refusé et retenu le message plutôt que d’envoyer en clair. Ce que le labo scellé n’a pas pu faire, c’est montrer DANE réussissant, et c’est une véritable contrainte à consigner.

Stalwart n’active DANE que lorsqu’il peut valider lui-même la clé racine DNS, via une connexion au résolveur qui n’est pas de l’UDP simple. Une racine de labo privée ne peut offrir cela, il désactive donc DANE et consigne un avertissement dans un chemin qui n’existe pas par défaut. Facile à manquer. Pire, pour une zone signée il signale ensuite les enregistrements TLSA comme « non signés par DNSSEC » alors que la vraie cause est son propre résolveur. Le message accuse le mauvais coupable.

Stalwart fournit un diagnostic de remise en lecture seule pour exactement cela : un endpoint qui parcourt tout le chemin sortant et n’envoie jamais de message. Il diffuse chaque étape au fur et à mesure :

GET /api/live/delivery/<domain>?timeout=20
ÉtapeCe qu’il rapporte
Recherche MXles serveurs de courrier, et si la recherche a été validée
MTA-STSla politique récupérée, et si le MX est autorisé
TLS-RPTl’adresse de rapport que le domaine publie
TLSAles enregistrements DANE, et s’ils sont signés par DNSSEC
Connect, EHLO, STARTTLSla session jusqu’à TLS, avec le chiffrement
Vérification DANE, QUITla correspondance du certificat, puis une coupure propre

Pas de MAIL FROM, pas de RCPT TO, rien de remis. C’est le test à lancer contre un vrai domaine DANE pour voir le cas positif que ce labo ne peut montrer.

Prouver le cas positif de DANE, c’est le lancer contre de vrais domaines publiant DANE depuis une instance connectée à Internet. C’est le seul test de cette série qu’un labo scellé ne peut faire, et la limite honnête de ce qu’un labo peut vous montrer.

Alors, l’intégré est-il meilleur ?

Voici ce autour de quoi tout l’article tournait. L’intégré, c’est moins à installer, moins à câbler, moins à tenir en phase d’une version à l’autre, et il y a une vraie valeur dans un processus unique où le résultat SPF est simplement là pour le contrôle DKIM et où les deux sont là pour le script Sieve, sans socket entre eux. Pour un petit déploiement qui veut une seule chose à exécuter et à mettre à jour, c’est une réponse réellement bonne, et rien de ce qui précède n’est une raison de ne pas l’utiliser.

Mais chaque partie difficile de cette série a survécu au passage dans un seul binaire. Les réglages par défaut restent permissifs à la livraison. Les règles doivent encore être trouvées et activées, à travers quatre mécanismes au lieu d’un. Le rejet sur échec DKIM a encore besoin d’un script écrit à la main. Le contrôle de l’expéditeur inconnu reste en fail-open sur une erreur DNS. MTA-STS rebondit encore là où il devrait différer. Rien de cela n’est devenu plus simple parce que le code vit dans un seul processus. Ce sont des propriétés du problème, pas de l’emballage.

Mettez les deux montages côte à côte et le motif saute aux yeux :

Montage PostfixStalwart
Éléments à exécuter4 paquets, 3 sockets1 binaire
Configurationmain.cf, un fichier texteune base de données, via une API
SPF, DKIM, DMARCmilter Rspamdintégré
DANE, MTA-STS sortantpostfix-tlspolintégré
Publication MTA-STSnginx séparéintégré
Rejet sur échec DKIMaction forcée Rspamdun script Sieve (l’étiquette intégrée le manque)
Expéditeur inconnu550 à MAIL FROM550 après DATA, fail-open sur SERVFAIL
Désaccord MTA-STS sortantdiffère, réessaierebondit dès la première tentative
IPv6 sortantutilisé par défautrepli jusqu’à bascule en v6 d’abord
Réglages par défaut livréspermissifspermissifs

C’est la conclusion honnête, et la même que l’article Postfix a atteinte par un autre chemin. Ce qui rend le courrier sûr, ce n’est pas le nombre de binaires. C’est quelqu’un qui décide que chaque règle compte, qui trouve où elle vit, qui l’active, et qui envoie le message qu’elle est censée arrêter pour vérifier qu’elle l’a fait. Stalwart vous laisse faire tout cela, dans un seul processus, via une API soignée. Il acceptera aussi, laissé seul, une session en clair, un expéditeur falsifié et une facture altérée, exactement comme tout le reste. Le binaire est unique. Le travail, non.


  1. Documentation de Stalwart — configuration via l’API de gestion JMAP et stalwart-cli, avec les réglages conservés dans le magasin de données plutôt que dans un fichier. Version 0.16.27 utilisée ici. ↩︎

  2. mail-auth, la bibliothèque que Stalwart utilise pour DKIM — son vérificateur DKIM signale un désaccord de hachage de corps comme un résultat neutre plutôt qu’un échec, ce qui explique pourquoi un rejet sur la seule étiquette d’échec ne rattrape pas un corps falsifié. ↩︎

  3. RFC 8461 §5 — SMTP MTA Strict Transport Security : en cas d’échec de validation de politique, « In all cases, MTAs SHOULD treat such failures as transient errors and retry delivery later », et un message « MUST NOT be permanently failed » avant que l’expéditeur n’ait vérifié s’il existe une politique mise à jour. ↩︎