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