#!/usr/bin/env python3 """Les mots de passe de soumission ne quittent plus la machine de Postfix. POURQUOI (2026-10-12). Directive : tout transit chiffre, a l'interne comme a l'externe. La mesure des ecoutes l'a montre sur les deux locataires : Postfix (edge-mta) faisait verifier chaque mot de passe de soumission par le Dovecot du mailstore, sur :12345, et le client SASL Dovecot de Postfix ne sait pas chiffrer. Le registre declarait ce flux `tls`. Le flux n'est pas chiffre, il est SUPPRIME : un Dovecot d'authentification seule vit a cote de Postfix, joint par socket local, et verifie sur l'annuaire en LDAPS. """ from __future__ import annotations import re import sys from pathlib import Path import jinja2 import yaml RACINE = Path(__file__).resolve().parents[2] sys.path.insert(0, str(RACINE / "scripts")) from resoudre_flux import charger_flux # noqa: E402 DOVECOT = RACINE / "roles/serveur_dovecot" POSTFIX = RACINE / "roles/serveur_postfix" def main() -> int: echecs: list[str] = [] # 1. Plus aucun role ne declare le port SASL reseau, d'un cote ou de l'autre. for role, flux in charger_flux().items(): for f in flux: if str(f.get("port")) == "12345": echecs.append(f"{role} declare encore le flux {f.get('sens')} 12345") # 2. Postfix lit le socket local, jamais un `inet:`, pour ses deux ports de soumission. taches = (POSTFIX / "tasks/main.yml").read_text(encoding="utf-8") chemins = re.findall(r"smtpd_sasl_path=(\S+)", taches) if len(chemins) != 2 or set(chemins) != {"private/auth"}: echecs.append(f"smtpd_sasl_path = {chemins}, attendu private/auth sur 587 et 465") # 3. Le Dovecot local est pose APRES le demarrage de Postfix (qui cree `private/`), # et seulement la ou la soumission existe. liste = yaml.safe_load(taches) noms = [t.get("name", "") for t in liste] i_auth = next((i for i, t in enumerate(liste) if (t.get("ansible.builtin.include_role") or {}).get("tasks_from") == "auth_locale.yml"), None) i_demarrer = next((i for i, n in enumerate(noms) if n == "Activer et démarrer Postfix"), None) if i_auth is None: echecs.append("serveur_postfix n'inclut pas serveur_dovecot/auth_locale.yml") else: if i_demarrer is None or i_auth < i_demarrer: echecs.append("le Dovecot local est pose avant le demarrage de Postfix") if "serveur_postfix_submission_actif" not in str(liste[i_auth].get("when")): echecs.append("le Dovecot local n'est pas conditionne a la soumission") # 4. Authentification SEULE : aucun paquet de protocole, aucune ecoute reseau. defauts = yaml.safe_load((DOVECOT / "defaults/main.yml").read_text(encoding="utf-8")) paquets = defauts.get("serveur_dovecot_auth_locale_paquets") or [] if set(paquets) != {"dovecot-core", "dovecot-ldap"}: echecs.append(f"auth locale installe {paquets}, attendu dovecot-core + dovecot-ldap") if "serveur_dovecot_sasl_reseau" in defauts or "serveur_dovecot_sasl_port" in defauts: echecs.append("serveur_dovecot porte encore ses variables SASL reseau") fichier = DOVECOT / "templates/auth-locale.conf.j2" if not fichier.exists(): echecs.append("serveur_dovecot n'a pas de gabarit d'authentification seule (auth-locale.conf.j2)") else: rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string( fichier.read_text(encoding="utf-8")).render( serveur_dovecot_ldap_uris="ldaps://idm-01.exemple.internal:636", serveur_dovecot_ldap_base="dc=exemple,dc=ca", serveur_dovecot_ldap_bind_dn="cn=dovecot,ou=services,dc=exemple,dc=ca", serveur_dovecot_ldap_bind_password="banc", serveur_dovecot_ldap_filter=defauts["serveur_dovecot_ldap_filter"], serveur_dovecot_postfix_socket_dir=defauts["serveur_dovecot_postfix_socket_dir"], ) for interdit in ("inet_listener", "protocols", "userdb"): if interdit in rendu: echecs.append(f"auth-locale.conf rend `{interdit}` : ce n'est plus de l'authentification seule") for attendu in ("passdb ldap {", "ldap_bind = yes", "unix_listener /var/spool/postfix/private/auth {"): if attendu not in rendu: echecs.append(f"auth-locale.conf ne rend pas `{attendu}`") # 5. Et la machine des boites n'ouvre plus le port. if "inet_listener sasl" in (DOVECOT / "templates/99-setops.conf.j2").read_text(encoding="utf-8"): echecs.append("99-setops.conf.j2 ouvre encore l'ecoute SASL reseau") # 6. La verification passe par l'annuaire chiffre : le resolveur partage vise LDAPS. annuaire = yaml.safe_load((RACINE / "roles/resoudre_annuaire/defaults/main.yml").read_text(encoding="utf-8")) if annuaire.get("resoudre_annuaire_encryption") != "ldaps" or int(annuaire.get("resoudre_annuaire_port", 0)) != 636: echecs.append("resoudre_annuaire ne vise plus LDAPS:636") for e in echecs: print(f"ECHEC : {e}") if not echecs: print("OK : la soumission verifie ses mots de passe sur place, l'annuaire en LDAPS ; aucun port SASL reseau.") return 1 if echecs else 0 if __name__ == "__main__": raise SystemExit(main())