85.1.36. pepsi-stage-milter

run a message past a sendmail/Postfix milter mail filter

Section du manuel:

1

85.1.36.1.1. Nom

pepsi-stage-milter - l’étape client milter du pipeline Pepsi.

85.1.36.1.2. Synopsis

pepsi-stage-milter [GLOBAL-OPTIONS] worker

85.1.36.1.3. Description

pepsi-stage-milter est un programme d’étape exécuté par pepsi-dispatch(1) comme worker persistant lisant les identifiants de message sur l’entrée standard. Pour chaque message, il ouvre une connexion vers un milter — le protocole de filtrage de courrier introduit par sendmail et adopté par Postfix — rejoue le message sous forme de session SMTP synthétique, applique toutes les modifications que le filtre demande, et achemine le message selon le verdict du filtre.

L’étape est un client milter et rien d’autre. Le filtre lui-même est un démon de longue durée existant qui écoute sur SOCKET, exactement comme sous sendmail et Postfix : son propre paquet, sa propre unité systemd, son propre compte utilisateur. Pepsi ne le démarre pas, ne l’arrête pas et ne le met pas en bac à sable — confiner un démon relève de l’unité de ce démon, et sous Debian (opendkim, clamav-milter, rspamd, spamass-milter, milter-greylist) c’est déjà fait là. Cette étape ne porte donc aucun privilège propre : pas de bit setuid, pas de bit setgid, pas de helper.

85.1.36.1.4. Filtrage après la mise en file

Un milter est conçu comme un filtre avant la mise en file, et Pepsi l’exécute après la mise en file. C’est le seul point où la prise en charge des milters par Pepsi n’est pas équivalente à celle de sendmail ou de Postfix.

Sous sendmail et Postfix, les rappels milter s’exécutent à l’intérieur de la session SMTP, avant que le serveur n’ait accepté la responsabilité du message. C’est ce qui permet à un milter de répondre 550 à un expéditeur falsifié sans engendrer le moindre rebond.

Le pipeline de Pepsi s’exécute entièrement après la mise en file du message : au moment où une étape voit une ligne, pepsi-ingress(1) a déjà répondu 250. Un REJECT de milter coûte donc ici un rebond vers l’adresse que l’enveloppe nommait — qui, pour du spam falsifié, est un tiers innocent. Un milter de greylisting ou de DNSBL repris d’une installation Postfix filtre toujours correctement, mais au prix d’un backscatter qu’un rejet avant la mise en file évite.

Les déploiements qui ne peuvent pas se le permettre devraient pointer REJECT_STAGE vers une pepsi-stage-discard(1) plutôt que vers une pepsi-stage-bounce(1), afin qu’un message rejeté soit abandonné silencieusement. Les filtres qui ne font qu”annoter — un signataire DKIM, un évaluateur de spam qui ajoute un en-tête sur lequel une pepsi-stage-if(1) ultérieure branchera — ne sont pas concernés.

85.1.36.1.5. Placement

Sur le chemin entrant, placez l’étape après pepsi-stage-decrypt(1), afin que le filtre voie du texte clair plutôt qu’un blob PGP ou S/MIME.

Sur le chemin de soumission, placez-la avant pepsi-stage-dkim-sign(1), afin que notre propre signature couvre ce que le filtre a modifié.

Un milter qui réécrit le corps casse nécessairement le hachage de corps DKIM de l”auteur et l”AMS ARC de ce déploiement, car les deux sont calculés sur des octets qui n’existent plus. C’est inévitable — c’est la même mise en garde que porte pepsi-stage-vacation(1) pour sa marque Subject: — et c’est sans conséquence sur une branche de remise locale, où rien en aval ne revérifie. Sur une branche qui relaie le message plus loin, préférez un filtre qui n’ajoute que des en-têtes.

85.1.36.1.6. La conversation de protocole

