#!/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:]))