Set-OPS-Public/scripts/devis_courriel.py
Daniel Allaire a90dff7ba7 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>
2026-08-08 09:42:45 -04:00

130 lines
5.5 KiB
Python
Executable file

#!/usr/bin/env python3
"""Devis du courriel : la chaîne Postfix → LDAP → LMTP → Dovecot → IMAP tient-elle ?
Lit les relevés de `playbooks/maintenance/devis-courriel.yml`. Sort en code 1 s'il y a un
écart.
La chaîne est longue et chaque maillon peut casser sans que le suivant s'en aperçoive. On
interroge donc chaque maillon là où il dit la vérité : `postmap -q` pour la résolution LDAP
de Postfix, `doveadm user` pour celle de Dovecot — c'est exactement là que la livraison
avait bloqué — et une vraie conversation SMTP/IMAP pour ce que les serveurs annoncent.
"""
from __future__ import annotations
import glob
import ipaddress
import json
import re
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
MOTIF = str(RACINE / "instance" / "devis-courriel.json.*")
def _valeur(brut: str, cle: str) -> str:
m = re.search(rf"^{cle}=(.*)$", brut, re.M)
return m.group(1).strip() if m else ""
def _dialecte(brut: str, port: str) -> str:
"""Ce que le serveur annonce sur un port donné."""
m = re.search(rf"^PORT={port}$(.*?)(?=^PORT=|\Z)", brut, re.M | re.S)
return m.group(1) if m else ""
def analyser(releves: list[dict]) -> list[str]:
ecarts = []
postfix = next((r for r in releves if r["role"] == "postfix"), None)
dovecot = next((r for r in releves if r["role"] == "dovecot"), None)
if postfix:
h, brut = postfix["hote"], postfix["brut"]
sonde = _valeur(brut, "SONDE")
if not _valeur(brut, "TROUVE"):
ecarts.append(
f"{h} : Postfix ne resout PAS « {sonde} » — rien ne peut lui etre livre "
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():
ecarts.append(f"{h}:{port} : aucune reponse SMTP")
elif "STARTTLS" not in d:
ecarts.append(f"{h}:{port} : STARTTLS n'est pas annonce — le courriel "
f"sortirait en clair")
supernet = postfix.get("supernet")
if supernet:
reseau_attendu = ipaddress.ip_network(supernet)
for morceau in _valeur(brut, "MYNETWORKS").split():
morceau = morceau.strip("[]")
try:
res = ipaddress.ip_network(morceau, strict=False)
except ValueError:
continue
if res.is_loopback:
continue
if res.version != reseau_attendu.version or not res.subnet_of(reseau_attendu):
ecarts.append(f"{h} : `mynetworks` autorise {res}, hors du supernet "
f"derive {reseau_attendu} — relais plus large que le tenant")
if dovecot:
h, brut = dovecot["hote"], dovecot["brut"]
sonde = _valeur(brut, "SONDE")
userdb = re.search(r"USERDB<<(.*?)>>", brut, re.S)
corps = userdb.group(1) if userdb else ""
if "mail_path" not in corps and "home" not in corps:
ecarts.append(f"{h} : Dovecot ne sait pas ou ecrire pour « {sonde} » — "
f"la resolution userdb echoue, la livraison LMTP aussi")
d = _dialecte(brut, "143")
if not d.strip():
ecarts.append(f"{h}:143 : aucune reponse IMAP")
elif "STARTTLS" not in d:
ecarts.append(f"{h}:143 : STARTTLS n'est pas annonce")
if postfix and dovecot:
if not _valeur(postfix["brut"], "TROUVE") and "mail_path" in dovecot["brut"]:
ecarts.append(
"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
def main(argv: list[str] | None = None) -> int:
fichiers = sorted(glob.glob(argv[0] if argv else MOTIF))
if not fichiers:
print("Relevés absents. Lancer d'abord : make courriel-plan", file=sys.stderr)
return 2
releves = [json.loads(Path(f).read_text(encoding="utf-8")) for f in fichiers]
ecarts = analyser(releves)
print(f"Devis du courriel — {' + '.join(r['hote'] for r in releves)}\n")
for e in ecarts:
print(f" [écart] {e}")
if not ecarts:
print("CONFORME : la chaîne tient, de la résolution LDAP à la boîte.")
return 0
print(f"\nÉCART : {len(ecarts)} divergence(s).")
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))