Chaque message est une connexion, fermée ensuite par SMFIC_QUIT. La session est reconstruite à partir de ce que pepsi-ingress(1) a noté sous state.origin (voir pepsi.state(7)) : l’adresse de connexion et son nom PTR, le nom HELO/EHLO, la version TLS et la suite de chiffrement, le listener sur lequel le message est arrivé, et les paramètres BODY=/SMTPUTF8 que le client a déclarés sur MAIL FROM.

Un message injecté localement — un rebond, une réponse automatique, une soumission via pepsi-sendmail(1) — n’a pas de pair distant. Plutôt que de sauter la phase de connexion, ce qui laisserait {client_addr} indéfini et ferait se déclencher de façon imprévisible le traitement particulier du courrier local d’un filtre, une session en boucle locale est synthétisée (127.0.0.1, localhost), ce que sendmail présente pour la soumission locale.

85.1.36.1.6.1. Négociation des options

L’étape offre la version de protocole PROTOCOL_VERSION (6 par défaut, comme dans Postfix), les actions de modification que ALLOW_ACTIONS autorise, et toute option de protocole SMFIP_* qu’elle sait honorer. Le filtre négocie la version à la baisse à partir de là, mais pas en dessous de 2 : un milter qui offre moins est refusé et le message prend le chemin ON_FAILURE. Deux règles supplémentaires régissent la réponse du milter.

Une action que le milter exige mais que cette étape n’a pas offerte est une erreur de configuration. Elle est signalée — en nommant l’indicateur manquant — et le message prend le chemin ON_FAILURE. Postfix ignore une telle demande en silence ; l’ignorer en silence, c’est ainsi qu’un opérateur découvre six mois plus tard qu’un milter de signature n’a jamais rien signé.

Une option de protocole que cette étape n’implémente pas est refusée. En revendiquer une puis ne pas l’honorer serait une violation de protocole de notre côté ; les options de blocs plus grands SMFIP_MDS_256K/SMFIP_MDS_1M en sont l’exemple, et ne sont jamais offertes.

Le bit SMFIF_SETSYMLIST n’est pas régi par ALLOW_ACTIONS et ne demande aucun droit. Il n’autorise aucune modification du message — il permet seulement au filtre de demander un ensemble de macros plus restreint que celui qui serait envoyé autrement — de sorte que le soumettre à la même barrière que « peut réécrire l’enveloppe » casserait des filtres ordinaires sans bénéfice pour la sécurité. Lorsqu’un milter nomme sa propre liste de macros pour une phase, cette liste est utilisée à la place de l’option MACROS_* correspondante.

85.1.36.1.6.2. Macros

Les ensembles de macros par défaut sont ceux de Postfix — milter_connect_macros, milter_helo_macros, milter_mail_macros, milter_rcpt_macros, milter_data_macros, milter_end_of_header_macros et milter_end_of_data_macros des versions stables de Postfix 3.x, entrée pour entrée (connect : j {daemon_name} {daemon_addr} v _) — de sorte que la documentation propre à un filtre s’applique sans modification. {daemon_addr} (et son synonyme {if_addr}) est l’adresse IP locale à laquelle le client s’est connecté, telle que pepsi-ingress(1) l’a notée ; une session arrivée par une socket UNIX n’en a pas. {if_name} reçoit aussi une réponse, avec le nom d’hôte du serveur, lorsqu’une liste le nomme. Une macro dont ce déploiement ne connaît pas la valeur n’est pas envoyée du tout, ce qui est la façon dont libmilter distingue « pas de TLS » de « TLS avec une suite de chiffrement vide » ; {cipher_bits}, {cert_subject} et {cert_issuer} figurent dans la liste par défaut mais ne sont jamais connus ici, car pepsi-ingress(1) ne les note pas, de sorte qu’un filtre les voit exactement comme depuis une session Postfix qui n’en avait aucun.

Les macros d’authentification RFC 4954 proviennent de ce que pepsi-ingress(1) a noté au sujet de la session (state.origin.auth_*, voir pepsi.state(7)) :

{auth_type}

