Set-OPS-Public/scripts/devis_courriel.py
Daniel Allaire c18debc25e
Some checks are pending
verifier / verifier (push) Waiting to run
resolution d'instance : une seule, partagee — au lieu de neuf copies
Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.

- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
  _frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.

Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un
moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas
l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier.

LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.

CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.

P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.

make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00

132 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
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent
MOTIF = str(instance_courante() / "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:]))