courriel : routage local par identifiant, et la livraison interne remise en marche

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@<domaine_interne>. 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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-08 09:42:45 -04:00
parent 4bfcf4944f
commit a90dff7ba7
8 changed files with 126 additions and 11 deletions

View file

@ -1,5 +1,47 @@
# CHANGELOG — Set-OPS # 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@<domaine_interne>`. 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 ## 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 **PostgreSQL.** Il rend visibles deux défauts déjà vécus ici : un réseau *écrit* dans

View file

@ -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 Dovecot — précisément le maillon où la livraison avait bloqué — et une vraie conversation
SMTP/IMAP pour ce que les serveurs annoncent. 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 Il vérifie aussi qu'une adresse **inexistante** ne résout pas. Ce n'est pas un luxe :
fourre-tout accepterait n'importe quel nom, et le devis ne mesurerait plus rien. 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 ## Un piège de construction, à connaître avant d'écrire le prochain

View file

@ -50,6 +50,14 @@
echo "FANTOME=$(postmap -q 'nexistepas-{{ 999 | random }}@{{ domaine_interne }}' \ echo "FANTOME=$(postmap -q 'nexistepas-{{ 999 | random }}@{{ domaine_interne }}' \
ldap:/etc/postfix/ldap-mailboxes.cf 2>/dev/null)" ldap:/etc/postfix/ldap-mailboxes.cf 2>/dev/null)"
echo "MYNETWORKS=$(postconf -h mynetworks)" 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 for P in 25 587; do
echo "PORT=$P" echo "PORT=$P"
exec 3<>/dev/tcp/127.0.0.1/$P || continue exec 3<>/dev/tcp/127.0.0.1/$P || continue

View file

@ -45,3 +45,8 @@ serveur_dovecot_lmtp_port: 24
# par pare-feu ; le trafic d'auth devrait être isolé/chiffré en prod. # par pare-feu ; le trafic d'auth devrait être isolé/chiffré en prod.
serveur_dovecot_sasl_reseau: false serveur_dovecot_sasl_reseau: false
serveur_dovecot_sasl_port: 12345 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

View file

@ -51,6 +51,20 @@ userdb static {
service lmtp { service lmtp {
inet_listener lmtp { inet_listener lmtp {
port = {{ serveur_dovecot_lmtp_port }} 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 %} {% endif %}

View file

@ -1,9 +1,29 @@
# Géré par Set-OPS (rôle serveur_postfix). Ne pas éditer à la main. # 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 }} server_host = {{ serveur_postfix_ldap_serveur }}
search_base = {{ serveur_postfix_ldap_base }} search_base = {{ serveur_postfix_ldap_base }}
query_filter = (mail=%s) domain = {{ serveur_postfix_domaine }}
result_attribute = mail query_filter = (&(objectClass=inetOrgPerson)(uid=%u))
result_attribute = uid
bind = yes bind = yes
bind_dn = {{ serveur_postfix_ldap_bind_dn }} bind_dn = {{ serveur_postfix_ldap_bind_dn }}
bind_pw = {{ serveur_postfix_ldap_bind_password }} bind_pw = {{ serveur_postfix_ldap_bind_password }}

View file

@ -31,6 +31,10 @@ smtpd_tls_protocols = >=TLSv1.2, !SSLv2, !SSLv3
smtp_tls_security_level = may smtp_tls_security_level = may
{% if serveur_postfix_lmtp_tls_verify | default(false) %} {% if serveur_postfix_lmtp_tls_verify | default(false) %}
# Zero-confiance : livraison LMTP vers Dovecot chiffree ET verifiee (cert step-ca). # 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_security_level = verify
lmtp_tls_CAfile = {{ serveur_postfix_lmtp_tls_cafile }} lmtp_tls_CAfile = {{ serveur_postfix_lmtp_tls_cafile }}
{% endif %} {% endif %}

View file

@ -46,12 +46,22 @@ def analyser(releves: list[dict]) -> list[str]:
if not _valeur(brut, "TROUVE"): if not _valeur(brut, "TROUVE"):
ecarts.append( ecarts.append(
f"{h} : Postfix ne resout PAS « {sonde} » — rien ne peut lui etre livre " 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"localement. Le routage local se fait par IDENTIFIANT : verifier qu'un "
f"c'est lui qui sert de cle (`query_filter = (mail=%s)`)") f"compte porte bien cet `uid` dans l'annuaire, et que la carte est "
f"limitee au domaine local (`domain =`)")
if _valeur(brut, "FANTOME"): if _valeur(brut, "FANTOME"):
ecarts.append(f"{h} : une adresse inexistante resout quand meme — la boite est " ecarts.append(f"{h} : une adresse inexistante resout quand meme — la boite est "
f"un fourre-tout, n'importe quel nom serait accepte") 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"): for port in ("25", "587"):
d = _dialecte(brut, port) d = _dialecte(brut, port)
if not d.strip(): if not d.strip():
@ -92,10 +102,10 @@ def analyser(releves: list[dict]) -> list[str]:
if postfix and dovecot: if postfix and dovecot:
if not _valeur(postfix["brut"], "TROUVE") and "mail_path" in dovecot["brut"]: if not _valeur(postfix["brut"], "TROUVE") and "mail_path" in dovecot["brut"]:
ecarts.append( ecarts.append(
"les deux moities divergent : Dovecot connait la boite, Postfix ne sait " "les deux moities divergent : Dovecot accepte la boite, Postfix ne sait "
"pas y router. Une identite ne porte qu'une adresse `mail`, et elle a ici " "pas y router. Dovecot a `allow_all_users = yes` et accepte donc "
"deux roles incompatibles — la notification (joignable par la personne) et " "N'IMPORTE QUELLE partie locale — le garde-fou est entierement chez "
"la cle de routage local (dans un domaine local). A trancher.") "Postfix, et il vient de sauter.")
return ecarts return ecarts