L'exploitant a retire la derniere regle heritee. `make frontiere-mesurer` rend CONFORME (code 0) : tout ce qui est declare est livre, tout le reste est refuse — y compris collab-01:9980, le seul qui livrait vraiment un HTTP 200 depuis le poste. Et mon instrument avait tort, pas la frontiere. Il comptait 38 ecarts en concluant depuis le client : « connexion etablie => la bordure a relaye ». Faux, verifie A LA DESTINATION : pendant que le poste tenait une connexion « etablie » vers idm-01:389, idm-01 n'en voyait aucune ; collab-01 n'en voyait aucune sur 9980. La frontiere repond a la poignee TCP sans relayer. Le devis raisonne desormais sur la LIVRAISON seule, et le controle ne rend le releve NUL que s'il LIVRE des donnees — qu'il ressorte AMBIGU est attendu ici et le rapport le dit a chaque execution. Cette relaxation rend aussi le sens sortant mesurable : il etait declare NUL en permanence. Nouveau mot-cle `poste: false` dans meta/flux.yml : un service publie n'est pas forcement fait pour un poste de travail. Le 25 entrant de Postfix est un flux serveur a serveur ; la frontiere l'etendait au VLAN d'administration ou le nftables de l'hote le refusait. Deux regles retirees. Le mot-cle vit avec le role qui sait ce que son port veut dire ; le generateur ne connait toujours aucun numero de port. Verifie : frontiere-plan sans ecart (41 regles, 12 routes), flotte 14/14, frontiere-mesurer CONFORME, prouver.py 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
12 KiB
Markdown
220 lines
12 KiB
Markdown
# Les devis de service : ce qui tourne correspond-il à ce qui est déclaré ?
|
|
|
|
> **Instrument ajouté le 2026-08-08**, après une série de défauts qu'aucun test n'avait
|
|
> signalés. Lecture seule — il ne modifie rien.
|
|
|
|
```
|
|
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
|
|
make frontiere-mesurer # ce qui n'est pas déclaré à la frontière est-il refusé ?
|
|
```
|
|
|
|
## Le trou qu'il comble
|
|
|
|
`scripts/prouver.py` porte 30 preuves. Elles sont toutes **statiques** : elles lisent le
|
|
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
|
|
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
|
|
propriétaire, que rien n'est codé en dur.
|
|
|
|
Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce.
|
|
|
|
Les quatre défauts du 2026-08-08 vivaient tous dans cet angle :
|
|
|
|
| Défaut | Le dépôt disait | La machine portait |
|
|
|---|---|---|
|
|
| politique de mot de passe | `length(12)…` | *aucune* — `abcd` accepté |
|
|
| entrée `ppolicy` figée | `pwdMustChange: TRUE` | `FALSE` |
|
|
| fédération en écriture | comptes écrits dans LDAP | `syncRegistrations` absent |
|
|
| prénom | `givenName` | `cn` (le nom complet) |
|
|
|
|
Chacun a été trouvé en relisant après avoir écrit. Aucun n'a été signalé.
|
|
|
|
## Ce qu'il vérifie
|
|
|
|
Le playbook `playbooks/maintenance/devis-identite.yml` **relève** deux dictionnaires — le
|
|
déclaré et le réel — et les dépose en JSON. `scripts/devis_identite.py` **compare**. La
|
|
séparation est délibérée : la comparaison en Jinja est illisible, et le dépôt a déjà cette
|
|
forme pour les devis réseau (Python raisonne, Ansible relève).
|
|
|
|
- **realm** — politique de mot de passe, anti-force-brute, « mot de passe oublié », relais SMTP
|
|
- **fédération** — mode d'écriture et les six clés de durcissement
|
|
- **mappeurs** — chaque attribut LDAP projeté
|
|
- **annuaire** — l'entrée `pwdPolicy`, attribut par attribut
|
|
- **comptes** — ceux qui n'ont pas d'adresse, pour qui « mot de passe oublié » ne mène nulle part
|
|
|
|
Le déclaré n'est jamais recopié ici : le playbook charge les défauts du rôle et appelle les
|
|
résolveurs. **Un devis qui redéclare ce qu'il vérifie ne vérifie rien.**
|
|
|
|
## Le devis des certificats
|
|
|
|
Il traque un défaut connu et récurrent : un certificat renouvelé **sur disque** mais
|
|
toujours servi **périmé** depuis la mémoire du service, tant que nginx / postfix / dovecot
|
|
/ slapd / step-ca n'ont pas été rechargés.
|
|
|
|
**Ce qu'il a fallu désapprendre pour l'écrire.** Les certificats step-ca vivent 24 h et le
|
|
minuteur les renouvelle toutes les ~14 minutes. Une empreinte servie *différente* de celle
|
|
sur disque est donc l'état **normal** : comparer les empreintes aurait produit un
|
|
vérificateur qui crie en permanence, et qu'on aurait appris à ignorer. Le signal utile est
|
|
l'**échéance de ce qui est réellement servi**, plus l'absence de `client_pki_reload_services`
|
|
— car alors rien ne rafraîchira jamais la copie en mémoire.
|
|
|
|
Il a trouvé quelque chose à sa première exécution : sur `infra-pki-01`, l'autorité
|
|
elle-même, le certificat était **expiré depuis plus de huit heures** et le renouvellement
|
|
échouait toutes les quatorze minutes sur `'step ca renew' requires the '--ca-url' flag`.
|
|
Rien ne le signalait.
|
|
|
|
## Le devis d'étanchéité de la frontière
|
|
|
|
Le devis des expositions pose la question **positive** — chaque exposition déclarée
|
|
répond-elle. Celui-ci pose la **négative** : la frontière laisse-t-elle passer ce que la
|
|
politique n'autorise pas ? Les deux sont nécessaires. Un pare-feu peut très bien servir
|
|
tout ce qu'on lui demande *et* laisser passer tout le reste.
|
|
|
|
Rien n'y est saisi. Les **cibles** sont les ports réellement en écoute dans la flotte —
|
|
sonder un port fermé ne prouverait rien du pare-feu, le refus viendrait de la machine. La
|
|
**politique attendue** est lue dans `devis_opnsense.py`, la source même qui configure la
|
|
frontière.
|
|
|
|
`scripts/sonde_tcp.py` porte deux principes, tirés de trois faux diagnostics :
|
|
|
|
- **Un contrôle avant tout verdict** — une adresse où personne n'écoute. S'il **livre** des
|
|
données, le relevé est déclaré **NUL**. Mieux vaut pas de mesure qu'une mesure fausse.
|
|
- **Établir n'est pas livrer.** La sonde fait parler le service : bannière, sinon requête
|
|
HTTP minimale, sinon poignée TLS. Sans réponse, le verdict est **AMBIGU**, jamais
|
|
« ouvert » — LDAP et PostgreSQL attendent un message qu'on ne fabrique pas ici, et un
|
|
synproxy se comporte exactement pareil.
|
|
|
|
**Seule la livraison compte, et c'est l'enseignement de la semaine.** Sur ce réseau, la
|
|
frontière répond elle-même à la poignée TCP pour toute destination qu'elle route, sans
|
|
jamais relayer : le contrôle ressort AMBIGU à chaque exécution, et le rapport le dit. Vérifié
|
|
**à la destination** le 2026-08-09 — pendant que le poste tenait une connexion « établie »
|
|
vers `idm-01:389`, `idm-01` n'en voyait aucune. Un port est donc conforme s'il livre quand il
|
|
doit livrer et ne livre rien quand il ne doit pas ; ce que fait la poignée ne regarde
|
|
personne.
|
|
|
|
Reste un écart de nature différente, et le devis le nomme comme tel : *livré attendu, rien
|
|
livré* = la bordure autorise et l'hôte refuse. Rien ne fuit, mais les deux couches ne
|
|
déclarent pas la même politique — et une politique qu'on ne peut plus lire est une politique
|
|
qu'on ne tient plus. C'est ce qui a fait naître le mot-clé `poste: false` dans
|
|
`meta/flux.yml` (voir [`frontiere-opnsense.md`](frontiere-opnsense.md)).
|
|
|
|
## Le devis des expositions
|
|
|
|
Il pose la question que se pose vraiment un utilisateur : **est-ce que mes services
|
|
répondent ?** Et quand la réponse est non, il dit *où* ça casse.
|
|
|
|
**Une vraie requête, jamais un `connect()`.** À travers l'OPNsense (anti-spoofing), toute
|
|
connexion TCP réussit — y compris vers une adresse où aucune machine n'existe. Et « lire
|
|
des données après connexion » ne vaut rien en TLS, où c'est le *client* qui parle en
|
|
premier : le port 443 d'un edge sain se comporte exactement comme un port mort. Seul le
|
|
code de retour d'une requête HTTPS complète tranche.
|
|
|
|
**Deux points de vue, et c'est leur différence qui diagnostique :**
|
|
|
|
| Point de vue | Ce qu'il traverse |
|
|
|---|---|
|
|
| depuis l'edge | edge + dorsal |
|
|
| depuis le poste | DNS + frontière + edge + dorsal — le chemin d'un vrai utilisateur |
|
|
|
|
L'un répond et pas l'autre : ce n'est pas le service, c'est le chemin.
|
|
|
|
**Un code n'est pas un verdict.** Un 502 ou un 504 *est* une réponse, et pourtant le dorsal
|
|
est mort derrière. Le premier jet ne testait que la présence d'un code et laissait donc
|
|
passer un dorsal tombé — trouvé par le test négatif, pas par la relecture. Un 302 ou un 401,
|
|
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. Ce n'est pas un luxe :
|
|
Dovecot a `allow_all_users = yes` et accepte donc n'importe quelle partie locale — le
|
|
garde-fou est entièrement chez Postfix.
|
|
|
|
**Et il regarde la file d'attente.** C'est la leçon la plus chère de la série : sa première
|
|
version disait `CONFORME` pendant que *toute* livraison interne était différée. Résolution
|
|
correcte, dialectes corrects, et rien qui arrive — Postfix exigeait TLS
|
|
(`lmtp_tls_security_level = verify`) vers un port Dovecot qui écoutait en clair. Les deux
|
|
côtés d'un même flux avaient été durcis séparément. **Un devis qui ne regarde que les
|
|
réglages ne dit pas si le service rend son service** ; la file est le seul endroit qui
|
|
révèle que le courrier ne bouge pas.
|
|
|
|
Le routage local se fait par **identifiant** (`uid`), pas par l'attribut `mail` — voir le
|
|
gabarit `ldap-mailboxes.cf.j2` pour la raison et le coût assumé.
|
|
|
|
## La règle dont ces devis découlent (D-68)
|
|
|
|
> **Écrire, puis relire et comparer** — quelle que soit l'interface. Choisir celle dont le
|
|
> chemin de *lecture* parle le même langage que le chemin d'*écriture*.
|
|
|
|
Ce n'est pas « toujours préférer l'API ». Cette règle-là n'aurait prédit aucune des pannes
|
|
du 2026-08-08. Sur six familles de défauts ce jour-là, **deux** venaient d'un CLI :
|
|
|
|
| Ce qui a menti | Interface |
|
|
|---|---|
|
|
| `kcadm -s` sur une map : succès, rien d'écrit | CLI |
|
|
| `grafana-cli` : « password changed ✔ » dans une base fantôme | CLI |
|
|
| `ldap_entry` : crée, ne modifie jamais | module Ansible |
|
|
| `grep postgresql.conf` : valeur fausse (surcharge `conf.d`) | lecture de fichier |
|
|
| `include_vars` : le devis rapportait le défaut, pas le réel | précédence Ansible |
|
|
| le comparateur d'expositions : un 502 compté comme vivant | mon propre code |
|
|
|
|
Le facteur commun n'est pas l'interface, c'est d'avoir **écrit sans relire**. Et la plupart
|
|
de la flotte n'a pas d'API du tout — Postfix, Dovecot, nginx, slapd, nftables. `postconf -h`
|
|
et `postconf -e` sont parfaitement symétriques : lecture et écriture dans le même
|
|
vocabulaire, c'est tout ce qu'on demande.
|
|
|
|
Une API est souvent préférable, mais pour une raison précise : elle rend la ressource
|
|
**entière**, ce qui permet le patron qu'on retrouve dans chaque devis — *fusionner
|
|
l'attendu dans le réel ; si rien ne change, c'est conforme*. Une interface qui n'accepte
|
|
que des écritures ne peut pas le soutenir.
|
|
|
|
**Les cinq devis sont cette relecture**, faite après coup et par une autre main que celle
|
|
qui a écrit. C'est ce qui les distingue d'un déploiement : `make deployer` réconcilie, les
|
|
devis constatent.
|
|
|
|
## 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
|
|
rôle tels quels fait donc mentir le devis : il compare au défaut du dépôt au lieu de la
|
|
valeur réellement déclarée par l'inventaire. Le premier jet rapportait
|
|
`client_pki_reload_services: []` sur les quatorze hôtes alors que quatre groupes le
|
|
déclarent — **le devis portait exactement le défaut qu'il est censé traquer.**
|
|
|
|
La forme correcte, reprise dans les deux devis :
|
|
|
|
1. retenir ce que l'inventaire déclare, **avant** tout chargement de défauts ;
|
|
2. charger les défauts du rôle (`include_vars`, sans `name:` — les défauts se citent entre
|
|
eux, et ces références ne se résolvent plus une fois enfermées dans un dictionnaire) ;
|
|
3. réimposer les valeurs de l'inventaire **clé par clé**, en boucle. `set_fact` n'accepte
|
|
pas un dictionnaire entier passé en argument libre : il le prend sans erreur et n'en
|
|
fait rien — c'est ainsi que le premier correctif a paru fonctionner tout en laissant le
|
|
devis mentir.
|
|
|
|
## Ce qu'ils ne font pas
|
|
|
|
Ils ne corrigent pas — c'est `make deployer` qui réconcilie. Ils répondent à l'autre
|
|
question, et sortent en code 1 s'il y a un écart.
|
|
|
|
Ils couvrent l'identité et les certificats. Le courriel, la base de données et les
|
|
expositions web attendent le même traitement ; le patron est là pour être repris.
|