Le mécanisme SASL qui a réussi — PLAIN ou LOGIN. Il est transmis exactement tel qu’il a été noté ; la mise en majuscules est celle de pepsi-ingress(1), qui canonicalise le nom du mécanisme en analysant AUTH (RFC 4954 §4), et non celle de cette étape. Délibérément non définie pour les chemins authentifiés qui ne sont pas SASL : un pair de socket UNIX, un certificat client TLS épinglé, une adresse MYNETWORKS. La macro est définie comme le mécanisme SASL, et un filtre qui la compare à une liste de mécanismes réels serait induit en erreur par un jeton qui n’en est pas un.

{auth_authen}

L’identité d’authentification : l”authcid SASL, ou le login vers lequel l’uid d’un pair UNIX s’est résolu. Une session peercred a donc une identité sans {auth_type} — la description honnête d’une session que le noyau, et non SASL, a authentifiée. mynetworks et client-cert ne nomment personne, de sorte que les deux macros y restent non définies.

{auth_author}

L’identité d’autorisation — l’identité sous laquelle le client a demandé à agir, à défaut celle sous laquelle il s’est authentifié, ce qui est la règle de sendmail. Elle ne diffère de {auth_authen} que lorsqu’un échange SASL PLAIN a fourni un authzid distinct.

{auth_ssf}

La force de la couche de sécurité SASL, qui vaut exactement 0 pour les deux mécanismes que Pepsi implémente : aucun des deux ne négocie de couche propre, et la confidentialité vient du TLS sous-jacent — que {cipher} décrit. Un mécanisme qui, lui, porte une couche laisserait cette macro non définie plutôt que de la sous-estimer à 0.

85.1.36.1.7. Verdicts

verdict du milter

où va le message

SMFIR_CONTINUE

NEXT_STAGE

SMFIR_ACCEPT

ACCEPT_STAGE, par défaut NEXT_STAGE

SMFIR_REJECT

REJECT_STAGE, par défaut le BOUNCE_STAGE de la section

SMFIR_TEMPFAIL

mis en pause pour réessai ; le BOUNCE_STAGE de la section une fois MAX_LIFETIME écoulé, ou failed s’il n’y en a pas

SMFIR_DISCARD

la ligne est supprimée, silencieusement

SMFIR_QUARANTINE

QUARANTINE_STAGE ; supprimé lorsque celle-ci n’est pas définie

Un SMFIR_REPLYCODE est traité comme un tempfail lorsque son code commence par 4 et comme un rejet sinon — la règle même qu’emploie SMTP. Son code, son statut étendu RFC 3463 et son texte sont notés séparément sous state.bounce, de sorte qu’un rapport de pepsi-stage-bounce(1) peut citer les mots du filtre et renseigner le champ Status: de la DSN à partir de son statut étendu.

Lorsque ni REJECT_STAGE ni le BOUNCE_STAGE de la section ne sont réglés, un message rejeté n’a nulle part où aller : la ligne est laissée en failed plutôt que discrètement avancée, de sorte que pepsi-failure-bouncer(1) ou l’opérateur décide.

Un tempfail qui a épuisé MAX_LIFETIME — le filtre a continué de différer le message, ou, avec ON_FAILURE = tempfail, n’a pu être joint pendant tout ce temps — n’est pas envoyé vers REJECT_STAGE. Ce n’est pas un verdict sur le message, et REJECT_STAGE est souvent une étape qui abandonne le courrier rejeté pour éviter la rétrodiffusion (le discard-rejected de l’assistant de configuration), ce qui perdrait du courrier parce qu’un filtre était arrêté. Il va vers le BOUNCE_STAGE de la section avec un state de rebond, comme un MTA fait rebondir un message qu’il n’a pu transmettre à temps, et sans BOUNCE_STAGE le message est laissé failed, où pepsi-status le montre et où pepsi-failure-bouncer(1) le fait rebondir.

