Question de l'exploitant : « ne devrait-on pas prendre les versions les plus recentes ? » Non — resoudre « la derniere » au deploiement detruirait la reproductibilite qu'on vient de prouver en rasant et remontant deux ecosystemes, et ferait de chaque reconstruction une loterie. Six majeures de Forgejo ne s'avalent pas en effet de bord d'un `make`. Mais il avait raison de tirer le fil : L'ECART ETAIT INVISIBLE. Il a fallu quatre requetes a la main pour decouvrir Forgejo 10.0.0 contre v16.0.2 publie, Keycloak 26.0.7 contre 26.7.1, Nextcloud 34.0.1 contre 34.0.2, oauth2-proxy a jour. Le devis ne juge pas, il RENSEIGNE : un retard n'est pas une faute, il sort donc en 0. Monter de version reste un acte explicite. Ce qui le distingue d'une liste ecrite a la main : les versions epinglees sont DERIVEES du depot. La source amont, elle, ne peut pas se deriver — elle depend de l'editeur — et vit dans une table. TOUTE VERSION EPINGLEE SANS ENTREE DANS CETTE TABLE FAIT SORTIR EN ERREUR : sans cette garde, un epinglage ajoute demain vieillirait sans que personne ne le voie. Une exemption reste possible, mais ecrite et motivee (deux le sont : la serie PHP de Debian, la version PostgreSQL vide). Eprouve dans les deux sens : un serveur_redis_version ajoute temporairement fait sortir en 1 en le nommant ; retire, retour a 0. Et il rappelle ce qu'on n'epingle PAS. Grafana n'a aucune version dans le depot — le 13.1.1 vu dans l'historique apt etait ce que son depot servait ce jour-la. Debian, Grafana, smallstep et Icinga prennent ce qu'on leur donne. Deux regimes coexistent dans la meme flotte, et les taire donnerait l'illusion que tout est maitrise. Verifie : versions-mesurer code 0, garde eprouvee, prouver.py 35 OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
Les devis de service : ce qui tourne correspond-il à ce qui est déclaré ?
Pour qui : l'exploitant qui veut savoir si ce qui tourne correspond à 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é ?
make mtu-mesurer # l'invité porte-t-il le MTU de sa zone SDN ?
make versions-mesurer # de combien nos épinglages ont-ils vieilli ?
Le trou qu'il comble
scripts/prouver.py porte 35 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 des versions
Il répond à une question posée le 2026-08-10 : « ne devrait-on pas prendre les versions les plus récentes ? » Non — résoudre « la dernière » au moment du déploiement détruirait la reproductibilité et ferait de chaque reconstruction une loterie. Mais l'écart, lui, était invisible : il a fallu quatre requêtes à la main pour découvrir que Forgejo avait six versions majeures de retard.
Ce devis ne juge pas, il renseigne. Un retard n'est pas une faute ; monter de version reste un acte explicite — on édite, on relit, on reconstruit, on repasse les devis.
La liste des versions épinglées est dérivée du dépôt (roles/*/defaults/*_version). La
source amont de chacune, elle, ne peut pas se dériver — elle dépend de l'éditeur — et vit
donc dans une table. Toute version épinglée sans entrée dans cette table fait sortir le
devis en erreur : sans cette garde, un épinglage ajouté demain vieillirait sans que
personne ne le voie, et on croirait tout surveillé. Une exemption reste possible, mais
écrite et motivée.
Il rappelle enfin ce que l'on n'épingle pas : les paquets Debian, Grafana, smallstep et Icinga prennent ce que leur dépôt sert au moment du déploiement. Deux régimes coexistent, et les taire donnerait l'illusion que tout est maîtrisé.
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).
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 :
- retenir ce que l'inventaire déclare, avant tout chargement de défauts ;
- charger les défauts du rôle (
include_vars, sansname:— les défauts se citent entre eux, et ces références ne se résolvent plus une fois enfermées dans un dictionnaire) ; - réimposer les valeurs de l'inventaire clé par clé, en boucle.
set_factn'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.