From 4bfcf4944fb1310c862e11782269cda21ea910b0 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sat, 8 Aug 2026 08:53:49 -0400 Subject: [PATCH] devis PostgreSQL et courriel : la serie des devis de service est complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostgreSQL — rend visibles deux defauts deja vecus ici : un reseau ECRIT dans pg_hba au lieu d'etre derive, et une ligne host en clair la ou il faut hostssl (verrou qui saute sans bruit, les clients verify-full continuant de marcher). Interroge pg_settings, jamais le fichier : un grep de postgresql.conf annonce ssl_cert_file=snakeoil alors que le serveur sert le certificat de l'AC — la valeur vient d'un conf.d que le grep ne voyait pas. L'instrument etait incomplet, pas la configuration. Courriel — chaque maillon interroge la ou il dit la verite : postmap -q pour la resolution LDAP de Postfix, doveadm user pour celle de Dovecot (le maillon exact ou la livraison avait bloque), vraies conversations SMTP/IMAP. Verifie aussi qu'une adresse INEXISTANTE ne resout pas — sinon la boite est un fourre-tout et le devis ne mesure plus rien. Trouve immediatement une divergence reelle : Dovecot connait la boite de sysadmin@chezlepro.internal, Postfix ne sait pas y router (query_filter (mail=%s), et l'attribut mail porte sysadmin@chezlepro.ca). Collision de roles : une identite ne porte qu'une adresse mail et on lui en demande deux — notification joignable hors du systeme, et cle de routage local. Arbitrage a rendre avant correction. Tests negatifs : PostgreSQL 3 ecarts nommes, code 1. Une variable morte trouvee dans une branche que le cas nominal n'emprunte jamais. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 36 +++++++ Makefile | 10 ++ docs/devis-services.md | 25 +++++ playbooks/maintenance/devis-courriel.yml | 118 ++++++++++++++++++++ playbooks/maintenance/devis-postgresql.yml | 62 +++++++++++ scripts/devis_courriel.py | 120 +++++++++++++++++++++ scripts/devis_postgresql.py | 100 +++++++++++++++++ 7 files changed, 471 insertions(+) create mode 100644 playbooks/maintenance/devis-courriel.yml create mode 100644 playbooks/maintenance/devis-postgresql.yml create mode 100755 scripts/devis_courriel.py create mode 100755 scripts/devis_postgresql.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec4753..e898c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # CHANGELOG — Set-OPS +## 2026-08-08 — Devis PostgreSQL et courriel : la série est complète + +**PostgreSQL.** Il rend visibles deux défauts déjà vécus ici : un réseau *écrit* dans +`pg_hba.conf` au lieu d'être dérivé, et une ligne `host` en clair là où il faut `hostssl` +— un verrou qui saute sans bruit, puisque les clients en `verify-full` continuent de +marcher. État : conforme. Test négatif rejouant les deux défauts plus un certificat +snakeoil : trois écarts nommés, code 1. + +Une leçon de méthode au passage : un `grep` de `postgresql.conf` annonce +`ssl_cert_file = snakeoil` alors que le serveur sert bien le certificat de l'AC — la valeur +vient d'un `conf.d/99-setops.conf` que le grep ne voyait pas. **C'était l'instrument qui +était incomplet, pas la configuration.** Le devis interroge `pg_settings`, jamais le +fichier. + +Et une erreur trouvée par le test négatif, pas par la relecture : une variable morte dans +une branche que le cas nominal n'emprunte jamais. Troisième fois aujourd'hui. + +**Courriel.** Chaque maillon interrogé là où il dit la vérité : `postmap -q` pour la +résolution LDAP de Postfix, `doveadm user` pour celle de Dovecot — précisément le maillon +où la livraison avait bloqué — et de vraies conversations SMTP/IMAP. Il vérifie aussi +qu'une adresse *inexistante* ne résout pas : sans ça, une boîte fourre-tout accepterait +n'importe quel nom et le devis ne mesurerait plus rien. + +**Il a immédiatement trouvé une divergence réelle.** Dovecot connaît la boîte de +`sysadmin@chezlepro.internal` (`mail_path = /var/vmail/sysadmin/Maildir`), et Postfix ne +sait pas y router : son `query_filter` est `(mail=%s)`, et l'attribut `mail` de l'annuaire +porte désormais `sysadmin@chezlepro.ca`. + +La cause n'est pas un réglage mais une **collision de rôles** : une identité ne porte +qu'une adresse `mail`, et on lui en demande deux — l'adresse de notification, qui doit être +joignable par la personne *hors* du système qu'on amorce, et la clé de routage local, qui +doit vivre dans un `virtual_mailbox_domains`. Les deux ne peuvent pas être la même valeur. +Ce n'est pas une régression (le compte n'avait aucun `mail` en début de journée, donc +n'était pas routable non plus) — le devis a rendu lisible un état qui l'était déjà. +Arbitrage à rendre avant correction. + ## 2026-08-08 — Devis des expositions : « est-ce que mes services répondent ? » Troisième devis de service. Il pose la seule question qui compte pour un utilisateur, et diff --git a/Makefile b/Makefile index e2dc2cc..319c6a9 100644 --- a/Makefile +++ b/Makefile @@ -418,6 +418,16 @@ ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue S | tail -1 | tr -d ' \r' .PHONY: frontiere-plan frontiere-appliquer +courriel-plan: ansible-runtime ## Chaine Postfix -> LDAP -> Dovecot -> IMAP (aucune ecriture) + @rm -f $(SETOPS_INSTANCE)/devis-courriel.json.* + @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-courriel.yml >/dev/null + @python3 scripts/devis_courriel.py + +postgresql-plan: ansible-runtime ## Chiffrement impose et portee des acces PostgreSQL (aucune ecriture) + @rm -f $(SETOPS_INSTANCE)/devis-postgresql.json.* + @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-postgresql.yml >/dev/null + @python3 scripts/devis_postgresql.py + expositions-plan: ansible-runtime ## Chaque exposition du plan repond-elle ? (edge et poste, aucune ecriture) @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-expositions.yml >/dev/null @python3 scripts/devis_expositions.py diff --git a/docs/devis-services.md b/docs/devis-services.md index 2dfd5f3..935dc38 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -7,6 +7,8 @@ make identite-plan # realm, fédération, mappeurs, politique, comptes make certificats-plan # certificats sur disque contre certificats réellement servis make expositions-plan # chaque `expose:` du plan répond-il, depuis l'edge et depuis le poste +make postgresql-plan # chiffrement imposé, et à quels réseaux +make courriel-plan # Postfix → LDAP → LMTP → Dovecot → IMAP ``` ## Le trou qu'il comble @@ -89,6 +91,29 @@ passer un dorsal tombé — trouvé par le test négatif, pas par la relecture. en revanche, est un service parfaitement vivant qui redirige vers l'IdP ou exige une authentification. +## Le devis PostgreSQL + +Deux défauts déjà vécus dans ce dépôt, et qu'il rend visibles : un réseau **écrit** dans +`pg_hba.conf` au lieu d'être dérivé (`10.11.0.0/16` sous un commentaire « AJUSTER » que +personne n'a suivi), et une ligne `host` en clair là où il faut `hostssl` — le verrou saute +sans bruit, puisque les clients en `verify-full` continuent de fonctionner. + +**Interroger l'état effectif, jamais le fichier.** Un `grep` de `postgresql.conf` annonce +`ssl_cert_file = snakeoil` sur `data-sql-01`, alors que le serveur sert bien le certificat +de l'AC : la valeur vient d'un `conf.d/99-setops.conf` que le grep ne voyait pas. C'était +l'instrument qui était incomplet, pas la configuration. Le devis lit `pg_settings`. + +## Le devis du courriel + +La chaîne est longue — Postfix → LDAP → LMTP → Dovecot → IMAP — et chaque maillon peut +casser sans que le suivant s'en aperçoive. Chacun est donc interrogé là où il dit la +vérité : `postmap -q` pour la résolution LDAP de Postfix, `doveadm user` pour celle de +Dovecot — précisément le maillon où la livraison avait bloqué — et une vraie conversation +SMTP/IMAP pour ce que les serveurs annoncent. + +Il vérifie aussi qu'une adresse **inexistante** ne résout pas : sans ce contrôle, une boîte +fourre-tout accepterait n'importe quel nom, et le devis ne mesurerait plus rien. + ## Un piège de construction, à connaître avant d'écrire le prochain `include_vars` au niveau du *play* **prime sur les `group_vars`**. Charger les défauts d'un diff --git a/playbooks/maintenance/devis-courriel.yml b/playbooks/maintenance/devis-courriel.yml new file mode 100644 index 0000000..1e9b076 --- /dev/null +++ b/playbooks/maintenance/devis-courriel.yml @@ -0,0 +1,118 @@ +--- +# Devis du courriel — RELEVE seul. Ne modifie rien (D-23). +# +# La chaine est longue — Postfix -> LDAP -> LMTP -> Dovecot -> IMAP — et chaque maillon +# peut casser sans que le suivant s'en apercoive. On interroge donc chaque maillon la +# ou il dit la verite : `postmap -q` pour la resolution LDAP de Postfix, `doveadm user` +# pour celle de Dovecot (c'est precisement la ou la livraison avait bloque), et une vraie +# conversation SMTP pour ce que le serveur accepte. +# +# make courriel-plan + +- name: Devis du courriel — Postfix + hosts: serveur_postfix + become: true + gather_facts: false + + tasks: + - name: Résoudre la connexion à l'annuaire + ansible.builtin.include_role: + name: resoudre_annuaire + + - name: Choisir un compte réel comme sonde + community.general.ldap_search: + dn: "{{ resoudre_annuaire_users_dn }}" + scope: onelevel + filter: "(objectClass=inetOrgPerson)" + attrs: ["uid"] + server_uri: "{{ resoudre_annuaire_uri }}" + bind_dn: "{{ resoudre_annuaire_bind_dn }}" + bind_pw: "{{ resoudre_annuaire_bind_password }}" + register: devis_courriel_comptes + changed_when: false + no_log: true + delegate_to: "{{ groups['serveur_openldap'] | first }}" + + - name: Retenir l'adresse sonde + ansible.builtin.set_fact: + devis_courriel_sonde: "{{ (devis_courriel_comptes.results | first).uid }}@{{ domaine_interne }}" + + - name: Relever Postfix (résolution LDAP, portée du relais, dialecte SMTP) + ansible.builtin.shell: + executable: /bin/bash + cmd: | + set -uo pipefail + echo "SONDE={{ devis_courriel_sonde }}" + echo "TROUVE=$(postmap -q '{{ devis_courriel_sonde }}' \ + ldap:/etc/postfix/ldap-mailboxes.cf 2>/dev/null)" + # Une adresse qui n'existe pas DOIT rester sans reponse : sinon la boite est + # un fourre-tout et n'importe quel nom serait accepte. + echo "FANTOME=$(postmap -q 'nexistepas-{{ 999 | random }}@{{ domaine_interne }}' \ + ldap:/etc/postfix/ldap-mailboxes.cf 2>/dev/null)" + echo "MYNETWORKS=$(postconf -h mynetworks)" + for P in 25 587; do + echo "PORT=$P" + exec 3<>/dev/tcp/127.0.0.1/$P || continue + read -r -t 5 _ <&3 + printf 'EHLO devis.local\r\n' >&3 + while read -r -t 5 L <&3; do + echo " $L" + [ "${L:3:1}" = " " ] && break + done + printf 'QUIT\r\n' >&3 + exec 3<&- 3>&- + done + register: devis_courriel_postfix + changed_when: false + + - name: Déposer le relevé Postfix + ansible.builtin.copy: + dest: "{{ playbook_dir }}/../../instance/devis-courriel.json.postfix" + mode: "0600" + content: >- + {{ {'hote': inventory_hostname, 'role': 'postfix', + 'supernet': setops_supernet | default(''), + 'brut': devis_courriel_postfix.stdout} | to_nice_json }} + delegate_to: localhost + become: false + +- name: Devis du courriel — Dovecot + hosts: serveur_dovecot + become: true + gather_facts: false + + tasks: + - name: Relever Dovecot (résolution userdb et dialecte IMAP) + ansible.builtin.shell: + executable: /bin/bash + cmd: | + set -uo pipefail + SONDE="{{ hostvars[groups['serveur_postfix'] | first]['devis_courriel_sonde'] }}" + echo "SONDE=$SONDE" + # `doveadm user` est le maillon exact ou la livraison avait bloque : c'est lui + # qui dit si Dovecot sait ou ecrire pour cette adresse. + echo "USERDB<<" + doveadm user "$SONDE" 2>&1 | head -12 + echo ">>" + echo "PORT=143" + exec 3<>/dev/tcp/127.0.0.1/143 || exit 0 + read -r -t 5 BANNIERE <&3 + echo " $BANNIERE" + printf 'a CAPABILITY\r\n' >&3 + while read -r -t 5 L <&3; do + echo " $L" + case "$L" in a\ *) break ;; esac + done + printf 'b LOGOUT\r\n' >&3 + register: devis_courriel_dovecot + changed_when: false + + - name: Déposer le relevé Dovecot + ansible.builtin.copy: + dest: "{{ playbook_dir }}/../../instance/devis-courriel.json.dovecot" + mode: "0600" + content: >- + {{ {'hote': inventory_hostname, 'role': 'dovecot', + 'brut': devis_courriel_dovecot.stdout} | to_nice_json }} + delegate_to: localhost + become: false diff --git a/playbooks/maintenance/devis-postgresql.yml b/playbooks/maintenance/devis-postgresql.yml new file mode 100644 index 0000000..e04f054 --- /dev/null +++ b/playbooks/maintenance/devis-postgresql.yml @@ -0,0 +1,62 @@ +--- +# Devis PostgreSQL — RELEVE seul. Ne modifie rien (D-23). +# +# On interroge l'ETAT EFFECTIF (`pg_settings`), jamais les fichiers. Un `grep` de +# `postgresql.conf` annonce `ssl_cert_file = snakeoil` sur cet hote, alors que le +# serveur sert bien le certificat de l'AC interne : la valeur vient d'un +# `conf.d/99-setops.conf` que le grep ne voyait pas. C'est l'instrument qui etait +# incomplet, pas la configuration. Constate le 2026-08-08. +# +# make postgresql-plan + +- name: Devis PostgreSQL — chiffrement et portee des accès + hosts: serveur_postgresql + become: true + gather_facts: false + + tasks: + - name: Relever les réglages TLS effectifs + ansible.builtin.command: + argv: + - sudo + - -u + - postgres + - psql + - -tAF| + - -c + - >- + select name, setting from pg_settings + where name in ('ssl','ssl_cert_file','ssl_key_file','ssl_ca_file') + register: devis_pg_tls + changed_when: false + + - name: Relever les règles d'accès effectives + ansible.builtin.command: + argv: + - sudo + - -u + - postgres + - psql + - -tAF| + - -c + # `address` et `netmask` sont du TEXTE dans cette vue, pas des inet : on les + # rend tels quels et Python les recompose. + - >- + select type, coalesce(address,''), coalesce(netmask,''), auth_method + from pg_hba_file_rules where error is null order by rule_number + register: devis_pg_hba + changed_when: false + + - name: Déposer le relevé sur le contrôleur + ansible.builtin.copy: + dest: "{{ playbook_dir }}/../../instance/devis-postgresql.json.{{ inventory_hostname }}" + mode: "0600" + content: >- + {{ { + 'hote': inventory_hostname, + 'supernet': setops_supernet | default(''), + 'tls': devis_pg_tls.stdout_lines | default([]), + 'hba': devis_pg_hba.stdout_lines | default([]), + } | to_nice_json }} + delegate_to: localhost + become: false diff --git a/scripts/devis_courriel.py b/scripts/devis_courriel.py new file mode 100755 index 0000000..5072650 --- /dev/null +++ b/scripts/devis_courriel.py @@ -0,0 +1,120 @@ +#!/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. L'attribut `mail` de l'annuaire porte une autre adresse, et " + f"c'est lui qui sert de cle (`query_filter = (mail=%s)`)") + 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") + + 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 connait la boite, Postfix ne sait " + "pas y router. Une identite ne porte qu'une adresse `mail`, et elle a ici " + "deux roles incompatibles — la notification (joignable par la personne) et " + "la cle de routage local (dans un domaine local). A trancher.") + 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:])) diff --git a/scripts/devis_postgresql.py b/scripts/devis_postgresql.py new file mode 100755 index 0000000..b57f60f --- /dev/null +++ b/scripts/devis_postgresql.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Devis PostgreSQL : le chiffrement est-il réellement imposé, et à qui ? + +Lit les relevés de `playbooks/maintenance/devis-postgresql.yml`. Sort en code 1 s'il y a +un écart. + +Deux défauts déjà vécus dans ce dépôt, et que ce devis rend visibles : + + - un réseau **écrit** dans `pg_hba.conf` au lieu d'être dérivé (`10.11.0.0/16` sous un + commentaire « AJUSTER » que personne n'a suivi) : la base acceptait un supernet qui + n'était pas le sien ; + - une ligne `host` (en clair) là où il faut `hostssl` : le verrou saute sans bruit, + puisque les clients en `verify-full` continuent de fonctionner. +""" + +from __future__ import annotations + +import glob +import ipaddress +import json +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +MOTIF = str(RACINE / "instance" / "devis-postgresql.json.*") +LOCAL = (ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("::1/128")) + + +def analyser(releves: list[dict]) -> list[str]: + ecarts = [] + for r in sorted(releves, key=lambda x: x["hote"]): + h = r["hote"] + tls = dict(l.split("|", 1) for l in r["tls"] if "|" in l) + + if tls.get("ssl") != "on": + ecarts.append(f"{h} : `ssl` n'est pas actif — tout le trafic est en clair") + cert = tls.get("ssl_cert_file", "") + if "snakeoil" in cert or not cert: + ecarts.append(f"{h} : certificat serveur « {cert or 'aucun'} » — ce n'est pas " + f"celui de l'AC interne, la confiance est-ouest ne tient pas") + if not tls.get("ssl_ca_file"): + ecarts.append(f"{h} : aucun `ssl_ca_file` — le serveur ne peut pas verifier " + f"un certificat client") + + supernet = ipaddress.ip_network(r["supernet"]) if r.get("supernet") else None + for ligne in r["hba"]: + champs = [c.strip() for c in ligne.split("|")] + if len(champs) < 4: + continue + type_, adresse, masque = champs[0], champs[1], champs[2] + if type_ == "local" or not adresse: + continue + # `address` peut valoir « all », « samehost », un nom d'hote, une adresse + # nue (`::1`) ou un CIDR ; `netmask` n'est renseigne que parfois. On essaie + # les deux recompositions avant de conclure que ce n'est pas un reseau. + res = None + for candidat in ((f"{adresse}/{masque}" if masque else None), adresse): + if not candidat: + continue + try: + res = ipaddress.ip_network(candidat, strict=False) + break + except ValueError: + res = None + if res is None: + if type_ != "hostssl": + ecarts.append(f"{h} : `{type_}` (et non `hostssl`) pour « {adresse} » " + f"— le chiffrement n'est pas IMPOSE, seulement possible") + continue + if any(res.subnet_of(l) for l in LOCAL if res.version == l.version): + continue + if type_ != "hostssl": + ecarts.append(f"{h} : `{type_}` (et non `hostssl`) pour {res} — le " + f"chiffrement n'est pas IMPOSE, seulement possible") + if supernet and res.version == supernet.version and not res.subnet_of(supernet): + ecarts.append(f"{h} : {res} est autorise mais n'appartient pas au " + f"supernet derive {supernet} — un reseau ECRIT au lieu " + f"d'etre derive") + 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 postgresql-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 PostgreSQL — {len(releves)} instance(s)\n") + for e in ecarts: + print(f" [écart] {e}") + if not ecarts: + print("CONFORME : chiffrement imposé, et aucun réseau hors du supernet dérivé.") + return 0 + print(f"\nÉCART : {len(ecarts)} divergence(s).") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))