ACCEPT_STAGE existe parce que Postfix applique une liste de milters, où ACCEPT signifie « sauter les suivants pour ce message ». Un milter par étape enchaîné par NEXT_STAGE n’a pas cette notion, de sorte que sans cette option ACCEPT et CONTINUE seraient indiscernables. Pointez-la au-delà du reste d’une chaîne de filtres pour rétablir la distinction ; laissez-la non définie et les deux verdicts sont la même chose.

La mise en quarantaine n’est pas un verdict à part entière. Un milter sendmail met en quarantaine puis renvoie tout de même un accept, de sorte qu’une demande de mise en quarantaine décide de l’acheminement quel que soit le verdict qui la suit. Pepsi n’a pas de dépôt de quarantaine — seulement un acheminement — donc, sans QUARANTINE_STAGE configurée, le message est abandonné.

85.1.36.1.7.1. Rejet par destinataire

Lorsque le filtre répond à SMFIC_RCPT par un rejet (5xx), ce destinataire est détaché sur sa propre ligne sœur à REJECT_STAGE, réduite à lui seul, et le reste du message poursuit sa route — le même éclatement qu’utilise pepsi-stage-relay-to-lmtp(1). state.dsn.rcpt est découpé en phase avec rcpt_to sur chaque ligne obtenue. Si tous les destinataires sont rejetés, la ligne source est supprimée, dans la même instruction qui crée les lignes sœurs, et seules les lignes sœurs subsistent. Le jeton de chaque ligne sœur nomme son destinataire plutôt que sa position, de sorte qu’une passe réessayée (après la réduction de la ligne source) ne peut entrer en collision avec une ligne créée par une passe antérieure. Lorsque ni REJECT_STAGE ni BOUNCE_STAGE ne sont réglés, il n’y a nulle part où les détacher : les destinataires sont donc conservés et le fait est consigné comme un avertissement ; les abandonner perdrait du courrier, et ignorer silencieusement le filtre est précisément le mode de défaillance que cette étape refuse.

Un tempfail (SMFIR_TEMPFAIL, ou un code de réponse 4xx) pour un destinataire n’est pas un rejet. La copie de ce destinataire est détachée sur une ligne sœur qui attend paused à cette étape, avec state.attempts et state.last_error, et est filtrée à nouveau après le délai de réessai, à partir des octets avec lesquels elle est arrivée ; les autres destinataires poursuivent leur route. La copie conserve l’heure d’arrivée du message, de sorte que son MAX_LIFETIME court à partir de là ; une fois celui-ci écoulé, elle va à BOUNCE_STAGE, ou est laissée failed lorsqu’il n’y en a pas — comme un tempfail portant sur tout le message, jamais vers REJECT_STAGE. Un filtre qui diffère chaque destinataire a différé le message, qui est mis en pause dans son ensemble.

Cela vaut pour tout filtre, quelle que soit sa négociation. En particulier, cela ne dépend pas de SMFIP_RCPT_REJ, qui demande tout autre chose : cet indicateur est un filtre qui demande à être informé des destinataires que le MTA lui-même a déjà rejetés. Pepsi s’exécute après la mise en file et ne rejette aucun destinataire de son propre chef pendant le rejeu, il n’y a donc jamais de tel destinataire à signaler.

85.1.36.1.8. Modifications

Les modifications de fin de message qu’un filtre peut demander sont régies par ALLOW_ACTIONS. SMFIR_ADDHEADER ajoute à la fin du bloc ; SMFIR_INSHEADER insère à la position absolue, comptée à partir de 0, qu’il nomme, et SMFIR_CHGHEADER remplace le n-ième champ compté à partir de 1 portant ce nom (une valeur vide le supprime, et un indice au-delà de la dernière occurrence ajoute un nouveau champ, comme le font sendmail et Postfix). Les deux indices comptent des choses différentes, ce qui est la seule partie de ce protocole facile à mal interpréter. SMFIR_REPLBODY remplace le corps en entier. SMFIR_ADDRCPT/SMFIR_ADDRCPT_PAR/SMFIR_DELRCPT réécrivent les destinataires d’enveloppe, avec state.dsn.rcpt reconstruit en conséquence (un destinataire ajouté par le filtre hérite du NOTIFY du premier destinataire d’origine et reçoit un ORCPT RFC 3461 §5.2.7 le nommant lui-même) ; SMFIR_CHGFROM réécrit l’expéditeur d’enveloppe.

