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>
130 lines
5.5 KiB
Python
Executable file
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:]))
|