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>
This commit is contained in:
Daniel Allaire 2026-08-08 08:53:49 -04:00
parent 0cc04177fb
commit 4bfcf4944f
7 changed files with 471 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

120
scripts/devis_courriel.py Executable file
View file

@ -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 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:]))

100
scripts/devis_postgresql.py Executable file
View file

@ -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) 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:]))