Une valeur d’en-tête fournie par un filtre est normalisée avant d’être stockée : les fins de ligne deviennent CRLF et toute ligne de continuation est forcée de commencer par un caractère blanc. Sans cela, une valeur contenant un saut de ligne suivi de Bcc: somebody serait restituée comme un second champ d’en-tête — une injection d’en-tête par un composant censé se contenter d’annoter le message. Un nom de champ invalide est refusé d’emblée plutôt qu’échappé.

Un corps de remplacement est normalisé de la même façon : les blocs SMFIR_REPLBODY sont concaténés et tout LF isolé devient un CRLF, car la RFC 5321 §2.3.8 termine les lignes de message par CRLF et §4.1.1.4 interdit de transmettre un CR ou un LF isolé. Un filtre qui lit le corps comme du texte et le réécrit avec des fins de ligne \n ne produit donc pas un message que le saut suivant doit deviner.

Les colonnes dénormalisées from_header et subject sont rafraîchies à partir du bloc réécrit, de sorte qu’un filtre qui étiquette un sujet ne laisse pas pepsi-queue(1) et pepsi-stage-check-whitelist(1) lire l’ancien.

Un message différé conserve les octets avec lesquels il est arrivé : les modifications d’une passe terminée par SMFIR_TEMPFAIL sont abandonnées, car le message est filtré à nouveau depuis zéro à la tentative suivante et les appliquer maintenant l’étiquetterait une fois par réessai.

85.1.36.1.9. Configuration

Les options vivent dans la section [stage-<name>] propre à l’étape (PROGRAM = pepsi-stage-milter) et sont documentées dans pepsi.conf(5) : SOCKET, MILTER_NAME, ACCEPT_STAGE, REJECT_STAGE, QUARANTINE_STAGE, ON_FAILURE, ALLOW_ACTIONS, PROTOCOL_VERSION, les timeouts CONNECT_TIMEOUT/COMMAND_TIMEOUT/CONTENT_TIMEOUT, les options de réessai partagées, et les sept listes MACROS_*.

NEXT_STAGE est obligatoire : SMFIR_CONTINUE est le verdict par défaut et y transmet le message, et ACCEPT_STAGE ne couvre que SMFIR_ACCEPT ; ni elle ni les cibles de rejet ou de quarantaine ne peuvent donc en tenir lieu. pepsi-setup(1) refuse une section qui en est dépourvue, et vérifie que ACCEPT_STAGE, REJECT_STAGE et QUARANTINE_STAGE nomment toutes des étapes existantes.

85.1.36.1.10. État

Entrées : state.origin (la session SMTP notée, utilisée pour construire les phases connect/helo/mail et leurs macros) et state.dsn (maintenu parallèle à toute réécriture de destinataires).

Sorties : state.milter — le nom du filtre (MILTER_NAME), le verdict, la version de protocole en vigueur, le temps écoulé, la liste des modifications appliquées et, lorsqu’ils sont présents, le code de réponse, le motif de mise en quarantaine et le nombre de destinataires rejetés. Sur le chemin ON_FAILURE, l’enregistrement prend au contraire la forme courte : le libellé propre à l’étape, le verdict failed et l’erreur. Avec ON_FAILURE = reject, cette erreur reste dans l’enregistrement et le journal : le rebond que reçoit l’expéditeur indique seulement que le filtre n’a pas pu être consulté. Rien ne lit l’enregistrement ; il existe pour que pepsi-queue(1)

puisse montrer pourquoi un message a été étiqueté et que pepsi-stage-if(1) puisse brancher dessus. Un message rejeté porte en outre state.bounce. La disposition de l’état est décrite dans pepsi.state(7).

