From a90dff7ba74e8bc6bd4e21eaea9956a54d8d39d5 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sat, 8 Aug 2026 09:42:45 -0400 Subject: [PATCH] courriel : routage local par identifiant, et la livraison interne remise en marche MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arbitrage rendu : le courrier local est route par uid, plus par l'attribut mail. Dovecot le faisait DEJA (mail_home = /var/vmail/%{user | username}) ; les deux moities ne s'accordaient que par coincidence, tant que mail valait uid@. Cout assume : l'adresse interne est derivee et ne se choisit plus ; en echange mail redevient libre de porter la vraie adresse de la personne. Puis la preuve de bout en bout a revele bien pire : toute livraison interne etait DIFFEREE. SSL_connect error to infra-mail-01:24: Connection timed out status=deferred (Cannot start TLS: handshake failure) Postfix etait durci (lmtp_tls_security_level = verify), le port LMTP de Dovecot ecoutait en clair — son ssl = required global ne concerne que les services de connexion. Les deux cotes d'un meme flux avaient ete traites separement. Corrige et accorde : ssl = yes sur l'inet_listener (TLS implicite) et lmtp_tls_wrappermode = yes cote client ; l'un sans l'autre ne marche pas. Livraison prouvee : status=sent (250 ... Saved), message dans /var/vmail/sysadmin/Maildir/.INBOX/new/. Le devis disait CONFORME pendant ce temps : il verifiait la resolution et les dialectes, jamais si le courrier BOUGE. Un devis qui ne regarde que les reglages ne dit pas si le service rend son service. Il releve desormais la file d'attente et ses raisons ; test negatif : la panne est nommee. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 42 +++++++++++++++++++ docs/devis-services.md | 16 ++++++- playbooks/maintenance/devis-courriel.yml | 8 ++++ roles/serveur_dovecot/defaults/main.yml | 5 +++ .../templates/99-setops.conf.j2 | 14 +++++++ .../templates/ldap-mailboxes.cf.j2 | 26 ++++++++++-- roles/serveur_postfix/templates/main.cf.j2 | 4 ++ scripts/devis_courriel.py | 22 +++++++--- 8 files changed, 126 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e898c51..93a439e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # CHANGELOG — Set-OPS +## 2026-08-08 — La livraison interne était en panne, et le devis disait CONFORME + +Suite de l'arbitrage sur l'adressage : **le courrier local est désormais routé par +identifiant**, plus par l'attribut `mail`. + +L'argument décisif n'est pas théorique — Dovecot le fait déjà : +`mail_home = /var/vmail/%{user | username}`, la partie locale, jamais `mail`. Les deux +moitiés n'utilisaient donc pas le même mécanisme et ne s'accordaient que par coïncidence, +tant que `mail` valait `uid@`. Aligner Postfix ne crée pas un modèle +nouveau : ça met fin à une incohérence. Coût assumé : l'adresse interne est dérivée de +l'identifiant et ne se choisit plus ; un alias voulu passera par `virtual_alias_maps`, non +câblé aujourd'hui. En échange, `mail` redevient libre de porter la vraie adresse de la +personne — celle que Keycloak affiche et que « mot de passe oublié » utilise. + +**Puis la preuve de bout en bout a révélé bien pire.** Un vrai courriel envoyé à +`sysadmin@chezlepro.internal` n'arrivait pas : + +``` +SSL_connect error to infra-mail-01[10.27.19.31]:24: Connection timed out +status=deferred (Cannot start TLS: handshake failure) +``` + +Postfix était durci (`lmtp_tls_security_level = verify`), le port LMTP de Dovecot écoutait +en clair — son `ssl = required` global ne concerne que les services de connexion. **Les +deux côtés d'un même flux avaient été traités séparément**, et toute livraison interne +était différée depuis, sans qu'aucun écran ne le montre. Corrigé des deux bouts, et +accordé : `ssl = yes` sur l'`inet_listener` (TLS implicite, ce que sait faire un listener) +et `lmtp_tls_wrappermode = yes` côté client. L'un sans l'autre ne marche pas. + +Livraison prouvée : `status=sent (250 2.0.0 … Saved)`, message présent dans +`/var/vmail/sysadmin/Maildir/.INBOX/new/`. + +**Le devis, lui, annonçait CONFORME.** Il vérifiait la résolution LDAP et les dialectes +SMTP/IMAP — tout était correct — et jamais si le courrier *bouge*. C'est la leçon la plus +chère de la série : **un devis qui ne regarde que les réglages ne dit pas si le service +rend son service.** Il relève désormais la file d'attente et ses raisons de blocage ; le +test négatif confirme qu'il aurait nommé cette panne. + +Au passage, deux fois où ma sonde était fausse et non le système : `/dev/tcp` sous `sh` +(qui ne le connaît pas), et `Maildir/new/` alors que l'INBOX est `Maildir/.INBOX/new/`. +Vérifier l'instrument avant d'accuser le composant, encore. + ## 2026-08-08 — Devis PostgreSQL et courriel : la série est complète **PostgreSQL.** Il rend visibles deux défauts déjà vécus ici : un réseau *écrit* dans diff --git a/docs/devis-services.md b/docs/devis-services.md index 935dc38..a07f84b 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -111,8 +111,20 @@ vérité : `postmap -q` pour la résolution LDAP de Postfix, `doveadm user` pour Dovecot — précisément le maillon où la livraison avait bloqué — et une vraie conversation SMTP/IMAP pour ce que les serveurs annoncent. -Il vérifie aussi qu'une adresse **inexistante** ne résout pas : sans ce contrôle, une boîte -fourre-tout accepterait n'importe quel nom, et le devis ne mesurerait plus rien. +Il vérifie aussi qu'une adresse **inexistante** ne résout pas. Ce n'est pas un luxe : +Dovecot a `allow_all_users = yes` et accepte donc n'importe quelle partie locale — le +garde-fou est entièrement chez Postfix. + +**Et il regarde la file d'attente.** C'est la leçon la plus chère de la série : sa première +version disait `CONFORME` pendant que *toute* livraison interne était différée. Résolution +correcte, dialectes corrects, et rien qui arrive — Postfix exigeait TLS +(`lmtp_tls_security_level = verify`) vers un port Dovecot qui écoutait en clair. Les deux +côtés d'un même flux avaient été durcis séparément. **Un devis qui ne regarde que les +réglages ne dit pas si le service rend son service** ; la file est le seul endroit qui +révèle que le courrier ne bouge pas. + +Le routage local se fait par **identifiant** (`uid`), pas par l'attribut `mail` — voir le +gabarit `ldap-mailboxes.cf.j2` pour la raison et le coût assumé. ## Un piège de construction, à connaître avant d'écrire le prochain diff --git a/playbooks/maintenance/devis-courriel.yml b/playbooks/maintenance/devis-courriel.yml index 1e9b076..22bff4a 100644 --- a/playbooks/maintenance/devis-courriel.yml +++ b/playbooks/maintenance/devis-courriel.yml @@ -50,6 +50,14 @@ echo "FANTOME=$(postmap -q 'nexistepas-{{ 999 | random }}@{{ domaine_interne }}' \ ldap:/etc/postfix/ldap-mailboxes.cf 2>/dev/null)" echo "MYNETWORKS=$(postconf -h mynetworks)" + # La file d'attente est le seul endroit qui dit si le courrier BOUGE. Sans ce + # releve, le devis a annonce CONFORME pendant que toute livraison interne + # etait differee sur un echec TLS — resolution correcte, dialectes corrects, + # et rien qui arrive. Constate le 2026-08-08. + echo "DIFFERES=$(mailq 2>/dev/null | grep -cE '^[0-9A-F]{8,}' || echo 0)" + echo "RAISONS<<" + mailq 2>/dev/null | grep -E '^\(' | head -3 + echo ">>" for P in 25 587; do echo "PORT=$P" exec 3<>/dev/tcp/127.0.0.1/$P || continue diff --git a/roles/serveur_dovecot/defaults/main.yml b/roles/serveur_dovecot/defaults/main.yml index 8e95931..dad2465 100644 --- a/roles/serveur_dovecot/defaults/main.yml +++ b/roles/serveur_dovecot/defaults/main.yml @@ -45,3 +45,8 @@ serveur_dovecot_lmtp_port: 24 # par pare-feu ; le trafic d'auth devrait être isolé/chiffré en prod. serveur_dovecot_sasl_reseau: false serveur_dovecot_sasl_port: 12345 + +# TLS sur le port LMTP. Doit rester ACCORDE avec `serveur_postfix_lmtp_tls_verify` du +# cote emetteur : l'un sans l'autre, la livraison interne s'arrete — soit elle part en +# clair, soit elle est differee sans fin. +serveur_dovecot_lmtp_tls: true diff --git a/roles/serveur_dovecot/templates/99-setops.conf.j2 b/roles/serveur_dovecot/templates/99-setops.conf.j2 index 60675a1..d982964 100644 --- a/roles/serveur_dovecot/templates/99-setops.conf.j2 +++ b/roles/serveur_dovecot/templates/99-setops.conf.j2 @@ -51,6 +51,20 @@ userdb static { service lmtp { inet_listener lmtp { port = {{ serveur_dovecot_lmtp_port }} +{% if serveur_dovecot_lmtp_tls %} + # TLS IMPLICITE sur ce port. Le `ssl = required` global ne concerne que les + # services de connexion (IMAP/POP) : ce port-ci ecoutait en clair. + # + # Postfix, lui, etait deja durci (`lmtp_tls_security_level = verify`). Les deux + # cotes d'un meme flux avaient donc ete traites separement, et la livraison + # interne etait DIFFEREE en silence — « SSL_connect error […] Connection timed + # out », 300 s par tentative, sans qu'aucun ecran ne le montre. Constate le + # 2026-08-08 par `make courriel-plan`. + # + # Implicite et non STARTTLS : c'est ce que sait faire un `inet_listener`, et le + # client doit donc etre en `lmtp_tls_wrappermode`. Les deux vont ensemble. + ssl = yes +{% endif %} } } {% endif %} diff --git a/roles/serveur_postfix/templates/ldap-mailboxes.cf.j2 b/roles/serveur_postfix/templates/ldap-mailboxes.cf.j2 index 991a3bf..9c83274 100644 --- a/roles/serveur_postfix/templates/ldap-mailboxes.cf.j2 +++ b/roles/serveur_postfix/templates/ldap-mailboxes.cf.j2 @@ -1,9 +1,29 @@ # Géré par Set-OPS (rôle serveur_postfix). Ne pas éditer à la main. -# Carte LDAP : valide qu'une adresse correspond à une boîte (attribut mail). +# +# Carte LDAP : valide qu'une adresse locale correspond à un compte. La livraison passe +# par LMTP (`virtual_transport`), donc Postfix ne se sert du résultat que comme d'un +# CONTRÔLE D'EXISTENCE — la valeur rendue est ignorée. +# +# ROUTAGE PAR IDENTIFIANT, et non par l'attribut `mail`. Dovecot le fait déjà +# (`mail_home = /var/vmail/%{user | username}` : la partie locale, jamais `mail`), et les +# deux moitiés ne s'accordaient que par coïncidence — tant que `mail` valait +# `uid@{{ '{{' }} domaine_interne {{ '}}' }}`. Le jour où l'adresse de notification d'une +# personne est devenue son adresse publique, la livraison interne a cessé sans qu'aucune +# erreur n'apparaisse nulle part. Constaté le 2026-08-08 par `make courriel-plan`. +# +# Conséquence assumée : l'adresse interne est DÉRIVÉE de l'identifiant et ne se choisit +# pas. Un alias voulu passerait par `virtual_alias_maps`, qui n'est pas câblé sur +# l'annuaire aujourd'hui. En échange, `mail` redevient libre de porter la vraie adresse de +# la personne — celle que Keycloak affiche et que « mot de passe oublié » utilise. +# +# `domain` limite la carte au domaine local : sans lui, Postfix interrogerait l'annuaire +# pour n'importe quel destinataire, et un `uid` qui existe suffirait à capter une adresse +# d'un autre domaine. server_host = {{ serveur_postfix_ldap_serveur }} search_base = {{ serveur_postfix_ldap_base }} -query_filter = (mail=%s) -result_attribute = mail +domain = {{ serveur_postfix_domaine }} +query_filter = (&(objectClass=inetOrgPerson)(uid=%u)) +result_attribute = uid bind = yes bind_dn = {{ serveur_postfix_ldap_bind_dn }} bind_pw = {{ serveur_postfix_ldap_bind_password }} diff --git a/roles/serveur_postfix/templates/main.cf.j2 b/roles/serveur_postfix/templates/main.cf.j2 index 67459cd..078d9d1 100644 --- a/roles/serveur_postfix/templates/main.cf.j2 +++ b/roles/serveur_postfix/templates/main.cf.j2 @@ -31,6 +31,10 @@ smtpd_tls_protocols = >=TLSv1.2, !SSLv2, !SSLv3 smtp_tls_security_level = may {% if serveur_postfix_lmtp_tls_verify | default(false) %} # Zero-confiance : livraison LMTP vers Dovecot chiffree ET verifiee (cert step-ca). +# `wrappermode` = TLS des la connexion : c'est ce que sait offrir un `inet_listener` +# Dovecot. Sans lui, Postfix tentait STARTTLS sur un port qui ne le propose pas, et +# la poignee de main expirait au bout de 300 s — livraison differee en silence. +lmtp_tls_wrappermode = yes lmtp_tls_security_level = verify lmtp_tls_CAfile = {{ serveur_postfix_lmtp_tls_cafile }} {% endif %} diff --git a/scripts/devis_courriel.py b/scripts/devis_courriel.py index 5072650..5aaf3ac 100755 --- a/scripts/devis_courriel.py +++ b/scripts/devis_courriel.py @@ -46,12 +46,22 @@ def analyser(releves: list[dict]) -> list[str]: if not _valeur(brut, "TROUVE"): ecarts.append( f"{h} : Postfix ne resout PAS « {sonde} » — rien ne peut lui etre livre " - f"localement. L'attribut `mail` de l'annuaire porte une autre adresse, et " - f"c'est lui qui sert de cle (`query_filter = (mail=%s)`)") + f"localement. Le routage local se fait par IDENTIFIANT : verifier qu'un " + f"compte porte bien cet `uid` dans l'annuaire, et que la carte est " + f"limitee au domaine local (`domain =`)") if _valeur(brut, "FANTOME"): ecarts.append(f"{h} : une adresse inexistante resout quand meme — la boite est " f"un fourre-tout, n'importe quel nom serait accepte") + # Un courrier qui ne bouge pas est le seul symptome que ni la resolution ni + # les dialectes ne revelent. + differes = _valeur(brut, "DIFFERES") + if differes.isdigit() and int(differes) > 0: + raisons = re.search(r"RAISONS<<(.*?)>>", brut, re.S) + detail = " ".join((raisons.group(1) if raisons else "").split())[:140] + ecarts.append(f"{h} : {differes} message(s) en attente dans la file — " + f"le courrier ne part pas. {detail}") + for port in ("25", "587"): d = _dialecte(brut, port) if not d.strip(): @@ -92,10 +102,10 @@ def analyser(releves: list[dict]) -> list[str]: if postfix and dovecot: if not _valeur(postfix["brut"], "TROUVE") and "mail_path" in dovecot["brut"]: ecarts.append( - "les deux moities divergent : Dovecot connait la boite, Postfix ne sait " - "pas y router. Une identite ne porte qu'une adresse `mail`, et elle a ici " - "deux roles incompatibles — la notification (joignable par la personne) et " - "la cle de routage local (dans un domaine local). A trancher.") + "les deux moities divergent : Dovecot accepte la boite, Postfix ne sait " + "pas y router. Dovecot a `allow_all_users = yes` et accepte donc " + "N'IMPORTE QUELLE partie locale — le garde-fou est entierement chez " + "Postfix, et il vient de sauter.") return ecarts