devis PostgreSQL et courriel : la serie des devis de service est complete
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 <noreply@anthropic.com>
2026-08-08 08:53:49 -04:00
|
|
|
#!/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
|
|
|
|
|
|
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
|
|
|
from inventory_rules import instance_courante # noqa: E402
|
|
|
|
|
|
devis PostgreSQL et courriel : la serie des devis de service est complete
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 <noreply@anthropic.com>
2026-08-08 08:53:49 -04:00
|
|
|
RACINE = Path(__file__).resolve().parent.parent
|
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
|
|
|
MOTIF = str(instance_courante() / "devis-postgresql.json.*")
|
devis PostgreSQL et courriel : la serie des devis de service est complete
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 <noreply@anthropic.com>
2026-08-08 08:53:49 -04:00
|
|
|
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:]))
|