Transitions : avancer vers NEXT_STAGE, ACCEPT_STAGE ou QUARANTINE_STAGE ; réacheminer vers REJECT_STAGE, ou vers BOUNCE_STAGE lorsqu’un tempfail a expiré ; mettre en pause pour réessai ; terminer (supprimer). Un rejet ou un tempfail par destinataire crée en outre des lignes sœurs (la ligne d’un destinataire en tempfail est créée paused à cette étape).

85.1.36.1.11. Commandes

worker

Exécuté comme un worker persistant de pepsi-dispatch(1), lisant les identifiants de message sur l’entrée standard.

85.1.36.1.12. Options globales

-c FILE, –config FILE

Lit la configuration depuis FILE au lieu de parcourir les emplacements par défaut.

-L LOGLEVEL, –log LOGLEVEL

Règle la verbosité de journalisation (par défaut info).

-v, –verbose

Affiche les messages de journal de toutes les sources.

-h, –help ; -V, –version

Affiche un résumé d’utilisation / la version et quitte.

85.1.36.1.13. Code de sortie

0

Le message a été traité et acheminé.

1

Une erreur s’est produite (message introuvable ou pas en running, étape mal configurée — par exemple un SOCKET inanalysable — ou erreur de base de données). Rien de ce que fait le filtre n’est une erreur : ni un milter injoignable, ni un milter qui a envoyé une modification inutilisable (un nom de champ d’en-tête invalide, un NUL dans une valeur), ni un milter qui s’est emballé — plus de 1024 modifications, plus de 64 Mio de corps de remplacement, ou un SMFIR_PROGRESS prolongeant un délai de réponse de plus de dix fois. Tous ces cas prennent le chemin ON_FAILURE ; la raison est écrite dans le journal.

85.1.36.1.14. Exemples

Signer le courrier sortant avec opendkim avant l’étape DKIM propre à Pepsi

[stage-opendkim]
PROGRAM = pepsi-stage-milter
SOCKET = unix:/run/opendkim/opendkim.sock
NEXT_STAGE = dkim-sign

Évaluer le courrier entrant avec rspamd sur TCP, en faisant rebondir ce qu’il rejette et en le laissant réécrire le corps lorsqu’il enveloppe un message de spam

[stage-spam-filter]
PROGRAM = pepsi-stage-milter
SOCKET = inet:127.0.0.1:11332
ALLOW_ACTIONS = addhdrs chghdrs chgbody quarantine
QUARANTINE_STAGE = quarantine
REJECT_STAGE = bounce
NEXT_STAGE = deliver
BOUNCE_STAGE = bounce

Le même filtre, dans un déploiement qui n’émettra pas de backscatter : le courrier rejeté est abandonné plutôt que de rebondir

[stage-spam-filter]
PROGRAM = pepsi-stage-milter
SOCKET = inet:127.0.0.1:11332
REJECT_STAGE = drop
NEXT_STAGE = deliver

[stage-drop]
PROGRAM = pepsi-stage-discard
DISPOSITION = failure
BOUNCE = no

Deux filtres en chaîne, où l’acceptation par le premier saute le second

[stage-greylist]
PROGRAM = pepsi-stage-milter
SOCKET = unix:/run/milter-greylist/milter-greylist.sock
NEXT_STAGE = spam-filter
ACCEPT_STAGE = deliver
REJECT_STAGE = bounce

[stage-spam-filter]
PROGRAM = pepsi-stage-milter
SOCKET = inet:127.0.0.1:11332
NEXT_STAGE = deliver
REJECT_STAGE = bounce

85.1.36.1.15. Voir aussi

pepsi-dispatch(1), pepsi-stage-if(1), pepsi-stage-bounce(1), pepsi-stage-discard(1), pepsi-stage-dkim-sign(1), pepsi-stage-decrypt(1), pepsi-queue(1), pepsi-setup(1), pepsi.conf(5), pepsi.state(7)

85.1.36.1.16. Bogues

Signalez les bogues au gestionnaire de tickets de Pepsi.