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:
parent
0cc04177fb
commit
4bfcf4944f
7 changed files with 471 additions and 0 deletions
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -1,5 +1,41 @@
|
||||||
# CHANGELOG — Set-OPS
|
# 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 ? »
|
## 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
|
Troisième devis de service. Il pose la seule question qui compte pour un utilisateur, et
|
||||||
|
|
|
||||||
10
Makefile
10
Makefile
|
|
@ -418,6 +418,16 @@ ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue S
|
||||||
| tail -1 | tr -d ' \r'
|
| tail -1 | tr -d ' \r'
|
||||||
|
|
||||||
.PHONY: frontiere-plan frontiere-appliquer
|
.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)
|
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
|
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-expositions.yml >/dev/null
|
||||||
@python3 scripts/devis_expositions.py
|
@python3 scripts/devis_expositions.py
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
make identite-plan # realm, fédération, mappeurs, politique, comptes
|
make identite-plan # realm, fédération, mappeurs, politique, comptes
|
||||||
make certificats-plan # certificats sur disque contre certificats réellement servis
|
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 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
|
## 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
|
en revanche, est un service parfaitement vivant qui redirige vers l'IdP ou exige une
|
||||||
authentification.
|
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
|
## 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
|
`include_vars` au niveau du *play* **prime sur les `group_vars`**. Charger les défauts d'un
|
||||||
|
|
|
||||||
118
playbooks/maintenance/devis-courriel.yml
Normal file
118
playbooks/maintenance/devis-courriel.yml
Normal 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
|
||||||
62
playbooks/maintenance/devis-postgresql.yml
Normal file
62
playbooks/maintenance/devis-postgresql.yml
Normal 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
120
scripts/devis_courriel.py
Executable 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 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:]))
|
||||||
100
scripts/devis_postgresql.py
Executable file
100
scripts/devis_postgresql.py
Executable 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) 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:]))
|
||||||
Loading…
Reference in a new issue