Set-OPS-Public/CHANGELOG.md
Daniel Allaire 11b5bb5733 temoins : l'instantane d'avant rasage est garde, et l'etat remis lui est compare
M4 a reconstruit Technolibre par le site qui la nomme ; les 7 jeux sont revenus
de l'instantane de 11:34, mais le premier depot d'apres reconstruction l'a chasse
par --keep-daily le jour meme : plus rien a quoi comparer.

Le depot d'avant rasage est etiquete avant-raser et garde jusqu'a la
reconstruction suivante. Le bilan dit ce qui a ete restaure et si c'est encore
au depot. setops-restaurer temoins et make temoins-etat comparent l'etat vivant
a cet instantane : une perte ou une identite changee est un ecart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:48:00 -04:00

1.2 MiB
Raw Blame History

CHANGELOG — Set-OPS

2026-10-07 (101) — M4 : Technolibre reconstruite par le site qui la nomme ; l'instantané d'avant rasage ne survivait pas

M4, la preuve par reconstruction (make reconstruire-locataire TENANT=OPS-Technolibre, lancée par l'exploitant, 44 min, journal OPS-Technolibre/logs/reconstruction-20261007-113441.log) :

  • le runner du site tire a204ead ; raser s'annonce « Locataire nommé … d'après sa face publiée », 13/13 VM détruites ; creer range les 13 VM dans le pool OPS-Technolibre ;
  • insémination, armement, montage : flux, socle, deployer-tout, valider à 0 échec (courriel de bout en bout, HTTPS par l'edge, Prometheus, restauration d'un instantané) ;
  • pare-feu Proxmox : 13/13 actifs, connectivite saine, 0 critique dans Icinga ;
  • les 7 jeux d'état restaurés depuis l'instantané pris à 11:34:44, à l'étape sauvegarder, juste avant le rasage (marqueurs de chaque nœud, journal restic).

La matérialisation est donc prouvée sur le cluster : le site crée, rase et range un locataire d'après sa face, sans monter son dépôt.

Le défaut : à 12:16, le premier dépôt de chaque machine reconstruite (gestionnaire « Premier rapport à Icinga ») a chassé l'instantané de 11:34 par --keep-daily, le même jour, sur les 7 nœuds. Rien de perdu (le dépôt de 12:16 porte l'état remis), mais plus rien à quoi le comparer, et plus moyen de refaire une restauration depuis l'avant-rasage. Les témoins prévus après coup n'ont pas pu être pris.

Et une lecture trompeuse : le bilan affichait un « candidat » recalculé à l'heure du bilan (l'instantané de la veille, une fois celui de 11:34 chassé), sans dire ce qui avait été restauré. Je m'y suis trompé un moment.

Fait :

  • Le dépôt d'avant rasage est étiqueté avant-raser (setops-sauvegarder.sh --avant-raser, nouvelle unité setops-sauvegarde-avant-raser.service, lancée par make sauvegarder-maintenant et l'étape sauvegarder). La rétention garde l'étiquette (--keep-tag) jusqu'à la reconstruction suivante (décision de l'exploitant) : le dépôt suivant la reçoit, l'ancien la perd, après le dépôt, jamais avant. Un nœud sans la nouvelle unité refuse de déposer plutôt que de déposer sans étiquette.
  • Le bilan (setops-restaurer etat) nomme l'instantané avant-raser, dit que le candidat est « ce que choisirait une restauration lancée maintenant », et marque chaque instantané restauré « (au dépôt) » ou « (RETIRÉ du dépôt) ».
  • Les témoins : setops-restaurer temoins extrait l'instantané d'avant rasage (ou --candidat), lit l'annuaire vivant, et setops-temoins compare. Écart = une perte (fichier, courriel, entrée, base, rôle, table) ou une identité changée (clés et certificats de l'AC, DKIM, instanceid de Nextcloud, userPassword d'une entrée). Le reste est listé (« modifié », « retiré », « nouveau ») sans être un écart. make temoins-etat [HOTE=] [SOURCE=candidat] ; l'étape bilan de la reconstruction les lance et s'arrête sur un écart.
  • temoins-etat et sa variable SOURCE entrent au registre des runbooks (la console les proposera) ; le runbook d'exploitation et le README du rôle décrivent l'étiquette et les témoins.

Éprouvé :

  • test_temoins.py (nouveau, dans make test) : 18 contrôles sur des arbres fabriqués, dans les deux sens (la vie ordinaire passe ; clé de l'AC, DKIM, instanceid, sysadmin remis à l'amorçage, entrée, base, rôle, table, courriel et fichier perdus échouent).
  • test_restauration.py : les témoins prennent l'instantané étiqueté, même plus ancien que le candidat ; un instantané chassé est dit « RETIRÉ » ; la sauvegarde étiquette, retire l'étiquette au seul précédent et après le dépôt, et la rétention la garde aussi au dépôt ordinaire.
  • Témoins : --keep-tag retiré, ou les témoins ramenés au candidat, trois tests échouent.
  • shellcheck des deux scripts rendus : rien, avant comme après.
  • make verifier conforme, 94/94.

Reste : déployer client_backup sur les deux locataires (les nœuds de Chezlepro n'ont pas la nouvelle unité : sauvegarder-maintenant y refuserait), puis reconstruire Chezlepro avec des témoins pris pour de vrai, puis étiqueter la release.

2026-10-07 (100) — Pré-vol de M4 : la face tient devant le cluster, deux en-têtes disaient faux

Le pré-vol, lancé par l'exploitant depuis le runner du site, en lecture seule : make locataire-raser TENANT=OPS-Technolibre (sans CONFIRMER) et make placement-plan TENANT=OPS-Technolibre. C'est la première confrontation de la face au vrai cluster, celle que M2 n'avait pas pu faire depuis le poste (errno 111). Résultat : 13 VM, chacune sous son nom, aucun conflit ; placement conforme (asgard, TrueNAS, gabarit 99998, les six réseaux t23*). Rien n'a été touché.

Deux en-têtes disaient faux sur le chemin TENANT=, sans rien changer à ce qui est visé :

  • raser.py annonçait « Écosystème monté » sur le runner du site, où rien n'est monté. Il dit désormais « Locataire nommé … d'après sa face publiée » (entete()), et le refus faute de nom dit « Nomme » au lieu de « Monte ».
  • devis_placement.py annonçait « tenant « ? » » : avec --locataire, aucun fichier source d'où tirer le nom. nom_du_tenant() rend celui qu'on a désigné.

Corrigés avant M4, pour que la reconstruction couvre le commit qu'on étiquettera.

Éprouvé :

  • test_raser.py juge l'en-tête sur la sortie réelle de raser.main, aux deux chemins, face à un faux cluster qui rend exactement les machines publiées.
  • test_devis_placement.py : le nom désigné, le nom tiré de la source, et l'en-tête affiché.
  • Témoins : les anciens libellés remis en place, les deux tests échouent (« EN-TETE TROMPEUR », puis l'assertion du devis).

2026-10-05 (99) — Matérialisation, M3 : la reconstruction nomme son locataire, et le pool suit

Un trou dans M2, trouvé avant tout usage. locataire-creer comparait les paramètres de clonage, pas la ligne qui part. Le sous-make cloner-vm nommait encore le pool par l'instance montée (devis_proxmox_pools.py --pool-actif). Sur le runner du site, sans instance, la VM serait née hors de tout pool. Sur le poste, avec Chezlepro monté, locataire-creer TENANT=OPS-Technolibre aurait rangé les VM dans le pool de Chezlepro. Mon make -n ne descendait pas dans le sous-make : il ne pouvait pas le voir. Personne n'a lancé locataire-creer entre-temps.

Fait :

  • devis_proxmox_pools.py --pool-du-locataire <dépôt> : même dérivation (pool_de), à partir de l'index de la face. cloner-vm l'emploie quand TENANT est donné.
  • reconstruire_locataire.py : les étapes raser et creer lancent sur le runner du site make locataire-raser TENANT=… et make locataire-creer TENANT=… ; plus de SETOPS_INSTANCE=/opt/setops/OPS-x. Le dépôt du locataire y reste tiré : sa face y est publiée.
  • docs/conception-contextes.md : l'état de l'étape 3.

Éprouvé :

  • scripts/tests/test_appels_locataire.py (dans make test) : un faux ansible-playbook consigne la ligne qui part réellement. Pour les 26 machines, trois appels : l'ancien chemin (instance montée), le site (aucune instance, TENANT=), le poste (l'autre locataire monté, TENANT=). Identiques à l'argument près.
  • Témoin : l'ancienne ligne de pool remise en place, le test échoue (pool vide côté site, pool de l'autre locataire côté poste).
  • Le test désigne les dépôts par des liens sans espace : le Makefile prend SETOPS_INSTANCE dans des $(wildcard …) qu'une espace coupe en deux (Espace Chezlepro sur le poste ; le runner du site vit sous /opt/setops). Premier essai faussé par là, les 26 « écarts » venaient de l'instrument.

Pas encore : la conformité horaire du site (conformite-fabric.sh) et eprouver_parefeu.py montent encore un locataire comme instance pour leurs devis — reste de l'étape 3, à part de M3. M4, la preuve par reconstruction, reste à faire et relève de l'exploitant.

2026-10-05 (98) — Matérialisation, M2 : les commandes du site nomment leur locataire

Le chemin (docs/conception-contextes.md, matérialisation M2) : le runner du site crée, rase et confronte le placement d'un locataire nommé, d'après sa face réseau, sans monter son dépôt comme instance.

Fait :

  • make locataire-creer TENANT=<dépôt> CONFIRMER=true remplace SETOPS_INSTANCE=… make flotte-creer. flotte-creer et creer-vm acceptent TENANT= : la liste des machines actives (contexte.py --hotes-actifs) et les paramètres de clonage (contexte.py --parametres-clonage … --hote) viennent de la face. Sans TENANT, l'ancien chemin est inchangé, et l'instance reste exigée.
  • make locataire-raser TENANT=<dépôt> CONFIRMER=true INSTANCE=<nom court> (et raser TENANT=) : raser.py --locataire prend les couples (hôte, VMID) dans la face. Les quatre verrous restent : liste, refus d'un VMID au nom étranger, CONFIRMER, nom tapé.
  • make placement-plan TENANT=<dépôt> : devis_placement.py --locataire lit les valeurs de placement (placement, désormais dans la face) et les ponts des machines publiées.
  • Les deux cibles entrent au runbook du site (P83).

Éprouvé :

  • Lignes de paramètres de clonage identiques à l'octet aux appels de l'ancien chemin, pour les 26 machines ; listes de machines actives identiques.
  • P94, « Créer, raser et placer un locataire par sa face visent les mêmes machines » : chaque intrant comparé à la commande de l'ancien chemin, lancée à part : lister-actifs (création), plan_derive (le plan, via serveurs.py lister) pour raser, proxmox.yml et les ponts dérivés de l'inventaire (placement). Aucun écart sur les deux locataires.
  • test_contexte.py, 116 contrôles : un VMID, un état, un pont, une valeur de placement altérés dans la face se voient, chacun du côté qu'il touche.
  • make -n : TENANT= appelle les nouvelles options sans instance montée ; sans TENANT et sans instance, refus.
  • make verifier conforme, 94/94. Les trois documents comptent 94 preuves.

Pas éprouvé ici : la confrontation au cluster. Depuis le poste, l'API Proxmox refuse la connexion (errno 111) aux deux chemins ; une sortie identique sur un refus ne prouve rien. Elle se fera depuis le runner du site (M3), puis par reconstruction (M4).

2026-10-05 (97) — Matérialisation, M1 : la face publie les paramètres de clonage

La décision de l'exploitant : la seconde voie, fidèle au modèle — le site matérialise les VM d'un locataire depuis sa face réseau, sans monter son dépôt. Plan validé avec lui : M1 la face publie les paramètres de clonage ; M2 des commandes du site qui nomment leur locataire (locataire-creer, locataire-raser, placement-plan TENANT=), à appels identiques ; M3 la reconstruction les emploie ; M4 la preuve par reconstruction.

Le constat : la matérialisation tourne déjà sur le runner du site, mais elle y monte le dépôt du locataire comme instance (SETOPS_INSTANCE=/opt/setops/OPS-x make flotte-creer), et make creer-vm lit son inventaire par inventory_host.py parametres-proxmox.

Fait (M1) : chaque machine de la face porte clonage, exactement ce que parametres-proxmox rend, calculé par la même fonction : VMID, adresse, masque, passerelle, VLAN, pont, stockage, disque, nœud, cœurs, mémoire, DNS d'amorçage, domaine, et les clés d'amorçage — publiques (vérifié : ssh-ed25519 du runner du site et du runner du locataire, aucun bloc privé). Faces republiées.

Éprouvé :

  • P93, « La face publie, machine par machine, les paramètres de clonage de l'inventaire » : la face publiée comparée à la commande parametres-proxmox elle-même, lancée à part pour chacune des 26 machines. Aucun écart.
  • Le témoin a d'abord signalé un écart sur ops-01 : la seule machine à deux clés d'amorçage, sur deux lignes, que ma lecture de la commande coupait au premier retour à la ligne. Le défaut était dans le témoin ; il lit désormais la sortie comme le shell (shlex).
  • test_contexte.py, 110 contrôles : un paramètre altéré dans la face se voit.
  • make verifier conforme, 93/93. Les trois documents comptent 93 preuves.

2026-10-05 (96) — Étape 3 : les pools et les tunnels d'administration lisent la face réseau

Le chemin (docs/conception-contextes.md §6), étape 3, matérialisation, premiers temps : les deux devis du site qui lisaient encore le plan des locataires.

Fait :

  • la face publie l'état de chaque machine (actif ou planifié, lu dans l'inventaire) et les pairs WireGuard entiers (vpn_admin les reprend tels quels ; des clés publiques et des adresses, rien de secret) ; faces republiées ;
  • devis_proxmox_pools.py prend dans la face le nom, le VMID et l'état de chaque machine, au lieu de relire plan/serveurs.yml et de re-dériver les VMID ;
  • vpn_admin.py prend dans la face les pairs et l'index, au lieu de plan/acces.yml et de la nomenclature. Sans face, l'ancienne lecture, et chacun le dit.

Le critère, comparé comme il faut (leçon de (95)) : un git worktree à 2e9b31b, arbre complet, même environnement. Identiques octet pour octet : le plan des tunnels (tunnels() et plan_vpn()), les pools, la frontière, Proxmox, le SDN, le commutateur, l'inventaire du site.

Témoins qui doivent bouger (test_contexte.py, 107 contrôles) : un VMID altéré dans la face est repris par le devis des pools ; des pairs retirés de la face retirent le tunnel du plan (1 pair, puis 0).

Validation : make verifier conforme, 92/92.

Reste de la matérialisation : les outils du poste qui agissent sur le locataire monté (placement, configuration Proxmox, clonage, rasage) lisent son inventaire. Décider qui matérialise — le poste sur l'inventaire du locataire, ou le site sur sa face — est une question pour l'exploitant.

2026-10-05 (95) — Une régression publiée : le devis des pools vide, et une comparaison qui ne testait rien

Ce qui s'est passé. (94) a fait passer la découverte des locataires par la face réseau, qui ne publiait que cinq champs de leur nomenclature. Le devis des pools dérive les VMID par deriver_nomenclature, qui lit aussi fonctions : sans ce champ, chaque pool de locataire s'est retrouvé vide (0 membre, toutes les VM « sans VMID »), sans erreur. Publié dans d0a6002.

Pourquoi la vérification ne l'a pas vu. Pour prouver « identique à HEAD », j'ai lancé l'ancien script (git show HEAD:scripts/X.py) à côté du nouveau. Mais il importait le nouveau module de découverte : pour tous les consommateurs de la découverte, la comparaison comparait le nouveau code à lui-même. Sept « IDENTIQUE » qui ne testaient rien.

Ce que ça a coûté : un devis publié faux. Rien n'applique les pools automatiquement, et le clonage nomme son pool par une lecture directe de la nomenclature, non touchée.

Corrigé :

  • la face publie aussi fonctions ; faces republiées. Les pools retrouvent leurs 13 VM par locataire ;
  • la comparaison refaite correctement : un git worktree à d6591cb (avant toute l'étape 3), arbre complet, même environnement. Les sept sorties du site (commutateur, frontière, Proxmox, SDN ×2, pools, inventaire du site) sont identiques octet pour octet. Une seule différence apparente — instance_active à la frontière — venait du worktree, sans lien instance : la frontière lit le lien et non SETOPS_INSTANCE, une des devinettes de contexte que la conception doit supprimer. Avec le même lien : identique ;
  • garde de régression dans test_contexte.py (105 contrôles) : chaque pool porte autant de VM que le locataire en publie, aucune sans VMID.

La leçon, retenue : comparer à l'ancien code se fait dans un arbre complet, jamais en lançant l'ancien script seul ; vérifier git diff --stat après une édition scriptée avant de comparer ; accompagner chaque « identique » d'un témoin qui doit changer.

Validation : make verifier conforme, 92/92.

2026-10-05 (94) — Étape 3 : le site découvre ses locataires par leur face réseau

Le chemin (docs/conception-contextes.md §6), étape 3. La découverte des locataires (devis_reseau.decouvrir_du_site) alimente tous les devis du site : frontière, Proxmox, SDN, pools, commutateur, et l'inventaire du site. Elle lisait la nomenclature de chaque locataire.

Fait :

  • relevé de ce que le site lit d'une nomenclature : cinq champs (index, categories, reservations, cidr_hote, federe) ; la face réseau les publie (nomenclature) ;
  • decouvrir_du_site part des noms que le site déclare (underlay.tenants) et prend index et zones dans la face publiée de chacun. Ordre, filtre federe, avertissement pour un nom sans dépôt et refus d'un site sans locataire inchangés. Sans face, la nomenclature, et elle le dit ;
  • faces republiées chez les deux locataires.

Le critère : les sept sorties des consommateurs du site sont identiques octet pour octet à celles de la version de HEAD, lancée à part : commutateur (8,8 Ko), frontière (167 Ko), Proxmox (63 Ko), SDN (ses deux formats), pools, inventaire du site (81 Ko). make frontiere-plan contre la frontière réelle : rien à faire.

Ce qui lit encore la nomenclature des locataires, nommé : la découverte de la fédération (decouvrir), qui sert sur le poste à garder les collisions d'index (P21) et à lister les instances — légitime, ce n'est pas le site — et que decouvrir_du_site appelle encore pour son repli ; vpn_admin.py (index pour le tunnel) ; devis_proxmox_pools (nommer les VM, avec le plan). Ils partiront avec la matérialisation et la fin du repli.

Éprouvé :

  • test_contexte.py, 103 contrôles : un index altéré dans la face publiée est celui que le site découvre (17, puis 99) — la découverte lit bien la face.
  • make verifier conforme, 92/92.

2026-10-05 (93) — Étape 3 : la frontière lit la face réseau

Le chemin (docs/conception-contextes.md §6), étape 3. Quatrième consommateur du site, le plus lourd : le devis de la frontière (167 Ko). Il lisait chez chaque locataire ses réseaux d'administration (group_vars), son inventaire (les adresses de chaque groupe, dont les runners et les primaires DNS de tous les locataires), ses variables (conditions seulement_si), son plan/domaines.yml et son plan/acces.yml.

Fait (devis_opnsense.py) : quatre lecteurs, la face d'abord, l'ancienne lecture en repli explicite — cibles_du_tenant() (adresses d'un groupe), admin_du_tenant() (l'intrant), applicable_chez() (le verdict publié), et la face pour le tunnel (présent si un pair est actif) et pour savoir qui publie des zones. Les copies en ligne du réseau et du port du tunnel ne servent plus que le repli ; elles partiront avec lui.

Un faux succès, attrapé avant le commit : la première modification s'est arrêtée sur une assertion (un commentaire séparait deux lignes du motif) avant d'écrire. La comparaison qui suivait a dit « identique » — l'ancien code comparé à lui-même. Relevé, refait ; la comparaison avec l'ancien code se fait désormais contre la version de HEAD, lancée à part.

Le critère : le devis de la frontière est identique octet pour octet depuis la face, par le repli, et par l'ancien code (HEAD). make frontiere-plan contre la frontière réelle : rien à créer, rien à retirer.

Éprouvé :

  • test_contexte.py, 102 contrôles : un verdict basculé dans la face publiée retire sa règle WAN (soumission Postfix 587 : 1 règle, puis 0) — la frontière lit bien la face.
  • make verifier conforme, 92/92.

Restent à basculer : la découverte des locataires (index et catégories, lus par la frontière, le SDN, les pools et le commutateur), le placement et le clonage.

2026-10-05 (92) — Étape 3 : le pare-feu Proxmox lit la face réseau

Le chemin (docs/conception-contextes.md §6), étape 3. Troisième consommateur du site : le devis du pare-feu Proxmox. Il lisait chez chaque locataire son inventaire (adresses, groupes, VMID), ses variables (pour les conditions seulement_si) et ses réseaux d'administration (intrant, plan/acces.yml).

Fait :

  • la face réseau publie le verdict de chaque flux conditionnel, groupe par groupe (conditions), évalué chez le locataire par la même flux_applicable : le site n'a plus à lire ses variables. Chez Technolibre : la soumission de Postfix (465, 587) et le DNS public (5300) actifs ; les ports locaux de la vigie (8080) et de la console (8090) non ;
  • contexte.inventaire_depuis_face() reconstruit l'inventaire minimal que lisent les fonctions de flux (appartenance, adresse, VMID — rien d'autre n'y est lu) ;
  • devis_proxmox_fw.py lit la face publiée : inventaire reconstruit, verdicts, administration. Sans face, l'ancienne lecture, et il le dit ;
  • les faces des deux locataires sont republiées (le champ nouveau).

Le critère : le devis Proxmox complet (63 Ko, les deux locataires) est identique octet pour octet, depuis la face ou par l'ancienne lecture.

Éprouvé :

  • test_contexte.py, 101 contrôles : un verdict basculé dans la face publiée retire sa règle du devis (Postfix 587 : 3 règles, puis 0) — le devis lit bien la face, il ne la contourne pas.
  • make verifier conforme, 92/92 (P92 garde la face publiée à jour, verdicts compris).

Restent à basculer : la frontière, le SDN, les pools, le placement et le clonage.

À commiter : face-reseau.yml republiée chez les deux locataires.

2026-10-05 (91) — Étape 3 : le locataire publie sa face réseau, et le site la lit

Le chemin (docs/conception-contextes.md §6), étape 3, sens locataire → site. Par symétrie avec la fiche du site, le site lit un fichier que le locataire publie, au lieu d'ouvrir ses fichiers internes.

Fait :

  • locataire.publier_face() écrit face-reseau.yml dans le dépôt du locataire (environ 33 Ko, aucun chemin propre au poste, aucune date : deux publications sont identiques, vérifié) ; make face-reseau-publier, au runbook « Matérialiser » juste après flux ; publiée chez OPS-Chezlepro et OPS-Technolibre ;
  • deux consommateurs du site basculent (site_inventaire.py) : les comptes de sauvegarde lisent la clé publique dans la face réseau (plus de group_vars ouverts sous un principal écrit en dur) ; les relations du DNS public y lisent zones, zones signées, adresse et port du primaire (plus d'adresse re-dérivée avec la séquence 1 par défaut). Sans face publiée, l'ancienne lecture reprend, et le dit.

Le critère : l'inventaire du site, sortie complète, est identique avant et après, octet pour octet ; identique aussi par le repli, quand on retire les faces publiées.

Éprouvé :

  • P92, « Chaque locataire a publié sa face réseau à jour » : une face absente ou périmée est refusée. Aucun écart.
  • test_contexte.py, 100 contrôles : une face publiée altérée est vue périmée.
  • make verifier conforme, 92/92. Les trois documents comptent 92 preuves.

Restent à basculer : la frontière, le pare-feu Proxmox, le SDN, les pools, le placement et le clonage, qui lisent encore les fichiers internes des locataires.

À commiter : face-reseau.yml chez les deux locataires.

2026-10-05 (90) — Étape 3, premier consommateur : l'inventaire d'un locataire se génère sans son site

Le chemin (docs/conception-contextes.md §6), étape 3 : les consommateurs lisent les fiches au lieu des fichiers de l'autre, à résultat identique. Premier consommateur : l'instancier.

Le site dépose sa fiche (décision du 2026-10-04) :

  • site.deposer_fiche(locataire) écrit fiche-site.yml dans le dépôt du locataire ; aucune date dedans, deux dépôts d'une même fiche sont identiques (vérifié) ;
  • make fiches-site-deposer (runbook « Tenir un site en état ») dépose chez chaque locataire dont le site est l'hébergeur actif ; le fichier se commite chez le locataire ;
  • déposée chez OPS-Chezlepro et OPS-Technolibre.

L'instancier la lit : la zone du site et ses résolveurs (avec sa règle propre, pas de délégation de sa propre zone), les serveurs de temps, le mode de routage, viennent de fiche-site.yml. Sans fiche, il lit encore le site monté, et le dit (transition).

Le critère, mesuré en mémoire sans rien écrire, pour les deux locataires :

inventaire généré
site monté, avec fiche identique au versionné, à l'octet près
site absent, avec fiche identique à l'octet près
site absent, sans fiche différent (sans temps, sans SDN, sans délégation DNS)

C'est la portabilité d'un locataire : son runner, qui n'a pas le dépôt de son site, génère désormais l'inventaire exact.

Éprouvé :

  • P91, « La fiche du site est déposée à jour, et l'inventaire se génère sans le site » : la fiche déposée est celle que le site calcule aujourd'hui, et l'instancier, lancé dans un processus sans le site, rend l'inventaire versionné. Aucun écart chez les deux locataires.
  • test_contexte.py, 97 contrôles : une fiche déposée altérée est vue périmée, et elle change l'inventaire généré (le test la remet en place).
  • make verifier conforme, 91/91. Les trois documents comptent 91 preuves.

À commiter : fiche-site.yml chez les deux locataires.

2026-10-05 (89) — Étape 2 terminée : les sorties, et le contrat entier tient

Le chemin (docs/conception-contextes.md §6), étape 2, frontière, dernier temps : ce qui sort de chez un locataire vers l'Internet. Avec lui, l'étape 2 est complète : chaque chose que le site lit chez un locataire est publiée par le locataire, et une preuve vérifie que la publication dit exactement ce que le site en tire.

Fait :

  • resoudre_flux.py relève, avec les mêmes conditions que les entrées (groupe opérationnel, flux_applicable), les sorties vers l'extérieur de chaque machine, et les publie dans son *.connectivite.json (sorties_externes : protocole, port, rôle). Ses machines acceptent tout en sortie : il ne les relevait pas, la frontière les filtre ;
  • flux régénérés chez les deux locataires : les 13 fichiers de connectivité de chacun gagnent le champ, aucun .nft ne bouge. La sonde connectivite l'ignore ;
  • la face réseau reprend ces sorties ; verifier_sorties(site, locataire) les confronte, machine par machine, aux règles de sortie de la frontière vers « tout sauf l'interne », et signale toute sortie vers une autre destination.

Éprouvé :

  • P90, « Ce qui sort d'un locataire vers l'Internet, la frontière le tient de lui » : aucun écart. Comparés chez Technolibre : 43 sorties publiées (machine × port), 7 règles de sortie (le système en 80, 443 et ICMP ; Postfix en 25 ; PowerDNS et le résolveur en 53). Les deux sorties fausses de serveur_ops étaient déjà retirées ((85), (87)).
  • test_contexte.py, 91 contrôles : une sortie retirée ou ajoutée est vue à la frontière.
  • make verifier conforme, 90/90. Les trois documents comptent 90 preuves.

Le bilan de l'étape 2 : sept preuves (P84 à P90) ; trois choses que le locataire jetait ou ne relevait pas et publie désormais (les clients nommés d'un port public, le drapeau poste, ses sorties) ; deux sorties fausses retirées de la frontière, dont un passage entre locataires ; une garde sur la passerelle de zone. Prochaine étape (3) : les consommateurs du site lisent les fiches au lieu des fichiers internes, à résultat identique.

2026-10-05 (88) — La passerelle n'est pas une porte : une garde sur l'API Proxmox

Le reste ouvert de (87) : chez un locataire en SDN, la passerelle de chaque zone est une interface de l'hyperviseur, dans le VRF du locataire. Une API Proxmox qui y répondrait serait jointe sans passer par la frontière. Fermée aujourd'hui (ECONNREFUSED).

Option choisie par l'exploitant : tester la propriété, depuis les machines des locataires, plutôt que lire net.ipv4.tcp_l3mdev_accept sur les hyperviseurs. Cela aurait demandé un nouveau rôle sur des machines que Set-OPS ne gère pas (Prometheus les tire ; leur node_exporter 1.5 n'a pas de collecteur sysctl).

Fait (nftables_baseline) :

  • la sonde connectivite, qui tourne chaque minute sur chaque machine, essaie aussi les ports de nftables_baseline_connectivite_passerelle_interdits ([8006]) sur la passerelle par défaut. ouvert → CRITIQUE (« PASSERELLE OUVERTE ») ; refusé, rejeté ou silencieux → attendu ; passerelle introuvable → avertissement, une garde qui ne s'exerce pas le dit. Ici, ouvert prouve bien quelque chose : entre une machine et sa passerelle, aucun relais ;
  • elle couvre toute cause : le réglage du noyau, un pare-feu d'hôte retiré, un service qui se mettrait à écouter partout ;
  • CONNECTIVITE_PASSERELLE=<adresse> vise une autre « passerelle » : le contrôle négatif de la garde ; nouvelle mesure de performance passerelle_ouverte.

Éprouvé, sonde rendue et lancée à la main :

  • cas normal sur ops-01 et web-dorsal-01 (Technolibre), web-frontal-01 (Chezlepro) : la passerelle de zone refuse le 8006, sonde verte, ses mesures habituelles intactes ;
  • au site (site-mon-01, passerelle = la frontière) : verte, aucune fausse alarme ;
  • témoin négatif (443 interdit, « passerelle » = la forge du site, qui répond) : CRITIQUE, code 2.

À déployer : nftables_baseline (groupe serveur_durci), chez les deux locataires et au site. Le ruleset nftables ne change pas ; seule la sonde change.

Validation : ansible-lint (0 violation), make verifier conforme, 89/89.

2026-10-05 (87) — Une règle qui disait qu'un locataire pouvait créer des VM

La question de l'exploitant : « un flux est permis entre une machine d'un tenant et l'API Proxmox ? » Déclaré : oui. roles/serveur_ops/meta/flux.yml portait une sortie 8006 vers externe, « API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant », et la frontière en tirait une règle pour le runner de chaque locataire.

Mesuré (2026-10-05) : non. Depuis trois machines de deux locataires, vers les neuf adresses des trois hyperviseurs et vers leur passerelle de zone, avec la forge du site pour témoin (200) :

  • 192.168.11.4x:8006 et 10.0.4.4x:8006, et leur 22 : ECONNREFUSED. La frontière refuse ; son journal porte les tentatives en block, par le refus audible du lien de transit. La règle déclarée visait « tout sauf les plages privées », et les hyperviseurs y sont : elle ne laissait passer aucun paquet ;
  • la passerelle de zone (10.23.19.1, l'hyperviseur lui-même dans le VRF du locataire) : ECONNREFUSED, sans passer par la frontière ; c'est l'hyperviseur qui refuse. Cause probable, non vérifiée (poste hors site, sans accès SSH aux hyperviseurs) : pveproxy écoute dans le VRF par défaut et net.ipv4.tcp_l3mdev_accept vaut 0.

Fait : la sortie est retirée de serveur_ops, avec la mesure en trace. Une règle morte qui affirme un pouvoir qu'aucun locataire ne doit avoir. Matérialiser reste au site (serveur_ops_site → SETOPS_FABRIC, 8006, inchangé). Registre des flux régénéré ; flux des locataires inchangés.

Simulé (make frontiere-plan) : 5 retraits — la règle 8006 des deux locataires et celle de la console du site (SETOPS_SITE_SERVEUR_OPS, morte pour la même raison), et les deux alias SERVEUR_OPS des locataires, devenus orphelins. Rien d'autre sur 335 règles et 12 routes.

Reste ouvert : le chemin par la passerelle de zone contourne la frontière. Il est fermé aujourd'hui ; la garde qui le surveillera est à décider avec l'exploitant.

Validation : make verifier conforme, 89/89.

2026-10-05 (86) — Étape 2 : la frontière, troisième temps (l'administration)

Le chemin (docs/conception-contextes.md §6), étape 2, frontière, troisième temps, première moitié : ce que l'administration atteint chez un locataire. Les sorties suivent.

Fait (scripts/contexte.py) :

  • la face réseau dit, pour chaque entrée ouverte à tous, si son flux l'ouvre aussi au poste (poste, vrai par défaut). Elle le lit dans le catalogue des flux du moteur, que le runner du locataire possède aussi : aucun changement chez le locataire. Une seule entrée est fermée au poste, le 25 de Postfix (un flux de serveur à serveur) ;
  • verifier_administration(site, locataire) confronte, pour chaque classe d'administration que la frontière range par interface (gestion, VPN), ce qu'elle atteint machine par machine avec ce que la face lui ouvre : le SSH, les entrées qui nomment l'administration parmi leurs sources, les entrées publiques ouvertes au poste. Le tunnel du locataire doit exister si la face en a un, et viser toute sa flotte ; ses ports (22 et 443) sont une politique du site, écrite dans le devis, pas une donnée du locataire.

Relevé, non modifié : le devis justifie poste: false sur le 25 par « deux couches qui ne déclarent pas la même politique » (mesure du 2026-08-09 : la bordure autorisait, nftables refusait). Depuis le 2026-09-29, nftables accepte le 25 de partout (un port public ouvert à tous) : l'administration y accède donc par la machine, pas par la bordure. La bordure est la plus stricte, sans danger ; mais les deux couches divergent de nouveau, dans l'autre sens.

Éprouvé :

  • P89, « Ce que l'administration atteint chez un locataire, la frontière le tient de lui » : aucun écart chez les deux locataires.
  • test_contexte.py, 86 contrôles : une administration altérée de quatre façons (le 25 ouvert au poste, les sources d'administration retirées d'une machine, un SSH d'administration retiré, le tunnel retiré) est vue à la frontière.
  • make verifier conforme, 89/89. Les trois documents comptent 89 preuves.

2026-10-05 (85) — Un passage entre locataires que rien ne justifiait

Trouvé en confrontant la frontière à la face réseau (sous-étape 3 de la frontière, docs/conception-contextes.md). Le relevé des sorties de Technolibre portait une règle anormale : son runner (serveur_ops) pouvait sortir en 443 vers tout le supernet de Chezlepro, et celui de Chezlepro vers tout celui de Technolibre, avec pour raison « cloner le génome depuis la forge du site ».

Cause : roles/serveur_ops/meta/flux.yml déclarait cette sortie vers le pair voisins_site, écrite le 2026-08-24, quand la forge du génome vivait chez un locataire voisin. Elle est au site depuis ; le pair n'avait pas suivi. Vu d'un locataire, voisins_site désigne les autres locataires : la frontière, censée refuser par défaut entre locataires, leur ouvrait un passage.

Ce que ça exposait : peu, en pratique. Côté destination, les machines n'admettent le 443 que depuis leur propre flotte et l'administration, dans leurs nftables comme dans le pare-feu Proxmox. Mais une couche d'isolement ne tenait plus. Et la règle ne servait à rien : le clonage du génome passe par la règle que déclare serveur_forge_site (chaque locataire vers 10.37.33.11, en 443).

Fait : la sortie est retirée de serveur_ops, avec une trace dans le fichier. Registre des flux régénéré (une ligne de moins) ; les flux générés des deux locataires ne changent pas (une sortie n'y figure pas : leurs machines acceptent tout en sortie).

Simulé (make frontiere-plan) : 2 règles périmées à retirer, une par locataire, et rien d'autre sur 338 règles et 12 routes. Application à la frontière : décision de l'exploitant (make frontiere-appliquer CONFIRMER=true).

Même vestige, inactif : serveur_artefacts déclare une sortie 3142 vers voisins_site (« prendre le cache du site comme amont »). Le rôle n'est déployé chez aucun locataire (seul le laboratoire le porte) : aucune règle n'en sort aujourd'hui. Signalé, non modifié.

Validation : make verifier conforme, 88/88.

2026-10-04 (84) — Étape 2 : la frontière, second temps (les entrées publiques)

Le chemin (docs/conception-contextes.md §6), étape 2, frontière, second des trois temps : ce qui entre chez un locataire depuis l'Internet.

Fait (scripts/contexte.py) :

  • la face réseau porte le port de son tunnel d'administration, dérivé de son index par inventory_rules.port_vpn_locataire() ;
  • verifier_entrees_publiques(site, locataire) confronte, machine par machine, les entrées que la face ouvre à tous (0.0.0.0/0) avec les règles du WAN (alias de destination développés en adresses, le supernet en toutes ses machines), les redirections (depuis l'adresse que la fiche du site attribue, vers la machine et son port) et la règle du tunnel ;
  • la construction du devis de la frontière est partagée (_devis_frontiere) entre les deux temps déjà faits.

Le port public d'un locataire est son port local : seul serveur_dns_public, rôle du site, déclare un port_public. La face ne porte donc pas de port public ; la vérification le dirait si une redirection d'un locataire en avait un autre.

Relevé en chemin, non corrigé ici : devis_opnsense.py recalcule le port du tunnel (52000 + index) au lieu d'appeler port_vpn_locataire(). Une copie de plus de la même formule ; elle disparaîtra quand la frontière lira la face réseau (étape 3).

Éprouvé :

  • P88, « Ce qui entre depuis l'Internet chez un locataire, la frontière le tient de lui » : aucun écart chez les deux locataires. Comparés chez Technolibre : 19 entrées ouvertes à tous (l'ICMP « fragmentation nécessaire » sur ses 13 machines, et 6 ports publics), 6 redirections, 8 règles WAN dont celle du tunnel (52023).
  • test_contexte.py, 79 contrôles : une entrée publique altérée de quatre façons (un port fermé, un port ouvert en trop, l'adresse publique, le port du tunnel) est vue à la frontière.
  • make verifier conforme, 88/88. Les trois documents comptent 88 preuves.

2026-10-04 (83) — Étape 2 : la frontière, premier temps (les identités)

Le chemin (docs/conception-contextes.md §6), étape 2, dernier consommateur : la frontière. Découpée en trois temps avec l'exploitant : les identités (ici), les entrées publiques (redirections, règles depuis Internet), les sorties et l'administration.

Fait (scripts/contexte.py) :

  • la face réseau porte ses zones : un sous-réseau par catégorie de sa nomenclature, dérivé de son index par sous_reseau_de, comme la frontière et le SDN le dérivent ;
  • verifier_frontiere(site, locataire) confronte les identités par lesquelles le devis de la frontière désigne le locataire à ce que sa face réseau et la fiche du site portent : le supernet (ses zones), l'administration (que la frontière range par interface, gestion et VPN), le tunnel, chaque alias de groupe (8 chez chaque locataire : les groupes qui ont des flux de bordure), une route par zone, et la traduction sortante par l'adresse publique que le site attribue ;
  • les notes que le devis imprime (« flux externe sans port_public », « sortie vers un groupe absent de ce site ») sont retenues pendant la vérification : utiles à qui lit le devis, du bruit dans une preuve. Elles serviront au temps suivant.

Éprouvé :

  • P87, « La frontière désigne chaque locataire par les identités qu'il publie » : aucun écart chez les deux locataires.
  • test_contexte.py, 73 contrôles : une identité altérée de cinq façons (zones, administration déclarée, tunnel, membres d'un groupe, adresse publique attribuée) est vue à la frontière. La vérification compare bien les 8 alias de groupe de Technolibre, contrôlé à part avant d'écrire la preuve.
  • make verifier conforme, 87/87. Les trois documents comptent 87 preuves.

2026-10-04 (82) — Étape 2 : les flux de chaque machine, et une information que le locataire jetait

Le chemin (docs/conception-contextes.md §6), étape 2, sens locataire → site : les flux. La face réseau porte désormais les flux de chaque machine, ceux que consomme le pare-feu Proxmox. Ceux de la frontière (interfaces, alias, traduction d'adresses, flux entre site et locataires) restent à faire.

Fait (scripts/contexte.py) :

  • la face réseau reprend, pour chaque machine, ce que le locataire a déjà résolu pour ses propres pare-feux (flux-genere/<hôte>.connectivite.json) : chaque entrée admise et ses réseaux d'administration ;
  • verifier_flux(site, locataire) compare, machine par machine et port par port, les sources admises par nftables (le locataire) et par Proxmox (le devis du site). Les deux écrivent la même politique autrement : règle par groupe contre sources réunies, ensembles nommés contre adresses en clair, « Internet » (0.0.0.0/0 contre « tout sauf RFC 1918 », doublé d'une règle « administration (poste) »), l'administration à part, l'ICMP nommé différemment. Ces différences sont neutralisées, et décrites dans le code.

Ce que la première mesure a trouvé : 3 écarts par locataire, tous sur edge-mta-01 (25, 465, 587). Ce n'était pas une divergence de politique, mais une information perdue : quand un port est à la fois public et ouvert à des clients internes ([externe, client_smtp]), resoudre_flux.py écrivait 0.0.0.0/0 et jetait la liste des clients nommés. Juste pour l'hôte, qui accepte de partout ; insuffisant pour Proxmox, dont l'Internet exclut les plages privées et qui doit admettre ces clients nommément. Telle quelle, la face réseau n'aurait pas suffi au site.

Corrigé à la source (décision de l'exploitant) : resoudre_flux.py publie, à côté de 0.0.0.0/0, les sources_declarees. Flux régénérés chez les deux locataires : seul edge-mta-01.connectivite.json change, aucun .nft ne bouge. La sonde connectivite, qui lit ces fichiers sur les machines, ignore le champ nouveau ; le fichier sera recopié au prochain passage de nftables_baseline, sans effet.

Éprouvé :

  • P86, « Machine par machine, le locataire et Proxmox admettent les mêmes entrées » : aucun écart chez les deux locataires.
  • test_contexte.py, 66 contrôles : des flux altérés de quatre façons (une source retirée, un port ajouté, les sources déclarées effacées, l'administration retirée) sont vus en écart.
  • make verifier conforme, 86/86. Les trois documents comptent 86 preuves.

2026-10-04 (81) — Étape 2, seconde moitié (les faits) : la face réseau du locataire

Le chemin (docs/conception-contextes.md §6), étape 2, sens locataire → site. La face réseau se livre en deux temps vérifiés : d'abord les faits que le site lit chez le locataire (ici), ensuite ses flux résolus, confrontés aux devis de la frontière et de Proxmox (à suivre).

Fait (scripts/contexte.py) :

  • locataire.face_reseau() publie, calculé chez le locataire : son index ; ses machines (adresse, groupes, paramètres de matérialisation proxmox_*) ; ses groupes ; son administration (l'intrant nftables_admin_ssh, son tunnel, ses pairs WireGuard avec leurs clés publiques) ; ses zones publiques (zones, signées, adresse et port de son primaire) ; la clé publique de sa sauvegarde. Elle porte l'empreinte de ses six sources.
  • verifier_face(site, locataire) interroge les consommateurs du site eux-mêmes, le site monté : la découverte des locataires (devis_reseau), le devis du pare-feu Proxmox (la flotte, chaque groupe qu'il utilise, l'administration), la frontière (admin_de), et l'inventaire du site (relations du DNS public, comptes de sauvegarde).
  • --face <locataire> l'affiche ; --verifier-faces confronte chaque couple.

Un faux écart, corrigé avant le commit : la première comparaison exigeait un ensemble d'adresses par groupe dans le devis Proxmox, et en signalait 19 absents par locataire. Le devis ne garde que les ensembles que ses règles utilisent (13 chez chacun) ; la vérification se fait désormais dans ce sens.

Deux lectures fragiles du site, que la face réseau fera disparaître à l'étape 3 : site_inventaire.py lit la clé de sauvegarde sous inventories/principal/ écrit en dur, et re-dérive l'adresse du primaire DNS avec la séquence 1 par défaut (juste aujourd'hui, les primaires sont des -01). La face réseau prend l'une dans le dossier d'inventaire réel du locataire, l'autre dans son inventaire.

Ce que la vérification ne couvre pas encore : la matérialisation est recopiée de l'inventaire, que le clonage lit par Ansible, sans consommateur Python à confronter ; les pairs WireGuard ne sont vérifiés qu'à travers le tunnel qu'ils ouvrent ; les flux arrivent dans le second temps.

Éprouvé :

  • P85, « La face réseau du locataire dit exactement ce que le site en tire » : 2 faces, aucun écart.
  • test_contexte.py, 60 contrôles : les deux faces réelles concordent, et une face altérée de huit façons (index, adresse d'une machine, membres d'un groupe, administration déclarée, tunnel, zones publiques, primaire DNS, clé de sauvegarde) est vue en écart à chaque fois.
  • make verifier conforme, 85/85. Les trois documents comptent 85 preuves.

2026-10-04 (80) — Étape 2, première moitié : la fiche du site, et la preuve qu'elle dit vrai

Le chemin (docs/conception-contextes.md §6), étape 2 : générer les fiches à côté de l'existant, et prouver qu'elles disent exactement ce que les lectures croisées produisent. Première moitié : le sens site → locataire. La face réseau du locataire suit.

Fait (scripts/contexte.py) :

  • site.fiche_pour(locataire) réunit ce que trois canaux portaient sans fiche : ce que le site attribue (index, adresse publique), ce qu'il offre (les intrants de service, calculés par site_intrants.contrat()), sa racine de confiance (le certificat lui-même), et ce que l'instancier lisait directement dans le site (zone et résolveurs à déléguer, serveurs de temps, mode de routage), par les mêmes fonctions qu'aujourd'hui. La fiche porte l'empreinte de ses quatre sources.
  • verifier_fiche(site, locataire) confronte la fiche à ce que le locataire porte : ses copies (la table de site_intrants), sa racine, et son inventaire généré (chrony_serveurs sur chaque machine, la délégation DNS sur la machine qui résout, le rattachement SDN).
  • locataire.domaine_interne(), lu comme le lit instancier._domaine_interne. La première version le cherchait dans 00-instance.yml ; il est dans 10-intrants.yml. La comparaison passait quand même, parce que les domaines du site et du locataire diffèrent : relevé et corrigé avant le commit.
  • _monter(site, locataire) : les modules d'avant lisent « le site monté » ; la fiche leur désigne le site voulu le temps de l'appel, puis remet l'environnement, même sur exception. Passerelle de transition, jusqu'à l'étape 3.
  • python3 scripts/contexte.py --fiche <locataire> affiche la fiche ; --verifier-fiches confronte chaque couple dont le site est l'hébergeur actif (un site de reprise ne dépose rien tant que la bascule n'a pas eu lieu).

Rien n'est encore déposé ni consommé : la fiche est calculée, pas écrite chez le locataire. Le dépôt et la bascule de l'instancier sont l'étape 3, à inventaire identique.

Éprouvé :

  • P84, « La fiche du site dit exactement ce que chaque locataire porte » : 2 fiches, SITE-Chezlepro → OPS-Chezlepro et → OPS-Technolibre, aucun écart.
  • test_contexte.py, 50 contrôles : les deux fiches réelles concordent, et une fiche altérée de sept façons (index, adresse publique, intrant de service, racine, serveurs de temps, délégation DNS, mode de routage) est vue en écart à chaque fois.
  • make verifier conforme, 84/84. AGENTS.md, devis-services.md et responsabilites-locataire-hebergeur.md comptent désormais 84 preuves.

2026-10-04 (79) — Étape 1 : le module contexte.py, que rien n'utilise encore

Le chemin (docs/conception-contextes.md §6), premier pas : le tronc commun et les deux classes existent, testées, sans qu'aucun script ne change.

Fait :

  • scripts/contexte.py : Ecosysteme (nom, dépôt, plan, voûte et sa clé, filiation), Site et Locataire, qui surchargent l'index, la voûte et l'inventaire (un script pour le site, le hosts.yml généré pour le locataire). Relations : site.locataires(), les absents nommés par locataires_absents(), et locataire.site(), l'hébergeur actif que nomme parente.yml. Un locataire peut figurer chez deux sites : SITE-Technolibre, site de reprise, déclare les mêmes deux locataires que SITE-Chezlepro.
  • contexte.actif() : le fichier contexte à la racine du moteur fait foi (site:<dépôt> ou locataire:<dépôt>) ; sans lui, les indices d'avant (SETOPS_*, liens), et le locataire l'emporte quand les deux sont montés, comme la console d'aujourd'hui. Un contexte nommé qui ne désigne rien est un refus, pas un contexte vide.
  • Le module réutilise voutes.cle_de, inventory_rules.dossier_inventaire et underlay.allocations au lieu d'en refaire des copies.
  • /contexte au .gitignore : propre à chaque machine, comme instance/ et underlay.yml.

Éprouvé : scripts/tests/test_contexte.py, 40 contrôles, inscrit dans make test. Sur une arborescence fabriquée (un site, des locataires, un dépôt ambigu refusé), puis sur les dépôts réels : chaque locataire déclaré par un site porte l'index que ce site lui attribue (17 et 23, chez les deux sites) et nomme un hébergeur qui existe. Les tests savent échouer : le module altéré (le site l'emporte sur le locataire) en fait échouer deux.

Ce que make verifier a trouvé, et corrigé avant ce commit :

  • P34 et P48 étaient rouges depuis (78), publié sans make verifier : la page de conception ne déclarait pas son lecteur, et la carte d'orientation comptait toujours 45 documents. La page déclare son lecteur ; la carte en compte 46 et la range dans le parcours « Le modèle », après meta-classe.md, que la page cite désormais (la méta-classe est le mécanisme de la classe Locataire).
  • P41 refusait la première version du module : il relisait SETOPS_INSTANCE lui-même. Il passe par les deux résolutions qui font foi, inventory_rules.instance_courante() et underlay.chemin().

python3 scripts/contexte.py sur le poste dit ce que les indices d'avant désignent : locataire OPS-Chezlepro ET site SITE-Chezlepro. C'est l'ambiguïté qu'un sélecteur lèvera.

2026-10-04 (78) — Conception : un tronc commun, deux classes (SITE et LOCATAIRE)

Demande de l'exploitant : en finir avec les « classes à tout faire ». Deux grandes classes, SITE et LOCATAIRE, qui héritent du tronc commun Set-OPS et surchargent ce qui dépend de leur contexte ; des consoles cohérentes avec leur contexte.

Page arrêtée avec lui : docs/conception-contextes.md. Rien n'est encore construit.

Le relevé qui la fonde :

  • 33 scripts devinent leur contexte, à partir de cinq indices (liens instance/ et underlay.yml, variables SETOPS_*, dossiers frères, SITE-*). Trois défauts passés en viennent, dont le make ci mélangé du jour.
  • Le site informe le locataire par trois canaux, sans fiche : des copies à la main (douze valeurs, un certificat, une valeur hors contrat), une lecture directe du site par l'instancier (mesurée : sans le site monté, l'inventaire de Technolibre perd son serveur de temps, son SDN et sa délégation DNS), et le génome.
  • Le site fouille six fichiers internes du locataire et recalcule ses flux avec sa propre version du moteur.

Décisions : Ecosysteme → Site, Locataire ; un contrat en deux fiches générées (la fiche du site pour chaque locataire, déposée par le site dans le dépôt du locataire ; la face réseau du locataire, flux déjà résolus compris) ; le poste devient un sélecteur ; le contexte actif se nomme dans un fichier contexte ; SITE-Modele à côté d'OPS-Modele ; un parente.yml pour le site. On commence par le moteur.

Ce que ça donne en plus : la portabilité. Un locataire qui change de site reçoit la fiche du nouveau, et son dépôt ne contient plus rien d'interne à l'ancien.

2026-10-04 (77) — Release v2026.10.04 : les deux locataires reconstruits sur f42d30b

Pourquoi une seconde fois le même jour. (72) prouvait 5340220. Ensuite, (73) et (74) ont touché client_backup, et setops-restaurer avec lui, l'outil même dont la reconstruction se sert pour remettre l'état. La validation de (76) a trouvé un test que (74) avait cassé. L'exploitant a demandé une nouvelle preuve avant toute release, avec les dernières corrections incluses.

Étape Technolibre Chezlepro
sauvegarder + raser 1 min 1 min
créer, inséminer, armer 7 min 8 min
monter (déploiement, état remis, valider) 32 min 32 min
pare-feu (13/13 VM, 0 critique) 2 min 2 min
total 42 min 42 min

Journaux : 3 151 et 3 155 lignes, aucun failed, aucun hôte injoignable, aucun fatal. Chez les deux, les 7 jeux d'état sont restaurés.

Relu sur les machines neuves, en plus de la grille de (72) (versions, épingles, vigie, vues, filtres Alloy, synthèse Grafana, cloud-init absent) :

  • (74) : --retry-lock 30m dans les 4 scripts de client_backup, sur les 7 nœuds sauvegardés de chaque locataire ;
  • (73) : web-dorsal-01 dit « RIEN A SAUVEGARDER » et « RIEN A RESTAURER » ; ce sont les deux seuls avertissements sur 183 services, et aucun hôte n'est en problème ;
  • le contrôle de restauration a tourné sur la flotte neuve : sur les 6 nœuds qui ont des données, restauration réelle, contenu recomparé ;
  • Chezlepro : le mot de passe de cn=icingaweb2, tourné plus tôt dans la journée, a survécu à la reconstruction. L'annuaire restauré et la vigie s'accordent, et la liaison LDAPS réussit.

Ce qui n'a pas été exercé : aucun script n'a eu à attendre un verrou (aucune mention dans les journaux). L'attente reste prouvée par l'essai sur dépôt jetable de (74) et par test_restauration.

Étiquette : v2026.10.04, signée par clé SSH comme v2026.08.21, posée sur ce commit. Le code prouvé est f42d30b ; ce commit n'ajoute que cette entrée. Limite connue, écrite dans l'étiquette : make ci (le modèle public) n'est pas conforme ; le modèle sera refait dans son propre dépôt, OPS-Modele.

2026-10-04 (76) — Validation avant release : un test cassé par (74), trois défauts d'outillage

Contexte : avant d'étiqueter, make verifier et make ci. La validation a trouvé une régression introduite par (74), que sa propre validation n'avait pas vue : --syntax-check et ansible-lint seulement, pas make test.

Corrigé :

  • scripts/tests/test_restauration.py : le test rendait restaurer.sh.j2 sans client_backup_attente_verrou (ajoutée en (74)) et échouait au rendu. Il la fournit désormais, et son faux restic exige --retry-lock avant la sous-commande : l'attente du verrou est couverte, plus seulement tolérée.
  • scripts/tests/test_frontiere_refus.py (depuis le 2026-10-01) : il lisait le WAN sous interfaces.wan, une clé que le devis ne produit pas (c'est if_wan). Il retombait sur « wan », juste au site par chance.
  • scripts/prouver.py : en mode --verifier, le harnais comptait les échecs (« 6 echec ») sans les nommer. Il nomme maintenant chaque preuve en échec, avec son détail.
  • Makefile : sur un clone sans aucune clé de voûte (la CI de la forge), l'export posait ANSIBLE_VAULT_IDENTITY_LIST= vide ; Ansible le lisait comme un fichier de mot de passe et refusait (« can not be a directory »). make ci mourait à sa première commande. Vide, la variable n'est plus exportée. Sur le poste, la valeur exportée est identique (mêmes 5 clés, comparée avant et après).

Pas corrigé ici, et nommé : make ci reste non conforme (77 OK, 6 échecs : P02, P20, P74, P79, P81, P82). Le modèle public exemples/modeles/socle date d'avant la séparation site / locataire et ne satisfait pas les gardes ajoutées depuis août. Décision de l'exploitant : le modèle aura son propre dépôt, OPS-Modele, comme les locataires. Aucune preuve n'a été assouplie pour le vieux modèle.

Validation : make verifier conforme (83/83), ansible-lint (0 échec). La release attend une reconstruction qui prouve (73), (74) et ces corrections.

2026-10-04 (75) — Exporter les voûtes une seconde fois ne bute plus sur la première

Signalé par l'exploitant : make voutes-exporter VERS=<clé> a refusé (« existe deja. On n'ecrase pas une sauvegarde »). Il a dû retirer l'ancienne archive à la main, puis relancer.

Cause : le nom par défaut était fixe (setops-voutes-<poste>.tar, et de même setops-cles-<poste>.tar[.gpg]). La docstring promettait setops-voutes-<date>-<poste>, mais le code ne datait pas. Les exports précédents n'avaient passé que parce qu'on datait soi-même par NOM=.

Fait (scripts/exporter_cles.py, scripts/exporter_voutes.py) :

  • le nom porte la date (<nom>-AAAA-MM-JJ-<poste>), et l'heure en plus au second export du même jour. Le refus de l'écrasement reste, en dernier recours ;
  • voûtes : si la dernière archive de ce poste sur le support porte exactement ces voûtes (mêmes chemins, mêmes empreintes), rien n'est écrit (« A JOUR »). Une copie de plus n'ajoute rien ;
  • le LISEZ-MOI déposé sur la clé et docs/sortir-les-cles-du-poste.md restaurent la plus récente archive (ls -t … | head -1). Leur setops-voutes-*.tar, avec plusieurs archives sur le support, passait plusieurs noms à tar -xf.

Éprouvé sur un support simulé (scratchpad, effacé ensuite) : premier export daté ; second export identique → « A JOUR », rien d'écrit ; une voûte changée (archive précédente altérée) → nouvelle archive horodatée à côté de la première.

Le LISEZ-MOI déjà sur la clé garde l'ancienne formule ; make cles-compagnons VERS=<clé> le remplace sans refaire l'archive.

2026-10-04 (74) — Un dépôt verrouillé n'est plus une panne

Constaté en vérifiant (73) : lancées ensemble sur web-dorsal-01, la vérification du dépôt et le contrôle de restauration se sont gênés. Le restic check tenait le verrou exclusif, et la sonde a rapporté « AUCUN INSTANTANE lisible » (critique) pendant 20 s. Les minuteurs peuvent faire de même sans personne : Persistent=true rattrape au démarrage tout ce qui a été manqué, d'un coup, et le contrôle du dimanche (04:00 + jusqu'à 60 min) peut croiser la vérification de 05:00. restic refuse aussitôt un dépôt verrouillé ; la sonde concluait à l'absence, ou le contrôle à la corruption.

Fait (client_backup) : les quatre scripts qui touchent au dépôt (sauvegarde, vérification, contrôle de restauration, setops-restaurer) appellent restic à travers une fonction qui ajoute --retry-lock {{ client_backup_attente_verrou }} (30 min par défaut ; le plus long des occupants est le contrôle de restauration). Les unités sont oneshot, sans délai d'expiration : l'attente n'est pas coupée.

Éprouvé sur un dépôt jetable (/var/tmp, web-dorsal-01 de Technolibre, retiré ensuite), le verrou tenu par un vrai processus restic, scripts rendus avec le rapport remplacé par un echo :

sans attente avec attente
contrôle de restauration face à un verrou partagé (backup en cours) critique « DEPOT CORROMPU OU ILLISIBLE », 0 s attend 17 s, puis vérifie
vérification du dépôt face à un verrou exclusif (check en cours) critique « AUCUN INSTANTANE lisible », 1 s attend, puis « OK », 8 s

Deux essais d'abord ne prouvaient rien et ont été écartés : un dépôt vide se vérifie trop vite pour qu'un verrou dure, et backup --dry-run n'en pose aucun.

Validation : --syntax-check de client_backup.yml, ansible-lint (0 violation).

2026-10-04 (73) — Un instantané vide dit pourquoi

Demande de l'exploitant : l'avertissement convient quand il n'y a rien à sauvegarder ; seul le message doit dire lequel des cas on voit. La sonde sauvegarde de web-dorsal-01 écrivait « N'EMPORTE RIEN … Legitime si ce noeud n'a pas encore de donnees — a confirmer » : la machine ne tranchait pas, et laissait l'humain aller voir.

Fait (client_backup) : devant un instantané vide, les deux sondes comptent ce que les chemins sauvegardés contiennent sur le nœud même (la liste de setops-sauvegarde).

  • sauvegarde : rien sur le nœud → avertissement « RIEN A SAUVEGARDER » ; des fichiers tous arrivés après l'instantané → avertissement « DONNEES ARRIVEES DEPUIS » (la prochaine sauvegarde les emportera) ; des fichiers déjà là quand l'instantané a été pris → critique « N'EMPORTE RIEN … La sauvegarde manque ses donnees ». Ce dernier cas était un avertissement : c'est une vraie panne.
  • restauration : avertissement dans les deux cas, message « RIEN A RESTAURER » ou renvoi à la sonde sauvegarde.

La sonde côté dépôt du site (serveur_backup) garde son message : le site ne voit pas le disque du nœud, il ne peut pas trancher.

Éprouvé sur web-dorsal-01 (Chezlepro), scripts rendus avec le rapport à Icinga remplacé par un echo : /srv/webapp vide → « RIEN A SAUVEGARDER » et « RIEN A RESTAURER » ; un chemin aux fichiers anciens → critique ; un fichier créé après l'instantané → « DONNEES ARRIVEES DEPUIS ».

Validation : --syntax-check de client_backup.yml, ansible-lint (0 violation).

2026-10-04 (72) — Les deux locataires reconstruits : les correctifs de (62) à (71) tiennent

La question de l'exploitant : une reconstruction ramène-t-elle les problèmes corrigés ces deux derniers jours ? Il a lancé les deux (make reconstruire-locataire TENANT=<locataire> CONFIRMER=true ARMER=oui). Aucun arrêt, aucun failed dans les deux journaux.

Étape Technolibre Chezlepro
sauvegarder + raser 1 min 1 min
créer, inséminer, armer 7 min 8 min
monter (déploiement, état remis, valider) 34 min 31 min
pare-feu (13/13 VM, 0 critique) 2 min 2 min
total 44 min 42 min

Chez les deux, les 7 jeux d'état ont été restaurés depuis l'instantané pris avant de raser (Nextcloud, PostgreSQL, rspamd, OpenLDAP, courriel, step-ca, web dorsal).

Relu sur les machines neuves (installées par les runners, sans le cache du poste) :

  • Keycloak 26.7.5, oauth2-proxy v7.15.5, Nextcloud 34.0.4 (hors maintenance, données revenues) : (67) et (68) ;
  • les 21 préférences apt posées ; step-cli 0.31.0, icingadb-redis 8.2.10, Grafana 13.2.3, Loki 3.7.8, Alloy 1.20.1 : (65) et (66) ;
  • cloud-init absent ;
  • la vigie sur sa base (config_backend = "db"), aucune erreur de migrations : (64) ;
  • Loki en warn et les 9 filtres Alloy des sondes : (62) et (63) ;
  • le tableau de synthèse comme accueil de Grafana : (70) ;
  • les trois vues métier posées et évaluées par le module : (71). Chezlepro : 25 nœuds racines ; seule « Sûreté des données » est en avertissement.

Icinga après coup : 183 services chez chacun, aucun hôte en problème. Seuls la sauvegarde et la restauration de web-dorsal-01 sont en avertissement : il n'héberge encore aucun site, son instantané est vide, et la sonde le dit. L'exploitant le juge normal.

2026-10-04 (71) — Vues métier dans la vigie : une par clientèle interne, dérivées

Demande de l'exploitant : que le module Business Process d'Icinga offre des vues d'ensemble à différentes clientèles internes. Choisi avec lui : personnel, direction, exploitation ; au site et chez les locataires ; dérivées du plan. Catalogue validé avant d'écrire.

Constat : le seul processus existant (supervision, écrit à la main dans les group_vars des deux locataires) visait un hôte icinga et des contrôles (load, procs, swap, ssh) qui n'existent plus. Il ne pouvait rien montrer de juste. Retiré.

Fait :

  • chaque sonde déclare son service métier dans le meta/supervision.yml de son rôle (metier:, 47 sondes, 38 rôles) ;
  • serveur_icingaweb2 porte le catalogue (serveur_icingaweb2_bpm_vues, _services) : titres, clientèles, regroupement « fondations » et socle par machine ;
  • bpm-membres.json.j2 calcule les contrôles de chaque service avec la même dérivation que serveur_icinga (hôtes du rôle et de client_sante, condition seulement_si), plus ce qui ne vient pas des rôles : sauvegardes (les deux modèles, lus chez client_backup), sante et ping4 de chaque machine, materiel des hyperviseurs, pattes de la frontière ;
  • bpm-vue.conf.j2 écrit setops-<vue>.conf. Une vue sans service n'est pas posée, et « Mes outils » n'existe que là où des personnes se connectent : pas au site.

Trois vues : Mes outils (personnel — se connecter, courriel, fichiers, édition, sites web, sans nom de machine) ; Services rendus (direction — un voyant par service, sûreté des données, « Fondations techniques ») ; Exploitation (tout, déplié jusqu'au contrôle et à la machine).

Éprouvé : chaque contrôle nommé comparé à ce qu'Icinga surveille vraiment — site 152, Chezlepro 183 : aucun absent, aucun service laissé hors vue. Trois défauts trouvés ainsi et corrigés : la condition seulement_si passée par | bool (« frontiere » devenait faux, journaux-frontiere disparaissait) ; les sauvegardes écrites « l'un ou l'autre » alors que le site porte les deux modèles ; l'edge classé « sites web publics » (c'est la passerelle des services internes : il fabriquait une vue « Mes outils » au site). Puis chaque vue évaluée par le module (icingacli businessprocess process check) au site et chez les deux locataires, sans erreur. Ce qu'elles disent aujourd'hui, et c'est juste : au site, socle en avertissement (gandalf, matériel) ; chez les deux locataires, sûreté des données en avertissement (sauvegarde VIDE de web-dorsal-01) ; chez Chezlepro, socle (correctif redis de edge-mta-01).

Validation : ansible-lint (0 violation), make verifier conforme (83/83).

2026-10-04 (70) — Un tableau de synthèse, au site et chez les locataires

Demande de l'exploitant : la seconde moitié de « rendre les tableaux plus parlants » — un tableau d'accueil. Contenu arrêté avec lui avant d'écrire ; site ET locataires.

Fait : tableau-synthese.json.j2 (« Set-OPS — Synthèse », setops-synthese), quatre rangées, quatre questions :

  1. Faut-il agir ? — tuiles colorées : matériel des hyperviseurs (niveau 0/1/2 tiré du même catalogue que le tableau Matériel et la sonde Icinga), swap le plus plein des hyperviseurs, disque le plus plein des machines, correctifs de sécurité en attente, redémarrages en attente, machines muettes ;
  2. Reste-t-il de la marge ? — mémoire et swap des hyperviseurs, disque et mémoire par machine (de la plus pleine à la moins pleine) ;
  3. Que se passe-t-il ? — processeur et trafic réseau (interfaces physiques) ;
  4. Où regarder ensuite ? — liens vers Matériel, Machines, PostgreSQL, Journaux et la vigie. Chaque panneau dit ce qu'il mesure, ses seuils et quoi faire quand sa couleur change.

Un gabarit, deux visages : au site (setops_materiel présent), il ajoute les hyperviseurs ; chez un locataire, il ne voit que ses machines. Le nom des machines vient de node_uname_info : les locataires n'ont pas l'étiquette hote du site.

Éprouvé avant de poser : chaque requête exécutée sur le vrai Prometheus, au site et chez Chezlepro. Une erreur trouvée ainsi : la tuile matériel affichait « 3 » — count(x) > 0 garde la valeur du compte ; elle rend désormais un niveau. Ce que la synthèse dit au premier regard, au site : matériel « à planifier » (disque de gandalf), swap de vishnu à 98,6 %, 65 correctifs de sécurité en attente, 9 redémarrages en attente.

Appliqué au site, puis aux deux locataires (17 et 14 panneaux), sans erreur de chargement.

Page d'accueil : la synthèse s'affiche à la connexion, au site et chez les deux locataires (GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH, posé par le rôle) — la question « faut-il agir ? » plutôt que la liste des tableaux. Relu dans l'environnement du processus Grafana des trois observatoires.

Validation : ansible-lint (0 violation), make verifier conforme (83/83).

2026-10-04 (69) — Le tableau Matériel dit quoi faire

Demande de l'exploitant : rendre les tableaux Grafana plus parlants — « phrases et contexte » d'abord, sur le tableau Matériel ; un tableau de synthèse ensuite.

Constat : les panneaux avaient déjà leur « pourquoi », mais au survol seulement, sans accents (écrits comme des commentaires de code : « CONCU », « annoncee »), et aucun ne disait quoi faire quand il change de couleur.

Fait :

  • scripts/materiel.py : les 14 textes réécrits en français accentué, et un champ action par mesure — quoi faire à l'orange ou au rouge. Seuils et expressions inchangés : la sonde Icinga n'en lit pas les textes.
  • tableau-materiel.json.j2 : un mode d'emploi en tête (à quelle question répond chaque rangée, ce que disent les couleurs) ; chaque description composée en trois temps — ce que ça mesure, les seuils, quoi faire ; les descriptions écrites à la main (ventilateurs, puissance, usure, secteurs, heures) reprises sur le même patron.

Appliqué au site (seul à porter ce tableau) : rendu vérifié en local (JSON valide, 28 panneaux), puis servi par Grafana sans erreur de chargement. Le passage a aussi monté Grafana du site de 13.2.1 à 13.2.3, son épingle (66).

Validation : ansible-lint (0 violation), make verifier conforme (83/83).

2026-10-04 (68) — Forgejo 16.0.5 et Nextcloud 34.0.4 ; Nextcloud apprend à monter, et vérifie sa signature

Demande de l'exploitant : poursuivre le relevé des logiciels en retard (Forgejo 16.0.2, Nextcloud 34.0.2). La frontière (OPNsense 26.7.4_1 -> 26.7.5), l'exploitant s'en charge par son interface.

Forgejo savait monter : binaire par version (forgejo-<version>), lien vers la courante, redémarrage au changement de lien, signature vérifiée. Seule la SIMULATION d'une montée échouait — le lien vers un binaire pas encore copié. Corrigé (lien sauté en simulation tant que le binaire n'est pas là). 16.0.5 vérifiée contre la clé épinglée (EB114F5E…), dépôt du site garni, appliquée au site : API en 16.0.5, la forge sert le génome (make genome-etat).

Nextcloud ne savait pas monter — même défaut que Keycloak (67) : creates: occ, le code restait celui de la version installée quelle que soit la version demandée. Et chez nous, data/ vit DANS le code. Fait : tasks/monter.yml, la mise à jour manuelle documentée par Nextcloud — maintenance ; ancien code mis de côté sous <racine>-<version> ; nouvelle version extraite ; on y ramène data/ (déplacée, pas copiée), la configuration, et tout ce que la livraison n'apporte pas (à la racine, dans apps/, dans themes/) ; occ upgrade ; php-fpm rechargé (cache d'opcodes revalidé toutes les 60 s) ; fin de maintenance. Refus d'une rétrogradation et d'un saut de branche. En cas d'échec, le rôle dit comment revenir. La reprise a été éprouvée sur des arborescences factices avant d'écrire : livré gardé neuf, manquant repris, config.php et data/ intacts.

Nextcloud installait sans vérifier. Signature PGP vérifiée désormais (scripts/verifier_signature.py, comme Keycloak et Forgejo) ; clé dans files/nextcloud-release.asc, empreinte 28806A87…937A (« Nextcloud Security »). Ancrage relevé et sa réserve écrite dans le rôle : clé servie par nextcloud.com, identifiant publié sur nextcloud.com/security, même clé sur keys.openpgp.org mais sans adresse vérifiée — toutes sources de la même organisation. Le dépôt du site sert aussi la signature.

Appliqué chez les deux locataires, chacun précédé d'une sauvegarde fraîche (fichiers et base) : 34.0.2 -> 34.0.4, repris alliance-logo.svg, apps/richdocuments, apps/user_oidc, themes/alliance. Relu chez chacun : status.php en 34.0.4 par l'edge, pas de maintenance ni de mise à jour de base en attente, Collabora et OIDC actifs, thème en place, données dans le nouveau code, bureau (Collabora) répond. Seule erreur : « Failed to connect to the app store » pendant occ upgrade — collab-01 n'a pas de sortie Internet, par conception.

À décider par l'exploitant : les anciens codes (/var/www/nextcloud-34.0.2, ~950 Mo chacun) restent pour le retour arrière ; les retirer une fois la version éprouvée.

Validation : ansible-lint (0 violation), make verifier conforme (83/83), simulations au site et chez les deux locataires.

2026-10-03 (67) — Keycloak 26.7.5 et oauth2-proxy v7.15.5 ; deux montées qui n'auraient rien monté

Demande de l'exploitant : mettre à jour Keycloak et oauth2-proxy, premiers du relevé des logiciels en retard (Keycloak 26.7.1 : quatre correctifs de retard, chacun corrigeant plusieurs CVE ; oauth2-proxy v7.15.3 : deux, tous deux de sécurité).

Deux défauts trouvés en préparant, avant tout déploiement — les deux rôles ne savaient pas MONTER. Ils ne faisaient jusque-là que des premières installations.

  • oauth2-proxy décidait sur la seule PRÉSENCE du binaire : changer la version sur une machine installée ne faisait rien. Le rôle demande désormais sa version au binaire.
  • Keycloak avait un repère par version (.kc-built-<version>), mais l'extraction portait creates: bin/kc.sh : sautée, puis kc.sh build sur les ANCIENS fichiers, puis repère de la NOUVELLE version — la machine se serait dite en 26.7.5 en restant en 26.7.1. Et extraire par-dessus ne suffit pas : les bibliothèques portent leur version dans leur nom, les deux générations auraient coexisté. Lors d'une montée, le rôle arrête Keycloak, retire la distribution précédente (data/ gardée), extrait, puis repose sa configuration et son thème comme à l'installation.

oauth2-proxy installait sans rien vérifier. Empreinte SHA-256 de l'archive écrite dans le rôle (serveur_oauth2_proxy_sha256), après relecture : la somme publiée par le projet recalculée sur l'archive. Une archive qui ne la porte pas — dépôt du site ou Internet — est retirée du cache et refusée. Keycloak, lui, vérifiait déjà sa signature PGP : 26.7.5 est signée par la clé épinglée (861AB50E…), contrôlé avant déploiement.

Appliqué : dépôt de binaires du site garni des deux versions (les runners n'auront pas à sortir) ; puis les deux locataires. Relu chez chacun : Keycloak 26.7.5 démarré en 6 s après la mise à jour automatique de sa base, aucune bibliothèque 26.7.1 restante, aucune erreur ; oauth2-proxy v7.15.5 sur mon-01 et ops-01 ; vigie et console renvoient vers la connexion Keycloak, issuer juste ; sondes identite, passerelle, vigie, console-ops au vert. Interruption de l'identité : environ une minute par locataire, pendant le remplacement.

À savoir : le rôle Keycloak n'est pas simulable jusqu'au bout — courriel-realm.yml échoue en --check faute de jeton d'administration, avec ou sans ce changement. L'extraction est sautée en simulation (l'archive n'y est pas copiée).

Vu au passage : web-dorsal-01 (Chezlepro) rapporte une sauvegarde VIDE depuis la reconstruction du 2026-10-01 — légitime s'il n'héberge encore aucun site, à confirmer.

Validation : ansible-lint (0 violation), make verifier conforme (83/83).

2026-10-03 (66) — Les épingles tiennent, cache ou pas

Demande de l'exploitant : faire respecter les épingles de paquets-tiers.yml.

Deux fuites, mesurées sur les 21 paquets épinglés et toutes les machines. (1) Sans le cache du contrôleur — c'est le cas des runners — les rôles installaient la dernière version publiée (apt install alloy, sans version). (2) Le socle (common_packages, upgrade: full) montait tout au-delà de l'épingle, cache ou pas. Résultat le jour même : sept paquets plus récents que leur épingle chez les deux locataires, et un au site (icinga-php-thirdparty 1.0.1, alors que le cache avait posé 1.0.0).

Épingles relevées sur ce que portent les locataires (en plus des trois de Grafana, 65) : step-cli 0.31.0-1, icingadb-redis 8.2.10-1, icinga-php-library et icinga-php-thirdparty 1.0.1-1. Cache retiré, empreintes vérifiées.

Fait — les épingles deviennent des préférences apt (paquets_tiers/tasks/epingles.yml), une par paquet, priorité 990 : apt préfère la version épinglée à toute plus récente, y monte un paquet plus ancien, et ne rétrograde jamais. Éprouvé avant d'écrire : au site, épingle sur 1.20.0-1 alors que 1.20.1-1 existe — candidat 1.20.0-1, dist-upgrade s'y arrête ; chez un locataire déjà en 1.20.1-1 — rien ne bouge. Posées par chaque rôle pour ses paquets, et par le socle pour TOUTES, avant son dist-upgrade. Garde : refus si un paquet est déjà plus récent que son épingle (la relever), ou si l'épingle ne prend pas — version absente de l'index, qu'apt ignore sans un mot (contrôle réservé à l'application réelle : en simulation, la préférence n'est pas écrite).

Deux défauts de paquets_tiers révélés par la première vraie mise à jour. Le rôle ne faisait jusque-là que des premières installations. (1) Sur site-dns-01, alloy 1.19.2 -> 1.20.1 a demandé quoi faire de /etc/default/alloy, modifié par client_journal — sans personne pour répondre : paquet déballé, NON configuré (iU). Réparé à la main (dpkg --configure --force-confold, fichier local gardé), puis corrigé : apt-get reçoit désormais --force-confdef --force-confold, comme le socle. Éprouvé sur site-cache-01 (ii, port local conservé). (2) La garde changed_when cherchait « 0 nouvellement installés », qu'apt écrit aussi lors d'une mise à jour : la montée se rapportait « ok ». Corrigé.

Appliqué au site (client_journal, 9 VM) : alloy 1.20.1-1 partout, configuré, préférence posée, aucune perte d'envoi. Les autres épingles se poseront au prochain passage du socle ou des rôles concernés — au site comme chez les locataires, qui n'ont encore rien reçu de ceci.

Vu au passage, non traité : le socle réinstalle cloud-init à chaque passage (« Installer les paquets communs ») et serveur_durci le retire derrière — un va-et-vient à chaque déploiement complet.

Validation : ansible-lint (0 violation), --syntax-check des sept playbooks touchés, make verifier conforme (83/83) ; socle simulé sur site-dns-01 (20 préférences, garde au vert).

2026-10-03 (65) — Épingles Grafana relevées sur ce que portent les locataires

Demande de l'exploitant : relever les épingles (point 4 du rapport d'anomalies).

make cacher-paquets avait été relancé, mais il retire ce que paquets-tiers.yml épingle : alloy 1.19.2-1, grafana 13.2.1, loki 3.7.7. Les locataires portent 1.20.1-1, 13.2.3 et 3.7.8 depuis leur reconstruction du 2026-10-01 — leurs runners n'ont pas ce cache et tirent la dernière version publiée. Un déploiement depuis le poste aurait donc tenté de les rétrograder ; il a fallu passer -e paquets_tiers_cache=/dev/null/aucun.

Fait : les trois épingles relevées sur ces versions ; cache retiré (empreintes vérifiées contre l'index du dépôt : alloy 3faed6cc381a…, grafana 55ec511999f9…, loki f6b0dcf22e08…). Le contournement n'est plus nécessaire. Le site, encore en 1.19.2 / 13.2.1 / 3.7.7, montera à son prochain passage — la config Alloy a été éprouvée sur 1.20.1.

La cause reste ouverte : sans cache, un runner installe la dernière version publiée, sans regarder l'épingle. L'écart se reformera à la prochaine publication de Grafana Labs.

2026-10-03 (64) — La vigie a sa base partout ; trois restes du site corrigés

Demande de l'exploitant : traiter les points 3 et 5 du rapport d'anomalies.

3. Icinga Web sans base chez les locataires. Depuis leur reconstruction, la vigie de Chezlepro journalisait 145 fois par heure, page ouverte, « Failed to load pending migrations : Please check if a db instance exists at all » ; Technolibre de même dès qu'on la regardait. Le site avait reçu sa base le 2026-09-15, mais seulement en mode db ; les locataires, en SSO, étaient restés en config_backend = "ini". Lu dans le code d'Icinga Web : ConfigMenu::createMigrationBadge compte les migrations SANS condition de permission — aucun réglage de rôle ne le fait taire. Fait : serveur_icingaweb2 résout et utilise sa base dans TOUS les modes (config_backend = "db", ressource icingaweb_db, schéma chargé une fois) ; les comptes en base et le compte d'amorçage restent propres au mode db. Base icingaweb2 ajoutée aux plans de Chezlepro, Technolibre, du lab et des deux modèles qui portent une vigie (observabilite, integral) ; vault_bd_icingaweb2 aux gabarits de voûte et à l'exemple public. Appliqué chez les deux locataires — secret posé par l'exploitant dans les trois voûtes (relu par l'assistant sans en afficher la valeur : 40 caractères, trois valeurs distinctes), puis serveur_postgresql, serveur_icingaweb2, serveur_nginx et serveur_ops_tenant. Relu : vigie en config_backend = "db", schéma chargé (6 tables), plus aucune erreur de migrations depuis le redémarrage de php-fpm (elle revenait toutes les 15 s) ; voûte du runner identique à celle du poste, à l'empreinte près, toujours chiffrée. Les préférences déjà rangées en fichiers ne sont pas reprises : ce ne sont que des réglages d'affichage.

Ce que le premier passage a révélé : psycopg2 absent du mon-01 d'un locataire. Les requêtes du rôle vers la base s'exécutent sur l'hôte de la vigie. Au site, c'est aussi celui de PostgreSQL, qui y avait posé python3-psycopg2 ; chez un locataire, la vigie est sur mon-01 et la base sur data-sql-01. Échec masqué par no_log, et invisible en simulation (la requête y est sautée : la base n'existe pas encore). Fait : le rôle pose python3-psycopg2 avant sa première requête.

5a. Le cache du site donné aux hyperviseurs, qui ne l'atteignent pas. Mesuré depuis asgard : 10.37.33.21:3142 injoignable. Or artefacts_amorcage leur était transmis par communes : un passage de client_journal aurait réécrit leur dépôt Grafana en http:// par ce mandataire, et apt l'aurait perdu. Fait : site_inventaire.py ne leur donne plus ni artefacts_amorcage, ni setops_depot_binaires, ni dns_amorcage (son jumeau, P79) — amorçages d'une VM née du gabarit, qu'aucun rôle des hyperviseurs ne lit. Simulé : un passage de client_journal n'y change plus que la config Alloy.

5b. apache2 sur site-mon-01. Tiré par libapache2-mod-php8.4, dont plus rien ne dépendait : au site, les paquets d'Icinga viennent du cache du contrôleur et s'installaient AVANT php8.4-fpm, et apt prenait la première alternative PHP. Les locataires, en une seule transaction, n'avaient pas ce reste. Fait : php8.4-fpm et nginx posés avant les paquets tiers ; apache2 retiré seulement si apt-get -s purge confirme qu'il part seul. Appliqué : 0 paquet apache, port 80 fermé, vigie en 302.

5c. Grafana Live sans WebSocket. Chaque page de l'observatoire journalisait GET /api/live/ws en 400, au site comme chez les locataires : l'edge relayait sans Upgrade. Le mécanisme existait (Collabora) ; Grafana ne le déclarait pas. Fait : websocket: true sur grafana dans les six plans qui le portent (site, deux locataires, lab, deux modèles). Appliqué au site : la poignée WebSocket atteint Grafana (401 sans session, au lieu de 400).

5d. ssl_protocols en double. Le nginx.conf de Debian le déclare déjà ; 99-setops.conf le répétait au même niveau — duplicate value "TLSv1.3" à chaque rechargement. Fait : la ligne de Debian est neutralisée, comme server_tokens avant elle. Appliqué au site : nginx -t sans avertissement.

Validation : ansible-lint (0 violation), make verifier conforme sur Chezlepro (83/83) et preuves conformes sur Technolibre (82 + 1 sautée, la voûte) ; voute.py verifier complet pour les deux. Au site : simulé puis appliqué, 0 échec.

Fuite à signaler : en lisant la config d'Icinga Web de Chezlepro, l'assistant a affiché en clair le mot de passe de liaison LDAP cn=icingaweb2 (bind_pw n'était pas masqué). Il n'est écrit nulle part ailleurs que dans la conversation ; une rotation est recommandée.

2026-10-03 (63) — Les locataires mesurés et corrigés : même bruit, et le courriel en ajoute

Demande de l'exploitant : mesurer chez les deux locataires les défauts corrigés au site (62), puis y appliquer les correctifs.

Mesuré (une heure, depuis chaque obs-01) — identique chez Chezlepro et Technolibre : sonde SSH 635 lignes/h par machine (environ 65 % de son journal) ; sonde TLS 53/h par machine, environ 650 sur obs-01 et infra-pki-01 ; audit de node_exporter à 31-35 % ; Loki qui se relit (100 à 186 lignes/h) ; aucune unité en échec, donc pas de reste à la façon des hyperviseurs. Environ 500 000 lignes de journal par jour et par locataire.

Ce que le site n'avait pas : le courriel. Postfix (edge-mta-01) écrit trois lignes par connexion de sonde — connect, lost connection after CONNECT, disconnect … commands=0/0 — et une quatrième sur le port chiffré (SSL_accept error) : 7 000 lignes par heure, la machine la plus bavarde de chaque locataire. Dovecot LMTP (infra-mail-01) en ajoute environ 105, dont une sur deux étiquetée Error. Fait : sept stage.drop de plus, même règle qu'en 62 — la phrase exacte, et seulement depuis une machine sondeuse. Une vraie session venue de la flotte ne perd que sa ligne connect from (son client= et son disconnect compté en commands=N/M restent) ; un échec TLS LMTP autre que « fermé avant de commencer » passe.

Éprouvé avant d'écrire, deux fois. Sur une heure de journaux réels de Chezlepro : 69 % de lignes jetées (96 % sur edge-mta-01), et le balayeur externe (censys-scanner.com) reste visible. Puis avec le binaire Alloy (v1.20.1, sur edge-mta-01) : un fichier de 16 lignes témoins à travers le même loki.process vers loki.echo — les 9 lignes de sonde jetées, les 7 légitimes reçues (source externe, [preauth], certificat refusé, scanner, vraie session SMTP, vrai échec TLS LMTP, témoin de fin).

make appliquer accepte ARGS, comme site-appliquer : simuler (--check --diff) passe par la même porte que l'application.

Trouvé en simulant : le cache de paquets du poste est plus vieux que les locataires. Ils portent alloy 1.20.1 et loki 3.7.8 (tirés à la reconstruction du 2026-10-01) ; le cache du poste fournit 1.19.2 et 3.7.7. La simulation saute l'apt-get install : elle ne montre rien. En réel, paquets_tiers tenterait une rétrogradation. Contournement pour ce passage : -e paquets_tiers_cache=/dev/null/aucun — le rôle se dégrade comme prévu (aucun manifeste, rien à déposer) et les paquets en place ne bougent pas. Remède de fond : make cacher-paquets depuis le poste.

Appliqué chez les deux locataires par l'exploitant (serveur_loki, client_journal, serveur_durci, avec -e paquets_tiers_cache=/dev/null/aucun), puis relu machine par machine : 26 sur 26 portent les 9 filtres sans aucune perte d'envoi, la règle never est armée (node_exporter : 0 événement en 30 s), Loki est en warn, versions inchangées (alloy 1.20.1, loki 3.7.8). Effet mesuré contre la même fenêtre la veille : journal −77 % (Chezlepro) et −86 % (Technolibre), audit −72 % et −80 %, edge-mta-01 −96 % et −98 %.

Deux pièges rencontrés en route. (1) Lancé depuis l'invite de l'assistant (!), ansible-playbook meurt sur « Ansible requires blocking IO » avant de toucher une machine — les trois premiers déploiements n'avaient rien posé. Parade : … < /dev/null 2>&1 | cat. (2) Une commande coupée par un retour à la ligne a lancé make appliquer SANS groupe : le Makefile a pris son défaut, serveur_debian, et l'a appliqué à tout Technolibre — une mise à jour de sécurité de redis (celle qu'unattended-upgrades avait déjà faite ailleurs le matin) et cloud-init réinstallé, neutralisé par son drapeau puis retiré par serveur_durci. Corrigé : appliquer, deployer-groupe et site-appliquer refusent désormais un GROUPE que l'opérateur n'a pas donné ($(origin GROUPE) vaut file quand il vient du défaut). Leur garde -z "$(GROUPE)" ne pouvait jamais se déclencher — le commentaire de site-appliquer le disait déjà, sans que la garde ait été corrigée. Éprouvé : sans GROUPE, les trois rendent le code 2.

Vu au passage, non traité : Icinga Web ne trouve pas sa base de configuration chez les deux locataires depuis leur reconstruction (Please check if a db instance exists at all, 145/h chez Chezlepro tant qu'une page de la vigie est ouverte).

Validation : ansible-lint (0 violation), make verifier conforme (83/83).

2026-10-03 (62) — Le site parlait trop dans ses journaux, et trois hyperviseurs échouaient en silence

Demande de l'exploitant : analyser les journaux centralisés (Loki) et les événements Icinga du site, en faire un rapport d'anomalies, puis corriger quatre points de ce rapport.

1. Un porteur de santé resté sur les trois hyperviseurs. client_sante y avait été posé le 2026-09-10, puis retiré du groupe le jour même : la route par défaut gelée (D-57) empêche un hyperviseur de joindre Icinga, et ses métriques sont depuis TIRÉES. Mais personne ne défaisait ce qui avait été posé. Le minuteur visait encore 10.0.36.11 (l'adresse de site-mon-01 d'avant la renumérotation) et échouait toutes les 15 min depuis au moins sept jours, soit environ 2 050 échecs par hyperviseur. node_exporter publiait donc sur chacun une unité en échec permanente : le jour où une vraie unité tombait, rien ne la distinguait. Fait : client_sante/tasks/retirer.yml (minuteurs arrêtés, unités, script, mot de passe d'API et AC retirés, reset-failed), joué par un 3ᵉ play hyperviseurs:!client_sante du playbook de groupe ; client_sante_unites_posees nomme ce que le rôle pose. site_inventaire.py ne dérive plus client_sante_icinga_url pour les hyperviseurs : la valeur faisait croire qu'ils poussaient. Relu : 0 unité setops-sante*, 0 unité en échec sur les trois, et aucune unité en échec dans Prometheus.

2. Le bruit de la sonde connectivite, filtré à la collecte. Depuis le 2026-10-01, chaque machine ouvre puis referme chaque minute une connexion vers les ports promis : sshd (« Connection closed by… ») et les serveurs Go — Forgejo, step-ca — (« TLS handshake error… EOF ») le notent. Le journal de chaque VM du site est passé de 2 000 à 14 000 lignes par jour, et ces deux phrases faisaient 98 % des « erreurs » de la forge et de l'autorité. Rien à corriger à la source : sshd journalise toute connexion fermée avant l'échange de clés. Fait : loki.process "sondes" dans le gabarit Alloy, avec deux stage.drop : la phrase exacte, et seulement depuis une machine de client_sante (client_journal_sondeurs, dérivé de l'inventaire). La même phrase venue d'ailleurs passe ; les sessions d'administration par rebond (pattes 10.37.x.1 de la frontière) passent, et c'est vérifié. Compte de ce qui est jeté : loki_process_dropped_lines_total{reason="sonde_connectivite"}, distinct des pertes que surveille la sonde journaux. Éprouvé avant d'écrire : alloy validate (v1.19.2) sur la config réelle. Appliqué aux 9 VM seulement — sur les hyperviseurs, le même rôle aurait aussi changé leurs sources apt (Grafana par le cache du site), un écart antérieur laissé à part.

3. Loki se relisait lui-même. À info, Loki écrit quatre lignes par requête, qu'Alloy lui renvoyait : 913 000 lignes en sept jours sur site-mon-01, et une recherche par motif retrouvait ses propres requêtes (« segfault » : 1 787 faux positifs). Fait : serveur_loki_log_level: warn. Relu : 55 lignes du service en dix minutes après le redémarrage, des erreurs seulement ; ce qui reste à info vient de Grafana.

4. L'audit enregistrait une lecture d'horloge toutes les 15 s. Le collecteur timex de node_exporter appelle adjtimex en lecture ; la règle time-change ne peut pas distinguer une lecture d'un réglage. 4 événements par minute, 35 % de la piste d'audit d'une VM. Fait : une règle never étroite, placée DEVANT (seul adjtimex, seul ce binaire ; chrony et settimeofday/clock_settime restent audités). Éprouvé à la main : 4 événements/min sans la règle, 0 avec ; auditctl accepte un exe= absent.

Trouvé en relisant : redémarrer auditd ne recharge pas les règles. Sur Debian 13, elles sont chargées par audit-rules.service (augenrules --load), qu'un redémarrage d'auditd ne rejoue pas : le handler laissait le fichier juste et le noyau sur les règles du dernier démarrage. Nouveau handler Recharger les regles auditd, sans failed_when: false. Relu : la règle never est armée dans le noyau sur les 9 VM.

Validation : --syntax-check des 4 playbooks, ansible-lint (profil production, 0 violation), make verifier conforme (83/83, carte à 51 pièces d'audit après le rapport du jour). Au site : --check --diff des quatre groupes, puis application, 0 échec. Icinga revient à une seule alerte, l'ancienne : secteurs défaillants sur gandalf. Le passage a aussi aligné le site sur des modes déjà décidés par le dépôt (/etc/setops 0700, /var/lib/setops 0755).

Reste du rapport, non traité ici : aucune notification Icinga n'a jamais été émise au site (0 objet Notification : les règles d'exemple exigent host.vars.notification.mail, que rien ne pose) ; disque sda de gandalf (osd.4) qui se dégrade ; lien SATA instable du SSD système de vishnu (CRC 16), que smartmon ne remonte pas.

2026-10-01 (61) — Une donnée restaurée l'emportait sur une règle du plan (Nextcloud)

Constat de l'exploitant : dans Nextcloud, sysadmin n'a pas les mêmes droits chez les deux locataires. Mesuré : administrateur (groupe admin) chez Technolibre, pas chez Chezlepro — reconstruite en 40 min juste avant.

Ce qui devait être identique et ne l'était pas. Les DONNÉES d'un locataire peuvent différer d'un autre : chacune revient de son propre passé. Les RÈGLES du plan, non : « le groupe d'habilitation est administrateur de Nextcloud » vaut pour tous. Or son effet est rangé dans la base de Nextcloud, et le rôle l'applique AVANT de remettre la base restaurée (la remise vient en fin de rôle, à cause du code des applications — 47) : sur la base neuve, le groupe n'existe pas encore, l'étape passe sans rien faire, puis la base d'avant revient avec son état d'avant. Technolibre avait reçu admin d'un déploiement antérieur, que la sauvegarde a gardé ; Chezlepro jamais. La donnée décidait à la place de la règle.

Correction : l'étape sort dans admin.yml, appelée comme avant depuis oidc.yml, et REJOUÉE juste après la remise d'un Nextcloud restauré. Les autres services n'ont pas ce défaut : Keycloak est remis avant son premier démarrage et reconfiguré ensuite ; l'annuaire réaligne les comptes de service sur la voûte après sa remise.

2026-10-01 (60) — La frontière refuse à voix haute à l'intérieur, et se tait sur l'Internet

Demande de l'exploitant : « pour éviter des délais internes en cas de pépin, les règles qui ne font pas face à l'extérieur ne doivent pas être en drop mais en reject ». Les machines refusaient déjà en le disant (nftables reject … admin-prohibited, Proxmox REJECT). La FRONTIÈRE jetait en silence sur toutes ses interfaces — c'est elle qu'avait mesurée la sonde au site (« délai » entre deux zones).

Fait : un reject final, journalisé, séquence 950 (après les autorisations, WireGuard et les silences déclarés), sur chacune des 12 interfaces internes — gestion, transit, l'ancienne patte du site, les 8 zones du site, la grappe de contrôle, WireGuard. Le WAN reste muet. Sûr : la frontière n'a qu'une règle héritée, sur le WAN — rien d'existant n'est court-circuité ; ce qui n'était pas autorisé était déjà refusé, seul le MODE du refus change.

Appliqué (sur accord) : 12 créations, 0 retrait ; plan ensuite vide (340 règles). Après coup : les 35 machines connectivite au vert (9 site, 13 + 13 locataires). Essai négatif : deux flux NON déclarés — site-edge-01 → site-forge-01:3000 (« délai » la veille) et Technolibre web-frontal-01 → site-forge-01:3000 — échouent en ECONNREFUSED en moins d'une milliseconde ; le flux déclaré témoin (443) passe.

Garde : P53 affinée (refus audible interne exigé, jamais sur le WAN) ; P50 ne compte comme silence que les block ; test_frontiere_refus.py sur le vrai devis. La sonde classe désormais ECONNREFUSED en AVERTISSEMENT (« service absent, ou refus de la frontière ») : le reject d'OPNsense rend un RST, indiscernable d'un port fermé — et depuis les seulement_si, aucun flux déclaré ne mène plus à un port sans service, ce cas n'est donc plus jamais normal.

2026-10-01 (59) — Chezlepro reconstruite par Set-OPS en 41 minutes, sans un arrêt

Lancée par l'exploitant (make reconstruire-locataire TENANT=OPS-Chezlepro CONFIRMER=true ARMER=oui) : 8 étapes sur 8, aucun arrêt, 41 min, contre 60 la veille et 43 visées.

Étape Durée
sauvegarder + raser 1 min
créer les VM 5 min
inséminer + armer 2 min
monter (déploiement, état remis, valider) 31 min
pare-feu (sonde connectivite) 2 min (20 la veille)
bilan < 1 min

Les 7 jeux d'état restaurés depuis l'instantané pris avant de raser ; Icinga à 0 critique ; l'archive Nextcloud prise au dépôt de binaires du site, plus sur Internet (58). Les deux corrections du jour se lisent dans ce chiffre : la sonde à la minute (−18 min), le dépôt du site rouvert (−20 min sur Technolibre, 63 min une heure plus tôt).

2026-10-01 (58) — Le dépôt de binaires du site rendait 403 : quatre rôles se disputaient un répertoire

Le symptôme (reconstruction de Technolibre lancée par l'exploitant, 63 min au lieu de ~43) : deployer-tout restait 20 min sur « Télécharger le tarball Nextcloud dans le cache du contrôleur » — 281 Mo depuis download.nextcloud.com à 130–230 Ko/s. Le runner, né à vide, devait les prendre au dépôt de binaires du SITE ; l'étape qui l'y cherche (failed_when: false, « le dépôt est une commodité ») avait rendu « ok » sur un 403.

La cause : le dépôt existe et est complet depuis le 2026-09-12 (Forgejo, Keycloak, Nextcloud 34.0.2, oauth2-proxy), LocalDirs est en place — mais apt-cacher-ng, qui ne tourne pas en root, ne traversait plus /var/lib/setops (0750). QUATRE rôles tiennent ce répertoire : common_packages, client_journal, serveur_ops_site en 0750, et serveur_artefacts en 0755. Le dernier déployé gagnait ; mes déploiements au site du matin l'avaient refermé (06:16). Le piège était documenté dans serveur_artefacts — pas chez les trois autres.

Correction : 0755 partout (le répertoire ne tient que des états de sondes, déjà lisibles) ; appliqué à site-cache-01 : les quatre archives répondent 200, à la taille exacte, depuis une VM de locataire.

La garde, test_repertoires_partages.py : un répertoire dont plusieurs rôles imposent le mode n'en a qu'un — chemins Jinja RÉSOLUS depuis les défauts (celui de serveur_artefacts s'écrit {{ … | dirname }}, une lecture littérale ne l'aurait pas vu). À sa première exécution, elle a trouvé deux autres désaccords :

  • /etc/setops : 0700 (client_backup, serveur_backup) contre 0755 (client_sante, serveur_icinga). Il tient des secrets, tous ses lecteurs tournent en root : 0700.
  • /srv/restic sur site-backup-01 : serveur_backup y est appliqué deux fois — en dépendance de serveur_backup_site (racine /srv/restic/site) et par son propre groupe (défaut /srv/restic, 0700 au compte restic). Joué seul, ce second passage refermait le parent des dépôts de tous les locataires (sshd StrictModes, mesuré le 2026-09-01). Dormant — un déploiement complet du site rejoue serveur_backup_site après. L'inventaire du site donne désormais au groupe la racine de la dépendance, LUE dans son meta/main.yml. Le test admet ce cas : une dépendance qui surcharge le chemin n'est pas un désaccord.

2026-10-01 (57) — Aucune exception : la soumission n'est ouverte que là où elle existe

J'avais présenté site-mon-01:465/587 (« sans service ») comme une exception tolérable. L'exploitant l'a refusée : « je ne comprends pas pourquoi on tolérerait cette exception » — et a demandé si c'était le relais qui avait une lacune. Il n'en a pas : le relais du site fait sortir le courriel des MACHINES par le 25 ; la soumission (465/587) est la porte des PERSONNES, authentifiée par l'annuaire — que le site n'a pas, par conception. Le défaut était dans le registre : ces deux flux, ajoutés le 2026-09-29, avaient été déclarés pour toute instance de Postfix, sans suivre l'interrupteur qui active le service (serveur_postfix_submission_actif, faux par défaut, levé par les seuls locataires). Ils le suivent désormais (seulement_si).

Site : site-mon-01 perd ses deux règles ; les 9 machines, sonde lancée directement après le déploiement (simulé d'abord) : tous les flux déclarés ouverts, aucun « sans service », aucune entrée hors des règles. Locataires et frontière : inchangés (edge-mta-01 garde ses 465/587, la frontière ses redirections publiques).

Les trois niveaux de pare-feu n'ouvrent plus aucun port où rien n'écoute — sans exception.

2026-10-01 (56) — Le registre des flux sait dire « seulement si » ; la sonde au site

Le registre ne savait pas exprimer une condition. La sonde connectivite montrait, chaque minute, des ports ouverts vers rien : la vigie (8080) et la console (8090) en SSO, liées à 127.0.0.1 derrière la passerelle — le code le savait (« la règle devient simplement sans objet »). seulement_si: {variable, egal} ou {variable, non_vide: true}, évalué PAR ÉCOSYSTÈME (variables d'hôte, group_vars, défauts du rôle) dans les trois générateurs — nftables, Proxmox, frontière. Une expression Jinja indéterminable garde le statu quo ; une variable absente partout compte comme vide pour non_vide.

Flux Condition Effet
vigie 8080 serveur_icingaweb2_nginx_bind == "" retiré chez les locataires (SSO) ; gardé au site
console 8090 serveur_ops_gui_auth == locale idem
forge 3000 serveur_forgejo_tls == false retiré au site (TLS propre sur 443)
AXFR 5300 dns_public_site non vide retiré au site (pas d'instance publique) ; gardé chez les locataires

Appliqué : locataires — nftables, et Proxmox (8 règles, 2 groupes de sécurité vides retirés), connectivite 13/13 au vert des deux côtés, l'edge à 27/27 ; site — nftables, ses deux « coupures » (site-dnspub-01 → site-dns-01:5300, site-edge-01 → site-forge-01:3000) disparues avec leurs règles. Frontière, sur accord de l'exploitant : 8 règles d'administration vers 8080/8090 et 2 alias orphelins retirés ; plan ensuite à 0 création, 0 retrait. Après coup : connectivite 13/13 au vert chez les deux locataires, et la vigie comme la console répondent toujours par l'edge (302 vers la connexion SSO).

Un défaut latent trouvé au passage : la console du SITE tourne en locale depuis le 2026-09-15, mais son plan ne le disait pas — et le défaut du rôle est oidc. Le prochain déploiement du site l'aurait liée à 127.0.0.1 derrière une passerelle inexistante. Le plan le déclare désormais (serveur_ops_gui_auth: locale).

La sonde au site (simulée d'abord, --check --diff) : la simulation a montré que le chemin de la liste pointait hors du dépôt (inventaire dynamique : même surcharge que le ruleset), que la tâche aurait changé les droits de /etc/setops (la liste, qui n'a rien de secret, va dans /usr/local/lib/setops/), et que l'activation du minuteur échouait en simulation. Corrigés, puis déployés : les 9 machines rapportent ; reste une information — site-mon-01:465/587, le relais du site n'offre pas la soumission.

2026-09-30 (55) — Première reconstruction sans un seul arrêt ; le pare-feu jugé par une sonde à la minute

Chezlepro, quatrième reconstruction, lancée par l'exploitant (reconstruire-locataire, détachée) : 8 étapes sur 8, aucun arrêt, 60 min, les 7 jeux d'état restaurés. Première reconstruction de bout en bout sans aucune intervention. Sur ces 60 min, 20 pour l'étape parefeu — « vraiment trop de temps », dit l'exploitant, qui propose des sondes de connectivité rapportant à Icinga chaque minute.

La sonde connectivite (déclarée par serveur_durci, déposée par nftables_baseline) : chaque VM teste chaque minute les flux SORTANTS que le registre lui promet. La liste est écrite par make flux à côté de chaque .nft, depuis les MÊMES règles résolues (flux-genere/<hôte>.connectivite.json). La cause est mesurée, pas supposée (2026-09-30, depuis web-frontal-01) : rejet Proxmox = EHOSTUNREACH → COUPÉ ; drop nftables = délai → COUPÉ ; port autorisé où rien n'écoute = ECONNREFUSED → information (pas une coupure). Elle signale aussi les connexions entrantes établies que les règles ne couvrent pas. Contrôle négatif : une liste imposée (flux bloqué, port fermé, flux ouvert) → COUPÉ, code 2, chaque cause nommée.

Le porteur à la minute : client_sante joue sondes-minute/ chaque minute (ttl 3 min), hors du répertoire au quart d'heure — sinon le ttl de 90 min masquerait le silence. Même script, mode minute. Icinga accepte une fraicheur: par sonde ; P64 reconnaît sondes-minute/.

L'étape parefeu (eprouver_parefeu.py --flotte --sondes, utilisée par reconstruire-locataire et parefeu-*-flotte) : refus si un flux est DÉJÀ coupé, activation des 13 VM d'un coup, attente des rapports postérieurs, seul ce qui change compte. Mesuré : Technolibre 135 s, Chezlepro 94 s (au lieu de ~20 min), 0 critique apparu. La matrice VM par VM reste pour le diagnostic (--hote).

Deux défauts de la sonde vus au premier déploiement, corrigés : une machine qui se parle par sa propre adresse (Loki sur obs-01) se déclarait « hors des règles » ; deux flux déclarés sans service (infra-edge-01 → mon-01:8080, → ops-01:8090 : interfaces liées en local derrière la passerelle SSO) étaient un avertissement permanent — ce sont désormais des informations nommées. Reste : retirer ces deux déclarations obsolètes du registre ; générer et déployer la sonde au SITE (ses listes ne sont pas encore générées — un serveur_icinga du site redéployé l'attendrait en rouge).

2026-09-30 (54) — Chezlepro au bout ; Technolibre arrêtée par un verrou dpkg

Chezlepro (lancée par l'exploitant, reprise DEPUIS=parefeu après (53)) : pare-feu 13/13 sans flux perdu, les 7 jeux d'état restaurés depuis les instantanés de 15:59. Silence de douze minutes pendant parefeu : la sortie du Python enfant restait dans son tampon (tuyau) — les sous-commandes écrivent désormais sans tampon.

Technolibre (lancée par l'exploitant) : arrêt à monter, mon-01 en échec dans common_packages — apt-get dist-upgrade refusé, verrou dpkg tenu par unattended-upgrades, né avec la VM. La tâche portait pourtant lock_timeout: 300 : il ne protège que la vérification du MODULE ; apt-get, appelé ensuite, n'attend pas — APT 3.0 ne donne d'attente qu'à la commande apt (Binary::apt::DPkg::Lock::Timeout "120"). Le verrou a été repris entre les deux. Correction : DPkg::Lock::Timeout posé pour tout appel d'APT (/etc/apt/apt.conf.d/10setops-verrou), en toute première tâche du rôle. Rien à voir avec la restauration : un tirage au sort du premier démarrage, que la troisième reconstruction de Chezlepro n'avait pas tiré.

Reprise DEPUIS=monter, second arrêt : collab-01 en échec — mais la tâche avait été tuée SUR L'AC (Killed, rc=137). client_pki : Dériver l'empreinte du root CA était déléguée à infra-pki-01 pour chacun des 13 hôtes, en parallèle : treize modules Python sur une machine de 765 Mo et 1 vCPU. Le noyau (OOM) a tué le module de collab-01, et Alloy au passage. L'empreinte est la même pour toute la flotte : run_once. Éprouvé depuis le runner de Technolibre : 1 délégation au lieu de 13, client_pki sans échec, AC saine.

2026-09-30 (53) — Troisième reconstruction (Chezlepro, lancée par l'exploitant) : arrêtée au pare-feu

L'exploitant a lancé lui-même make reconstruire-locataire TENANT=OPS-Chezlepro CONFIRMER=true. sauvegarder → raser → creer → inseminer → armer → monter : sans intervention. Arrêt à parefeu (code 4), deux fois : Icinga portait des critiques sauvegarde puis restauration sur les sept détenteurs d'état.

Le défaut était dans (49). Le premier rapport à un Icinga neuf se déclenchait sur le changement de icinga-ca.crt — or client_sante dépose le MÊME fichier, plus tôt dans le déploiement : sur une flotte neuve, il était déjà là, « ok », et rien ne partait. L'épreuve de (49) avait joué client_backup seul, fichier retiré : elle ne pouvait pas le voir. sauvegarde est passé au vert à 17:01 par son minuteur de 4 h ; restauration aurait attendu le dimanche. Correction : client_backup retient lui-même l'empreinte de l'Icinga qui l'a entendu, dans un marqueur à lui, écrit APRÈS le rapport. Éprouvé sur le vrai cas (les nœuds neufs de Chezlepro) : premier rapport sur les 7, Icinga à 0 critique ; second passage sans aucun gestionnaire.

Et la procédure du pare-feu s'arrêtait sur des critiques qu'elle n'avait pas causés : elle relève désormais les critiques AVANT l'activation, et seul ce qui apparaît après compte. collab-01, activé deux fois pendant ces arrêts, n'a perdu aucun flux (16/16).

Reprise : make reconstruire-locataire TENANT=OPS-Chezlepro CONFIRMER=true DEPUIS=parefeu.

2026-09-30 (52) — make reconstruire-locataire : une commande, du site à la recette

Les reconstructions du jour passaient par cinq endroits — poste, runner du site, poste, runner du locataire, poste — avec des commandes SSH tapées à la main. La séquence vit désormais dans scripts/reconstruire_locataire.py, lancé depuis le POSTE (seul à tenir à la fois la voûte du locataire et l'accès au runner du site) :

sauvegarder → raser → creer → inseminer (runner du site) → armer (poste ; pause, sauf ARMER=oui) → monter (make monter-flotte sur le runner du locataire, suivi toutes les 30 s) → parefeu (--flotte) → bilan (ce que chaque jeu d'état est devenu).

TENANT= désigne l'écosystème en toutes lettres ; le nom qu'exige raser en est DÉRIVÉ (raser.nom_court, une seule dérivation), pas redemandé. La première version exigeait aussi INSTANCE= — relevé par l'exploitant : « pourquoi cette cible a besoin de se faire dire "chezlepro" deux fois ? ». Le garde-fou de raser n'a de sens que pour raser seul, qui rase l'écosystème du lien instance/ ; ici, rien d'implicite n'est à confirmer. raser n'a pas d'autre condition que sa confirmation. Arrêt à la première étape en échec, avec la commande de reprise (DEPUIS=<étape>) ; journal complet dans <locataire>/logs/.

Le runner du site se met à jour avant sa première étape (quelle que soit la reprise) : il tire le moteur, le plan du locataire et SON dépôt de site — déduit du lien underlay.yml, pas nommé —, en avance rapide seulement, et refuse devant une modification locale (éprouvé : une ligne ajoutée au README du site → REFUS, rc=1 ; fichier remis). Avant, seul raser tirait, deux dépôts sur trois, et une reprise DEPUIS=creer ne tirait rien.

Éprouvé sur Technolibre de armer à bilan (les étapes non destructives) : 26 min, 0 échec — armement, flotte montée et recettée, pare-feu 13/13 sans flux perdu, bilan. Un défaut de forme corrigé : chaque relevé de suivi recopiait tout le journal distant ; seul le nouveau y va désormais. Reste l'épreuve entière, destruction comprise.

2026-09-30 (51) — Ce qui était fait à la main dans les reconstructions entre dans le code

Le bilan de l'exploitant : aucune des deux reconstructions du jour n'est allée au bout seule. Au-delà des défauts corrigés, trois gestes vivaient hors du dépôt :

  1. La séquence du runner du locataire était un script écrit dans /tmp du runner. Elle devient make monter-flotte CONFIRMER=true : flux, AC et DNS (_amorcer-socle), deployer-tout, valider — étapes horodatées, arrêt à la première en échec. reconstruire s'appuie désormais dessus (et gagne valider, qu'il n'avait pas).
  2. La réactivation du pare-feu Proxmox était une boucle tapée à la main. eprouver_parefeu.py --flotte porte la procédure pour toutes les VM, une à une, le runner en dernier, arrêt au premier refus — la règle qui la rend sûre vit avec elle. Cibles parefeu-verifier-flotte (mesure) et parefeu-activer-flotte (depuis le poste : lui seul tient à la fois les accès du locataire et le runner du site).
  3. La sauvegarde fraîche avant de raser reste une ÉTAPE du runbook flotte-refaire, pas une condition de raser : décision de l'exploitant, « une confirmation suffit ».

--flotte a trouvé un défaut dès sa première passe (lecture seule, Technolibre) : il s'est arrêté sur infra-edge-01, un flux 10.37.0.17 → 443 « hors des règles ». C'était le navigateur de l'exploitant, depuis la zone d'administration du site — légitime, et effectivement admis par Proxmox. La matrice ne retenait des IPSet que les membres qui sont des MACHINES ; les RÉSEAUX (t23-admin : 10.37.0.0/24…) étaient ignorés, et l'observateur ne connaissait l'administration que sur le port 22. Les réseaux de chaque règle (exclusions ! comprises) comptent désormais sur tous les ports. Seconde passe : 13/13, runner en dernier, aucun flux perdu. Ce faux refus aurait bloqué une reconstruction le jour où l'exploitant a un onglet ouvert sur ses services — c'est-à-dire presque toujours.

2026-09-30 (50) — Le web frontal n'est plus un détenteur d'état

Le frontal RELAIE : ses vhosts, son WAF et sa page 404 se redéploient depuis le dépôt. Le catalogue de sauvegarde lui gardait /srv/web, venu de l'époque où il servait du statique — ses instantanés étaient vides, et valider le disait « À CONFIRMER » à chaque passage. Retiré du catalogue et de la liste en clair : Icinga, le témoin de dépôt du site et P36 le suivent du même geste (P36 : 82 OK, 0 échec). Le frontal ne restaure plus rien.

Ce que ça a révélé : client_backup, devant un nœud qui n'a plus rien à sauvegarder, retirait la sauvegarde mais pas les VÉRIFICATIONS du dépôt et de la restauration — elles auraient rapporté, toutes les quatre heures, à des services qu'Icinga ne déclare plus. Elles partent désormais avec elle. L'intégration client_backup quitte ensuite le plan des frontaux de Chezlepro et de Technolibre.

2026-09-30 (49) — Un Icinga neuf entend les nœuds tout de suite (signal corrigé en (53))

Le défaut (45, point 4) : un Icinga neuf marque sauvegarde et restauration CRITIQUES tant qu'aucun rapport n'est arrivé — voulu : le silence doit alerter. Mais les minuteurs ne rapportent que toutes les 4 h (dépôt) et le DIMANCHE (restauration) : après chaque reconstruction, Icinga restait rouge jusqu'à une semaine, et on déclenchait les contrôles à la main (Technolibre, puis Chezlepro, ce 2026-09-30).

Le signal retenu : le compte de rapport d'un nœud ne sait que DÉPOSER, il ne peut pas demander à Icinga s'il l'a déjà entendu. Mais chaque nœud recopie l'AC d'Icinga : un Icinga refait a une AC neuve, un nœud refait n'en a pas encore de copie. Dans les deux cas, cette copie change — et elle seule notifie « Premier rapport a Icinga » : déposer, vérifier le dépôt, vérifier la restauration, dans cet ordre. Une retouche de gabarit ne le déclenche pas : elle ne dit rien de ce qu'Icinga a reçu.

Éprouvé sur web-frontal-01 de Technolibre, copie de l'AC retirée (Icinga « neuf ») : les trois unités tournent (04:39:37, :40, :42), rc=0 — donc acceptées par Icinga, le script échouant sinon. Second passage : aucun gestionnaire, changed=0. Garde statique ajoutée à test_restauration.py.

2026-09-30 (48) — Chezlepro rasée et reconstruite par les runners : l'état est revenu, à l'identique

La preuve visée : que (47) remette réellement l'état, sur une vraie reconstruction.

Déroulé : make sauvegarder-maintenant (8 instantanés à 03:06) ; empreintes témoins relevées ; depuis le runner du site raser (13/13), flotte-creer, inseminer ; armement depuis le poste (voûte, clé de voûte, identité SSH du runner reprise de la voûte) ; sur le runner de Chezlepro : _amorcer-socle, deployer-tout, valider — 0 échec.

Chaque jeu a choisi l'instantané de 03:06, le dernier avant la naissance des machines (03:12), et l'a remis :

Témoin Avant Après
racine de l'AC F0:32:89:A0:BB:98:79:AC identique — la flotte s'est enrôlée auprès d'elle
sysadmin empreinte 4406f2fa…, changé le 2026-09-16, pas de changement forcé identique
annuaire 12 entrées 12
Nextcloud 217 fichiers, instanceid ocytcfy7ss5a identique
clé DKIM 760208b7… identique — l'enregistrement publié reste valable
Keycloak 100 tables, 2 utilisateurs identique
Nextcloud (base) 139 tables 139
boîtes root root (+ testmail, écrit par valider)

Un défaut trouvé, dans le code d'hier soir : la mesure « l'annuaire est-il vierge ? » filtrait par grep -v — sur un annuaire VRAIMENT vierge, plus aucune ligne, grep sort en 1, pipefail fait échouer la tâche. Technolibre, déjà peuplée, ne pouvait pas l'exercer. Comptage par awk, puis reprise à deployer-tout : l'AC et les bases, déjà remises, ont été reconnues « actées » et n'ont pas été rejouées.

Après : les 8 dépôts passent la garde ; contrôles de dépôt et de restauration rapportés à Icinga ; recette — fédération LDAP authentifiée, passerelle vers Keycloak, vigie, Nextcloud, Dovecot ; essai.chezlepro.ca sur .61 → 200, page absente → la 404 du PSPBT. Pare-feu Proxmox réactivé VM par VM : 13/13, flux identiques avant et après, Icinga à 0 critique.

Ce que ça prouve, et ce que ça ne prouve pas. Une reconstruction complète d'un écosystème, conduite par les runners, rend l'état qu'on lui a confié — la limite qu'énonçait honnêtement (46). Le SITE, lui, n'a toujours jamais été reconstruit ; et chaque reconstruction a encore trouvé un défaut (ici un seul, dans le code neuf).

2026-09-30 (47) — La reconstruction remet l'état : chaque rôle propriétaire restaure le sien

Le défaut (46) : une reconstruction repartait d'un état neuf. Les instantanés se restauraient pour PROUVER qu'ils s'ouvrent, jamais pour être remis en service.

Le principe retenu : pas d'étape à part dans la chaîne — chaque rôle qui POSSÈDE un état le remet au moment où il le créerait neuf, et l'ordre vient des couches. La chaîne des runners (_amorcer-socle → deployer-tout) et make reconstruire n'ont pas changé.

Rôle Point de remise
serveur_step_ca avant step ca init ; refus si la voûte n'ouvre plus les clés restaurées
serveur_postgresql bases juste créées, chacune rejouée en UNE transaction
serveur_openldap fin de rôle (le LDIF exige ppolicy), puis comptes de service réalignés sur la voûte
serveur_nextcloud fin de rôle : base + fichiers + config ensemble, occ upgrade
serveur_rspamd avant la génération DKIM — sinon chaque reconstruction changeait la clé publiée
serveur_dovecot, serveur_forgejo, serveur_web_* racine encore vide

Nextcloud se remet en fin de rôle, pas avant l'installation : le code de user_oidc et richdocuments n'est pas sauvegardé ; restaurée avant, la base les dirait installés et occ app:install refuserait. serveur_postgresql exclut donc sa base (et celle d'Icinga, dont l'historique est lié à un environnement non sauvegardé).

Quelle incarnation : le dernier instantané pris AVANT la naissance de la machine — la date de sa clé d'hôte SSH (machine-id, lui, vient du gabarit : tous les nœuds portent le 2026-09-01). Un état en place n'est jamais écrasé d'office ; ce qui est remplacé est mis de côté (/var/backups/setops-avant-restauration/) ; chaque jeu laisse un marqueur.

La sauvegarde refuse de déposer tant qu'un état d'avant attend (code 3). Sans cette garde, restic forget --keep-daily 7 aurait gardé l'instantané de l'état NEUF et chassé celui d'avant dès qu'ils tombaient le même jour — ce matin, ils étaient à cheval sur minuit par hasard.

Nouveaux gestes : make sauvegarder-maintenant (juste avant raser, inscrit au runbook flotte-refaire), make restauration-etat, make restauration-renoncer. Outil de nœud utilisable sans Ansible : setops-restaurer (avec des répétitions qui ne touchent à rien : annuaire --essai, base --vers, fichiers --vers).

Garde : scripts/tests/test_restauration.py — chaque détenteur d'état du catalogue doit avoir son point de remise (une liste qui suit une autre prend du retard), et l'outil, rendu depuis son gabarit avec des doublures de restic/psql, doit choisir l'incarnation d'avant, refuser d'écraser, bloquer puis libérer la sauvegarde, et couper la section d'une base avant le DROP DATABASE postgres;. Le test a trouvé un défaut avant tout déploiement : une apostrophe dans ${c:-…}, que bash lit comme un guillemet ouvrant.

Déployé sur Technolibre (runner, deployer-tout) : 13 hôtes, 0 échec. Chaque jeu vivant reconnu en_place (rien écrasé) ; les deux serveurs web, vides, ont pris le chemin a_restaurer depuis leur instantané d'avant. make sauvegarder-maintenant : les 8 dépôts passent la garde. Répétitions sur les vraies données, sans rien toucher : annuaire à blanc (12 entrées rejouables depuis 71fb5481), courriel remis dans un répertoire jetable.

La répétition a trouvé un défaut : psql tourne en postgres, qui ne lit pas le répertoire jetable de root (0700) — en reconstruction, la base serait restée vide et le déploiement aurait échoué. La section passe désormais par l'entrée standard ; le test l'exige.

Pas encore éprouvé par une reconstruction réelle : l'épreuve est la prochaine.

2026-09-30 (46) — Technolibre : l'état d'avant la reconstruction remis en place, sans reconstruire

Le constat : après (45), le mot de passe de sysadmin était revenu à celui de l'amorçage. La reconstruction n'avait rien restauré — aucune étape de Set-OPS ne réinjecte les instantanés. « Restauration vérifiée » (make valider) veut dire : restaurée dans un répertoire temporaire et lue, pas remise en service. Chezlepro est dans le même cas (racine d'AC et annuaire nés le 2026-09-13).

Perdu, mesuré contre l'instantané de 23:50 (restic-tech) : sysadmin (changé le 2026-09-16), racine et intermédiaire de l'AC (nouvelle empreinte), bases Keycloak et Nextcloud, fichiers Nextcloud (197 → 108), une boîte aux lettres.

Remis en place, sur les machines existantes (l'état neuf mis de côté avant chaque geste, dans /root/avant-restauration-2026-09-30 de chaque nœud) :

  • annuaire (idm-01) : slapadd -u à blanc, puis base remplacée ; empreinte du mot de passe = celle d'avant, sans changement forcé. amorcage_acces ne touche qu'un compte absent : il ne le réécrasera pas ;
  • Keycloak (data-sql-01) : section keycloak du pg_dumpall rejouée dans une base vide, Keycloak arrêté ; sonde identite verte (fédération LDAP authentifiée) ;
  • Nextcloud (collab-01 + data-sql-01) : base, data/ et config/ ensemble (les secrets d'instance voyagent avec les données), cache Redis purgé, occ upgrade (richdocuments) ;
  • courriel (infra-mail-01) : /var/vmail.

Le piège du runbook s'est présenté : la section nextcloud se terminait par DROP DATABASE postgres;. La découpe coupe désormais aussi sur DROP DATABASE, et la garde « hors-périmètre » l'avait vu.

Non remis : l'AC (aucune confiance extérieure ne s'y rattache — le poste ne fait confiance à aucune des deux racines ; la remettre imposerait de réenrôler les 13 machines) ; icingadb (historique de supervision) ; forgejo (base orpheline, plus de forge au plan).

Contrôle : make valider depuis le runner de Technolibre, 0 échec ; sondes identité, passerelle, vigie, collaboration, boîtes, tableaux vertes.

Reste à coder : l'étape de réinjection dans la reconstruction elle-même, dans l'ordre AC → confiance de la flotte → annuaire → bases → fichiers et courriel. Chezlepro n'est pas reconstruite tant qu'elle n'existe pas.

2026-09-30 (45) — Technolibre reconstruit depuis le runner du site, puis par son propre runner

La preuve visée : la limite 2 du jalon de reconstruction autonome — un locataire refait PAR LES RUNNERS, pas depuis le poste. Le runner du site rase, recrée les VM et amorce le runner du locataire (sans aucun de ses secrets) ; le poste l'arme (sa voûte et sa clé, le seul geste humain) ; le runner du locataire déploie et valide sa flotte.

Déroulé : sauvegarde fraîche des 8 machines qui portent un état (vérifiée) ; raser (13 VM, toutes 123…) et flotte-creer depuis le runner du site ; inseminer (socle + moteur) ; armement depuis le poste ; sur le runner de Technolibre : amorçage du socle (AC, DNS), deployer-tout, make valider — 0 échec, valider vert, restaurations vérifiées. Puis le pare-feu Proxmox VM par VM (procédure éprouvée : 13/13, flux identiques avant et après, Icinga à 0 critique), et la recette.

Quatre défauts trouvés — c'est le rôle de l'épreuve — tous corrigés dans le code :

  1. L'identité SSH du runner changeait à chaque reconstruction : il fabriquait sa paire à sa naissance, la flotte naissait avec la clé DÉCLARÉE — refus sur les 13 machines. Chez Chezlepro, la clé déclarée était déjà celle d'un runner disparu. Désormais la clé privée vit dans la voûte du locataire, l'armement la remet, et refuse si sa clé publique n'est pas déclarée au plan (garde éprouvée en défaut). Pour cette passe, les 12 VM encore vierges ont été recréées avec la bonne clé.
  2. make flux sur le runner d'un locataire amputait les pare-feux : sans underlay.yml ni plan du site, les paires qui en dépendent ne se résolvaient à rien — ni transfert de zone depuis le DNS public du site, ni collecte de la fabric, ni insémination. Le générateur s'abstient désormais (les règles committées font foi). Vu sur le runner avant tout dommage durable ; les fichiers committés ont été remis pendant le déploiement.
  3. Keycloak : un client absent faisait échouer les mappers de groupes et de rôles — sous set -euo pipefail, grep sans correspondance sortait avant la branche « client absent ». Jamais vu tant qu'un client forgejo subsistait d'une époque où le locataire avait une forge.
  4. (conception — corrigé en (49)) Un Icinga neuf marque aussitôt sauvegarde et restauration critiques (« jamais rapporté ») ; la restauration n'étant vérifiée que le dimanche, elle resterait rouge jusque-là après chaque reconstruction. Ici : sauvegarde, vérification du dépôt et contrôle de restauration déclenchés à la main sur la flotte reconstruite.

Et une garde qui a fait son travail : le runner a refusé de déployer un génome en retard d'un commit sur la forge du site — publié pendant qu'il travaillait.

Recette : Proxmox 0 écart ; frontière mesurée conforme ; depuis l'Internet sur .60 : 404 du frontal, SMTP (bannière de son edge-mta-01), 465/587/993 en TLS 1.3 ; zone technolibre.ca republiée et reprise par le DNS public du site (nouveau numéro de série).

2026-09-29 (44) — La page du PSPBT devient la page 404 du frontal de Chezlepro

Précision de l'exploitant sur (43) : c'est le frontal qui doit servir cette page, comme page 404 par défaut — pas le dorsal comme page d'un site.

  • Le rôle serveur_web_frontal accepte une page 404 du locataire (serveur_web_frontal_page_404, un chemin dans le dépôt du locataire) : le serveur par défaut la sert, statut 404, à tout nom qu'il ne publie pas ; sans page déclarée, il ferme toujours sans réponse (444). La page est internal (pas de lecture directe de /404.html : 404 aussi), avec sa propre CSP (styles et scripts en ligne, rien d'extérieur). Le nom inconnu n'obtient toujours aucun service.
  • Chezlepro, puis Technolibre à la demande de l'exploitant : contenus/frontal-404.html (le fichier site web.html de l'exploitant), déclaré dans leurs variables du frontal. Technolibre éprouvé de même sur 69.70.26.60 (racine, chemin quelconque, nom inconnu → 404).
  • essai.chezlepro.ca revient à sa page de test, CSP par défaut (la CSP propre au site, posée en (43), est retirée).

Éprouvé depuis l'Internet : http://69.70.26.61/, un chemin quelconque, /404.html et un nom inconnu → 404 avec la page du PSPBT ; essai.chezlepro.ca → 200, la page de test.

2026-09-29 (43) — essai.chezlepro.ca sert la page du « Parti de la Sainte-Paix et du Bon Temps »

À la demande de l'exploitant : le fichier site web.html (le seul du dossier parti politique du bonheur national) devient public/index.html du dépôt essais/essai-web sur la forge du site (mise à jour par l'API, depuis le runner du site), puis le dorsal de Chezlepro le tire.

Une CSP propre à ce site. La page porte ses styles et ses scripts dans le HTML (<style>, <script>, onclick) : la CSP par défaut du dorsal ('self') les aurait bloqués — page sans mise en forme, boutons muets. Le site essai déclare donc style-src et script-src avec 'unsafe-inline', pour lui seul ; rien d'extérieur n'est autorisé. Vérifié depuis l'Internet : 200, l'en-tête CSP servi est celui du site.

2026-09-29 (42) — Le WAF des frontaux passe en blocage

Avant de basculer, le journal d'audit relu : chez Chezlepro, la seule transaction signalée était l'injection de test ; chez Technolibre, les requêtes du prototype (dont ?q=bonjour marqué 920350 parce qu'il visait une adresse IP brute). Aucune requête légitime signalée — sur un trafic encore mince, ce qui est dit dans la déclaration.

Par locataire (group_vars/serveur_web_frontal.yml : serveur_web_frontal_waf_mode: "On"), pas dans le défaut du rôle : un nouveau locataire commence en détection et observe.

Éprouvé depuis l'Internet, sur essai.chezlepro.ca : page et recherche normales → 200 ; injection SQL → 403 ; script injecté (XSS) → 403 ; remontée de répertoires → 400 (nginx la refuse avant même le WAF). La sonde frontal dit désormais le mode en clair (« blocage », « détection seule »).

Un faux positif se corrige par une exclusion ciblée du CRS (REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf), pas en revenant en détection.

2026-09-29 (41) — Le chemin public éprouvé de bout en bout : Internet → .61 → WAF → dorsal → forge du site

Le contenu vient de la forge du site. Organisation essais (distincte du génome), dépôt public essai-web et sa page public/index.html, créés par l'API de la forge depuis le runner du site ; lecture anonyme vérifiée.

Le dorsal fait confiance à la forge du site, et à elle seule. Comme le runner : la racine de l'AC du site n'entre pas dans le magasin système, git config http.<url>.sslCAInfo la limite à l'adresse de la forge. L'adresse et la racine sont LUES chez le runner du locataire (serveur_ops_forge_amont, serveur_ops_forge_amont_ac) — pas de seconde copie. Premier passage : le répertoire de la racine manquait (créé désormais). Posé aussi chez Technolibre.

Déclaré au plan de Chezlepro : application essai_web (dorsal, port 80, expose: essai.chezlepro.ca) et le site essai du dorsal ; inventaire régénéré (make instancier, écart relu : serveur_web_dorsal_hostname et sans_exposition du frontal — une variable que seul l'edge lit ; le certificat interne du frontal passe par client_pki, filtré).

Éprouvé depuis l'Internet (le wifi du poste sort par un autre accès) :

  • le DNS public du site répond essai.chezlepro.ca → 69.70.26.61 — la zone du locataire, notifiée et transférée ;
  • la page est servie : .61 → frontal → dorsal ;
  • une injection SQL est journalisée par le WAF (942100, 949110) et passe (200) : mode détection, comme prévu ;
  • un nom inconnu sur .61 est refusé.

Sondes : sites-servis « 1 servi », frontal « 1 exposition publique servie, WAF CRS en DetectionOnly ».

Reste : Let's Encrypt (le 443 attend son certificat) ; passer le WAF en blocage après lecture de son journal.

Plancher de Chezlepro (fait ensuite, après simulation) : une seule ligne ajoutée sur les 13 machines, essai.chezlepro.ca → 10.17.21.31 (le frontal) ; toutes la résolvent, plancher reste conforme (il ne compte que le domaine interne). Depuis une machine de la flotte, le 80 du frontal ne répond pas, et c'est voulu : il n'est ouvert qu'à l'Internet et à l'administration.

2026-09-29 (40) — Le domaine public désigne le frontal ; les noms publics pointent vers l'adresse du locataire ; le dorsal refuse les inconnus

Point 3. domaines.yml des deux locataires : le domaine public (chezlepro.ca, technolibre.ca) désigne serveur_web_frontal — l'edge garde les noms internes. Aucun nom public n'est encore exposé : rien de visible ne change avant le premier site.

Les noms exposés d'une zone publique pointent vers l'adresse DU LOCATAIRE. Ils pointaient vers ip_publique_site (.62), l'adresse du site — donc vers son DNS public, pas vers le frontal du locataire. serveur_powerdns_ip_expositions = ip_publique (repli ip_publique_site) ; les serveurs de noms (dns1) restent à l'adresse du site.

Le DNS public, confirmé tel que l'exploitant le décrit : chaque locataire tient sa zone sur son PowerDNS (instance publique, signée) ; le DNS public du site (site-dnspub-01, joint par .62:53) la reçoit par NOTIFY + transfert TSIG ; le registraire désignera ce serveur. Éprouvé à l'instant : le retrait de git.chezlepro.ca (entrée 35) déployé chez Chezlepro, le site a pris le nouveau numéro de série et ne sert plus ce nom.

client_pki ne demande à l'AC interne que le domaine interne. step-ca n'a aucune politique de noms : il aurait signé les noms publics du frontal, qui relèvent de Let's Encrypt. Filtre sur le champ domaine de l'exposition. Un premier essai par expression régulière vidait la liste de l'edge (l'échappement de \. ne passe pas de la même façon dans un bloc YAML replié) : vu au rendu, avant tout déploiement. Vérifié ensuite : l'edge garde ses six noms, chaque service du site le sien, le frontal le seul sien.

Le dorsal refuse les noms inconnus (444, comme l'edge et le frontal) ; le site Debian par défaut est retiré. Posé chez les deux locataires : sondes sites-servis et apps-servies saines (elles interrogent par nom et par port d'application, pas par le défaut).

2026-09-29 (39) — Le web frontal devient reverse proxy + WAF ; le dorsal porte les sites

L'architecture, précisée par l'exploitant : le web dorsal est le serveur des sites ; le web frontal est le reverse proxy et le WAF devant les services web publics du locataire. L'edge reste la porte des services internes, pour l'administration.

Le frontal (serveur_web_frontal, réécrit) :

  • ses vhosts dérivent des expositions dont le domaine public le désigne (domaines.yml, edge: serveur_web_frontal) — la même dérivation que l'edge, filtrée sur son groupe ; il ne sert plus rien lui-même ;
  • WAF : ModSecurity v3 (libnginx-mod-http-modsecurity) + OWASP CRS 3.3.7 (modsecurity-crs), paquets Debian. Éprouvé avant d'écrire le rôle, sur le vrai frontal : une injection SQL journalisée (942100, 949110) en détection, refusée (403) en blocage, une requête saine servie dans les deux cas. Un premier essai « prouvait » l'inverse : return 200 s'exécute avant la phase où ModSecurity inspecte — l'essai, pas le WAF, était faux. L'inclusion livrée par Debian ne charge que le moteur : le rôle compose moteur, mode, CRS et exclusions. DetectionOnly d'abord ; On après lecture du journal ;
  • serveur par défaut qui refuse tout nom inconnu (444) ; 80 seulement en attendant Let's Encrypt ; sonde frontal : nginx et configuration, module WAF chargé et CRS présent, chaque exposition publique servie à travers lui.

Le dorsal (serveur_web_dorsal) reprend les sites statiques (clone, vhost, CSP) et la sonde sites-servis ; il est relayé par l'edge (noms internes) ET le frontal (noms publics). Flux : frontal → dorsal 80 ; le frontal ne reçoit plus rien de l'edge.

Posé chez les deux locataires : rôles, pare-feux des deux machines, Proxmox (2 IPSets, 4 groupes), Icinga. frontal et sites-servis au vert (aucune exposition publique ni site déclarés encore) ; frontal → dorsal : 200 ; frontière : rien à changer. Au premier déploiement, nginx -t a refusé la configuration AVANT tout rechargement (server_tokens déjà actif dans le nginx.conf de Debian 13) — nginx a continué de servir l'ancienne.

Reste : le domaine public de chaque locataire désigne encore l'edge (domaines.yml) ; le dorsal sert la page d'accueil de Debian à un nom inconnu (un serveur par défaut qui refuse, comme à l'edge et au frontal) ; Let's Encrypt.

2026-09-29 (38) — Frontière appliquée : chaque locataire entre et sort par sa propre adresse

Appliqué (plan relu avant) : 47 créés, 2 retirés ; convergé, 0 écart, 336 règles ; pool et WAN d'accord (4/4).

Prouvé par la table d'états de la frontière, pas seulement par un connect().

  • Sortie : 10.17.20.11 (Chezlepro) sort en 69.70.26.61, 10.23.20.11 (Technolibre) en 69.70.26.60.
  • Entrée, depuis l'Internet (le wifi du poste sort par un autre accès, vu comme 69.70.26.50) : .61:25 → 10.17.16.21:25 et .60:25 → 10.23.16.21:25, chacun avec la bannière de SON edge-mta-01 ; 465, 587 et 993 en TLS 1.3 sur les deux adresses.
  • Le 80 n'aboutit pas : rien n'écoute sur le web frontal, qui n'a encore aucun site déclaré (serveur_web_frontal_sites: []). La chaîne (redirection, filtre, Proxmox, hôte) est posée ; à revérifier au premier site publié, avec son TLS.

Une erreur de lecture, de ma part. Un premier test concluait « 25 muet » : il lisait la bannière trop tôt, dans une construction shell qui avalait la réponse. Repris avec une vraie connexion chronométrée : établie en 0,00 s, bannière immédiate. Le fournisseur de l'accès wifi n'y était pour rien (un MX tiers répondait, ce que j'avais vérifié).

2026-09-29 (37) — Les services publics complets : web frontal et courriel chiffré ; externe ouvre vraiment chez l'hôte

Ce qui manquait au plan (36), relevé par l'exploitant : le web public vers le web frontal, et les ports du courriel chiffré.

  • Web frontal (serveur_web_frontal) : 80 et 443 externe — la porte publique du locataire. Le 443 attend son TLS (Let's Encrypt, plus tard) : la redirection est prête.
  • Postfix : la soumission 587 (STARTTLS obligatoire) devient publique ; le 465 (TLS direct, submissions, RFC 8314) est ajouté au rôle, mêmes restrictions (authentifié ou refusé). Déployé chez les deux locataires : 465 et 587 répondent en TLS 1.3 depuis la flotte. Dovecot : IMAPS 993 seulement (pas de POP3, donc pas de 995).

Un défaut plus ancien, trouvé en lisant les pare-feux générés. Un flux externe combiné à une source nommée — le 25 de Postfix [externe, client_smtp], la soumission [flotte, externe] — rendait une règle nftables limitée à CETTE source : l'Internet, que la frontière redirige et que Proxmox laisse passer, aurait été refusé par l'hôte lui-même. resoudre_flux.py ouvre désormais ces ports à tous chez les locataires (sauf le SSH, dont l'externe est celui de la gestion). Au site, rien ne s'élargit : un premier essai y retirait l'ICMP de découverte de MTU et le 25 du relais ; il a été remplacé avant toute pose.

Posé : Postfix (465), pare-feu des machines (edge-mta-01, web-frontal-01 des deux locataires ; 465 limité à la flotte sur site-mon-01), pare-feu Proxmox (4 groupes, convergé). Au passage, la copie de /etc/hosts dans le chroot de Postfix était restée à l'ancien plancher : un plancher rejoué ne la rafraîchit pas — seul un passage du rôle Postfix le fait.

Frontière — en attente de l'accord de l'exploitant : 47 à créer, 2 à retirer. NAT sortant .61 / .60 ; par locataire, six redirections depuis son adresse (80, 443 → web frontal ; 25, 465, 587 → edge-mta-01 ; 993 → infra-mail-01), leurs règles WAN, et les chemins gestion/VPN vers 465, 587, 80, 443 ; le 465 du relais du site pour les zones du site.

2026-09-29 (36) — Le NAT des locataires par leur adresse publique (devis ; application en attente)

La règle : les services publics d'un locataire passent par SON adresse publique, et sont redirigés par la frontière ; sa sortie est traduite avec cette même adresse.

Ce que faisait la frontière. Sortie : les deux locataires et le site sortaient tous par l'adresse du WAN (wanip, .62) — vus de l'Internet, une seule machine. Entrée : seule la redirection du site existait (DNS public 53) ; un flux externe de locataire (Postfix 25, Dovecot 993) produisait une règle de filtrage vers une adresse privée, sans redirection devant — une publication qui avait l'air faite et ne recevait rien.

Le devis (devis_opnsense.py) :

  • NAT sortant de chaque locataire vers l'adresse que le pool du site lui attribue ; le site garde wanip ; sans attribution, repli sur wanip (et une note).
  • Tout flux entrant externe d'un locataire — hors SSH de gestion, hors ICMP — devient une redirection DEPUIS son adresse publique vers la machine unique qui porte le rôle (plusieurs machines : aucune redirection, une note). La règle WAN existante voit l'adresse traduite.
  • Gardes : pas deux redirections sur la même adresse/protocole/port public ; l'adresse d'une redirection de locataire est celle que le pool lui attribue.
  • appliquer_opnsense.py : l'adresse publique entre dans l'identité d'une redirection (les deux locataires publient le 25) — sauf wanip, pour que celle du site ne soit pas recréée ; le plan l'affiche.

Plan (non appliqué) : NAT Chezlepro → .61, Technolibre → .60 (2 remplacés) ; redirections .61/.60 : 25 → edge-mta-01, 993 → infra-mail-01. Le pare-feu Proxmox reçoit déjà ces deux ports depuis +t<i>-internet (entrée 33).

2026-09-29 (35) — Le pool d'adresses publiques du site ; chaque locataire reçoit la sienne

La règle : l'hébergeur détient un pool d'adresses publiques, et c'est le site qui en attribue une à chaque locataire — celle de son web frontal et de son courriel.

L'état du boîtier, relu avant d'écrire. Le WAN de la frontière porte 69.70.26.62 (le site) et quatre alias IP /32 posés par l'exploitant : .58, .59, .60, .61. L'outillage ne gère pas les adresses virtuelles : un frontiere-appliquer ne les retire pas.

Le pool, déclaré au site (SITE-Chezlepro/opnsense.yml, opnsense_ips_publiques) : .61 → OPS-Chezlepro, .60 → OPS-Technolibre, .59 et .58 libres.

  • Le contrat du site (site_intrants.py) rend ip_publique : l'adresse attribuée au locataire monté. Les deux locataires la déclarent (10-intrants.yml) ; make site-intrants-verifier est conforme chez les deux, et dit l'écart si l'un recopie une autre adresse (éprouvé : .59 déclarée chez Chezlepro → NON CONFORME, les deux valeurs données).
  • Le pool est validé au même endroit : adresse invalide ou privée, adresse du site (opnsense_wan_ip), locataire que le site n'héberge pas, locataire servi deux fois — refusés (test_pool_ips_publiques.py).
  • make frontiere-plan compare, en lecture seule, le pool aux alias du WAN : une adresse attribuée absente du WAN, ou une adresse du WAN hors du pool, est signalée. Aujourd'hui : 4 au pool, 4 sur le WAN.
  • git.chezlepro.ca → 69.70.26.59 retiré de la zone publique de Chezlepro : une copie de l'ancienne production, et .59 n'y sert plus.

ip_publique_site reste l'adresse du SITE (serveurs de noms publics). La suite : le web frontal de chaque locataire, joint par son adresse (redirection à la frontière, TLS Let's Encrypt).

2026-09-29 (34) — La frontière appliquée : l'edge hors de l'Internet, l'administration traduite chez les tenants

Un premier plan refusé. Retirer externe de l'edge faisait aussi disparaître, à la frontière, les chemins gestion → edge et VPN → edge des locataires : chez un locataire, le devis ne rendait le chemin du poste qu'au travers des flux externe (option poste). Les flux admin, appliqués par Proxmox et nftables, n'y étaient pas traduits — ils l'étaient au site. Le devis les traduit désormais chez les locataires aussi, par les mêmes portes que le SSH de gestion (gestion, WAN d'administration s'il est déclaré, VPN) ; un port non numérique (derive) est sauté avec une note plutôt que rendu en règle invalide.

Appliqué (plan relu avant) : 4 règles retirées — « WAN, n'importe qui → edge 80/443 » des deux locataires ; 18 ajouts — gestion/VPN → edge du site en 80, et gestion/VPN → Grafana 3000, Icinga Web 8080, console 8090 des locataires (déclarés admin par leurs rôles, déjà permis par Proxmox et nftables ; Icinga Web et la console n'écoutent qu'en local chez les locataires). Convergé : 0 écart, 293 règles. Depuis le poste : edges des deux locataires et du site en 443 (302) et 80 (301), Dovecot 993 en TLS 1.3. La route du poste vers le site (10.37.0.0/16 via 10.37.0.1), ajoutée par l'exploitant, ferme la régression notée en (33).

La mesure de la frontière accusait l'edge à tort. frontiere-mesurer rendait infra-edge-01:80 « rien n'est livré : l'hôte refuse ». C'était le serveur par défaut de nginx, qui ferme sans rien dire un nom inconnu (return 444) — la sonde envoie Host: sonde. Mesuré : l'edge ferme en 0,00 s (FIN, lecture vide) ; le contrôle et un port bloqué n'aboutissent pas (délai). sonde_tcp.py compte désormais une fermeture propre après la requête comme une réponse de la destination ; le silence reste AMBIGU, et le contrôle garde l'instrument (test_sonde_tcp.py : parle, lit-puis-ferme, muet). Mesure conforme chez les deux locataires. Au passage : la mesure écrit toujours dans instance/ — mesurer un autre locataire que celui monté écrase le relevé du premier.

Architecture précisée par l'exploitant, pour la suite : l'edge expose les services internes à l'administration ; les services publics d'un locataire le seront par son web frontal, avec des certificats Let's Encrypt ; le site attribue à chaque locataire une IP publique de son pool.

2026-09-29 (33) — L'edge fermé à l'Internet ; le pare-feu Proxmox reçoit ce que la frontière laisse entrer

La règle, mise au point par l'exploitant : les URL *.internal des edges ne s'ouvrent qu'à la zone d'administration de leur écosystème ; ce qui sera publié sur l'Internet le sera par le web frontal, pas par l'edge.

L'edge déclarait pourtant 80/443 externe. Conséquences, dérivées du même registre : la frontière portait « WAN, n'importe qui → edge 80/443 » chez les deux locataires, et le pare-feu de la machine (nftables) de chaque edge — site compris — acceptait 80 et 443 de toute source. Seul le pare-feu Proxmox, qui sautait tout externe, fermait la porte. Désormais serveur_nginx ne déclare plus d'externe : 443 depuis flotte et admin, 80 (redirection) depuis admin. Vérifié avant : le poste joint les edges par le réseau de gestion (10.37.0.17, règle admin), et les journaux d'accès des trois edges ne montrent aucun client hors flotte et administration.

Le pare-feu Proxmox et les flux externe. Il les sautait tous (« la frontière s'en charge ») ; depuis que les VM rejettent par défaut, un service publié aurait été refusé par sa propre VM — et le poste ne joignait pas Dovecot 993, que la frontière lui ouvre. Même partage que la frontière : un flux entrant externe est reçu depuis +t<i>-internet (0.0.0.0/1, 128.0.0.0/1, les trois blocs RFC 1918 exclus en nomatch — jamais une source vide, et un IPSet refuse /0), et depuis +t<i>-admin quand le poste en est client (poste, vrai par défaut ; faux pour le 25 de Postfix). Le SSH externe reste réservé à l'administration. Rendu : Postfix 25 (Internet), Dovecot 993 (Internet, admin), ICMP « fragmentation nécessaire » (découverte de MTU). appliquer_proxmox_fw pose et relit les exclusions. test_proxmox_fw_externe.py garde les invariants : edge jamais ouvert à l'Internet, tout externe reçu, aucune source vide, exclusions en nomatch.

Le web frontal ne déclare encore que le 80 depuis l'edge : le rendre public (son propre TLS, sa redirection à la frontière) est l'étape suivante, pas celle-ci.

Posé. Pare-feu Proxmox (depuis le runner du site) : 2 IPSets internet, 8 groupes mis à jour, convergé (0 écart). Premier passage : Proxmox a refusé frag-needed — nom nftables ; l'API attend fragmentation-needed (ICMP_PROXMOX dans le devis). Les autres règles des groupes srv-debian étaient restées en place (SSH 13/13 chez chaque locataire, ping de supervision). Nftables des trois edges, après simulation : les deux « toute source » retirées, 80 réservé à l'administration. Depuis le poste : Dovecot 993 répond (TLS 1.3) chez les deux locataires — il était rejeté par la VM — ; vigie/auth en 443 (302) et 80 (301 vers HTTPS) par leurs noms.

Une régression, de ma part : le poste ne joint plus l'edge du SITE. Il a une route par son lien de gestion (10.37.0.17) vers 10.17.0.0/16 et 10.23.0.0/16, mais aucune vers les zones du site (10.37.x) : il y va par le wifi (192.168.13.254) et arrive à l'edge avec une adresse hors administration. Seule la règle « toute source » le laissait passer ; je ne l'ai vu qu'après l'avoir retirée (le contrôle d'avant, sur les journaux d'accès, ne montrait que des adresses internes — la traduction en route cachait le chemin). Remède sur le poste, comme pour les tenants : 10.37.0.0/16 via 10.37.0.1 sur la connexion filaire.

2026-09-29 (32) — L'edge expose aux personnes ; entre machines du site, on va au service

La règle, énoncée par l'exploitant : l'edge sert à exposer des interfaces web ; les connexions entre les VM du site ne passent pas par lui. Les anciens planchers du site la respectaient (forge, observatoire, vigie → les services eux-mêmes) ; c'est la dérivation qui avait tort, en envoyant à l'edge tout nom d'un domaine qui en a un. Ma recommandation de (31) — rejouer ces planchers par l'edge — tombe.

Déclarée par domaine, pas imposée partout. Chez les locataires, les machines passent encore par l'edge pour auth.<domaine> : Keycloak n'écoute qu'en clair derrière lui, et le canal serveur-à-serveur de l'OIDC (Grafana, oauth2-proxy) en dépend. Nouveau champ de domaines.yml : resolution_interne: service | edge (absent = edge). Le site déclare service.

  • expositions_des_applications rend resout_vers ; une exposition qui se sert elle-même (sans port, ou sans edge) mène toujours au service.
  • Le plancher (hosts.j2) et la zone (zone.db.j2, A et empreinte _plancher) suivent la même règle ; test_empreinte_plancher.py couvre un nom résolu vers son service.
  • client_pki ajoute au certificat du service le nom qui mène à lui : sans cela, une reconstruction de site-forge-01 aurait rendu un certificat sans forge.genese.internal, que le runner du site appelle en direct.
  • Validation (valider_domaines) et schéma : valeur hors edge/service refusée. Pas de defaut au schéma : le GUI l'aurait écrit à chaque sauvegarde (test_rendu_gui l'a vu).

Posé au site, après simulation. Zone : console, forge, observatoire, vigie → leurs services (10.37.31.11, .33.11, .36.11, .36.11). Planchers des 9 machines : console et site-dnspub-01 ajoutés ; site-dnspub-01 ramené aux services. Cache d'Unbound vidé. Le runner du site joint la forge par son nom, en direct (ls-remote : tête publiée) ; les deux locataires ouvrent leur session SFTP de sauvegarde. Sonde plancher : 35 machines sur 35 conformes (13 + 13 + 9). Chez les locataires, rien ne change (simulation : 0 enregistrement, 13 planchers sur 13 intacts). Les runners des locataires joignaient déjà la forge du site en direct, par son adresse.

À savoir. Au prochain passage de client_pki au site, site-ops-01 recevra console.genese.internal dans son certificat (le nom mène désormais à lui) : réémission normale.

2026-09-29 (31) — Sonde plancher ; elle a trouvé une régression que j'avais causée au site, corrigée à la source

La sonde plancher (rôle client_sante, sur toute machine qui rapporte). Le plancher /etc/hosts et la zone PowerDNS suivent le même plan, chacun quand son rôle est rejoué (voir (30)). La zone publie désormais _plancher.<domaine> TXT "n=… sha256=…" : le nombre et l'empreinte des paires « nom adresse » que le plancher DOIT porter — flotte active ET planifiée, expositions du domaine. La sonde calcule la même chose sur /etc/hosts, en interrogeant l'autoritatif directement, sans transfert de zone (AXFR reste fermé). En cas d'écart elle nomme ce qu'elle peut : noms publiés absents (nombre), adresses différentes, noms inconnus de l'autoritatif. Manquant ou réadressé : critique ; en trop seulement : avertissement. seulement_si: hosts_statiques_actif. test_empreinte_plancher.py rend les deux gabarits sur un même inventaire et exige la même empreinte.

Chez les deux locataires : 26 machines sur 26 conformes (19 noms chacun). Mises en défaut sur mon-01 (Technolibre), sur des copies du plancher : vide, sans console → critique, « 1 nom publié absent » ; forge-01 en trop → avertissement nommé ; mon-01 réadressé → critique, les deux adresses données.

Ce qu'elle a trouvé au site — et ce que j'y avais cassé. En (30), j'ai redéployé la zone du site sans simulation préalable, en attribuant la réécriture au seul site-dnspub-01. La zone faisait en réalité pointer sauvegarde, pki et dns.genese.internal vers l'edge du site (10.37.37.11), qui ne les sert pas. Les locataires sauvegardent vers sauvegarde.genese.internal, résolu par le DNS du site : de 05 h 23 à 11 h 51, ce nom menait à l'edge, où aucun SFTP n'attend. Aucune sauvegarde n'est tombée dans la fenêtre (elles tournent vers 02 h 30), mais celles de la nuit auraient toutes échoué.

La cause est dans la dérivation. expositions_des_applications attribuait tout FQDN d'un domaine à l'edge de ce domaine. Or un edge ne publie que ce qui a un port : nginx n'écrit un vhost que pour une exposition qui en porte un. Depuis que genese.internal a un edge (14 septembre), les trois expositions sans port lui étaient attribuées ; les anciens planchers du site, antérieurs, les envoyaient encore aux bonnes machines, et la zone chargée avant (30) aussi. Corrigé : une exposition sans port se sert elle-même (test_expositions_sans_port.py) ; la preuve de prouver.py qui refuse qu'une exposition retombe sur son propre groupe dans un écosystème à edge excepte désormais celles sans port. Zone du site redéployée après simulation (trois A, vers 10.37.35.11, .32.11, .34.11), cache d'Unbound vidé pour genese.internal. Depuis les deux locataires, sauvegarde rend 10.37.35.11 et une session SFTP s'ouvre dans le bon dépôt. site-dnspub-01, dont le plancher avait été généré avec la dérivation fautive, rejoué seul : conforme.

Correction de (30) : la zone du site n'y avait pas seulement reçu site-dnspub-01 — elle avait aussi envoyé ces trois noms vers l'edge.

Ce qui reste, et qui est une décision. Les 8 autres machines du site ont un plancher antérieur à l'edge : forge, observatoire et vigie y mènent directement aux services, et console, site-dnspub-01 y manquent (le DNS du site les résout). La sonde les montre critiques. Les rejouer ferait passer le runner du site par l'edge pour joindre la forge en HTTPS : la lecture y a été éprouvée (ls-remote identique), mais l'edge limite une requête à 16 Mo et Set-OPS-public en pèse 171 — un envoi de rattrapage n'y passerait pas. Et client_pki retiendra, à son prochain passage au site, pki.genese.internal sur site-pki-01 plutôt que sur l'edge — le nom qu'il sert vraiment.

2026-09-29 (30) — console ne se résolvait pas : un plancher périmé et une zone jamais rechargée

Constat. Depuis l'edge de chaque locataire, console.<domaine> ne se résolvait pas, alors que l'edge la sert. Les autres expositions se résolvaient.

Première cause : le plancher /etc/hosts n'avait pas été rejoué. Il dérive du plan (inventaire + expose), mais n'est posé que par le socle (serveur_debian). La console a été ajoutée au plan après le dernier passage du socle ; seul ops-01, redéployé depuis, la portait. Rejoué seul (hosts_statiques, sans common_packages et sa mise à jour complète) sur les 26 machines des deux locataires, après simulation : console ajoutée, forge-01 et forge retirées (l'ancienne forge des locataires, sortie du plan).

Seconde cause, plus grave : PowerDNS servait une zone antérieure à son fichier. Le fichier de zone, réécrit le 16 septembre, portait console ; PowerDNS servait la version chargée à son démarrage (13 septembre chez Chezlepro, 14 chez Technolibre). Le rechargement ne passait que par un handler, et un handler en attente est abandonné quand une tâche échoue plus loin dans le même passage ; aux passages suivants, le fichier ne change plus et rien ne le redemande. La sonde zones comptait les zones servies, pas leur fraîcheur : verte.

  • Rôle serveur_powerdns : à chaque passage, chaque zone (interne et publique) dont le fichier est plus récent que son chargement (bind-domain-status) est rechargée (bind-reload-now). Ce qui est à jour n'est pas touché.
  • Sonde zones : critique si une zone servie est plus ancienne que son fichier au-delà de serveur_powerdns_sonde_tolerance (900 s), en nommant les zones. Mise en défaut par ce paramètre : critique.
  • Posé sur les trois (Chezlepro, Technolibre, site) : deux zones rechargées chez chaque locataire ; au site, la zone et une zone inverse réécrites (le DNS public site-dnspub-01 n'y était pas encore). zones au vert partout, « chacune conforme à son fichier ». console se résout depuis les deux edges, par le plancher et par PowerDNS.

Une erreur de ma part, rattrapée par la garde de syntaxe. ${#tableau[@]} dans une sonde ouvre un commentaire Jinja. test_sondes_syntaxe.py l'a vu ; mais j'avais enchaîné le déploiement de Technolibre sans attendre le test, et la pose de la sonde y a échoué (la réconciliation, elle, était passée). Corrigé et redéployé ; la configuration publique réécrite pendant ce passage raté ne différait que par des commentaires.

Ce qui n'est pas traité ici. Les machines des locataires interrogent le résolveur du site, qui répond NXDOMAIN pour leurs noms internes : leur résolution repose entièrement sur le plancher. C'est l'état voulu tant que le basculement vers leur résolveur (client_resolveur_apply) n'est pas confirmé. Et rien ne signale encore qu'un plancher est en retard sur le plan : il suit le plan au déploiement, pas en continu.

2026-09-29 (29) — passerelle fait le trajet d'un visiteur jusqu'à la page de connexion ; fin de la revue des sondes

Cinquième et dernière des sondes « répond » de la revue (25).

Avant. check_http sur /ping : vert tant que le processus oauth2-proxy vit, même quand plus personne ne peut entrer — client supprimé ou renommé dans Keycloak, URL de retour retirée du client, IdP injoignable depuis la machine de la passerelle.

Maintenant. Après /ping, la sonde fait le trajet d'un visiteur : /oauth2/start doit renvoyer vers l'émetteur configuré (lu dans la configuration déployée), pour CE client ; puis l'IdP, joint depuis cette machine avec le même magasin de confiance que la passerelle, doit servir sa page de connexion (200). C'est aussi le chemin de l'échange du code au retour. Un refus de Keycloak est nommé (Invalid parameter: redirect_uri, Client not found…) ; un IdP injoignable rend le code d'erreur de curl.

Ce qu'elle ne teste pas : le secret du client. Le vérifier demande un échange de jeton raté, que Keycloak écrit dans le journal de sécurité du realm — toutes les 15 minutes, par passerelle, ce serait noyer le journal où l'on cherche les vraies tentatives. Le cas sain n'y écrit rien.

Éprouvé. Au vert sur les quatre passerelles (vigie et console, chez les deux locataires), 35 ms ; le site n'en a pas. Mises en défaut chez Chezlepro : URL de retour non autorisée (serveur_oauth2_proxy_sonde_retour) → 400 nommé ; configuration illisible ; port fermé ; magasin de confiance inutilisable → « n'atteint pas l'IdP (curl 77) ». Toutes critiques. À savoir en lisant le journal du realm chezlepro : ces épreuves y ont laissé cinq LOGIN_ERROR (redirect_uri vers exemple.invalid), les 28 et 29 septembre.

Au passage. Le port de la sonde était écrit en dur (4180) : il dérive de l'écoute. Un échec de /ping sortait sur deux lignes : une seule. Et l'archive oauth2-proxy repartait du contrôleur à chaque déploiement, pour la même raison que Keycloak en (26) — le repère était dans /tmp ; c'est désormais le binaire installé. Plus aucun rôle ne prend /tmp pour repère. Limite qui demeure, dans les deux rôles : un changement de VERSION ne réinstalle pas (l'extraction s'arrête sur creates) ; à traiter le jour d'une montée de version.

Bilan de la revue. Les cinq sondes qui disaient « répond » disent désormais « sert » : filtrage (GTUBE analysé), edge (chaque exposition servie), identite (Keycloak authentifié auprès de l'annuaire), vigie (base, annuaire ou comptes, Redis), passerelle (trajet jusqu'à la connexion).

2026-09-28 (28) — vigie vérifie ce que la vigie lit, pas seulement qu'elle répond

Quatrième des cinq sondes « répond » de la revue (25).

Avant. check_http sur la boucle locale : un 302 vers le formulaire de connexion suffisait. Une vigie dont la base est injoignable (mot de passe tourné d'un seul côté, pg_hba, certificat), dont l'annuaire refuse la liaison, ou dont le Redis est tombé, sert très bien ce formulaire — puis une erreur, des tableaux vides, ou un compte connecté sans aucun droit parce que ses groupes ne se lisent plus.

Maintenant. Après check_http, la sonde lit la configuration DÉPLOYÉE de la vigie (resources.ini, authentication.ini, groups.ini, modules/icingadb/) et éprouve chaque ressource qu'elle utilise, avec SES identifiants et le même pilote PHP : la base du moteur (compte des hôtes, zéro = critique), l'annuaire (liaison + lecture de la racine) ou la base des comptes, et Redis (PING). Un tenant (annuaire) et le site (base locale) passent par le même code. Les secrets restent dans les fichiers (0640), rien n'est affiché.

Éprouvé. Au vert sur les trois : Chezlepro et Technolibre (13 hôtes, soit ceux du plan ; annuaire ; Redis), site (23 hôtes, base des comptes et groupes, Redis) ; 51 ms. Mises en défaut sur Technolibre, sur une copie de la configuration : table absente (serveur_icingaweb2_sonde_table), Redis sur un port fermé, liaison à l'annuaire refusée, mot de passe de la base faux — les quatre critiques, chacune nommant sa cause. Au site, codes HTTP attendus changés : critique, « ne sert plus ses pages ».

Reste de la revue : passerelle (oauth2-proxy atteint-il Keycloak ?).

2026-09-28 (27) — La sonde d'Icinga Web 2 s'appelle vigie, plus console

Le vocabulaire des plans dit vigie pour Icinga Web 2 (vigie.<domaine>) et console pour la console d'exploitation Set-OPS (serveur_ops, console.<domaine>, sonde console-ops). La sonde d'Icinga Web 2 s'appelait pourtant console : dans Icinga, mon-01!console parlait de la vigie, et les entrées (25) et (26) ci-dessous en ont hérité (« console : Icinga Web lit-il sa base ? »). Renommée vigie : déclaration, gabarit (sonde-vigie.sh.j2), tâche, et le catalogue des services. console-ops garde son nom : reprendre tout de suite le nom libéré aurait donné deux sens à « console » dans l'historique.

Posé sur les trois supervisions (Chezlepro, Technolibre, site) dans l'ordre : serveur_icinga (service et filtre du compte de dépôt, dérivés des déclarations), serveur_icingaweb2 (la sonde), client_sante (qui a retiré l'orphelin console.sh sur les trois). vigie est au vert partout ; le service console n'existe plus. Son historique Icinga repart à zéro sous le nouveau nom.

2026-09-28 (26) — identite vérifie que Keycloak atteint l'annuaire ; l'archive Keycloak cesse de voyager pour rien

Troisième des cinq sondes « répond » de la revue (25).

identite (Keycloak). S'arrêtait à la découverte OIDC du realm. Or les comptes vivent dans LDAP : un lien rompu vers l'annuaire (certificat, mot de passe de liaison tourné d'un seul côté, slapd tombé) laisse la découverte parfaite et refuse chaque connexion. La sonde demande désormais à Keycloak de s'authentifier auprès de l'annuaire avec SA PROPRE configuration (testLDAPConnection, testAuthentication, componentId de la fédération enregistrée, secret masqué : Keycloak substitue le secret stocké — la sonde ne manipule jamais le secret LDAP). Le jeton admin est pris avec les identifiants de /etc/keycloak/keycloak.env (0640, lus en root, jamais affichés ni passés en argument). Éprouvé avant d'écrire : 204 sur la vraie fédération, 400 AuthenticationFailure avec un faux bindDn. Chez les deux locataires : fédération openldap authentifiée, reçue par Icinga (0,12 s). Mise en défaut par paramètre (serveur_keycloak_sonde_federation) : critique, « la fédération n'existe pas » ; fichier d'identifiants illisible : avertissement, « fédération non vérifiée ». Sans fédération (serveur_keycloak_ldap_federation: false), la sonde s'en tient à la découverte.

Au passage : l'archive Keycloak repartait à chaque déploiement. « Déjà posée ? » regardait /tmp/keycloak-<version>.tar.gz, que le système vide : l'archive repartait du contrôleur à chaque passage (changed sur les deux locataires). Le repère est désormais le marqueur .kc-built-<version> : cette version est en place, rien à apporter. Redéployé : changed=0 sur les deux.

Reste de la revue : console (Icinga Web lit-il sa base ?), passerelle (oauth2-proxy atteint-il Keycloak ?).

2026-09-28 (25) — Deux sondes qui disaient « répond » apprennent à dire « sert » ; l'edge refuse les noms inconnus

Revue de toutes les sondes, à la recherche de celles qui resteraient vertes pendant que le service ne rend plus rien — le cas de tableaux. La plupart vont déjà au fond (vraie requête SQL, recherche LDAP, résolution réelle, cibles collectées…). Cinq ne le font pas : filtrage, edge, identite, console, passerelle. Les deux premières sont faites.

filtrage (rspamd). S'arrêtait à /ping. Soumet désormais GTUBE, le message de test que tout filtre doit rejeter : rien n'est envoyé ni livré, rspamc lit le verdict. Chez les deux locataires : rejeté, 15/15. Mise en défaut par paramètre : critique, « n'analyse plus ».

edge (nginx). Vérifiait la configuration et les ports. Interroge désormais chaque exposition EN LOCAL (son nom, son SNI, vers 127.0.0.1) : un 5xx ou un silence la fait passer critique, en la nommant. Même filtre que les vhosts (hôte, adresse, port) : au site, dns, pki et sauvegarde n'ont pas de vhost et ne sont pas attendus.

Ce que l'épreuve de edge a révélé : un nom inconnu recevait Keycloak. Sans serveur par défaut sur 443, nginx confiait tout nom inconnu au premier vhost — absente.chezlepro.internal obtenait un 302 vers /admin/. Une porte qui ne devrait pas exister, et une sonde aveugle : une exposition dont le vhost aurait disparu restait « servie »… par Keycloak. Et le site par défaut de Debian restait actif sur 80 (serveur_nginx_desactiver_defaut: false, sans raison écrite). Désormais 000-defaut.conf : 444 sur 80, ssl_reject_handshake sur 443 ; le site Debian est retiré. Posé sur les trois edges : expositions toujours servies (6, 6, 4), noms inconnus et IP nue refusés, et la sonde voit une exposition fictive (critique).

Une erreur de ma part, en direct, et sa garde. sonde-edge.sh.j2 redéfinit la syntaxe des commentaires Jinja ({=# … #=}) ; j'y ai écrit un commentaire ordinaire, sorti tel quel dans le script : la sonde a rendu une erreur de syntaxe sur les trois edges (les edges servaient). Corrigé aussitôt. test_sondes_syntaxe.py (dans make test) rend chaque gabarit de sonde — en respectant l'en-tête #jinja2: — et exige que bash -n l'accepte : 44 gabarits valides, et l'erreur recréée est refusée.

Relevé en passant, non réglé : console.chezlepro.internal ne se résout pas DEPUIS l'edge (ni dans son /etc/hosts, ni au DNS) ; le poste le résout par le sien. L'edge le sert bien.

2026-09-28 (24) — La sonde « tableaux » vérifie aussi les sources de données

Elle était verte pendant que les tableaux des locataires n'affichaient rien : elle demandait si Grafana répond et si sa base tient — pas s'il peut montrer quelque chose. Elle interroge désormais la santé de CHAQUE source de données (/api/datasources/uid/…/health) et passe CRITIQUE en nommant celle qui est en défaut. Le mot de passe d'administration est lu là où il est, dans le drop-in systemd de Grafana (0600 root), pas recopié ; illisible, la sonde le dit (avertissement « sources non vérifiées ») au lieu de se taire.

Éprouvée sur obs-01 de Technolibre : saine ; puis une source Loki temporaire recréant le défaut du jour (http://localhost:3100 face à un Loki en HTTPS) → CRITIQUE, source nommée ; source retirée → saine. Déployée sur les trois Grafana : tableaux au vert, « sources saines (Loki, Prometheus) ».

2026-09-28 (23) — Grafana des locataires : aucune donnée, parce que l'URL de Loki ne suivait pas son TLS

Signalé par l'exploitant : une erreur de plugin et aucun panneau rempli sur les Grafana d'obs-01, chez les deux locataires.

Cause. Les locataires activent serveur_loki_tls_actif : Loki sert en HTTPS. La source Loki de Grafana était écrite en dur, http://localhost:3100 : Loki coupait la connexion (« connection reset by peer »). Les tableaux construisent leur variable host depuis Loki — 21 appels en échec sur label/host/values —, elle restait vide, et AUCUN panneau n'affichait rien, ceux de Prometheus compris. Le même défaut que la sonde de Loki, corrigé pour elle seule le 2026-09-10 : un paramètre qui ne suit pas l'interrupteur dont il dépend.

Correctif. serveur_grafana_loki_url se DÉRIVE de serveur_loki_tls_actif de l'hôte Loki : en TLS, https://<fqdn>:3100 — le nom qui est dans le certificat, localhost n'y est pas ; l'autorité interne est dans le magasin de confiance du système, Grafana vérifie. Sans TLS (le site), rien ne change. Vérifié par l'API de Grafana chez les deux locataires : Loki et Prometheus sains, 13 hôtes rendus pour host, plus aucune erreur Loki au journal.

2026-09-28 (22) — Pairs WireGuard de Technolibre appliqués ; un renommage n'est plus une création

Appliqué (vpn_admin.py appliquer --portee OPS-Technolibre, à la demande de l'exploitant) : technolibre-mathieu-portable devient technolibre-responsable-portable. fabric au vert : les quatre plans de la fabric sont conformes.

Ce que l'application a révélé. Le « nouveau » pair portait la MÊME clé publique que l'ancien : le même appareil, renommé. Le script créait avant de retirer ; OPNsense a refusé la création (« Public keys should be unique ») — mais le retrait, lui, était passé. La configuration n'avait plus AUCUN pair pour ce tunnel ; seul le fait que l'échec empêche le rechargement a gardé l'ancien en service. Rejoué aussitôt : le nouveau pair est créé, relu en service.

Corrigé : rapprocher reconnaît un renommage à la clé (à créer ET à retirer, même clé) et le fait en MODIFICATION sur place (set_client) — l'accès n'est jamais coupé, la frontière ne voit jamais deux fois la même clé. Un vrai remplacement (autre clé) reste une création suivie d'un retrait. Éprouvé sur un état simulé, dans les deux cas.

2026-09-28 (21) — La sonde « genome-a-jour » : le filet sous make publier

make publier tient la forge du site à jour dans le geste courant. Reste le jour où l'on publie autrement — un git push fait à la main, un runner injoignable au moment de publier. La sonde genome-a-jour (runner du site) compare la tête de chaque dépôt de la forge du site à celle d'eregion, où chaque commit du poste arrive en premier — comme REPÈRE seulement : eregion reste hors du chemin du génome.

Seulement ce qu'eregion laisse lire sans identifiant (aujourd'hui : le moteur, celui qui avait quatre commits de retard le 2026-08-26). Les dépôts privés sont NOMMÉS comme non comparés : donner au site un jeton sur une forge héritée l'y lierait.

Un écart neuf n'est pas un retard : make publier pousse eregion une minute avant la forge du site. La sonde retient depuis quand chaque écart dure et ne parle qu'au-delà d'une heure. Éprouvée en vrai : commit poussé sur eregion SEULEMENT → « écart récent, toléré » ; écart vieilli de deux heures → avertissement « FORGE DU SITE EN RETARD… make publier » ; make publier → à jour, écart oublié. Reçue par l'Icinga du site.

2026-09-28 (20) — make publier : eregion ET la forge du site, en un geste

Le défaut. La forge du site fait autorité (D-81), mais les commits naissent sur le poste et n'y arrivent que par make genome-pousser — un geste à part, que rien ne force ni ne signale. Un git push vers eregion la laisse en retard. Le 2026-08-26 : quatre commits, dont le correctif du pare-feu Proxmox. Aujourd'hui même, make genome-etat la trouvait en retard d'un commit.

make publier (scripts/publier.py), pour chaque dépôt que le runner du site porte — la liste de genome-pousser (serveur_ops_depots), lue et non recopiée : refuse si eregion porte des commits absents du poste (une fusion acceptée serait écrasée sur la forge du site), signale ce qui n'est pas committé, pousse sur eregion ; puis genome-pousser, puis genome-etat, et dit si la forge du site porte bien la tête du poste. Un refus arrête tout AVANT la forge du site : mieux vaut la laisser en retard que la nourrir d'un dépôt incomplet. Ce commit est le premier publié ainsi.

2026-09-28 (19) — Chaque semaine, chaque sauvegarde est rouverte pour de vrai

Le trou. sauvegarde disait qu'un instantané existe, récent et non vide ; rien ne disait qu'il se rouvre. Aujourd'hui, c'est à la main qu'on a restauré les dumps PostgreSQL et LDAP — et découvert que deux locataires sur deux ne déposaient rien hors de leur flotte.

Le contrôle (client_backup, minuteur du dimanche 04:00 ± 1 h), joué par le nœud lui-même, seul détenteur de la clé : restic check --read-data-subset=10% (l'intégrité, en relisant des données, pas seulement l'index), puis restauration RÉELLE du dernier instantané avec --verify (contenu recomparé), puis comptage des fichiers face à l'instantané. Répertoire temporaire détruit quoi qu'il arrive ; nice et E/S au repos. Rapporté au service restauration (fraîcheur 8 jours, liée au ttl par test_fraicheur_icinga.py).

Le piège évité : le compte d'API des nœuds n'écrit que sur des services NOMMÉS ; sans l'ajout de restauration au filtre, chaque rapport aurait reçu un 404 « No objects found » sur un objet présent — le défaut du 2026-09-02.

Premier passage, déclenché au déploiement : 19 nœuds, 3 à 6 s chacun. Tout se restaure, contenu recomparé — Nextcloud (≈ 140 Mo), la base, le courriel, l'annuaire, la PKI chez chaque locataire, la forge du génome (1723 fichiers) au site. Avertissement sur les instantanés VIDES (courriel et web sans données encore), comme sauvegarde le dit déjà.

2026-09-28 (18) — La sonde « fabric » : la fabric dit-elle encore ce que le plan dit ?

Le trou. Le pare-feu Proxmox, le SDN, la frontière et les accès WireGuard ont chacun un plan de lecture (*-plan) ; personne ne les jouait sans raison. Aujourd'hui même : 26 VM sans pare-feu, sans signal.

Sur le runner du site (serveur_ops_site) : un minuteur horaire joue les quatre plans (4 s au total), additionne ce qui reste à créer, retirer, mettre à jour ou corriger, et consigne le constat dans /var/lib/setops/conformite-fabric.json. La sonde fabric le lit — une sonde n'a que quelques secondes : écart → avertissement (avec le premier détail), plan illisible → inconnu, constat de plus de 3 h → CRITIQUE (un minuteur mort ne doit pas laisser la dernière bonne nouvelle au vert). Éprouvée sur quatre constats fabriqués, puis en vrai : site-ops-01!fabric en avertissement sur un écart RÉEL — les pairs WireGuard de Technolibre, en attente de décision.

Ce qu'elle a attrapé avant même d'exister. En mesurant la durée des plans, frontiere-plan voulait 4 créations : des alias et des règles « SSH depuis le tunnel du locataire, sur le WAN ». Cause : le tunnel ajouté à admin_de plus tôt dans la journée ; la frontière range chaque réseau d'administration par interface et l'avait classé WAN — une règle qui ne correspondrait jamais. admin_de redevient l'intrant (la frontière) ; admin_avec_tunnel sert l'est-ouest (Proxmox, épreuve). Les deux plans : 0 écart.

2026-09-28 (17) — La sonde « disque » : espace et inodes, sur les 35 machines

Le trou. Seuls le cache apt et les dépôts de sauvegarde avaient une sonde d'espace. Rien ne regardait le disque de la base, des boîtes, de Nextcloud, ni de Loki qui grossit chaque jour ; Prometheus collecte l'occupation, mais aucune règle n'en fait une alerte. Un disque plein aurait arrêté la base ou le courriel sans prévenir.

La sonde. Pour chaque système de fichiers réel (pas tmpfs, overlay…), l'espace ET les inodes — une file de petits fichiers épuise les inodes avec la moitié de l'espace libre, et l'erreur parle alors d'un disque « plein » qu'un df dit à moitié vide. Seuils 80 / 90 % (client_sante_disque_avert / _crit) ; relevé du jour : au plus 22 % d'espace et 7 % d'inodes. Une ligne, le pire système de fichiers, les métriques de tous.

Où elle vit, et pourquoi. Déclarée ET posée par client_sante, dont le groupe est exactement « tout hôte qui rapporte ». Pas par common_packages, qui porte correctifs : il fait un upgrade: full à chaque passage, et poser un script aurait mis à jour toute la flotte. Une première version la déclarait dans serveur_debian : P64 l'a refusée — il suit le playbook du groupe déclarant pour trouver le dépôt, et client_sante n'y est pas. La garde avait raison ; la déclaration a suivi le rôle qui dépose.

Déployé (Icinga d'abord, pour que les premiers rapports aient un destinataire) : 35 services disque, tous alimentés et OK — 13, 13 et 9. make verifier : CONFORME.

2026-09-28 (16) — La procédure d'activation du pare-feu Proxmox entre au dépôt

scripts/eprouver_parefeu.py, par make proxmox-fw-eprouver TENANT= HOTE= (aucune écriture) et make proxmox-fw-activer-vm TENANT= HOTE= CONFIRMER=true. Ce qui a servi à filtrer les 26 VM, sous une forme qui resservira : une VM reconstruite perd ses options de pare-feu.

Pour UNE VM : matrice tirée du devis (chaque membre de chaque source), connexions entrantes établies confrontées aux règles, matrice avant, activation par le runner du site (seul à joindre l'API du cluster), matrice après, sondes relancées, critiques d'Icinga. Refus d'activer si un flux réel échappe aux règles ou si un flux échoue déjà.

Plus d'exclusions à la main : un échec vers un port que rien n'écoute hors de 127.0.0.1 est classé « sans objet » (nginx lié en local derrière la passerelle SSO, frontal sans site) — c'est ce que la procédure manuelle tranchait au cas par cas. Le VMID se dérive du devis, et du bloc du BON locataire : au premier essai, le script visait ops-01 de Chezlepro pour une demande sur Technolibre — les noms d'hôtes se répètent d'un locataire à l'autre.

Éprouvé : --plan sur ops-01 (8090 reconnu sans objet), --activer de bout en bout sur web-frontal-01 (runner : « Rien à faire », après = avant, Icinga : 0 critique). Limite écrite : un flux UDP est testé en TCP sur le même port.

2026-09-28 (15) — Les 26 VM des locataires filtrées par Proxmox ; proxmox-fw-plan : « Rien à faire »

Chezlepro, 13/13, dans le même ordre que Technolibre et par la même procédure, VM par VM : matrice complète tirée du devis, flux entrants établis confrontés aux règles, matrice avant, activation, matrice après, sondes relancées sur les 13 nœuds, Icinga. Toutes : avant = après, 0 critique. Au-delà : DNS UDP réel à travers infra-dns-01 ; les 13 rapports sante reçus après l'activation de mon-01 ; et de bout en bout, courriel-plan (Postfix → LDAP → Dovecot → IMAP) et identite-plan : CONFORME.

Une régression introduite, puis fermée. nftables admet le SSH depuis l'intrant nftables_admin_ssh PLUS le tunnel du locataire (10.x.29.0/24) ; le devis Proxmox ne lisait que l'intrant. Filtré, Technolibre refusait donc le SSH venu de son propre tunnel (de 13:27 à ~15:40, SSH seulement, aucun service). Une seule dérivation désormais, inventory_rules.tunnel_admin_de, pour les deux ; IPSets admin à trois membres ; make flux identique octet pour octet.

Deux devis cassés depuis le 2026-09-13, trouvés en vérifiant. courriel-plan et identite-plan appelaient resoudre_annuaire sans nommer de compte de service : ils échouaient sur une assertion, avant toute connexion. Ils empruntent maintenant le compte du service qu'ils vérifient (postfix, keycloak).

Reste à faire : une règle Proxmox pour les flux venus d'Internet (externe) avant la première exposition d'un locataire.

2026-09-28 (14) — Technolibre entièrement filtré par Proxmox, VM par VM

Les quatre dernières, une à la fois : infra-dns-01, obs-01, ops-01, mon-01. Pour chacune : matrice complète tirée du devis, flux entrants établis observés et confrontés aux règles, matrice avant, activation, matrice après, sondes relancées sur les 13 nœuds, Icinga. Toutes : avant = après, 0 critique. Technolibre : 13/13 VM filtrées.

Ce que la procédure a arrêté, à raison, et pourquoi c'était sans défaut :

  • obs-01 se parle à lui-même par sa propre adresse (Prometheus, Alloy→Loki) : ce trafic passe par lo, jamais par le pont de Proxmox — l'analyse l'écarte désormais ;
  • infra-edge-01 → ops-01:8090 et → mon-01:8080 échouaient DÉJÀ avant : en mode oidc, nginx n'écoute qu'en local (le défaut le documente — une passerelle SSO qu'on ne peut pas contourner) ; c'est oauth2-proxy (4180) qui est publié, et la matrice le teste. La règle du port nginx reste pour le mode locale, sans objet ici.

Vérifié au-delà de la matrice : résolution DNS réelle en UDP à travers infra-dns-01 (noms internes et externes, depuis deux nœuds) ; pour mon-01, les 13 rapports sante reçus APRÈS l'activation — le flux passif vers 5665 traverse le filtre.

2026-09-28 (13) — Technolibre, deuxième lot : l'edge, l'identité, la base, la PKI

Activées : infra-edge-01, idm-01, data-sql-01, infra-pki-01.

Deux vérifications, parce qu'une seule ne suffit pas. La matrice tirée du devis ne teste que ce qui est DÉCLARÉ ; un flux réellement utilisé mais non déclaré la passerait, puis serait coupé. Donc, en plus : relever sur chaque VM les connexions entrantes ÉTABLIES (trois relevés), et vérifier que chaque couple source→port est couvert par une règle. 11 flux observés, 0 hors des règles (SSH d'administration et forme IPv4-mappée d'obs-01 comprises). Matrice étendue à TOUS les membres de chaque source : 100/100 avant, 100/100 après. Sondes de santé relancées sur les 13 nœuds : 133 services revérifiés, aucun critique.

2026-09-28 (12) — Technolibre, premier lot : quatre VM de plus filtrées, preuve comprise

Activées : web-dorsal-01, collab-01, edge-mta-01, infra-mail-01. Méthode : une matrice tirée du DEVIS lui-même — pour chaque règle des groupes de ces VM, un hôte réel de la source autorisée → VM:port —, jouée AVANT (19/19) puis APRÈS (19/19, aucun changement). Icinga : aucun critique.

Le filtrage mord, et c'est Proxmox qui mord. Un contrôle négatif doit distinguer les deux couches : nftables, dans la VM, accepte TOUT l'ICMP ; Proxmox ne l'accepte que de srv-icinga. Depuis data-sql-01 : pas de réponse des VM filtrées (collab-01, web-frontal-01), réponse des non filtrées (idm-01, infra-pki-01).

Un trou à fermer AVANT d'exposer un locataire. Le devis saute les flux dont la seule source est externe (« la frontière s'en charge »). Mais un flux redirigé par la frontière arrive sur la VM avec sa source Internet : sous REJECT, sans règle, il serait refusé. Sans effet aujourd'hui — la frontière ne redirige vers aucune VM de locataire (relu : seul le DNS public du site) —, bloquant le jour d'une exposition.

2026-09-28 (11) — Première VM filtrée par Proxmox, et le ping de supervision qu'aucune règle ne portait

Activée : web-frontal-01 de Technolibre (--vm 123603101), pare-feu enable=1, policy_in=REJECT, groupes cli-metrique, srv-debian, srv-web-frontal. Vérifié flux par flux : SSH du poste, rapports de santé et de sauvegarde acceptés, node_exporter joint par obs-01, SSH depuis la flotte (autorisé par srv-debian, déclaré), port 80 ouvert à l'edge seul (rien n'y écoute encore : aucun site). Icinga : tout vert — sauf un critique NOUVEAU.

ping4 : 100 % de pertes. Le ping de supervision EST déclaré (serveur_debian, echo-request depuis serveur_icinga), mais _ports() ne garde que les nombres : le type ICMP était rangé parmi les « ports dérivés » et le flux sauté. Aucune règle, donc REJECT. _cibles() rend désormais un flux ICMP en règle proto: icmp + icmp_type, que l'applicateur transmet (icmp-type) et compare. Appliqué : srv-debian des deux locataires accepte echo-request depuis srv-icinga ; ping4 revenu OK (0,20 ms).

Vérifié avant d'aller plus loin : le « sans source » serveur_icinga:5665 que recense le devis est le flux de serveur_backup, qu'aucun locataire ne porte — les rapports passifs, eux, ont leurs règles (cli-backup, srv-debian). Et le recensement ne déclare plus sauté un flux ICMP qu'il rend.

2026-09-28 (10) — Le pare-feu est-ouest de Proxmox ne filtrait aucune VM des locataires

Mesuré. Pare-feu du datacenter actif, firewall=1 sur les cartes des 26 VM de Chezlepro et Technolibre — mais AUCUNE n'avait son propre pare-feu activé ni un seul groupe affecté. Proxmox ne filtrait rien entre elles ; seul nftables, dans chaque VM, tenait l'est-ouest. Le plan de proxmox-fw-appliquer le rattrapait d'un bloc : 64 changements, dont l'activation en REJECT des 26 VM à la fois — autant de pannes possibles si une règle manquait.

Par étapes. appliquer_proxmox_fw.py prend --objets-seulement (IPSets et groupes, aucune VM) et --vm <vmid> (répétable) ; le plan affiché est exactement ce qui sera appliqué. Posés aujourd'hui, les OBJETS seuls, inertes tant qu'aucune VM ne les porte : IPSets admin passés aux sources d'administration actuelles (10.37.0.0/24, VPN 10.37.29.0/24), membres retirés (ancien forge-01, x.18.21), objets des rôles disparus (backup, forgejo, artefacts) retirés, srv-debian, srv-ops, srv-powerdns, srv-ops-tenant créés, douze groupes remis au devis. Relu : objets conformes, restent les 26 affectations.

2026-09-28 (9) — P02 : l'écriture d'une table s'arrêtait au premier commentaire

make verifier : CONFORME, 83 OK, 0 échec — P02 échouait depuis le 2026-09-16.

La cause. _fusion_table (écriture ligne à ligne des registres du plan, par le GUI) délimitait une entrée par les lignes plus indentées qui la suivent, et s'arrêtait à la première ligne de commentaire. Le 2026-09-16, chezlepro.ca a reçu des commentaires À L'INTÉRIEUR de son entrée (signature DNSSEC, relevé de la zone) : l'entrée était coupée en deux, et les lignes suivantes testées comme entrées de la table. - {nom: "@", type: A, …} y passait, clef - {nom, YAML = une liste → 'list' object has no attribute 'get'. Le GUI ne pouvait plus écrire domaines.yml.

Le correctif. L'étendue d'une entrée traverse commentaires et lignes vides tant que la prochaine ligne significative est encore plus indentée ; un commentaire suivi de l'entrée SUIVANTE reste avec elle. Et seules les lignes à l'indentation des enfants directs peuvent être des entrées. Réécrire le vrai domaines.yml sans changement le rend octet pour octet.

Le test aussi avait une prémisse périmée. Retirer une entité devait garder TOUS les commentaires du fichier — juste tant qu'aucune entrée n'en portait à l'intérieur. Un commentaire intérieur part désormais avec son entrée (laissé, il expliquerait la voisine) ; ceux qui l'entourent restent, et c'est ce que le test exige maintenant.

2026-09-28 (8) — L'amont du cache des locataires suit le site, au lieu d'une adresse morte

serveur_artefacts_amont valait http://10.0.33.21:3142 chez Chezlepro ET Technolibre : l'adresse du cache du site avant qu'il prenne son index (10.37.3x). Inerte aujourd'hui — aucun hôte de ces écosystèmes ne porte serveur_artefacts, et apt passe par artefacts_amorcage (mesuré sur les nœuds : 10.37.33.21:3142) —, mais un piège le jour où un locataire reprendrait son propre cache : son amont serait né mort. La preuve d'amorçage ne regardait que les intrants, pas ce fichier. La valeur est désormais DÉRIVÉE : "http://{{ artefacts_amorcage }}", la seule adresse que cette preuve garde alignée.

2026-09-28 (7) — Rotation des clés WireGuard de l'exploitant, sans qu'aucune privée ne s'affiche

Pourquoi. Les deux clés privées de daniel-portable (tunnel du site, tunnel de Chezlepro) ont fui dans l'historique d'une session d'assistance. On les change.

Ce que vpn_admin.py ne savait pas faire. pair-nouveau AFFICHE la privée pour qu'on la colle ; lancé par un assistant ou dans un terminal journalisé, cet affichage la dépose dans un historique — exactement la fuite qu'on répare. Trois ajouts :

  • cle-appareil --vers <fichier> : la privée va droit dans un fichier en 0600 (refus d'écraser), seule la publique s'affiche. Sert à la création comme à la rotation ;
  • config --cle <fichier> --vers <fichier> : la configuration COMPLÈTE, écrite en 0600 ;
  • plan|appliquer --portee site --portee OPS-X : sans filtre, appliquer réconcilie TOUS les tunnels — la rotation aurait emporté l'écart en attente de Technolibre (un pair retiré que personne n'a encore décidé de retirer).

Et service : ? après un rechargement réussi : OPNsense répond result, pas status.

Fait. Deux paires tirées dans ~/wireguard-rotation/ (hors dépôt), cle_publique remplacée au plan du site et de Chezlepro — nom et adresse inchangés —, appliqué sur ces deux seuls tunnels. Relu EN SERVICE sur la frontière : les deux nouvelles clés actives, les deux anciennes absentes. Technolibre intact.

2026-09-28 (6) — Les voûtes sortent du poste, à côté des clés qui les ouvrent

Ce qui manquait. La clé USB portait les CLÉS des voûtes, pas les voûtes. Or elles sont gitignorées : sur aucune forge, seulement sur le poste et sur le runner de chaque écosystème. Poste et runner perdus ensemble, les clés restaurées n'ouvraient rien, et chaque secret était à régénérer. La runbook cles affirmait pourtant « les voûtes sortent chiffrées ».

scripts/exporter_voutes.py (make voutes-recenser, make voutes-exporter VERS=…). Une archive à part, setops-voutes-<date>-<poste>.tar : restaurer_cles.py range chaque clé d'après son seul nom, et les voûtes s'appellent presque toutes vault.yml — elles s'y seraient écrasées. Chaque voûte garde donc son chemin relatif aux dépôts ; restauration : tar -xf … -C <dossier des dépôts>. Pas de seconde couche : elles sont déjà chiffrées par une clé qui n'est pas dans cette archive, et le script REFUSE tout fichier dont l'en-tête n'est pas $ANSIBLE_VAULT. Il refuse aussi une destination dans l'infrastructure et l'écrasement d'une archive existante, puis relit empreinte par empreinte.

Fait et éprouvé : six voûtes (Chezlepro, Chezlepro-lab ×2, Technolibre, les deux sites) sur la clé USB, 80 Ko ; extraites dans un répertoire temporaire, chacune rouverte avec les clés (33, 25, 2, 37, 20 et 14 entrées), puis détruites. Le mode d'emploi de la clé, la runbook cles et sortir-les-cles-du-poste.md le disent désormais. À refaire après toute écriture dans une voûte.

2026-09-28 (5) — L'exportateur PostgreSQL de Chezlepro, et où vivent vraiment les voûtes

Le dernier critique de Chezlepro. obs-01!collecte : Prometheus scrutait data-sql-01:9187, où rien n'écoutait. serveur_postgresql ne pose l'exportateur que si vault_pg_exportateur existe ; Technolibre l'avait, pas Chezlepro. Ajouté à sa voûte selon la procédure (copie, lecture par un tube et comptage : 32 clés, chiffrement vers un fichier neuf relu : 33 clés, les 32 d'origine identiques, puis remplacement). Rôle redéployé : exportateur actif, pg_up 1. Chezlepro n'a plus aucun critique.

La promesse alignée sur Prometheus (même jour). vault.yml.example promettait « VIDE = … Prometheus ne dérive aucune cible » ; le gabarit dérive pourtant la cible de tout hôte de serveur_postgresql, secret ou pas. Le comportement est gardé — une base sans métriques doit se voir — et la phrase dit désormais ce qui arrive : sans le secret, la collecte d'obs-01 rougit, et renseigner le secret l'éteint. Corrigé dans les quatre exemplaires : exemples/vault.exemple.yml, docs/metriques-conception.md, et les vault.yml.example de Chezlepro et de Technolibre.

Où vivent les voûtes. sortir-les-cles-du-poste.md et exporter_cles.py disaient les voûtes chiffrées répliquées sur les forges. Elles sont gitignorées : chacune vit sur le poste et sur le runner de son écosystème (copie chiffrée par serveur_ops_tenant). Le runner de Chezlepro portait donc la voûte d'avant ce changement ; redéposée, empreintes identiques. Les deux phrases sont corrigées.

2026-09-28 (4) — Une sonde posée sous condition n'est attendue que là où elle est posée

Ce que la fraîcheur a fait voir. Chez les deux locataires, obs-01!journaux-frontiere est passé au rouge et y serait resté pour toujours. serveur_loki ne pose cette sonde que si une étiquette de frontière est déclarée — au SITE, jamais chez un locataire, dont la frontière appartient à l'hébergeur — et la RETIRE sinon. Icinga, lui, l'attendait sur tout hôte du groupe. Trois rôles posent ainsi leur sonde sous when: (journaux-frontiere, audit, console-ops) ; les deux autres n'étaient pas rouges par chance, leur condition étant vraie partout.

Le correctif. La déclaration porte la même condition que le dépôt : seulement_si: <variable> dans meta/supervision.yml, lue par setops-sondes.conf.j2 dans l'inventaire de l'hôte, sinon dans les défauts du rôle qui la définit (defini_par) — la valeur même que ce rôle voit. test_sondes_conditionnelles.py (dans make test) exige qu'un when: qui pose une sonde ait son seulement_si, sur la même variable, avec un défaut littéral ; vérifié en retirant volontairement une condition.

Simulé puis appliqué : chez chaque locataire, un seul changement — journaux-frontiere retiré d'obs-01. Technolibre n'a plus aucun critique ; Chezlepro n'en garde qu'un, son exportateur PostgreSQL, faute du secret vault_pg_exportateur.

Corrigé le même jour : les journaux de la frontière SONT couverts. Cette entrée affirmait le contraire, tirée d'un --diff où journaux-frontiere n'apparaissait pas — un diff ne montre que ce qui CHANGE, pas ce qui existe. Mesuré ensuite : la frontière envoie en TCP vers site-mon-01:1514 (Alloy), qui porte bien serveur_loki ; la sonde site-mon-01!journaux-frontiere est verte, 748 lignes reçues en 10 min.

2026-09-28 (3) — Un service qui n'a jamais rien reçu passe au rouge

Le trou. Tous les services passifs (sauvegardes, santé, sondes de rôle) étaient déclarés enable_active_checks = false, avec une commande qui exécutait /bin/true. Le ttl de chaque envoi couvrait le silence qui SUIT un rapport ; un service qui n'en a jamais reçu restait « en attente », gris, pas rouge. C'est ce qui a caché dix nœuds de Chezlepro sans sauvegarde pendant seize jours.

Le correctif. Un gabarit, setops-rapport-attendu (dans setops-sauvegardes.conf.j2), que les trois familles importent : service ACTIF, commande dummy en CRITIQUE, check_interval = le seuil — patron documenté d'Icinga 2 pour la fraîcheur. Chaque rapport repousse l'échéance ; seul le silence la laisse arriver, y compris le silence initial. Seuils = les ttl que les nœuds envoient (6 h, 45 min, 90 min), dans serveur_icinga_fraicheur_*. Une liste qui en suit une autre : test_fraicheur_icinga.py (dans make test) les compare et refuse tout service passif sans échéance — vérifié en cassant volontairement une valeur.

Prouvé en vrai sur site-mon-01 : rapport envoyé avec un ttl de 60 s → CRITIQUE à t+60 s (« AUCUN RAPPORT… ») → vrai rapport de santé → OK. Déployé sur les trois Icinga (site, Chezlepro, Technolibre) : 120 + 138 + 138 services, tous avec échéance.

Ce que ça a aussitôt montré. Chez les deux tenants, dix services n'avaient JAMAIS reçu de rapport, et rougissent maintenant : obs-01!journaux-frontiere, et neuf sondes de rôle (console, passerelle, édition, cache, filtrage, sites/apps servis) — leur client_sante n'a pas été redéployé depuis que ces sondes existent. Leur Icinga surveillait aussi encore forge-01, retiré du plan : la configuration est réalignée, mais obs-01!collecte reste rouge tant que Prometheus scrute l'ancienne adresse (10.x.21.11:9100).

2026-09-28 (2) — Deux locataires sur deux n'avaient pas de sauvegarde hors de leur flotte

Question de l'exploitant : les sauvegardes des tenants passent-elles ? Non. Mesuré côté dépôt (site-backup-01, ce que l'hébergeur voit sans la clé) puis côté nœuds.

Technolibre — refusé chaque nuit, restic-tech vide. Ses huit nœuds à état se présentaient comme restic, le compte du SITE : Permission denied (publickey), plusieurs fois par jour. Chezlepro déclare deux lignes (client_backup_cible ET client_backup_utilisateur_distant) ; Technolibre n'avait recopié que la première, et le rôle retombait sur son défaut. Ajouté restic-tech (OPS-Technolibre ef8c905), déployé, sauvegarde lancée : huit premiers instantanés, 79 Mo ; le dump PostgreSQL restauré (4,4 Mo, 435 tables) se lit.

Chezlepro — dix nœuds sur onze sans client_backup du tout. Seul infra-pki-01 déposait (9 instantanés depuis le 2026-09-13). Les autres n'avaient ni script, ni minuteur, ni secret : ils ne frappaient même pas à la porte. Les dates de naissance le situent au redéploiement du 2026-09-12 au soir — client_backup y a abouti sur infra-pki-01 (21:22) et infra-dns-01 (21:28), les deux nœuds amorcés en premier, jamais sur les autres, alors que client_sante, plus haut dans site.yml, les a tous atteints (21:59). Aucun journal n'a été gardé : échec ou interruption, on ne sait pas. Le rôle, relancé sans aucune modification, a réussi partout. Sept premiers instantanés ; dump PostgreSQL (6 bases, 435 tables) et annuaire (12 entrées) restaurés et lus. /var/vmail, /srv/web et /srv/webapp sont vides sur le disque aussi : rien de manqué.

Pourquoi personne ne l'a vu. Un nœud sans client_backup n'a pas non plus sa vérification : il ne rapporte RIEN. Un service passif qui n'a jamais reçu de résultat reste « en attente » dans Icinga — il ne passe jamais au rouge. Le ttl n'alerte que sur un silence qui SUIT un rapport. C'est le trou à fermer : une fraîcheur qui vaut aussi pour le premier rapport.

2026-09-28 (1) — La sauvegarde de la forge du génome passait ; sa vérification parlait dans le vide

Question de l'exploitant : la sauvegarde de site-forge-01 passe-t-elle vraiment ? Oui, et c'est maintenant mesuré jusqu'à la restauration : setops-sauvegarde réussit chaque nuit (dernier instantané 2026-09-28 02:43, 9 conservés, ~45 Mio), et set-ops-public.git restauré depuis latest passe git fsck, avec pour tête 859ac07 — exactement ce que la forge portait à 02:43, avant les poussées de la journée.

Ce que la mesure a trouvé en chemin. La vérification que chaque nœud fait de SON dépôt (setops-verification-depot, celle qui détient la clé) échouait toutes les quatre heures depuis le 2026-09-20 sur site-forge-01, site-mon-01 et site-pki-01 : 48 fois « ECHEC du rapport Icinga : 404 No objects found ». Le nœud rapportait à <nœud>!sauvegarde ; setops-sauvegardes.conf.j2 ne créait ce service que dans un écosystème SANS dépôt. Le site en a un (site-backup-01) : seuls existaient les services du témoin du dépôt, site-backup-01!sauvegarde: <nœud> — au vert, et donc crus complets.

Le gabarit choisissait l'un OU l'autre témoin ; il crée désormais les deux quand un dépôt existe. Déployé sur site-mon-01, vérification relancée sur les trois nœuds : six services au vert, et les deux témoins disent la même chose (1723 fichiers, 40 Mo pour la forge). Chez les tenants, sans dépôt, rien ne change.

2026-09-27 (1) — Patient 0 effacé, l'index 29 libéré

Ses machines n'existaient plus depuis le 2026-09-06 (D-83), mais son plan restait sur disque en federe: true : la fédération lui réservait l'index 29, et quatre machines du site ouvraient SSH, apt, DNS et HTTPS à 10.29.0.0/16 — un périmètre vide.

Ce qui est fait :

  • SITE-Chezlepro/underlay.yml : OPS-Patient0: 29 retiré de tenants.
  • SITE-Chezlepro/plan/serveurs.yml : le runner du site ne clone plus ops-patient0.
  • make flux : 10.29.0.0/16 sort des règles de site-backup-01, site-cache-01, site-dns-01, site-dnspub-01 et site-forge-01 — et rien d'autre ne change.
  • Le dépôt local OPS-Patient0/ est supprimé, puis, le 2026-09-28, ses deux copies distantes : genome/ops-patient0 sur la forge du site (API, 404 relu) et Chezlepro/OPS-Patient0 sur eregion (interface web — le jeton du poste n'a pas write:repository ; 404 relu). La clé de sa voûte est détruite (shred).
  • Les commentaires et documents vivants qui le nommaient gardent leur leçon sans le nommer (moteur, modèles, tenants, sites). Le CHANGELOG, les rapports de preuve datés et la chronique restent tels quels : ce sont des archives.
  • filiation-emancipation.md §« Qui est le parent ? » ramené à la décision et au chemin réel du génome.

Corrigé le 2026-09-28 : il n'y a pas de « SPOF eregion ». D-82, D-83 et ce paragraphe affirmaient que eregion alimente la forge qui fait autorité (« le poste y pousse, la forge du site en tire »). Le chemin mesuré dit autre chose : make genome-pousser porte les commits du poste en bundle au runner du site, qui pousse sur sa forge — eregion n'y figure pas. C'est une forge héritée (forge.alliance-boreale.ca), porte publique des contributions, qui ne fera jamais partie de Set-OPS ; la redondance vient d'une forge par site, chacune inséminée du génome. La phrase avait été recopiée trois fois sans que personne ne suive le chemin — moi compris, la veille.

Ce qui n'est pas encore sur le réseau. Les .nft régénérés doivent être posés par le runner du site. Le SDN (zone t29, VLAN 1291-1296), la frontière et le pare-feu Proxmox n'ont pas pu être relus depuis le poste : le cluster ne répondait pas (22 et 8006).

Mesuré en passant, et corrigé le 2026-09-28. make sdn-plan, cluster injoignable, annonçait « à créer : 33 » — TOUTES les zones, Chezlepro comprise. Une lecture en échec rend {'_erreur': …}, et rep or [] parcourait ce dict comme une liste vide. appliquer_sdn.py s'arrête désormais, comme frontiere-plan. Et t29 rejoint ANCIEN_NOMMAGE : sortie du devis sans cela, la zone aurait été lue comme étrangère et laissée en place, avec ses VLAN 1291-1296.

devis_proxmox_fw.py et devis_proxmox_pools.py balayaient encore TOUS les dossiers frères (decouvrir()), alors que la frontière et le SDN s'en tiennent à underlay.tenants (decouvrir_du_site(), dont la docstring le demandait déjà pour « les devis qui équipent un site »). Le runner du site, qui gardait un clone de patient 0, proposait donc de recréer ses groupes ; le poste comptait un dossier de CI comme tenant. Les deux devis suivent maintenant la même liste : 2 tenants, et non 3.

Filtré ainsi, le devis ne voyait plus du tout t29 — ni à créer, ni à RETIRER : son périmètre ne retient que les préfixes des tenants présents. 3 IPSets et 6 groupes t29- restaient posés sur le cluster. appliquer_proxmox_fw.py ajoute à son périmètre les étiquettes des zones de ANCIEN_NOMMAGE (une liste, pas deux), et reçoit le même garde que le SDN contre un cluster muet.

Posé le 2026-09-28, depuis le runner du site. SDN : zone t29, 3 VNets, 3 sous-réseaux retirés, le plan relu dit « Rien à faire ». Frontière : 44 objets PATI29 et 3 routes 10.29.x retirés, 283 règles inchangées, rien d'autre au plan relu. Pare-feu Proxmox : les 6 groupes et 3 IPSets t29- retirés un par un par l'API — PAS par proxmox-fw-appliquer, dont le plan mêle 64 changements préexistants sur t17/t23 qui demandent leur propre revue. Ce retrait a révélé un dernier défaut : l'applicateur envoyait force dans le CORPS d'un DELETE, que Proxmox refuse (501) ; aucun IPSet n'était donc jamais retiré. Corrigé (?force=1). La strophe FRR des nœuds de sortie garde t29 : ils sont injoignables en SSH depuis le runner.

Wiki republié (P60) : deux de ses pages nommaient patient 0. make verifier : 82 OK, 1 échec — P02, test_ecriture_plan.py sur domaines.yml ('list' object has no attribute 'get'), présent avant ce changement.

2026-09-25 — Le registre disait « mesure » d'une cible qui coupe l'amont

21 preuves sur test_runbooks.py, verifier à 0 écart. Un outil tiers veut n'offrir de ce moteur que ce qui n'agit pas. Le seul champ qui le lui dise est nature. Il fallait donc qu'il ne mente pas — et il mentait une fois.

Une mesure qui demande confirmation n'en est pas une

filiation → emancipation-prouver se déclarait nature: mesure et portait fixes: {CONFIRMER: "true"}. Les deux ne peuvent pas être vrais ensemble : la cible refuse sans confirmation, et son propre refus dit pourquoi — « cette preuve COUPE l'amont quelques secondes pour mesurer ». La coupure est retirée quoi qu'il arrive, mais pendant ce temps la fonction éprouvée peut échouer.

Le mot « prouver » avait emporté la décision. Un constat rapporté ne rend pas inerte le geste qui l'obtient. L'étape est désormais nature: ecriture, et son pourquoi dit ce qu'elle coupe.

verifier() refuse maintenant cette contradiction : rien ne la voyait, et chaque lecteur du registre refaisait l'enquête. Mesuré avant correction : un écart, exactement celui-là.

Lire le registre sans analyser un arbre fait pour l'œil

runbooks.py lister --json rend le registre assemblé d'un bloc. Sans lui, un outil tiers n'avait le choix qu'entre analyser la sortie humaine — qui dérive au premier changement de mise en page — et importer ce module, ce qui le lie à nos noms internes. Les deux se paient plus tard.

L'affichage humain ne change pas, et une preuve le tient : un lister qui rendrait toujours du JSON passerait sinon les épreuves du drapeau sans que personne ne le voie.

La recette tranche, le registre ne se compare plus à lui-même

Le premier invariant comparait nature à fixes — deux champs écrits par la même main. Le même mensonge repassait en EFFAÇANT la ligne fixes, et trois cibles le portaient ainsi : flotte-creer, deployer-tout, reconstruire refusent sans confirmation, le registre ne le déclarait pas, et un assistant les lançait telles quelles — sortie en 2. Le contrôle lit désormais la RECETTE, qui ne ment pas : elle refuse, et c'est ce refus que l'exploitant rencontre.

Une étape qui agit barre celles qui la suivent

Passer emancipation-prouver à ecriture l'a placée derrière un ménage destructif : la console débloque d'office une étape « mesure », mais fait attendre toute autre que la précédente ait réussi dans la session. Un ménage qu'on peut n'avoir rien à faire rendait donc la preuve injouable. depots-perimes est marqué facultative — il nettoie ce que la filiation a laissé, il n'est pas son préalable.

Mesuré sur le registre réel : 17 runbooks, 127 étapes. 84 sont de nature mesure — c'est la surface qui n'agit pas, et la seule qu'un outil puisse offrir sans confirmation. Les 110 dont les fixes ne portent pas de CONFIRMER en contiennent 26 d'écriture, dont « déploie TOUTE la flotte » : compter sur ce chiffre pour dire « sûr » serait une erreur de lecture.

2026-09-20 (12) — Une décision appliquée à moitié se croit tenue

83 preuves, make test à 0 échec. L'exploitant a demandé : « n'avions-nous pas décidé d'un plan d'adressage dérivé pour chaque flotte, qu'elle soit tenant ou site ? » Oui. La décision est écrite dans l'underlay du site, datée du 12 septembre :

« sites et locataires se partagent la classe A, chacun avec SON index. Le site prend 37, le locataire garde 17. make instances les compte ensemble depuis ce jour. »

Ce que l'entrée (11) affirmait, et qui était faux

Elle disait qu'un site « déclare ses adresses, il n'a aucun seed dont les faire descendre ». C'était la recopie d'un commentaire en tête de SITE-Chezlepro/plan/serveurs.yml — périmé depuis le jour où le site a reçu son index, et lu au lieu d'être mesuré.

Un commentaire périmé se propage : celui-ci s'est retrouvé le même jour dans la docstring de ConsoleSite et dans une entrée de CHANGELOG. C'est exactement ce que CARTE-DU-SITE.md décrit chez un locataire — inerte et crédible : le moteur ne le lit pas, donc rien ne le corrige ; un humain le lit, et le croit.

La mesure

Les sept zones du site suivaient 10.<index>.<vlan>.0/24 à la lettre — et étaient pourtant écrites : sous_reseau et passerelle stockés pour chacune. C'est précisément ce que P20 interdit à un locataire, sous le titre « Adressage 100 % dérivé du seed (aucun stocké) ». Et la portée de P20 était « l'instance liée + les modèles » : elle ne regardait jamais le site.

Les valeurs concordaient parce qu'un humain les avait tapées juste, ce jour-là. Rien ne le garantissait : une zone écrite 10.36.x serait passée sans un mot.

Deux natures dans une seule liste

reseaux: mélangeait ce qui peut dériver et ce qui ne le peut pas. Chaque entrée déclare maintenant sa nature :

| nature: fabric | grappe, iSCSI, Ceph, transit, VXLAN, management | adressage dicté par les participants d'un lien physique (D-02, D-04) — il reste écrit | | nature: site | les sept zones du site | elles descendent de son index, et ne stockent plus rien |

Le loader dérive sous_reseau et passerelle à la lecture, pour que les consommateurs ne voient aucune différence. Quatorze valeurs stockées ont disparu du fichier, et ce que les consommateurs lisent est identique — écart mesuré : zéro. make underlay, make devis-sdn et make devis-reseau passent.

P20 juge désormais les deux natures, et refuse une entrée qui ne déclare pas la sienne : on ne peut pas juger ce qu'on ne sait pas lire. Contrôle négatif joué : une zone remise à 10.36.31.0/24 est refusée, en nommant l'index dont elle aurait dû descendre.

Ce qui reste écrit, et qui est une dette nommée

Les machines du site gardent leurs ip, vmid et noeud dans son plan. Ce n'est plus une doctrine — « le site est le terrain, il n'a rien dont faire descendre » — puisque le seed existe maintenant pour elles aussi. C'est une dette, et la nommer est le minimum : un objectif qu'on abandonne sans le dire devient un objectif qu'on croit atteint.

P02 et P60 restent, pour les raisons déjà consignées.

2026-09-20 (11) — Deux fonctions qui doivent rendre la même forme finissent par ne plus la rendre

83 preuves, make test à 0 échec, 8 tests de rendu. La console avait deux assembleurs de charge — inventaire_api pour un locataire, inventaire_api_du_site pour un site. Ils ont dérivé deux fois le même jour, et ce n'était pas une malchance : c'est ce qui arrive toujours à deux copies d'une même obligation.

Ce que la duplication a coûté, mesuré

En forme. serveurs valait [] d'un côté et {} de l'autre. Les deux « vides », et la page ne les lit pas pareil : charger() levait, et la console d'un site mourait avant sa première vue.

En contenu. Cinq registres étaient servis vides — « pour ne pas fabriquer un faux plan de tenant ». Le site n'a pas de faux plan : il a le sien, au même format, que les lecteurs du moteur lisent sans broncher. Mesure : 9 serveurs, 21 applications, 2 bases, 1 domaine, tous invisibles à l'écran.

Un tronc, deux branches, et le rôle distinct de la portée

Console assemble — un seul endroit où la charge se compose, et c'est lui qui fixe les clés et leurs formes. Les branches ne décident que de ce qui leur appartient : d'où vient le plan, d'où vient l'inventaire, quels pouvoirs elles portent.

Branche Ce qu'elle est Son plan Son inventaire
ConsoleLocataire configure ses services dérivé d'un seed artefact généré
ConsoleSite matérialise le terrain déclaré (ip, vmid, noeud) un script
ConsolePoste l'atelier — hérite du locataire idem locataire idem locataire
Console ne pilote rien, et le dit aucun vide

Le rôle et la portée ne se confondent pas. Le rôle est une propriété de la classe : ce pour quoi cette console existe. La portée se calcule depuis les pouvoirs : ce qu'elle peut vraiment, ici et maintenant. Un poste privé de la voûte du site reste l'atelier du mainteneur et n'engendre pourtant rien — figer la portée sur la classe lui aurait rendu un pouvoir que la mesure lui refuse.

Ce découpage a immédiatement montré un trou : ConsolePoste héritait du refus d'un locataire — « elle ne sait pas sur quelle fabric poser » — alors qu'il monte la carte. Ce qui lui manque, c'est la voûte. Un message qui accuse la mauvaise absence fait chercher au mauvais endroit.

Le jugement des assistants remonte dans le tronc

Il était écrit deux fois : dans la route qui liste les assistants, et dans celle qui exécute une étape. Deux copies d'une même règle finissent par ne plus dire la même chose — et celle qui se trompe est toujours celle qui exécute, parce que c'est la moins relue. Console.peut_conduire() répond aux deux.

Le gestionnaire ne demande plus « est-ce un site ? » pour choisir un assembleur : il demande sa charge à sa console, et ignore laquelle lui répond.

Ce qui a été vérifié

make test à 0 échec, les 8 tests de rendu sous node — dont deux neufs : la console de site sert bien son plan, et les deux charges portent les mêmes types. La console a été lancée pour de vrai : contexte poste, 13 serveurs, 24 applications, 17 runbooks, 127 étapes conduisibles, 0 écart.

Et make verifier a trouvé une faute que j'avais laissée passer : le playbook depots_perimes.yml livré une heure plus tôt violait risky-shell-pipe. J'avais passé --syntax-check dessus et ansible-lint seulement sur le rôle voisin. Corrigé — les tubes retirés, pipefail armé sans -e pour qu'un git qui échoue laisse le script répondre au lieu de faire tomber la tâche.

Deux échecs restent. P02, antérieur, consigné à l'entrée (6). Et P60 : le wiki publié est en retard d'un commit depuis que l'entrée (6) a documenté les Assistants — make wiki-publier WIKI_REMOTE=… le republie, et l'adresse publique n'est celle d'aucun plan, donc elle ne se devine pas.

2026-09-20 (10) — Un vide doit avoir la forme de ce qu'il remplace

83 preuves, make test à 0 échec, 7 tests de rendu. La console de console.genese.internal — celle du runner de SITE-Chezlepro — ne montrait rien. Elle a douze machines.

charger() levait, et la page mourait avant de dessiner

inventaire_api sert serveurs en liste ; inventaire_api_du_site le servait en table. Les deux sont « vides », et la page ne les lit pas pareil : (data.serveurs || []).map(...) trouve {} — truthy, sans .map — et charger() lève. Toute la console d'un site s'arrêtait là, avant la première vue, et le message accusait une méthode manquante plutôt qu'une forme qui ment.

Un vide doit avoir la forme de ce qu'il remplace. Sinon ce n'est pas un vide, c'est un autre objet qui se fait passer pour lui — et le malentendu ne se voit qu'au moment où quelqu'un appelle une méthode dessus.

Un test compare désormais les deux charges type par type pour les clés qu'elles partagent, avec deux écarts admis et motivés. Il ne demande pas les mêmes valeurs — un site n'a pas de plan de locataire — seulement les mêmes formes.

Et la vue regardait au mauvais endroit

Le registre vide était voulu : « on ne fabrique pas un faux plan de tenant pour remplir l'écran ». C'est juste — un site déclare ses machines dans son propre plan, avec ip, vmid et noeud écrits, là où un locataire les dérive de son seed ; il est le terrain, il n'a rien dont les faire descendre.

Mais la vue d'accueil tirait de ce registre vide le message d'un locataire sans serveurs — « + Serveur », make serveurs-bootstrap — et les douze machines, servies dans hotes, n'étaient dessinées nulle part. Une console de site montre maintenant ses machines : mêmes tuiles que les serveurs d'un locataire, en lecture seule, avec leur adresse, leur VMID, leurs rôles et leur point de vie ; le panneau droit dit ce que cette console peut — matérialiser le terrain, n'entrer chez aucun locataire — et renvoie aux Assistants.

C'est le défaut du 2026-09-16 par une autre porte. La première fois, l'inventaire était vide et la page dessinait ce vide comme un plan vide. Cette fois l'inventaire est plein et c'est la vue qui regarde ailleurs. Le premier correctif a donné à la console de quoi répondre ; il ne lui a pas appris où regarder.

Ce qui a trouvé le défaut

Pas une lecture : le banc. test_rendu_gui.py dessine les vues sous node dans un DOM simulé. En lui donnant pour la première fois la charge d'une console de SITE, il a levé (data.serveurs || []).map is not a function — c'est-à-dire la cause réelle, sous le symptôme que je m'apprêtais à corriger. Sans lui, j'aurais livré une vue juste par dessus un charger() qui lève, et la console serait restée vide.

Ce qui a été vérifié

node --check sur le bloc <script> (147 707 caractères), les 7 tests de rendu, make test à 0 échec, runbooks.py verifier à 0 écart, P81 et P83 vertes. Le test neuf nomme chaque machine du site attendue à l'écran, refuse le message d'amorçage d'un locataire, et vérifie que choisir une machine montre ses rôles.

P02 reste en échec, pour la raison antérieure consignée à l'entrée (6).

2026-09-20 (9) — Ce qui n'existe qu'ici ne se détruit pas depuis ici

83 preuves, make test à 0 échec, 133 cibles documentées. Le runner de Chezlepro-locataire portait encore le clone de SITE-Chezlepro — la carte de son hébergeur — longtemps après que son plan ait cessé de la déclarer. serveur_ops clone ce qui est déclaré ; il ne retirait rien.

Pourquoi ce retrait n'est pas dans le rôle

Un rôle qui efface des dossiers à chaque passage est une grenade dégoupillée : une faute de frappe dans serveur_ops_depots suffirait à perdre du travail local. Le retrait est donc un geste séparé, qui regarde par défaut et n'efface que sur CONFIRMER=true — comme raser et site-raser.

make depots-perimes nomme ce qu'un runner porte et que son plan ne déclare plus, puis dit ce qu'une confirmation emporterait. Trois choses ne sont jamais retirées, même confirmées : ce qui n'est pas un dépôt git, ce qui porte des modifications non validées, et ce qui porte des commits qu'aucun distant ne porte.

La garde a servi au premier essai

Le relevé a nommé deux dossiers non déclarés : SITE-Chezlepro, et venv — l'environnement Python du runner. venv a été écarté parce que ce n'est pas un dépôt git. Une version naïve de ce geste aurait effacé l'environnement d'exécution du runner, et l'aurait fait en se disant satisfaite.

C'est la raison d'être de la première des trois règles : un dossier qu'on ne sait pas lire n'est pas un dossier périmé, c'est un dossier qu'on ne comprend pas. Le doute se résout en s'abstenant, pas en effaçant.

Écrire, puis relire — ici : regarder, puis vérifier

Le geste relit après avoir écrit : un state: absent qui se dit satisfait sans que le dossier ait disparu est exactement le genre de succès qui ment. Mesure après coup — le runner du locataire ne porte plus que Set-OPS-public et OPS-Chezlepro, ce que son plan déclare, et sa console rend portee=tenant, configurer=True, materialiser=False, fabric=False : même le pouvoir de LIRE la fabric est tombé avec la carte, ce qui est juste — il n'y a plus de miroir à consulter.

Ce qui a été vérifié

--syntax-check sur le playbook, python3 scripts/runbooks.py verifier (0 écart, la cible neuve est portée par le runbook « Filiation »), make test à 0 échec. Le geste a été passé deux fois sur le runner réel : une fois pour regarder — rien touché, changed=0 — puis une fois confirmé, changed=1, avec sa relecture.

P02 reste en échec, pour la raison antérieure consignée à l'entrée (6).

2026-09-20 (8) — Un pouvoir se mesure sur ce qu'on peut ouvrir, pas sur ce qu'on peut lire

83 preuves, make test à 0 échec (21 tests neufs aujourd'hui). La vue Assistants a rendu visible un écart que les quatre boutons d'avant cachaient : le runner de Chezlepro-locataire se déclarait poste et offrait 126 étapes sur 126, création et destruction de machines comprises.

Ce que la console lisait, et ce que le document exigeait

docs/responsabilites-locataire-hebergeur.md §2 attache chaque pouvoir à une voûte : calculer ne demande aucun secret, configurer demande celle du tenant, matérialiser celle du site. Le code, lui, lisait les symlinks — c'est-à-dire les cartes.

Le runner de Chezlepro montait la carte du site sans en avoir jamais eu la voûte. Les gestes de fabric qu'il proposait seraient partis puis tombés sur un secret vide : un échec au milieu du chemin, là où un refus net aurait dit la vérité avant de commencer. Et un refus qui arrive trop tard envoie chercher la panne dans la fabric, pas dans le pouvoir.

contexte() dérive désormais les pouvoirs des voûtes présentes, et la portée découle des pouvoirs au lieu de les précéder. On ne prouve pas qu'une voûte s'ouvre — le mot de passe se tape à l'exécution — mais son absence, elle, est décisive et se mesure sans rien ouvrir : une voûte absente ne s'ouvrira jamais. Lire une carte reste permis : le pouvoir fabric continue de suivre le symlink, parce que consulter un miroir n'est pas engendrer.

Chezlepro-locataire est un locataire

Il vit dans sa coquille et reçoit de l'hébergeur qui le porte — même quand cet hébergeur est lui-même. Que le même humain tienne les deux rôles ne fusionne pas les deux pouvoirs : ça rend la coupure plus nécessaire, puisque plus rien d'extérieur ne la rappelle.

Son plan cesse donc de déclarer la fabric (serveur_ops_underlay: "", comme TechnoLibre) et de cloner le dépôt de son hébergeur. Il porte maintenant sa propre copie de la racine d'autorité du site — un certificat public, identique à l'octet près à celui que TechnoLibre porte déjà — au lieu de la dériver du symlink qu'il n'a plus.

Retirer une déclaration doit retirer l'artefact

serveur_ops_underlay vide voulait dire « ce poste exploite sans engendrer », et le rôle se contentait de ne pas poser le lien. Un runner qui l'avait déjà le gardait — avec le pouvoir que son plan ne lui donnait plus. C'est la même forme que la liste qui suit une autre : une déclaration qui ne vaut que dans un sens ne décrit plus rien.

Le rôle retire maintenant le lien quand la fabric n'est plus déclarée. Il ne retire qu'un lien, jamais un fichier : si une vraie carte occupe la place, elle n'a pas été écrite par ce rôle, et il le dit plutôt que de détruire un contenu qui n'est pas le sien. Le chemin de cette place est dérivé une seule fois, en défaut du rôle — trois copies d'un même chemin finissent par ne plus désigner le même endroit.

Ce qui a été vérifié

--syntax-check sur playbooks/groupes/serveur_ops.yml, ansible-lint sur le rôle (profil production, 0 échec, 0 avertissement), make test à 0 échec, P81 et P83 vertes. Six tests neufs montent quatre faux disques et exigent la portée qui leur revient, dont le défaut lui-même, nommé : carte présente, voûte absente → tenant, jamais poste.

L'état des trois runners est mesuré avant la correction — site : fabric + voûte du site ; Chezlepro : instance + voûte du tenant + carte sans voûte du site ; TechnoLibre : instance + voûte du tenant, aucune fabric. Le premier relevé était faux : readlink -f rend un chemin même quand la cible n'existe pas, et faisait voir une fabric à TechnoLibre qui n'en a pas. Refait avec un test d'existence.

P02 reste en échec, pour la raison antérieure consignée à l'entrée (6). Le clone SITE-Chezlepro subsiste sur le runner du locataire : il ne donne plus aucun pouvoir une fois le lien retiré, et le retirer serait détruire un dossier que personne n'a demandé de détruire.

2026-09-20 (7) — Une portée jugée trop haut ferme une séquence à qui elle appartient

83 preuves, make test à 0 échec (15 tests de runbooks). Les assistants livrés à l'entrée (6) pesaient la portée au runbook. Une console de locataire le montre en une lecture : locataire-deployer et machine-une y étaient hors de portée — donc un locataire ne pouvait pas déployer sa propre flotte, ce qui est exactement son métier.

Une seule étape qui matérialise fermait tout le reste

flotte-creer et creer-vm engendrent des VM : ils exigent la fabric, que le locataire n'a pas. Déclarés dans une séquence dont la portée valait pour l'ensemble, ils faisaient basculer en poste les étapes voisines — deployer-tout, valider, verifier-hote — qui ne demandent pourtant que ce que le locataire possède déjà.

Mesuré sur la console de TechnoLibre, portée tenant : 6 runbooks conduisibles sur 17, et parmi les onze fermés, les deux qui décrivent son travail quotidien.

La portée se pèse désormais à l'étape. Le runbook n'en donne que le défaut ; l'étape qui exige davantage le déclare. Le locataire conduit donc sa séquence et bute précisément là où il faut : sur la machine à engendrer, pas sur le déploiement qui suit. La page ferme l'étape seule, en disant sa raison sur cette étape ; l'inspecteur compte ce qui est hors de portée plutôt que de barrer l'ensemble.

Côté serveur, la garde de /api/runbook-etape lit la portée de l'étape visée par son index, et non celle du runbook : juger sur le runbook aurait refusé un geste que la console avait le droit de faire, ou accepté l'inverse.

Ce que cette correction dit du reste

Un droit qui se calcule sur l'ensemble se trompe toujours dans le même sens : il refuse à quelqu'un ce qu'il a le droit de faire, et le refus paraît fondé puisqu'il nomme un vrai manque. Il a fallu une console de locataire réelle pour le voir — sur le poste, qui porte les deux liens, les dix-sept séquences s'affichaient conduisibles et rien ne clochait.

Quatre tests neufs refusent le retour du défaut, dont un qui nomme le cas exact : deployer-tout et valider restent à portée d'un locataire, flotte-creer non.

Ce qui a été vérifié

python3 scripts/runbooks.py verifier (0 écart), make test à 0 échec, P83 verte. La correction est mesurée sur les trois consoles après déploiement. Aucun rôle, playbook ou réglage d'infrastructure modifié. P02 reste en échec, pour la raison antérieure déjà consignée à l'entrée (6).

2026-09-20 (6) — Cent trente-deux cibles, et aucune ne dit dans quel ordre

83 preuves (P83 est neuve), make test à 0 échec. Le Makefile porte 132 cibles documentées. Chacune dit ce qu'elle fait ; aucune ne dit quand, ni après quoi, ni ce qu'il faut avoir mesuré avant. La console offrait donc des boutons sans séquence, et l'ordre des gestes vivait en prose dans des documents que la console ne porte pas.

Un exploitant devant un site neuf n'avait pas de quoi commencer

Rien dans l'interface n'apprenait que site-creer précède forge-amorcer, que le premier passage de site-deployer-tout s'arrête sur une forge vide — et que ce n'est pas un échec mais le maillon suivant — ni que rien n'est « prêt » avant valider. C'est exactement ce que le principe fondateur exige : un opérateur exploite Set-OPS sans IA. Sans la séquence, la console ne tenait cette promesse qu'à moitié.

La vue Assistants conduit 17 runbooks, 126 étapes, du premier jour d'un site à la remise au client. Les 132 cibles y sont : chacune portée par un assistant, ou exemptée avec son motif. Une exemption muette est refusée — c'est un oubli déguisé.

Le registre ne recopie pas le Makefile, et la garde est écrite avec lui

docs/runbooks-construction.yml déclare seulement ce que le Makefile ne peut pas porter : l'ordre, la nature du geste, la portée, le pourquoi. Le libellé de chaque étape est lu dans le Makefile au moment de servir — jamais recopié. Une cible renommée se voit donc à l'écran, elle ne s'invente pas.

Ce fichier est malgré tout une seconde liste à côté de la première, et une liste qui suit une autre prend du retard : vu quatre fois dans ce dépôt en une seule journée. P83 est donc écrite en même temps que la liste, pas après. Elle refuse une étape qui vise une cible absente du Makefile, une cible documentée que nul runbook ne porte et que nul motif n'exempte, une cible à la fois portée et exemptée, et une portée que la console ne saurait pas traduire en pouvoir. Onze tests unitaires lui présentent des registres faux — un par forme de retard — et exigent qu'elle les refuse : une garde qui rendrait toujours « rien à signaler » passerait P83 tous les jours sans rien garder.

Le navigateur ne nomme pas une commande, il nomme une place

/api/runbook-etape ne lance jamais la cible que la page demande : il lance ce que le registre déclare à cet index-là, avec les seules variables déclarées, et refuse tout le reste en disant pourquoi. Une page périmée — ou compromise — ne peut pas réclamer raser depuis un assistant de mesure. L'index compte : « Le premier jour d'un site » joue site-deployer-tout deux fois, et les deux places n'ont pas le même sens ; chercher par nom seul les confondrait.

Les valeurs imposées par une étape (CONFIRMER=true et compagnie) ne viennent jamais du corps de la requête : une confirmation qu'on peut s'envoyer à soi-même n'en est pas une. Ce qui protège, côté page, c'est d'écrire DETRUIRE — le seul garde-fou qui résiste à un clic distrait.

La portée se dérive, elle ne se déclare pas. Un assistant de site exige materialiser, un de locataire configurer, un de poste les deux. La garde de cette route est plus fine que celle des autres, et c'est voulu : un seul mot y serait soit trop laxiste, soit trop sévère. Un assistant hors de portée reste visible et lisible — savoir que le geste existe, et chez qui il se fait, fait partie du métier. Ce qui est refusé, c'est de le lancer.

La séquence est un garde-fou, pas une décoration

Une étape qui écrit attend que la précédente non facultative ait réussi : sinon on bâtit sur un terrain qu'on n'a pas vérifié. Une étape qui mesure reste toujours offerte, même après un échec — mesurer pour comprendre est exactement ce qu'on fait ensuite. Cette asymétrie est la seule chose que l'interface impose d'elle-même.

Ce qui a été vérifié, et ce qui ne l'a pas été

python3 scripts/runbooks.py verifier (0 écart), make test à 0 échec — 11 tests neufs compris — et les 83 preuves rejouées. La console a été lancée pour de vrai : GET / rend 200, /api/runbooks sert 17 runbooks avec les libellés lus au Makefile, six requêtes malformées sont refusées une à une (cible absente du runbook, runbook inconnu, bon nom au mauvais index, variable requise absente, valeur commençant par un tiret, valeur hors du vocabulaire déclaré), et une étape de mesure s'exécute de bout en bout avec son journal daté.

make verifier n'est pas vert, et ce n'est pas de ce changement. P02 (scripts/tests/test_ecriture_plan.py) échoue sur domaines.yml — trois erreurs dans _fusion_table, où un bloc réindenté se relit comme une liste là où le code attend une table. Mesuré sur une copie de HEAD avec la même instance montée : l'échec est identique avant ce travail. Il reste ouvert, et il a une conséquence pour l'exploitant : ajouter ou retirer un domaine public depuis la vue Domaines lèverait au moment d'enregistrer. L'aller-retour sans modification, lui, fonctionne.

Aucun rôle, playbook ou réglage n'est modifié ; aucune VM touchée, aucune voûte ouverte.

2026-09-20 (5) — Celui qui pousse avance son propre clone

82 preuves dans le rapport du 17 septembre, non rejouées ici. Le correctif de l'entrée (4) attendait sa preuve : il l'a eue en conditions réelles, et en la donnant il a montré le cas qu'il ne couvrait pas.

Le déclenchement, mesuré deux fois

Le commit f9a20b0 poussé sur la forge du site, rejouer serveur_ops chez les deux locataires a fait avancer leur clone de 6367d85 à f9a20b0. La tâche « Redémarrer la console quand le moteur a avancé » est passée en changed sur les deux : console de TechnoLibre repartie à 15h33m33 contre 15h16m49, celle de Chezlepro à 15h38m02 contre 15h16m47. Les deux sondes rendent 0. Le mécanisme a été vu fonctionner, pas simulé — et la tâche s'était abstenue plus tôt le même jour, quand les clones étaient déjà au niveau de la forge.

Le site ne peut pas se voir avancer

genome_pousser.yml fait avancer le clone du runner du site sans qu'aucun rôle ne passe : c'est lui qui pousse, donc il doit d'abord recevoir. Mesure du même jour — son clone est passé à f9a20b0 pendant la poussée, sa console est restée démarrée à 15h16. Le correctif de serveur_ops ne peut rien pour lui : quand le rôle passera, le clone sera déjà à jour, et la tâche s'abstiendra à juste titre. L'hébergeur gardait donc précisément le défaut corrigé chez ses locataires.

Le remède tient là où vit la cause. Le playbook nomme déjà la transition dans son verdict (6367d85 → f9a20b0) : il en tire maintenant la conséquence et redémarre la console du site quand c'est le moteur qui a bougé. Un plan poussé ne coupe pas les pages ouvertes, et un runner sans console n'est pas touché — l'unité systemd est vérifiée avant.

Personne d'autre que ce playbook ne sait que ce clone a avancé. Une information qui n'existe qu'à un endroit doit y être utilisée, sinon elle se perd et le défaut se reconstitue au passage suivant.

Ce qui a été vérifié, et ce qui ne l'a pas été

--syntax-check sur playbooks/maintenance/genome_pousser.yml, contre l'inventaire du site et contre celui d'un locataire ; ansible-lint sur le playbook (profil production, 0 échec, 0 avertissement). La condition de déclenchement est éprouvée sur quatre cas : moteur avancé, moteur immobile, DEPOT= visant un plan seulement, et la boucle sans résultat du mode --check.

Le redémarrage de la console du site a été observé à la première poussée suivante. f9a20b0 → f958739 sur la forge, la tâche est passée en changed, et la console du site est repartie à 15h46m27 contre 15h16m45 — sonde à 0, vestibule locale rendant 401 à une requête anonyme. Les cinq autres dépôts, reconnus immobiles, n'ont rien redémarré : la condition ne réagit qu'au moteur. make genome-etat rend failed=0 sur les six.

Les trois correctifs de la journée ont donc été vus fonctionner sur la machine que chacun concerne : le rôle chez les deux locataires, le playbook de poussée chez l'hébergeur. Aucune preuve du harnais rejouée, aucune voûte ouverte, aucune VM touchée.

2026-09-20 (4) — Le code sur le disque n'est pas le code servi

82 preuves dans le rapport du 17 septembre, non rejouées ici. Les trois runners portaient un moteur périmé, et leurs consoles servaient un moteur plus vieux encore. Deux causes distinctes, l'une dans la chaîne de distribution, l'autre dans le rôle.

Une forge en retard, et trois runners qui ne tirent que lorsqu'on les rejoue

Mesure du 20 septembre : la forge du site portait 19bbadf (16 septembre) pour les six dépôts, le poste 6367d85. Le runner du site était à 504840f — vingt-trois commits en arrière — et les deux runners de locataire à 8456d29, quinze en arrière. Aucun écart de contenu : chaque révision trouvée était un ancêtre du poste, donc du retard seul. make genome-etat le disait déjà, et nommait le remède. Les commits manquants touchaient scripts/inventory_gui.py, dont « la console dit sa portée » et « la page cesse de proposer ce que le serveur refuse » — ce que l'exploitant voyait à l'écran.

make genome-pousser remet la forge au niveau du poste ; rejouer serveur_ops remet les clones au niveau de la forge. Rien ne tire tout seul : aucune minuterie ne rafraîchit un runner, et c'est assumé, mais l'écart ne se signalait nulle part entre deux rejeux.

Le handler existait, il n'était branché que sur le fichier d'unité

Après les déploiements, les trois consoles portaient 6367d85 sur leur disque et servaient toujours le code chargé le 16 septembre à 16h36. La cause tient en deux lignes : le clone du génome n'était enregistré que pour ses tentatives, sans notify, et la tâche de service demande state: started, qui ne fait rien quand le service tourne déjà. python3 scripts/inventory_gui.py lit son code au démarrage, et seulement là — le même piège que le certificat renouvelé qu'un nginx non rechargé continue de servir périmé.

Le rôle retient désormais si le moteur a avancé — serveur_ops_depot_moteur, jamais un chemin recopié — et redémarre la console dans ce seul cas. Un plan qui avance ne coupe pas les pages ouvertes. L'expression du when est éprouvée sur cinq cas, dont le mode --check où la boucle ne rend aucun résultat.

Une sonde qui ignore son mode fabrique une panne permanente

console-ops rendait exit 2 sur les deux runners de locataire — « LE VESTIBULE EST OUVERT : deployer, creer et raser sont a la portee de qui trouve l'adresse » — sur des consoles parfaitement fermées. Elle frappait 127.0.0.1:8090, le seul endroit d'où, en mode oidc, un 200 est le résultat attendu : nginx y est réduit à la boucle locale précisément pour qu'on ne puisse pas contourner la passerelle SSO. Mesure du jour : ss et la configuration déployée concordent, oauth2-proxy écoute sur *:4180 et rend 302 vers Keycloak à une requête anonyme.

La sonde connaît maintenant son mode. En locale, nginx est le vestibule et doit refuser : le verdict ne change pas. En oidc, la serrure n'est pas un code HTTP mais une adresse d'écoute — toute écoute du port public hors de la boucle locale est la panne, et le refus de la passerelle reste mesuré par la sonde passerelle de serveur_oauth2_proxy, posée sur le même hôte. Deux sondes, deux serrures, aucune qui devine le port de l'autre.

Une panne permanente finit par ne plus être lue. Un instrument qui ne sait pas d'où il mesure invente des défauts, et use la confiance qu'on met dans les vrais.

Ce qui a été vérifié, et ce qui ne l'a pas été

--syntax-check sur playbooks/groupes/serveur_ops.yml et ansible-lint sur le rôle (profil production, 0 échec, 0 avertissement, 14 fichiers). Le gabarit de la sonde est rendu dans ses deux modes, sans reste Jinja, et passe bash -n ; son filtre d'écoute est éprouvé sur cinq formes d'adresses réelles. Le rôle est appliqué aux trois runners et la sonde rejouée sur chacun.

Le redémarrage automatique n'était pas observé au moment d'écrire ces lignes : les clones étaient déjà au niveau de la forge, la tâche s'est donc correctement abstenue. La preuve est venue le jour même, une fois ce commit poussé sur la forge du site — elle est mesurée dans l'entrée (5) ci-dessus. Les trois consoles ont été redémarrées à la main ce jour-là pour servir le code courant. Aucune preuve du harnais n'a été rejouée, aucune voûte ouverte, aucune VM créée ou détruite.

2026-09-20 (3) — La capacité du système ne se déduit pas de la méthode

82 preuves dans le rapport du 17 septembre, non rejouées ici. Le premier comparatif SOC 2 expliquait la différence entre méthode et attestation. Il répondait à côté de la question : le système construit peut-il satisfaire les critères, et que lui manque-t-il ?

Nommer les contrôles disponibles, puis les réglages qui restent à relire

Le dossier HTML/PDF 2026-09-20-comparatif-setops-soc2, hors dépôt dans livraisons/, est recentré sur cette capacité : oui sous conditions, pas une conformité acquise. Les identités, flux, certificats, traces, sauvegardes et moyens de reprise sont rapprochés de critères identifiés, sans déclarer une couverture exhaustive ni un résultat d'audit.

La lecture relève des points concrets : vérification des clés d'hôtes SSH désactivée, interfaces Prometheus/Loki déclarées sans authentification interne, TLS optionnel dans les défauts de certains rôles et limite du VPN sans MFA. Les défauts de rôle ne sont pas présentés comme les réglages réels d'une instance. La conservation des traces, la résistance des sauvegardes, les objectifs de reprise et les traitements applicatifs restent à vérifier selon les engagements. Le dossier propose une recette, il ne l'exécute pas.

Corriger la réponse sans modifier l'infrastructure

Le générateur et le README de livraison sont mis à jour ; les quatre fichiers précédents sont archivés avant remplacement. Les huit pages PDF, leur texte, leurs liens et leur pagination sont contrôlés ; la page HTML est vérifiée de 320 à 1440 pixels et sans JavaScript. Les références officielles AICPA et les constats de code sont relus. Les dossiers clients, le kit VPN et l'habillage partagé restent inchangés.

Aucun rôle, playbook ou réglage SSH/TLS n'est modifié. Aucun sondage de VM, aucune ouverture de voûte, aucun test d'intrusion : la syntaxe Ansible et les preuves ne sont pas rejouées pour cette modification strictement documentaire.

2026-09-20 (2) — Un contrôle rejouable n'est pas une attestation indépendante

82 preuves dans le rapport du 17 septembre, non rejouées ici. Comparer Set-OPS à un service couvert par SOC 2 exige de distinguer l'architecture, son exploitation et l'assurance indépendante : aucun compteur du harnais ne donne un taux de conformité SOC 2.

Comparer sans faire passer une capacité pour un statut

Un nouveau dossier HTML/PDF de huit pages, 2026-09-20-comparatif-setops-soc2, est ajouté à livraisons/, hors dépôt, avec son générateur et sa documentation. Il reprend l'habillage partagé sans modifier les dossiers clients ni le kit VPN. Les références officielles AICPA, consultées le 20 septembre, accompagnent la distinction entre rapport et certification du logiciel, les types 1 et 2 et les catégories des Trust Services Criteria.

Le rapprochement nomme les mécanismes de Set-OPS, les limites de leurs preuves, les questions d'exploitation et la frontière hébergeur/locataire. Les écarts proposés restent des questions de préparation, pas un audit de l'entreprise ; aucun rapport SOC 2 d'un fournisseur ou de Chezlepro n'a été examiné. Ni conformité ni supériorité de sécurité ne sont attribuées au projet.

Structure, sources, liens, rendu de 320 à 1440 pixels, lecture sans JavaScript et PDF de huit pages sont vérifiés. Aucun sondage des VM, aucune ouverture de voûte et aucune modification du moteur d'exploitation.

2026-09-20 — Une nouvelle couverture ne rajeunit pas une mesure

82 preuves dans le rapport du 17 septembre, non rejouées ici. Les dossiers de livraison datés du 16 septembre présentaient des réponses HTTP anciennes comme un état de livraison et ne décrivaient pas encore les tunnels d'administration nominatifs. Leur révision distingue les capacités, les faits déclarés au plan et les usages à recevoir.

Donner envie sans transformer le dossier en attestation

Les quatre HTML et leurs quatre PDF de livraisons/, hors de ce dépôt, sont revus pour Chezlepro et TechnoLibre : bénéfices, services reliés, prise en main, autonomie, remise des clés et responsabilités. Les noms du 16 septembre restent stables ; la révision du 20 septembre est visible. Les réponses 200/302 restent des mesures anciennes, pas une preuve actuelle de SSO. La séparation des runners n'est plus présentée comme une suppression des pouvoirs de l'hyperviseur.

Une source pour les deux formats, et des responsabilités qui ne disparaissent pas

Les générateurs lisent les machines, applications et expositions dans les plans ; la charte conserve ses vingt lignes, lues dans le tableau canonique. L'habillage commun est distinct de celui du kit VPN confidentiel, laissé intact. Un README décrit la régénération et les originaux sont archivés avant remplacement.

Les quatre pages sont vérifiées de 320 à 1440 pixels ; chaque PDF compte sept pages. Les liens, les titres, les cellules de la charte, les adresses dérivées et la pagination sont contrôlés. Aucun déploiement, aucune lecture de voûte et aucun sondage de VM : ces vérifications portent sur les documents, pas sur l'état actuel des services.

2026-09-19 — L’histoire commence là où les traces commencent

82 preuves dans le dernier rapport versionné, non rejouées pour cette page. Le premier commit contient déjà 308 fichiers : raconter la naissance de Set-OPS comme une succession d'étapes antérieures aurait inventé ce que Git ne montre pas.

Les décisions se racontent, leurs sources restent consultables

promo/histoire.html raconte huit étapes à partir des 641 commits accessibles depuis 05b85eb, du 24 juin au 17 septembre 2026. Les reconstructions, les limites des preuves statiques et les changements de direction gardent leur date et leur périmètre. Le récit renvoie aux commits charnières ; les 641 titres originaux, leurs dates et leurs identifiants sont intégrés dans une archive recherchable, avec un graphique d'activité issu du même corpus. Aucun service externe, aucune police distante, aucun moteur Ansible modifié.

La page Capacités donne accès à cette chronique. Le README de promo/ précise son caractère figé, son fonctionnement hors ligne et les éléments à actualiser ensemble pour prolonger le récit. Les vérifications de cette livraison portent sur la page et ses interactions, pas sur les playbooks ni sur la flotte.

2026-09-17 (6) — TechnoLibre a son tunnel, et son kit de mise en service

  • Instance admins-technolibre (wg23, port 52023, 10.23.29.0/24), pair declare dans le plan de TechnoLibre ; deux regles bornees a SETOPS_TENANT_TECH23 ; 13 machines redeployees (0 failed).
  • Defaut d'ergonomie corrige avant de servir : pair-nouveau --locataire refusait de proposer une adresse a un ecosysteme qui n'a encore AUCUN pair — c'est-a-dire exactement le premier. L'oeuf et la poule, sur le geste d'ouverture.
  • Kit remis au client (livraisons/generer-kit-vpn.py, hors depot) : reseau, port, cle publique du serveur et adresses des machines sont DERIVES ou LUS, jamais recopies. Le document dit lui-meme que la premiere cle privee a voyage et comment la remplacer par une cle tiree sur l'appareil.

make prouver : CONFORME, 82 OK.

2026-09-17 (5) — Un tunnel d'administration par locataire : il declare, le site sert

  • Nouveau registre de plan : plan/acces.yml (acces_admin_vpn), une entree par personne ET par appareil, cle PUBLIQUE seule. Decrit au schema (donc editable a la console), valide par valider_acces : nom personne-appareil, cle WireGuard bien formee, adresse en /32 dans le tunnel derive, jamais celle de la frontiere, jamais en double.
  • Tout derive de l'index : reseau 10.<index>.29.0/24, port 52000+index, instance wg<index>. Rien a allouer, aucune collision (P21 garde l'unicite des index).
  • scripts/vpn_admin.py tient desormais le tunnel du site ET celui de chaque locataire ; --locataire vise le bon. Le nom du pair sur le boitier porte l'ecosysteme : deux locataires peuvent avoir chacun leur daniel-portable.
  • L'isolement est GARDE : verifier refuse une regle dont la source est le tunnel d'un locataire et la destination autre chose que SON supernet — sans quoi un ecosysteme obtiendrait un acces chez un voisin en ajoutant un pair dans son propre plan. Mise en defaut eprouvee ; P24 l'execute avant toute ecriture.
  • Pare-feu des machines : resoudre_flux derive le reseau du tunnel du plan du locataire, et seulement s'il declare un pair ACTIF.

Mesure : wg17 actif en 10.17.29.1 avec son pair, port 52017 ouvert sur le WAN, deux regles bornees a SETOPS_TENANT_CHEZ17, 13 machines de Chezlepro redeployees (0 failed). make prouver : CONFORME, 82 OK.

2026-09-17 (4) — Le tunnel d'administration, eprouve en production : deux trous refermes

Le tunnel montait, la poignee de main se faisait, une machine de LOCATAIRE repondait — et aucune machine du SITE, ni la console de la frontiere.

  • SSH des machines du site : le socle declare son 22 en pair: [flotte, externe], jamais admin. L'acces vivait du REBOND par la frontiere (la connexion part alors du boitier, que pf laisse sortir sans regle) ; par le tunnel, la source est exterieure et rien ne l'autorisait. Les locataires marchaient deja : leur regle SSH nait de nftables_admin_ssh.
  • Console et API de la frontiere : aucun flux ne la designait comme destination d'administration. Monter le tunnel faisait perdre le moyen de reparer la regle manquante — il a fallu remettre l'adresse d'avant pour appliquer le correctif.
  • Une regle par port : OPNsense refuse « 22,443 » (« Please specify a valid portnumber, name, alias or range »). Aucun flux jusqu'ici n'en portait deux.

Mesure, tunnel monte et adresse d'avant retiree : site-mon-01, site-ops-01, site-dnspub-01, les deux infra-dns-01 des locataires et la console OPNsense (HTTP 200) repondent tous, vus depuis 10.37.29.2. make prouver : CONFORME, 82 OK.

2026-09-17 (3) — L'administration entre par un tunnel nominatif, pas par le runner

Question posee : « le runner ne devrait-il pas etre le rebond SSH des admins ? » Non — il detient la cle de la voute du site et les cles SSH de toutes les machines. Une session humaine compromise deviendrait le plan de controle, et reconstruire le runner couperait l'acces.

  • Instance WireGuard admins (port 51821, 10.37.29.0/24), creee par scripts/vpn_admin.py — a COTE du tunnel site-a-site vers le site pair, jamais melee a lui. Perimetre strict : un pair attache a une autre instance n'est jamais touche.
  • Un pair = une personne ET un appareil. Le plan ne porte que des cles PUBLIQUES ; pair-nouveau tire la paire et affiche la privee une fois. etat: absent revoque, et tout pair de notre instance que le plan ne declare plus est RETIRE.
  • Le reseau du tunnel est un reseau d'administration, et tout en derive : pare-feux des machines du site (resoudre_flux), contrat vers les locataires (site_intrants, donc leurs machines aussi), regles de la frontiere (devis_opnsense).
  • devis_opnsense connait une troisieme voie d'arrivee. Un CIDR d'administration etait soit « gestion » soit « WAN » ; celui d'un tunnel n'est ni l'un ni l'autre, et sa regle aurait ete posee sur une patte que ce trafic n'emprunte jamais.
  • Un seul port ouvert sur l'Internet : 51821/udp vers l'adresse publique de la frontiere.

Mesure : wg1 active en 10.37.29.1 ; pfctl porte la regle du port et les 15 acces du tunnel ; les 31 machines (site + deux locataires) acceptent 10.37.29.0/24 en SSH, 0 failed. make prouver : CONFORME, 82 OK. Document : docs/acces-administration.md.

2026-09-17 (2) — La frontiere rejoint la supervision du materiel

  • Exportateur : greffon officiel os-node_exporter installe et configure par l'API d'OPNsense, n'ecoutant que sur la patte de supervision (10.37.36.1:9100), collecteurs utiles seulement. Declare dans opnsense.yml (opnsense_exportateur_metriques) : c'est ce fait du boitier qui fait deriver la cible Prometheus (job="frontiere", hote="bifrost-1") et l'hote Icinga — sans lui, rien n'est derive.
  • Flux serveur_prometheus -> frontiere:9100. Le bloc des flux vers la frontiere ouvrait TOUT flux a chaque locataire et chaque zone : juste pour l'heure du socle, beaucoup trop large pour une scrutation. Un role autre que le socle ne part desormais que de SES machines du site. Et le saut des flux frontiere chez les locataires arrive AVANT la creation de l'alias du role : le plan montrait deux alias SETOPS_*_SRV_PROMETHEUS que rien ne referencait.
  • Plan de la frontiere : 1 regle (opt9, tcp/9100, SETOPS_SITE_SERVEUR_PROMETHEUS -> SETOPS_FRONTIERE), appliquee et chargee.
  • Carte d'orientation : les equipements ne sont plus « sans supervision » (hyperviseurs et frontiere) ; les commutateurs le restent.

Mesure : up{job="frontiere"} = 1 ; check_materiel.py --hote bifrost-1 : OK, 4 coeurs a 41 °C. make prouver : CONFORME, 82 OK.

2026-09-17 (1) — Le materiel se regarde et se juge : hyperviseurs

Les hyperviseurs exposaient deja 68 temperatures, leurs ventilateurs, SMART et l'usure NVMe, collectes par Prometheus depuis le 2026-09-10 — et regardes par personne.

  • scripts/materiel.py : un catalogue de capteurs et de seuils, lu par Grafana ET Icinga. Releve sur les trois cartes ASUS TUF X670E / Ryzen 9 7900X : AUXTIN* non cables, PCH_* a 0, DDR5 et cartes reseau exposees seulement par le noyau 6.14 de vishnu, SSD Samsung qui portent leur temperature dans l'attribut 190.
  • Prometheus : chaque cible de la fabric porte hote="asgard".
  • Grafana : tableau « Set-OPS — Materiel » (27 panneaux) genere depuis le catalogue.
  • Icinga : un hote par hyperviseur, juge par up ; service materiel (check_materiel.py).

TROUVE EN LE CONSTRUISANT : le disque sda de gandalf (WD Red Plus 10 To, OSD HDD osd.4 de Ceph, 26 225 h) porte 56 secteurs en attente, 17 irreparables et 2 realloues — stables sur une semaine, maximum a vie 63 °C. Ceph HEALTH_OK. Avertissement dans Icinga ; a remplacer.

Deux fautes eprouvees avant d'etre laissees : jointures refusees apres le reetiquetage (doublons de series — cote droit desormais agrege), et un 422 rapporte comme « Prometheus injoignable » (la sonde dit maintenant ce que Prometheus refuse).

Mesure : asgard OK (6 capteurs), vishnu OK (13), gandalf AVERTISSEMENT (secteurs sda). Toutes les requetes du tableau repondent. make prouver : CONFORME, 82 OK.

2026-09-16 (10) — Publier un service du site sur l'Internet : la redirection devient une capacite

Phase 2, quatrieme volet. Le devis du site sautait tout flux entrant externe (« rien d'autre n'entre chez le site depuis l'exterieur ») : aucun service du site ne pouvait etre publie autrement qu'a la main.

  • Un flux porte deux ports : port, ce que la machine ecoute, et port_public, ce que l'Internet frappe. serveur_dns_public declare WAN:53 -> 1053 (UDP et TCP) : le 53 public aboutit au frontal dnsdist, jamais a PowerDNS.
  • devis_opnsense emet une redirection et sa regle WAN (source any, destination la machine, port local). Une cible unique ou rien ; pas de port_public, pas de publication (une note le dit). La garde refuse une redirection sans regle, ou vers une cible publique.
  • appliquer_opnsense reconcilie les redirections (d_nat, identite setopsrdr:), sans regle associee creee par le boitier, reflexion NAT desactivee, et les charge (d_nat/apply). Le formulaire encode desormais les champs imbriques (rule[destination][network]) — un seul niveau aurait envoye une destination vide.
  • Pare-feu d'hote de site-dnspub-01 regenere et pose : 1053 ouvert a tous, 53 reste limite aux supernets des locataires.

Plan de la frontiere, lu et non applique : 4 objets a creer (2 regles, 2 redirections), 286 inchanges, rien a retirer. make prouver : CONFORME, 82 OK.

APPLIQUE PAR L'EXPLOITANT, puis mesure depuis un poste qui sort par une AUTRE adresse publique (.50) : SOA des deux zones servis, MX en TCP, 2 RRSIG, ANY UDP tronque, AXFR refuse, recursion et zone .internal REFUSED. pfctl : 2 rdr vers 10.37.37.21:1053 ; plan : 0 a creer. dns1.chezlepro.ca publie chez Namespro, resolu par 9.9.9.9. Retirage horaire du site observe (23:04, 23:05). Devis de bascule : 0 perdu ; seul prealable restant, le second serveur de noms.

2026-09-16 (9) — Un frontal devant le serveur public : dnsdist borne ce qu'on demande

Phase 2, troisieme volet. Un autoritatif signe DNSSEC expose sans frontal est un amplificateur : 60 octets a adresse usurpee rendent 1 a 3 Ko a la victime.

  • serveur_dns_public installe dnsdist sur <hote>:1053 (la frontiere y redirigera son 53) ; PowerDNS garde le 53 de la machine pour les NOTIFY des primaires.
  • Regles eprouvees dans l'unite reelle, avec le vrai PowerDNS derriere : NOTIFY et UPDATE refuses, AXFR/IXFR refuses, ANY en UDP tronque, debit par /24 (UDP 20 q/s -> tronque, TCP 40 q/s -> abandon). Rafale de 300 requetes : 70 reponses, 230 tronquees. Recursion : REFUSED. Signatures DNSSEC relayees.
  • Sonde zones-publiques : interroge par le frontal, alerte si dnsdist est arrete.
  • Idempotent (second passage : 0 changement). Aucune console de controle ouverte.

PIEGE DE MESURE : dig envoie ANY en TCP par defaut. Le premier essai « montrait » une regle inoperante ; +notcp et le compteur de la regle ont montre qu'elle agissait.

Rien n'est encore expose : aucun flux externe, aucune redirection a la frontiere.

2026-09-16 (8) — DNSSEC : le locataire signe avec la cle de sa voute

Phase 2 du DNS public, deuxieme volet. Eprouve sur les vraies machines AVANT d'etre code : cle importee depuis un fichier, transfert signe vers le site, PRESIGNED pose tout seul, delv « fully validated » (reponses negatives comprises).

  • La cle vit dans la voute du locataire (vault_dnssec_<zone>), tiree par scripts/dnssec.py generer. Une cle nee sur la machine mourrait a la reconstruction, et le DS publie rendrait le domaine BOGUS. Le DS se calcule depuis la voute seule (make dnssec-ds) ; le calcul local a ete confronte a pdnsutil : identique a l'octet.
  • setops-dnssec-zone met chaque zone dans l'etat du plan (cle unique, conforme a la voute), idempotent (second passage : 0 changement), et REFUSE de changer la cle ou de retirer la signature tant qu'un DS est publie — une mesure qui echoue vaut refus.
  • CAA issue letsencrypt.org sur chezlepro.ca (tous ses certificats publics mesures viennent de Let's Encrypt). Pas sur technolibre.ca : c'est a son responsable de le dire.
  • Sonde du site : zone signee servie nue = critique ; signatures sous 6 jours = avertissement, sous 3 = critique. Mise en defaut eprouvee.
  • valider_domaines refuse dnssec: true hors primaire-cache.
  • P18 deplie les familles de secrets derivees du plan : 'vault_dnssec_' ~ zone exige une cle par zone signee, au lieu d'exiger un nom vault_dnssec_ que personne ne peut fournir. Au passage : vault_pg_exportateur manquait au gabarit de Chezlepro (P18 ne lit que l'instance montee).

UNE FAUTE DE CONCEPTION, TROUVEE PAR LE CALCUL APRES LE DEPLOIEMENT. La premiere version posait SOA-EDIT INCEPTION-EPOCH : serial servi = max(serial, inception). PowerDNS date l'inception au debut de la semaine PRECEDENTE ; notre serial suit l'heure du dernier changement et la depasse donc pendant deux semaines. Le serial n'aurait bouge qu'a l'expiration meme des signatures du site — une fenetre BOGUS a chaque cycle. Passe a SOA-EDIT EPOCH : serial servi = l'heure, le site retire la zone a chaque rafraichissement.

Mesure en production : serial servi par le primaire = l'heure exacte ; le site a retire les deux zones par NOTIFY sans intervention ; ancres = DS calcules depuis les voutes, delv valide MX, CAA, TXT, A et NXDOMAIN sur les deux zones. make prouver : CONFORME, 82 OK, 0 echec.

LIMITE : le retirage horaire (rafraichissement du SOA) n'est pas encore observe ; le journal du site le dira dans l'heure.

2026-09-16 (7) — Les zones publiques portent le courriel : reproduire avant de basculer

Phase 2 du DNS public, premier volet. Une zone publique ne portait que des A d'exposition : basculer chezlepro.ca aurait fait disparaitre son MX, son SPF et son DMARC — le courriel de production — a la minute ou le registraire aurait suivi.

  • Enregistrements declares au plan (domaines_publics.<zone>.enregistrements) : A, AAAA, CNAME, MX, TXT, CAA. valider_domaines les passe au crible ; le schema les decrit (et porte desormais les enum d'une liste d'entrees) ; zone-publique.db.j2 les rend, TXT coupes en tranches de 255.
  • Le serveur de noms porte le nom que le site declare (dns_public_nom: dns1.chezlepro.ca), transmis par le contrat du site. ns1.<zone> aurait deplace ns1.chezlepro.ca, qui existe en production vers .53. Le role refuse un enregistrement redeclare a ce nom.
  • chezlepro.ca reproduit la production (releve par 9.9.9.9, Namespro refusant le transfert) ; technolibre.ca est preparee (courriel chez Koumbit), sans les marques heritage=external-dns.
  • make dns-bascule-devis (scripts/dns_bascule.py) : plan contre DNS en service, aucune ecriture. Verdict mesure : chezlepro.ca 12 identiques, 0 perdu ; technolibre.ca 8 identiques, 0 perdu. Bloquent encore : un seul serveur de noms (le .ca en exige deux) et dns1.chezlepro.ca non publie.

Deux fautes trouvees par l'epreuve, avant la production :

  • une boucle Jinja dans set_fact rend une chaine : in y cherchait une sous-chaine, et lepro.ca « appartenait » a la liste. La liste passe en JSON.
  • shlex.split sur une valeur du plan (non citee) effacait les espaces : v=spf1 mx devenait v=spf1mx, et le devis voyait deux ecarts qui n'existaient pas.

Mesure en production : deploiement des deux primaires 0 failed ; le site a tire les nouveaux serials par NOTIFY sans intervention ; les 20 couples (nom, type) du plan sont servis a l'identique par site-dnspub-01. make prouver : CONFORME, 82 OK, 0 echec.

2026-09-16 (6) — Le DNS public en production, et cinq fautes que seule la production montrait

La phase 1 tourne. Chaque ligne ci-dessous a ete MESUREE sur les machines, pas deduite d'un recapitulatif Ansible :

primaires (Chezlepro, TechnoLibre)   pdns@public actif ; zone .internal demandee a
                                     l'instance publique : REFUSED ; AXFR sans cle : refuse
site-dnspub-01                       2 zones sur 2, serials IDENTIQUES aux primaires ;
                                     tirage AUTOMATIQUE en 80 s, jamais force
                                     recursion, AXFR sortant, zone interne : REFUSED
                                     version annoncee : aucune
AXFR non signe vers un primaire      refuse PAR LE PRIMAIRE (pas un delai : le chemin
                                     existe, seule la cle manque)
NOTIFY                               compteur du secondaire 3 -> 5 pour deux envois
frontiere                            8 regles CHARGEES, verifiees dans pf

L'epreuve de l'outil avait tourne dans un /tmp, sous l'utilisateur qui la lancait, sans unite systemd, sur la boucle locale. Elle ne pouvait voir aucune des cinq fautes suivantes.

1. Ecrire n'est pas charger — l'applicateur de la frontiere

Deux coupures reseau pendant frontiere-appliquer : les objets ont ete ECRITS dans la configuration d'OPNsense, l'applicateur s'est arrete avant filter/apply. Au passage suivant, le plan comparait la configuration au devis, les trouvait d'accord, et rendait « Rien a faire » — sans jamais charger. pfctl montrait zero regle du DNS public pendant que le plan disait « a creer : 0 ». Relancer n'aurait JAMAIS repare : le code connaissait ce defaut pour les routes seules. Desormais, CONFIRMER=true charge meme quand il n'y a rien a creer, et l'echec dit que la relance terminera le chargement.

2. site-verifier promettait une verification qu'il ne faisait pas

Son aide : « verifie que playbooks/site.yml correspond aux couches declarees ». Son code : les couches et le graphe, jamais le fichier. serveur_dns_public etait classe, coherent, et ABSENT du playbook qui deploie le site. verifier rend maintenant le fichier en memoire et le compare ; eprouve contre la version committee, il nomme le groupe manquant. P08 appelle cette commande : la garde vient sans nouvelle preuve.

3. SQLite ecrit son journal a cote du fichier

pdns@public refusait de demarrer : « attempt to write a readonly database ». Le fichier appartenait a pdns — le REPERTOIRE, /var/lib/powerdns, a root. Reproduit en pdns hors systemd : ce n'etait pas le confinement, c'etaient les droits. La base vit dans un sous-repertoire a pdns, toutes les commandes pdnsutil s'executent en pdns, et l'ancienne base — qui portait une cle TSIG — est retiree.

4. La question precede le transfert

Le secondaire n'a tire AUCUNE zone. Avant un transfert (TCP), il demande le SOA — EN UDP. Seul le TCP etait declare : la frontiere laissait passer l'AXFR et jetait la question. Mesure : SOA en UDP, delai ; en TCP, reponse. Un tirage force a la main passait, et aurait fait croire que tout marchait. P82 exige desormais les deux protocoles.

5. failed_when: false avalait un socket introuvable

pdns_control --config-name=public cherchait son socket a l'emplacement par defaut, alors que l'unite le cree dans /run/pdns-public. La notification echouait a CHAQUE deploiement, et le handler le taisait. socket-dir est declare, et le failed_when: false est retire : un canal de controle casse est un defaut, pas un bruit.

Ce qu'elles ont en commun

Toutes rendaient un SUCCES : un plan conforme, un orchestrateur coherent, un deploiement vert, un tirage force reussi, un handler sans erreur. C'est la famille de P79, et la raison pour laquelle chaque verification de cette entree a ete faite sur la machine qui devait utiliser le resultat.

2026-09-16 (5) — Le DNS public, phase 1 : le locataire ecrit, le site sert

Les noms publics des locataires etaient chez un tiers, et le mode autorite: primaire-cache du plan etait valide par le schema et consomme par rien. Cette phase le fait exercer, sans rien exposer a Internet.

LOCATAIRE   infra-dns-01   pdns@public   primaire cache de chezlepro.ca ─┐ NOTIFY
                                                                           │ AXFR signe TSIG
SITE        site-dnspub-01               secondaire public  ◄──────────────┘
            10.37.37.21  (zone de publication, pas sur l'edge)

Eprouver l'outil avant le role

Deux instances PowerDNS 4.9.17 jetables, sur une machine du site, en repertoire temporaire. Sept verifications, et trois enseignements que personne n'aurait devines :

  • allow-axfr-ips et TSIG sont ALTERNATIFS. Une adresse listee obtient la zone sans signature — le journal le dit : « allowed: client IP is in allow-axfr-ips ». La configuration evidente rend TSIG decoratif, sans un message. Et la valeur par defaut autorise la boucle locale : mon premier test negatif n'en etait pas un.
  • Le primaire notifie aussi les adresses de ses NS — donc, en production, les IP PUBLIQUES des serveurs de noms, depuis l'interieur du locataire. only-notify les borne.
  • bind-dnssec-db est pris en charge alors que ldd du module ne montrait pas SQLite.

Trois fautes evitees en concevant

  1. Une instance a part. Faire ecouter l'instance principale sur l'adresse de l'hote aurait permis au serveur public du site d'INTERROGER la zone .internal du locataire — de lire la carte de ses machines. C'est ce que la charte des responsabilites refuse a l'hebergeur. pdns@public est la seule a ecouter l'hote.
  2. Le serial suit le contenu. Le role portait un serial fige : sans secondaire, sans consequence ; avec un secondaire, il garde l'ancienne zone POUR TOUJOURS.
  3. Un mot de flux, pas un nom de role. serveur_powerdns existe aussi au site : une sortie « vers serveur_powerdns » aurait vise le PowerDNS du site lui-meme.

Ce qui est pose

  • le role serveur_dns_public (secondaire, aucun transfert sortant, notifications des seuls primaires, version-string=anonymous, aucun appel vers les serveurs de l'editeur) ;
  • serveur_powerdns exerce primaire-cache dans pdns@public ;
  • les relations se DERIVENT des plans des locataires (site_inventaire.py), l'adresse du primaire de leur nomenclature ; le site transmet dns_public_site et ip_publique_site par son contrat ;
  • deux mots de flux calques sur runner_site ; l'alias des primaires ne vise que les locataires qui publient (Patient0, qui n'a aucune zone publique, en a ete retire) ;
  • les cles TSIG dans les trois voutes, ecrites avec les gardes connues ;
  • la relation de replication avec SITE-Technolibre, declaree des deux cotes, inactive.

P82

Refuse une zone .internal publiee, une zone declaree et non servie (ou l'inverse), une adresse publique privee, une replication declaree d'un seul cote, une instance publique qui autoriserait autre chose que la boucle locale au transfert — et l'exposition du serveur public a Internet tant que toutes ses zones ne sont pas signees DNSSEC. La condition de la phase 2 est ecrite comme une garde, pas comme une promesse.

2026-09-16 (4) — La page cesse de proposer ce que le serveur refuse

La portee etait derivee et gardee au serveur ; la page, elle, proposait encore tout. On apprenait donc l'interdit AU CLIC — au milieu d'un geste, sur une console qu'on croyait la bonne.

Un seul entonnoir. Les quatre gestes d'hote passent par lancer(mode, index) : une garde la couvre les cinq endroits qui emettent un bouton « Deployer ». POUVOIR_PAR_MODE y rattache chaque mode a son pouvoir, et le refus NOMME sa raison.

Les boutons demeurent, inertes. Les retirer ferait croire que le geste n'existe pas — meme raisonnement que les integrations universelles, affichees en lecture seule plutot qu'omises. Ils portent leur raison en infobulle : « exploiter n'est pas engendrer » sur la creation de VM chez un locataire, « elle n'a la voute d'aucun locataire » sur la configuration chez un site.

La fabric est marquee. Chez un locataire, les devis de commutateurs et de frontiere sont calcules depuis une COPIE locale de la carte du cluster — et une copie avait deja diverge, deux listes de stockages contradictoires pour le meme materiel. Le panneau reste visible et cesse d'etre lu comme une source.

P81 s'etend : elle refuse desormais aussi une page qui aurait perdu POUVOIR_PAR_MODE ou l'un de ses quatre modes. Le serveur et la page doivent porter la meme coupure.

Le banc de rendu a attrape ce que node --check ne voit pas

Ma garde de page prenait pour un contexte TOUT ce qu'on lui donnait. Dans le banc, la reponse d'une autre route est passee pour un contexte, puis peut() a lu contexte.peut[...] sur undefined : TypeError a l'ouverture, page morte. La syntaxe, elle, etait juste — node --check restait vert, et c'est exactement pourquoi test_rendu_gui.py existe : le rendu, et non la seule syntaxe.

Deux corrections, et la seconde compte autant que la premiere :

  • on verifie la forme avant d'adopter un contexte (peut est un objet, portee une chaine) ;
  • sans contexte connu, on ne retire rien. Une console qui griserait ses boutons parce qu'elle n'a pas su lire sa portee serait pire que le defaut corrige : elle empecherait un geste legitime sans rien expliquer. Le serveur, lui, refuse quand meme — c'est lui la garde.

Et une couleur ne se relit pas, elle se regarde : mon badge de portee posait un texte sombre sur un fond clair, dans une console en theme sombre. Vu sur une capture, pas dans le code.

Au passage, une faute de ma part, attrapee par le harnais

En eprouvant les refus, j'ai vise une machine REELLE depuis une console de locataire simulee (SETOPS_UNDERLAY neutralise). /api/instancier n'a pas ete refuse — c'est correct — et a donc regenere hosts.yml de TechnoLibre SANS la fabric : proxmox_pont et chrony_serveurs perdus, proxmox_etiquette_vlan change, 13 hotes d'ecart.

P03 l'a vu au passage suivant, et le fichier a ete restaure par git. Une epreuve de garde n'a pas besoin d'une cible vivante ; celle-ci en a pris une.

2026-09-16 (3) — La console affichait un ecosysteme vide, et c'etait le site

Servie par le runner d'un SITE, la console d'exploitation montrait zero serveur, zero application, zero base — sans une erreur, sans un avertissement. Le site a sept machines.

Trois faits corrects, mis bout a bout, produisaient un mensonge :

serveur_ops RETIRE le lien `instance` sur un runner d'hebergeur
  (juste : un lien perime vers un tenant serait pire)
charger_yaml rend {"all": {"children": {}}} sur un fichier absent
  (juste : une console sans plan ne doit pas tomber)
la page dessine ce vide comme un plan vide
  (faux : l'inventaire d'un site est un SCRIPT, pas un hosts.yml)

C'est la forme exacte que P79 garde partout ailleurs — une derivation qui ne trouve rien ne se distingue pas d'une derivation qui n'a rien a trouver — sous son pire visage : celui d'un ecosysteme qu'on croit sans machines.

La portee se DERIVE, elle ne se declare pas

Un serveur_ops_gui_mode: site|tenant au plan aurait ete une SECONDE liste, qui prend du retard des qu'on reconfigure un runner sans y penser. Les deux symlinks, eux, SONT le pouvoir — et roles/serveur_ops exige deja de savoir lequel il est :

instance/      -> je configure CET ecosysteme (j'ai sa voute)
underlay.yml   -> je materialise sur CETTE fabric (j'ai celle du site)

les deux -> poste du mainteneur     instance seul -> console de locataire
underlay seul -> console de SITE    aucun -> rien a piloter, et elle le dit

Meme coupure que les trois portees de docs/responsabilites-locataire-hebergeur.md : calculer, configurer, materialiser.

Ce que ca change

Une console de SITE sert desormais l'inventaire dynamique de ses machines (site_inventaire.inventaire(), importe — pas un sous-processus par page). Ses registres de plan partent vides avec leur raison : on ne fabrique pas un faux plan de tenant pour remplir un ecran.

POUVOIR_REQUIS exige un pouvoir pour CHAQUE route POST, et le refus dit pourquoi : « interdit » sans raison envoie chercher une panne la ou il n'y en a pas. La garde est au serveur ; le bouton grise n'est qu'une politesse.

Et la page nomme la console. Trois consoles identiques a trois URL differentes etaient un piege pour qui en ouvre deux.

P81

Elle refuse une portee sans source d'inventaire, un site dont l'inventaire ne rend aucune machine, et toute route POST absente de la table — c'est la garde anti-derive : une route d'ecriture ajoutee demain s'executerait sinon sur une console qui n'en a pas le pouvoir, et personne ne le verrait avant l'incident.

2026-09-16 (2) — Les responsabilites, deduites des pouvoirs

Un partage de responsabilites se redige d'habitude en distribuant des devoirs. Celui-ci les DEDUIT, et la regle tient en une ligne :

Qui peut, doit. Qui ne peut pas, ne peut pas etre tenu — et personne ne peut se decharger sur celui qui ne peut pas.

docs/responsabilites-locataire-hebergeur.md part donc des trois portees qui existent deja dans le moteur — calculer, configurer, materialiser — et en tire vingt lignes, chacune citant le mecanisme qui la rend vraie : un role, une garde, une commande.

Les lignes qui ne se devinaient pas

Les sauvegardes se partagent en deux, et la coupure est nette. L'hebergeur repond de ce que sa sonde peut constater : le depot accepte-t-il encore une ecriture ? Il ne peut pas juger un instantane — il heberge des octets chiffres qu'il ne peut pas ouvrir. « Cet instantane est-il recent, complet, restaurable ? » se demande a qui detient la cle. La verification suit la cle.

L'acces de secours est dit plutot que taire. D-40 donne a l'hebergeur un sudo sur les machines qu'il exploite : exploiter, c'est pouvoir tout lire. La charte l'ecrit, et enchaine sur sa consequence — c'est la raison d'etre du second temps de la remise, et de sa DATE.

Une ligne sans vis-a-vis est un trou, pas une zone partagee. C'est la garde de lecture du tableau : le silence n'a jamais cree de responsabilite.

Le PDF LIT la doctrine, il n'en porte pas de copie

Les chartes remises a Chezlepro et a TechnoLibre sont engendrees depuis le tableau du depot, entre ses ancres. Deux textes qui diraient deux choses differentes seraient pires que l'absence de charte : chacun aurait raison de croire le sien. Le generateur REFUSE de rendre si l'ancre a disparu, plutot que de livrer une charte tronquee.

Au passage, les deux generateurs partagent desormais commun.py — la liste des locataires et la feuille de style existaient en double.

Ce qui n'est pas tranche est nomme

Le recouvrement de la cle du responsable designe, le changement de responsable, et la duree de retention avant purge. Une ligne rassurante sans mecanisme derriere vaut moins qu'un point ouvert ecrit.

2026-09-16 (1) — Remettre un ecosysteme : une procedure, un outil, une garde

Livrer se terminait par une phrase : « tes cles te seront remises separement ». Ce qui se passait ensuite n'etait ecrit nulle part — ni ce qu'on remet, ni dans quel ordre, ni ce qu'on garde. Le geste qui donne le controle d'une organisation etait le seul geste lourd du depot sans procedure, sans outil et sans preuve.

Et il portait une faute qui ne se serait vue de personne : les cles vivent toutes dans le meme dossier (~/.config/setops-vault-*). Remettre « les cles » d'un revers de main, c'est remettre celles du SITE et celles des autres locataires.

Deux temps, et ils ne se confondent pas

Ce qui passe Ce que le client peut
Temps 1 — l'identite la cle de SA voute, sa voute chiffree, la racine de SON AC creer, retirer, habiliter ses gens — des le premier jour
Temps 2 — la machine sa cle SSH entre, celle de l'hebergeur sort, la voute change de mot de passe, les secrets tournent tout, y compris se passer de nous

Le second temps applique a une LIVRAISON ce que migration-tenant.md applique deja a un DEPART : revoquer, pas transmettre. Sans lui, l'hebergeur garde a vie l'acces aux secrets d'un client qui se croit chez lui — et personne ne decide jamais de le garder : on oublie de le rendre, et le silence transforme l'oubli en etat de fait.

Ce que l'outil refuse

scripts/remise.py reprend la forme d'exporter_cles.py — ecrire puis RELIRE, ne jamais afficher une valeur, refuser une destination dans l'infrastructure — et y ajoute deux refus propres a la remise : partir sans la racine de l'AC (le client apprendrait a cliquer sur « continuer quand meme »), et emporter autre chose que l'ecosysteme monte. Cette derniere garde tient en une ligne, _cle_de_voute(), qui ne retient que le role instance parmi les trois que voutes.py nomme.

remise-recleer mesure avant d'estampiller : une cle presente, une cle revoquee, une cle de voute differente de celle remise. Un registre qui dirait « revoque » pendant que le plan garde la cle de l'hebergeur flatterait tout le monde.

Le registre, et une question ouverte depuis longtemps

remise.yml se pose chez le locataire a cote de parente.yml : de qui il descend d'un cote, a qui il appartient de l'autre. Il porte des empreintes SHA256, jamais des valeurs — il est versionne et pousse sur trois forges.

Il declare enfin le responsable designe. D-18 le decide depuis longtemps ; migration-tenant.md §9 listait « ou est-il declare ? » parmi ses questions ouvertes. La reponse est la seule qui ne devine rien : c'est la personne qui RECOIT, nommee au moment ou elle recoit.

P80

Elle refuse un registre incomplet, un second temps echu et non fait, un second temps declare fait pendant que le plan ne revoque aucune cle, et un secret qui se serait glisse dans un fichier versionne. Elle ne juge PAS un ecosysteme sans registre : le lab, patient 0 et l'ecosysteme de l'hebergeur ne seront jamais remis a personne.

2026-09-15 (7) — P79 : une derivation qui ne trouve rien ne passe plus pour un succes

En deux jours, pour monter l'edge du site puis les trois consoles, le meme defaut est revenu sous sept noms. Chaque fois un repli rendait un SUCCES au lieu d'un refus : deploiement vert, devis muet, harnais vert. Une derivation qui ne trouve rien ne se distinguait pas d'une derivation qui n'a rien a trouver.

Ce que P79 regarde

Temoin Le repli qu'il attrape
jumeaux dns_amorcage derive sans artefacts_amorcage : le gabarit garde son ancien proxy apt
patte une zone occupee absente de opnsense_if_zones : regles posees sur la patte plate
edge une exposition qui retombe sur son propre groupe dans un ecosysteme qui A un edge
bouchon l'edge du site sert ssl-cert-snakeoil.pem, faute de group_vars
rechargement un edge qui renouvelle son certificat sans recharger nginx
internet une sortie vers un role absent traduite en sortie vers l'Internet
flux les regles d'hote rejouees pour CHAQUE locataire, pas seulement l'instance montee

Chaque temoin lit sa source directement — inventaire, plan, table ecrite, meta/flux.yml — et ne passe jamais par la derivation qu'il juge. La lecon de P43 et de P67 : une garde qui reproduit le raisonnement qu'elle verifie ne verifie rien.

Le temoin des flux rejoue resoudre_flux.py nftables dans un dossier temporaire qui reprend l'instance par liens : rien n'est ecrit dans les depots.

Eprouvee en lui remettant chaque faute sous les yeux

Chaque temoin prend ses donnees en parametre. Les sept fautes ont ete reinjectees une a une dans les donnees reelles du site (zone retiree de la table, edge retire de domaines.yml, certificat, rechargement et jumeau retires de l'inventaire, regle !SETOPS_INTERNES sur 636 ajoutee au devis) : sept refus, et zero sur les donnees saines. Sans edge, le repli « le service se sert lui-meme » reste juste, et la garde se tait.

Et elle a trouve deux ecarts reels des sa premiere execution

OPS-Chezlepro-lab   14 regles de machines retirees du plan, aucune pour ops-01
OPS-Patient0        4 regles perimees (admin en 10.17.0.0/24, sans le refus
                    `admin-prohibited`), aucune pour ops-01

Les deux dataient d'avant le renumerotage du site : make flux n'avait jamais ete relance avec eux montes. Regeneres par SETOPS_INSTANCE, sans basculer l'instance. Ce sont des apercus, rien n'est applique a une machine.

Ce qu'elle ne couvre pas

La collision des noms publics par groupe (P67 la garde). client_pki qui rend changed=0 sans comparer ses SAN : cela se mesure sur la machine (make certificats-plan). Un registre facultatif exige par include_vars : celui-la echouait bruyamment.

Au passage

Les gabarits de voute de OPS-Patient0 et OPS-Chezlepro-lab portaient vault_setops_console_oidc depuis la session precedente, jamais commite, et le premier avait un commentaire coupe de sa cle. Remis en forme et commites.

2026-09-15 (6) — Les trois consoles repondent, chacune derriere sa propre serrure

console.genese.internal        401              vestibule HTTP Basic (pas d'annuaire)
console.chezlepro.internal     302 -> Keycloak  oauth2-proxy, client `setops-console`
console.technolibre.internal   302 -> Keycloak  oauth2-proxy, client `setops-console`

Chaque ecosysteme sert sa console, chez lui, sous le nom que son plan lui donne. Le site avec un mot de passe parce qu'il n'a pas d'annuaire ; les locataires avec le leur.

La pile, identique partout, verifiee sur la machine

127.0.0.1:8765   le GUI          — sa seule serrure est de n'ecouter que la
127.0.0.1:8090   le vestibule      boucle locale ; il sert sa page a qui la demande
  0.0.0.0:4180   la passerelle   — seule publiee, et elle renvoie vers Keycloak

Ce que le deploiement a demande

Un secret OIDC de 48 caracteres dans chaque voute de locataire (memes gardes : copie de surete, dechiffrement vers un fichier, relecture, en-tete verifie, empreinte comparee, clair passe au shred), un client setops-console dans chaque Keycloak, et la regle d'hote edge -> ops-01:4180.

CETTE DERNIERE A MANQUE DEUX FOIS, POUR LA MEME RAISON. make flux ecrit pour l'INSTANCE MONTEE : lance avec Chezlepro monte, il n'a rien regenere pour TechnoLibre. Le devis etait juste, le fichier de TechnoLibre n'existait simplement pas — et l'edge rendait 502 derriere un TLS parfait.

Chez un locataire ce flux ne traverse pas la frontiere (le SDN de Proxmox tient les passerelles de zone) : c'est le pare-feu d'HOTE qui le porte. Au site, c'etait la frontiere. Le meme flux, deux couches differentes, selon qui route.

Deux mesures a moi qui ont menti

tls=1 sur TechnoLibre. J'ai conclu a une chaine invalide. L'AC que j'avais recuperee faisait 0 octet : le ssh qui devait la lire avait echoue sans que je regarde son code de sortie. Le certificat servi portait le bon nom et le bon emetteur.

Les cles d'hote de TechnoLibre ont change a sa reconstruction, et known_hosts porte encore les anciennes. Je n'y ai pas touche — c'est le fichier de l'exploitant, et une entree qui change est exactement ce qu'un avertissement doit faire remarquer. La chaine a donc ete lue dans ce que l'edge SERT, pas dans ce qu'une machine aurait bien voulu me donner.

Consequence assumee : la console de TechnoLibre est verifiee par son COMPORTEMENT (302 vers son Keycloak) et par le nom de son certificat, pas par une verification complete de chaine depuis le poste — il y faudrait sa racine, que je n'ai pas pu lire.

2026-09-15 (5) — La console chez les locataires, une collision de noms, et un fichier que j'ai ecrase

Les deux locataires declarent desormais leur console derriere leur SSO. Le chemin a traverse une collision reelle et une faute de ma part.

console.<domaine>, derriere oauth2-proxy

ops-01   serveur_ops_gui_actif: true, auth: oidc
         le GUI sur 127.0.0.1:8765, le vestibule nginx sur 127.0.0.1:8090
         la passerelle sur 4180, publiee par l'edge

oidc ET PAS locale : ces ecosystemes ont un annuaire. Cette console lance des deploiements et peut RASER — un groupe se revoque sans deploiement (D-66), un mot de passe partage devant ce pouvoir est un accident qui attend.

ET LE VESTIBULE N'ECOUTE PLUS QUE LA BOUCLE LOCALE EN oidc. Le gabarit annoncait cette protection — « lui seul doit pouvoir frapper ce port » — en s'en remettant a meta/flux.yml, qui ouvre le 8090 depuis [edge, admin]. L'edge pouvait donc joindre la console SANS passer par la passerelle. Un port qui n'est pas ouvert ne se contourne pas ; une regle, si.

UNE COLLISION DE NOMS QUE LA GARDE NE VOYAIT PAS

serveur_oauth2_proxy est mono-instance par machine : une devant la vigie sur le noeud de supervision, une devant la console sur le runner. La table des noms publics etait indexee par GROUPE :

mon-01  serveur_oauth2_proxy_hostname = console.chezlepro.internal
ops-01  serveur_oauth2_proxy_hostname = console.chezlepro.internal

La passerelle de la VIGIE se croyait la console. Son URL de retour OIDC aurait vise l'autre machine, et le SSO de la supervision serait tombe — pour un service auquel on n'avait pas touche.

Une application nomme un COUPLE (machine, role). instancier et site_inventaire en ont la clef juste desormais.

ET P67 NE LE VOYAIT PAS, parce qu'elle indexait par groupe comme la derivation qu'elle garde : elle comparait la valeur a elle-meme. Une garde qui reproduit le raisonnement qu'elle verifie ne verifie rien. Elle compare maintenant par machine, et elle a ete eprouvee en lui remettant la faute sous les yeux.

CE QUE J'AI CASSE, ET LA MESURE QUI ME L'A CACHE

J'ai ecrit group_vars/serveur_ops.yml avec cat > sans regarder s'il existait. Il existait : 84 lignes chez Chezlepro, 77 chez TechnoLibre. J'en ai detruit 68 — la liste des depots du genome, la branche master de TechnoLibre, l'amont de la forge, la source de la cle de voute.

Deux preuves sont tombees (P05, P32). J'ai d'abord accuse le retrait des forges de la veille, et construit une explication complete : « le site expose l'amont, le locataire ne le porte pas ». Elle etait fausse — le fichier restaure le portait deja.

LA MESURE QUI M'A RASSURE A TORT :

(cd $e && git diff -- inventories/*/group_vars/serveur_ops.yml)

Le glob est developpe par le shell PARENT, ou inventories/ n'existe pas. Le motif est passe tel quel, n'a rien matche, et git diff n'a rien affiche. J'ai lu ce silence comme « aucun fichier ecrase ». C'est git status — sans glob — qui a fini par montrer le M.

Une commande qui ne trouve rien et une commande qui trouve que rien n'a change rendent le meme silence. Encore la meme forme que les six replis des deux derniers jours, cette fois dans mes propres mains.

Restaure par git checkout, puis les trois lignes de la console ajoutees SANS toucher au reste. Harnais : 77 OK.

2026-09-15 (4) — La console d'exploitation est allumee, et publiee par l'edge

https://console.genese.internal/    401 sans justificatif, la console avec

Le site est le premier ecosysteme a servir son GUI par un nom, en TLS verifie. Il a fallu qu'il ait un edge pour que ce soit possible.

Le vestibule reste, et c'est le contraire d'une contradiction

La veille, on RETIRAIT le vestibule de la vigie : Icinga Web 2 sait s'authentifier, en poser un devant reinventait sa page de connexion. Ici on en GARDE un, et la difference est tout le sujet :

Icinga Web 2   a une page de connexion, des comptes, des groupes   -> pas de vestibule
GUI Set-OPS    `GET /` sert la page A QUI LA DEMANDE, jeton inclus -> le vestibule EST
                                                                      sa seule serrure

Le jeton du GUI garde contre le CSRF, pas contre un visiteur. Ce qui protege la fabric, c'est que le service n'ecoute que 127.0.0.1 — verifie sur la machine :

LISTEN  127.0.0.1:8765     <- le GUI, joignable de nulle part ailleurs
LISTEN    0.0.0.0:8090     <- le vestibule, seul publie

locale (HTTP Basic) parce qu'un site n'a ni annuaire ni Keycloak. Chez un locataire ce sera oidc, derriere oauth2-proxy — et c'est la que l'integration a sa place.

Controle dans les deux sens

Une serrure ne se prouve qu'en la forcant ET en l'ouvrant :

sans justificatif   HTTP 401
avec                152 805 octets — « Set-OPS · Votre artisan numerique »
                    182 vues du GUI, le jeton injecte dans la page

Et la sonde console-ops mesure exactement ca — elle exige un REFUS sur une requete anonyme, parce qu'un 200 y serait la pire des reponses :

site-ops-01 | console-ops | OK | Console vivante, vestibule (locale) en place :
                                 une requete anonyme rend HTTP 401.

Le mot de passe le plus lourd de la voute

48 caracteres la ou les autres en ont 32 ou 40. Il n'ouvre pas une console de lecture : il ouvre deployer, creer, instance-utiliser et l'edition du plan. Le cout d'un caractere de plus est nul ; celui d'une console forcee ne l'est pas.

2026-09-15 (3) — Le charabia de la console : deux bases confondues, et un etat sans domicile

L'exploitant, apres s'etre connecte : « icingaweb2 affiche un tas de charabia quand j'ouvre une session ». Deux causes, dont la premiere etait de mon fait.

UN ROLE PARTAGE REND DES FAITS PARTAGES

serveur_icingaweb2 appelle resoudre_base DEUX fois : une pour la base du MOTEUR (icingadb, en lecture) et une pour celle des COMPTES (icingaweb2). Le second appel ecrase les faits du premier — resoudre_base_entree, resoudre_base_db_host... — et les gabarits, rendus apres les deux, lisaient la SECONDE base partout :

[icingadb]      dbname = "icingaweb2"    <- la base des comptes
[icingaweb_db]  dbname = "icingaweb2"

La console cherchait donc icingadb_schema dans la base des comptes, ne l'y trouvait pas, et rendait une trace PHP a chaque page. Les 66 tables du moteur etaient intactes a cote. Un defaut qui accuse la base pendant que la base va bien.

Celui qui appelle un role partage deux fois doit NOMMER ses resultats avant de le rappeler. C'est « une liste qui suit une autre », appliquee au temps plutot qu'a l'espace.

Introduit la veille, en remontant le bloc de la base au-dessus du rendu des .ini — un correctif d'ORDRE qui a cree un defaut de PORTEE.

LA CONSOLE N'AVAIT PAS DE DOMICILE POUR SON PROPRE ETAT

config_backend = "ini" laissait les preferences en fichiers. Tenable — sauf que le cadre de MIGRATION d'Icinga Web 2 veut une instance de base quoi qu'il arrive :

Failed to load pending migrations : Please check if a db instance exists at all
Cannot load preferences for user "icinga-admin" : Cannot load resource config ""

La seconde n'apparaissait qu'A LA CONNEXION. Un journal muet ne prouvait donc rien tant que personne n'avait ouvert de session — et c'est pour ca que le controle a consiste a en ouvrir une vraie, pas a regarder le journal d'une console au repos.

Des lors que la console a une base, il n'y a aucune raison d'y ranger les comptes et pas le reste : config_backend = "db", config_resource = "icingaweb_db". Les preferences d'un utilisateur le suivent alors d'un navigateur a l'autre.

Eprouve en ouvrant une session

session ouverte   oui
tableau de bord   « Current Incidents :: Dashboard »
/icingadb/services  44 459 octets, 0 trace
/icingadb/hosts     31 169 octets, 0 trace
journal             1 ligne en 100 s (aucune erreur)

CE QUE J'AI ATTRIBUE A TORT A MON PROPRE GESTE

J'ai recharge php-fpm a la main et conclu que c'etait ca. Les horloges disent le contraire :

derniere erreur          10:15:32
php-fpm redemarre        10:15:44   <- par le handler du role
mon rechargement         ~10:22     <- sur un systeme deja sain

Le role etait deja juste. Mes mesures intermediaires portaient sur une fenetre (--since "-5min") qui enjambait le correctif : je lisais des erreurs d'AVANT en croyant observer l'APRES. Une fenetre de mesure qui contient le moment du changement ne mesure ni l'un ni l'autre etat.

2026-09-15 (2) — Un vestibule devant une application qui savait deja s'authentifier

L'exploitant, en voyant la boite du navigateur sur vigie : « Dans le contexte d'un site, cela complexifie inutilement les choses puisque Icinga Web 2 permet nativement de gerer des comptes. Ce genre d'intervention n'est pertinente que pour integrer Icinga a Keycloak chez les tenants. »

Il a raison, et la correction retire du code au lieu d'en ajouter.

Ce que le mode locale faisait, et pourquoi c'etait de trop

Il posait un auth_basic nginx devant le backend external : nginx demandait le mot de passe, Icinga Web 2 croyait le REMOTE_USER qu'il recevait. Ca marchait. Ca reinventait une page de connexion devant une application qui en a une, et ca privait l'exploitant de ce que l'application sait faire seule.

UN VESTIBULE N'A DE SENS QUE DEVANT UNE APPLICATION QUI NE SAIT PAS S'AUTHENTIFIER. Celle de Set-OPS — le GUI — est dans ce cas, et son vestibule reste. Icinga Web 2, non.

Le mode db : le backend natif

authentication.ini   backend = "db"   resource = "icingaweb_db"
groups.ini           backend = "db"   — les groupes aussi sont natifs
nginx                plus aucun auth_basic

CE QUE CA REND, ET QUI MANQUAIT. La gestion des comptes DANS l'interface : creer, desactiver, changer un mot de passe ne demande plus un deploiement ni un passage par la voute. Le moteur ne pose qu'UN compte — celui qui permet d'entrer la premiere fois — et il ne l'ecrase jamais : un mot de passe change dans l'interface appartient a celui qui l'a change.

ET D-66 REDEVIENT APPLICABLE SANS ANNUAIRE. Les groupes d'Icinga Web 2 vivent en base : roles.ini habilite donc sysadmin, un GROUPE, et non plus une personne nommee en dur. Revoquer quelqu'un redevient un clic.

UNE BASE A ELLE, PAS UN COIN D'icingadb. Les tables de comptes appartiennent a l'application web ; celles du moteur sont reecrites par ses migrations. Les meler ferait disparaitre les comptes le jour d'une mise a jour, sans que personne n'ait touche aux comptes.

LE HACHAGE EST CELUI QUE L'APPLICATION VERIFIE : password_hash() de PHP, la fonction exacte qu'Icinga Web 2 appelle a la connexion. Un hachage d'un autre outil donnerait une base valide et une connexion impossible.

Deux pieges du renommage

when: serveur_icingaweb2_auth != 'locale' gardait l'appel a resoudre_annuaire. Juste tant que locale etait le seul mode sans annuaire ; renomme en db, la garde a cesse de garder et le role a reclame un secret de liaison LDAP qui n'existe pas. Les conditions nomment desormais les modes qui VEULENT un annuaire (ldap, external) : une condition qui dit ce qu'elle veut survit a un renommage, une qui dit ce qu'elle refuse, non.

L'ordre des taches. Le bloc de la base avait pris la place de l'ancien vestibule — c'est-a-dire APRES le rendu des .ini, qui nomment cette base. Le gabarit lisait des variables inexistantes, et l'echec etait censure par no_log : changed: true et rien d'autre. Une tache qui manipule des secrets ne peut pas dire ce qui lui manque ; c'est a l'ordre de ne pas la mettre dans cette situation.

Eprouve

auth_basic dans nginx      0
backend declare            db / icingaweb_db
compte en base             icinga-admin, actif
depuis le poste            302 -> /authentication/login -> 200
la page                    « Icinga Web 2 Login », champs username et password

2026-09-15 (1) — L'edge publie : trois noms, en TLS verifie de bout en bout

observatoire.genese.internal   http=302   tls=0    (Grafana, vers sa connexion)
vigie.genese.internal          http=401   tls=0    (le vestibule tient)
forge.genese.internal          http=200   tls=0

tls=0 : verification reelle contre la racine du site, pas un -k complaisant.

expositions n'etait resolu nulle part

serveur_nginx declare egress port: derive, pair: expositions — « je sors vers ce que je publie ». Ce mot n'etait consomme par AUCUN moteur : ni le pare-feu d'hote, qui ne filtre pas l'egress, ni le devis de frontiere, qui ne le connaissait pas.

IL N'AVAIT JAMAIS EU BESOIN DE L'ETRE. Chez un locataire, les passerelles de zone sont tenues par le SDN de Proxmox : le trafic edge -> amont ne traverse pas le boitier. Au SITE, elles sont tenues par la frontiere — premier endroit ou un edge doit franchir la bordure pour atteindre ce qu'il relaie. Une declaration peut dormir des mois avant que la premiere fabric ne la reveille.

UNE REGLE PAR AMONT, avec SON port :

opt10  tcp  443   SERVEUR_NGINX -> SERVEUR_FORGEJO
opt10  tcp  3000  SERVEUR_NGINX -> SERVEUR_GRAFANA
opt10  tcp  8080  SERVEUR_NGINX -> SERVEUR_ICINGAWEB2

Jamais une regle large. Un edge qui publie trois services ne doit joindre que ces trois-la : l'ouvrir sur « tout le site » annulerait ce qu'une zone de publication separee cherche a obtenir.

LA MEME FAUTE QU'HIER, AVALEE DE LA MEME FACON

Premiere ecriture de cette resolution : U.lire_plan_site(...) dans un fichier ou le module s'importe sous underlay_mod. Le NameError est tombe dans un except Exception large, et le devis a rendu une note plausible — « le plan du site n'en declare aucune avec un port » — alors que le plan en declarait trois.

C'est exactement le defaut du 2026-09-14 sur site_inventaire.py, refait 24 h plus tard par la meme main. Le except est resserre a (OSError, ValueError) : une faute de frappe doit faire du bruit, pas une phrase credible.

Le schema de l'amont suivait une constante, pas le port

proxy_pass http://… etait ecrit en dur. La forge termine deja son TLS sur 443 : elle recevait du clair sur un port qui attend une poignee de main, et rendait 400. Du TLS parfait a l'entree, une erreur de protocole a la sortie.

Le schema suit desormais le port (serveur_nginx_ports_tls), et l'amont est verifie : proxy_ssl_verify on contre l'AC interne. Un edge qui relaie en TLS sans verifier ne fait que DEPLACER la confiance — le visiteur croit l'edge, l'edge ne croit personne.

2026-09-14 (23) — Du TLS qui ressemblait a du TLS, et une regle qu'un tenant n'a jamais eu besoin d'ecrire

Trois defauts de plus sur le chemin de l'edge, et le premier est le plus grave de la journee.

L'edge servait ssl-cert-snakeoil.pem

Le certificat bouchon de Debian, sur les trois noms. serveur_nginx l'a pour DEFAUT, avec un commentaire qui date d'avant la PKI : « a remplacer par des certificats de l'AC interne plus tard ». Un locataire le remplace dans group_vars/serveur_nginx.yml ; l'inventaire du site est DYNAMIQUE et n'a pas de group_vars.

LES AUTRES REPLIS RENDAIENT UN SERVICE MUET OU UNE REGLE INERTE. Celui-la rend du TLS qui ressemble a du TLS : le cadenas s'affiche, la connexion est chiffree, et rien n'est prouve — le certificat n'est signe par personne et ne porte aucun des noms servis. C'est la seule sorte de panne qui rassure.

Deux ecritures d'une meme regle, et seule l'une a appris

site_inventaire.py construisait sa PROPRE liste d'expositions, avec edge = le groupe de l'application. Le commentaire disait pourquoi : « le site n'a pas d'edge, donc chaque service se sert lui-meme ». C'etait vrai jusqu'a hier.

client_pki retient un FQDN si son edge est un GROUPE DE CET HOTE. L'edge appartient a serveur_nginx ; cette boucle rendait serveur_grafana, serveur_icingaweb2... Aucun nom ne correspondait. La boucle est remplacee par un appel a expositions_des_applications — la regle n'existe plus qu'une fois.

La cicatrice refaite sur la machine suivante

site-forge-01 porte ce commentaire depuis des semaines : « Un cert renouvele sur disque reste servi perime tant que le consommateur n'est pas recharge. La cicatrice est deja dans le depot ; on ne la refait pas. » Elle a ete refaite sur site-edge-01, qui n'avait pas de client_pki_reload_services.

disque : forge, pki, dns, sauvegarde, observatoire, vigie, site-edge-01
servi  : site-edge-01

Ou en est l'edge

certificat servi   les 6 noms, signe par l'AC du site
verification TLS   verif_tls=0 depuis le poste, contre la racine du site
vhosts             forge, observatoire, vigie — en 443

CE QUI RESTE, ET C'EST UNE VRAIE PIECE

Les trois noms repondent en TLS verifie, puis rien : http=000. L'edge ne joint pas ses amonts, et le devis le dit lui-meme :

note : serveur_nginx declare un port `derive` que le plan du site ne resout pas
       — aucune regle emise.

serveur_nginx declare bien egress port: derive, pair: expositions. Ce flux n'a JAMAIS eu besoin d'etre resolu a la frontiere : chez un locataire, les passerelles de zone sont tenues par le SDN de Proxmox, et le trafic edge -> amont ne traverse pas le boitier. Au site, elles sont tenues par la frontiere — et c'est le premier endroit ou un edge doit franchir la bordure pour atteindre ce qu'il relaie.

Resoudre expositions en une regle PAR AMONT (son hote, son port) est la piece qui manque. Elle se voit maintenant parce que le site est la premiere fabric ou un edge et ses services vivent dans des zones differentes.

Et forge relaie en http://10.37.33.11:443 — du clair vers un port TLS, d'ou son 400. L'amont d'une exposition deja servie en TLS doit etre https://.

2026-09-14 (22) — L'edge du site est debout, et quatre replis silencieux l'ont retarde

site-edge-01 est nee, deployee, et sert forge, observatoire et vigie en 443. Le chemin jusque-la a traverse quatre defauts qui ont tous la MEME forme : un repli qui rend un succes au lieu d'un refus.

1. Le gabarit dore porte une adresse d'avant le renumerotage

Failed to update apt cache after 5 retries
Acquire::http::Proxy "http://10.0.33.21:3142"

Le fichier le dit lui-meme : « Gere par Set-OPS pendant la FABRICATION DU GABARIT », date du 2026-09-01, avec l'ancienne adresse du cache. Le socle ne le remplace que when artefacts_amorcage is defined — et site_inventaire.py derivait dns_amorcage sans deriver son JUMEAU. Les machines nees AVANT le renumerotage n'ont rien vu : l'adresse etait juste, puis client_artefacts a pose un fichier qui trie apres.

La premiere machine neuve du site l'a revele. Le site fournissait deja cette valeur a ses locataires (site_intrants.py) ; il ne se la donnait pas a lui-meme.

2. Une table de pattes tenue a la main, et un repli qui deplace au lieu d'omettre

Le devis rendait « rien a faire », et le cache restait injoignable. opnsense_if_zones associe chaque zone du site a sa patte de frontiere ; site-publication n'y etait pas, et _if_de() retombe alors sur l'ANCIENNE PATTE PLATE. Les 20 regles de l'edge etaient posees sur vlan030 — syntaxiquement correctes, jamais rencontrees, puisque le trafic de l'edge entre par vlan037.

Le fichier documentait deja un incident identique, un mois plus tot, pour la patte de la fabric : « La regle etait syntaxiquement correcte et ne correspondait jamais. » Ajoutee, le devis a rendu 20 a creer, 20 a retirer — le meme jeu de regles qui change de patte.

3. Un registre facultatif qui faisait echouer un role

serveur_nginx chargeait applications.yml ET domaines.yml sans condition. Un SITE ne publie rien a l'Internet : son plan n'a pas le second.

Could not find or access '.../SITE-Chezlepro/plan/domaines.yml'

Le role releve desormais ce qui EXISTE avant de charger. Un registre absent laisse domaines_publics indefini, et le filtre le traite deja comme vide.

4. Le vhost genere faisait 43 octets, et le deploiement etait vert

C'est le plus retors des quatre. expositions_des_applications resout, pour chaque nom expose, l'edge de son domaine PARENT — et sans registre, retombe sur le groupe de l'application elle-meme (serveur_grafana, serveur_icingaweb2). Jamais serveur_nginx. Aucune exposition n'etait donc retenue, le fichier ne contenait que son en-tete, et rien n'echouait.

Une derivation qui ne trouve rien ne se distingue pas d'une derivation qui n'a rien a trouver. Le site declare donc plan/domaines.yml : genese.internal, autorite auto-heberge, edge serveur_nginx, sans DNSSEC — le TLD internal. est nie par la racine signee, signer sous une chaine rompue ajoute du travail sans ajouter de preuve.

Et grafana n'avait pas de port: : le vhost le disait dans son propre en-tete — « une application sans hote actif, ou sans port, est listee mais NON publiee ».

Etat

site-edge-01   10.37.37.11   deployee (174 taches, 66 changements)
nginx          ecoute 80 et 443
vhosts         forge -> 10.37.33.11:443
               observatoire -> 10.37.36.11:3000
               vigie -> 10.37.36.11:8080
frontiere      274 regles, devis muet

Ce qui reste, et c'est precis

Le certificat de l'edge ne porte encore que site-edge-01.genese.internal. Il a ete emis avant que les expositions existent, et client_pki rend changed=0 : il ne compare pas son jeu de SAN a celui qu'il derive maintenant. Tant que ce n'est pas fait, les trois noms repondent sur un certificat qui ne les couvre pas.

forge relaie par ailleurs en http://10.37.33.11:443 — du clair vers un port TLS, d'ou son 400. L'amont d'une exposition deja servie en TLS doit etre https://.

Deux corrections a faire, et aucune n'est une surprise : ce sont les deux dernieres jointures entre le plan et ce que l'edge en tire.

2026-09-14 (21) — Le site gagne un edge, et P23 a nomme le geste qui manque

Le site servait trois applications web sans jamais les PUBLIER. L'observatoire, la vigie et la console d'exploitation ne se joignaient que par adresse:port, en clair, depuis le plan d'administration — et GF_SERVER_ROOT_URL promettait un https:// que personne ne terminait.

Une zone a elle seule, et pas un coin d'une autre

site-publication, VLAN 37, 10.37.37.0/24. Toutes les autres zones du site separent ce qui AGIT de ce qui est AGI : le runner seul, l'autorite seule, le temoin a part. Celle-ci separe autre chose — ce qui est adresse de l'exterieur de ce qui ne doit jamais l'etre.

Un edge est par definition la machine qu'on attaque en premier : c'est la seule dont l'adresse est publiee. La loger dans site-supervision l'aurait mise dans le meme domaine de diffusion que la base d'Icinga ; dans site-genome, a cote de la forge dont tout descend. Une machine exposee ne partage pas son voisinage.

site-edge-01   10.37.37.11   2 vCPU, 2 Go, 20 Go   vmid 9012

PETITE, ET SANS SAUVEGARDE, parce qu'elle ne porte aucun etat : un edge termine du TLS et relaie. Ce qu'il perdrait en tombant se recompose en le redeployant — sauvegarder un relais, ce serait sauvegarder une copie du plan.

ELLE NE S'EXPOSE PAS ELLE-MEME. Ce sont les expose: des AUTRES applications qui deviennent ses vhosts et les SAN de son certificat. Elle n'est le nom de rien ; elle est la porte de tous.

P23 a nomme le geste que l'outillage ne sait pas poser

reseau 'site-publication': passerelle 10.37.37.1 n'est l'adresse d'aucun hote
declare sur ce reseau — passerelle fantome

appliquer_opnsense sait poser des REGLES et des ROUTES ; il ne cree pas d'INTERFACE. Declarer la patte 10.37.37.1 dans les hotes de l'underlay n'est donc pas une formalite : c'est ce qui rend le geste manuel visible et verifiable. Tant que vlan037 n'existe pas sur la frontiere, cette ligne est une promesse que le reel doit tenir — et le harnais la relit a chaque passage.

C'est le meme principe que partout ici : on ne cache pas ce qu'on ne sait pas faire, on le DECLARE, et la garde se charge de le rappeler.

Ce qui est pret, et ce qui attend une main

Pret et derive : la zone, la machine, l'application, les 28 regles de frontiere (aucun retrait), le trunk du commutateur — switchport trunk allowed vlan 1,31,32,33,34,35,36,37,40 — et le vlan 37 / name site-publication a declarer.

Attend une main, parce qu'aucune API du depot ne le couvre :

1. OPNsense — creer le VLAN 37 sur le parent des zones du site, l'assigner,
   lui donner 10.37.37.1/24, l'activer.
2. Le commutateur — les deux lignes ci-dessus.

Ensuite seulement : make site-creer, make frontiere-appliquer, et le deploiement. Creer la VM avant la passerelle donnerait une machine sans route — pire qu'aucune machine.

Harnais : 77 OK, 0 echec.

2026-09-14 (20) — La console d'exploitation devient un service, et elle n'avait aucune serrure

Decision de l'exploitant : « j'aimerais que chaque runner expose le GUI de Set-OPS a travers ce edge ». La construire a d'abord demande de mesurer ce qu'on allait publier.

CE QUI A CHANGE LA FORME DE TOUT LE RESTE

Le GUI n'a AUCUNE authentification. Mesure du 2026-09-14, dans son propre code :

GET  /         sert la page A QUI LA DEMANDE, avec le jeton ECRIT DEDANS
POST /api/*    exige ce jeton — que la page vient de donner a tout le monde

Le jeton est une garde CSRF, pas une serrure. La seule serrure est --hote 127.0.0.1 : le GUI est sur parce qu'il n'est joignable que de sa propre machine.

Ce qu'il offre a qui entre : deployer, creer, instance-creer, instance-utiliser, pousser, et l'edition du plan. La fabric entiere. L'exposer tel quel aurait livre un ecosysteme a quiconque trouve l'URL.

Ce qui est construit

Le service reste sur la boucle locale, toujours. Ce qui est publie est un nginx local qui authentifie D'ABORD et relaie ensuite.

setops-gui.service le GUI, 127.0.0.1:8765, sous le compte setops, dans le venv du runner
nginx local port declare au plan, server_name derive du plan (P67)
vestibule oidc (oauth2-proxy -> Keycloak) par defaut, locale (HTTP Basic) en repli
flux ingress [edge, admin] — la meme paire que Grafana et la vigie, pour la meme raison
sonde console-ops

oidc PAR DEFAUT, ET CE N'EST PAS UNE PREFERENCE. Cette console peut raser un ecosysteme. Un mot de passe partage devant ce pouvoir est un accident qui attend ; un groupe d'annuaire se revoque sans deploiement (D-66). locale existe pour un ecosysteme qui n'a pas d'annuaire — un SITE — et c'est ecrit comme un repli, pas comme un choix.

L'AUTHENTIFICATION EST POSEE AU NIVEAU DU server, pas d'un location : placee dans le seul bloc de relais, elle laisserait passer tout chemin qu'un location plus specifique attraperait en premier. Meme geste que pour la vigie, et meme raison.

DEUX REGLAGES QUI NE SONT PAS DU CONFORT. proxy_read_timeout a 3600 s : un deploiement dure, et le delai par defaut de nginx couperait la reponse au milieu — la page dirait echec pendant que la fabric continue. proxy_buffering off : sans lui, la sortie d'un deploiement arriverait d'un bloc a la fin, et la console resterait muette des minutes.

La sonde mesure une SERRURE, ce qu'aucun greffon ne pense a faire

console-ops verifie deux choses, et la seconde est l'inverse d'une sonde ordinaire :

1. le GUI repond sur la boucle locale        -> sinon plus personne ne pilote
2. une requete ANONYME au vestibule est REFUSEE (401/403, ou renvoi en `oidc`)

Un 200 y est la pire des reponses. Il veut dire que le vestibule laisse entrer. Ca ne fait echouer personne — c'est exactement pourquoi il faut le mesurer.

P54 a attrape une contrainte que j'avais ratee

serveur_ops est un role d'insemination : le SITE le pose sur le runner d'un locataire qui vient de naitre, sans detenir la voute de ce locataire. Citer vault_setops_gui_admin dans ce role rendait l'insemination impossible — le site reclamait un secret qu'il n'a pas, et par construction ne doit pas avoir.

serveur_ops cite vault_setops_gui_admin dans roles/serveur_ops/defaults/main.yml :
le SITE ne detient pas cette voute

Le role declare donc un PARAMETRE vide, et la couche qui detient le secret est la seule a le nommer. La garde a tenu deux fois : la seconde, le nom ne survivait plus que dans le TEXTE d'un message d'erreur — ou il etait de toute facon faux, puisque la voute varie selon qui publie.

Ce qui reste

Le site n'a pas d'edge. L'exploitant vient d'en autoriser un : il publiera l'observatoire, la vigie et la console du site sous leurs noms, en TLS. C'est la suite.

2026-09-14 (19) — Une console pour le site, et un repli qui ouvrait vers l'Internet

Le site calculait 88 verdicts que personne ne pouvait lire. Il a desormais sa console — et la construire a revele un defaut du devis de frontiere qui, lui, etait deja applique.

Un troisieme mode d'authentification : locale

Icinga Web 2 n'a pas d'OIDC natif. Ses deux modes existants supposent l'un un ANNUAIRE (ldap), l'autre une PASSERELLE SSO (external). Un SITE n'a ni l'un ni l'autre : ce sont des services d'ECOSYSTEME, et l'hebergeur n'en est pas un.

locale : nginx authentifie en HTTP Basic et pose REMOTE_USER ; l'application croit ce que le serveur web lui dit — c'est exactement le contrat du backend external, avec un vestibule plus simple. Ni backend d'annuaire, ni backend de groupes, ni ressource LDAP : un backend ldap qui vise une ressource inexistante ne rend pas une liste vide, il fait ECHOUER chaque ouverture de session.

C'est le meme choix que serveur_grafana_connexion_locale et le compte local de Forgejo au site. L'habilitation nomme alors une personne, ce qui contredit D-66 (« le groupe, jamais des personnes ») — la regle suppose un annuaire, et il n'y en a pas. C'est ecrit dans roles.ini.j2 plutot que contourne en silence.

auth_basic est pose au niveau du server, pas du seul bloc PHP : place la, il aurait laisse passer tout ce que try_files sert directement. Une authentification qu'on contourne par un chemin voisin n'en est pas une.

Deux pieges rencontres en chemin

resoudre_annuaire etait appele sans condition, et tombait sur un plan sans annuaire :

object of type 'NoneType' has no len()

Un message qui ne nomme ni l'annuaire, ni le role qui le demandait, ni la raison. Deux corrections : le consommateur ne resout plus d'annuaire quand son mode n'en a pas, et resoudre_annuaire emploie default('', true) — le second argument dit « remplace aussi ce qui est FAUX », c'est-a-dire None. Sans lui, None traverse et heurte | length.

serveur_icingaweb2 ne declarait son entree que pour l'edge. Le site n'en a pas : nginx ecoutait, php-fpm repondait, et le pare-feu ne laissait entrer personne. serveur_grafana portait deja la reponse — joignable depuis le plan d'administration partout, sans quoi un deploiement sans edge n'a plus de console. Les deux consoles de l'observabilite avaient la meme contrainte et une seule des deux l'avait ecrite.

LE DEFAUT DU DEVIS : un role absent traduit en « tout l'Internet »

make frontiere-plan proposait cinq regles. Quatre etaient attendues. La cinquieme :

+ regle  opt9  tcp  636  SETOPS_SITE_SERVEUR_ICINGAWEB2 -> !SETOPS_INTERNES

serveur_icingaweb2 declare une sortie LDAPS vers serveur_openldap. Le site n'en a pas. Le devis cherchait la destination parmi les roles PRESENTS, n'en trouvait aucun, et retombait sur !SETOPS_INTERNES — la forme de « vers l'Internet ». La frontiere aurait autorise la console a parler LDAPS a n'importe quelle machine du monde, pour joindre un annuaire qui n'existe pas.

C'est « une source vide ouvre le port », cote DESTINATION. Le repli est juste quand le pair est lointain — un depot Debian, un serveur NTP. Il est faux des que le pair NOMME un role : le flux ne parle alors pas de l'Internet, il parle d'une machine, et elle n'est pas la. Le devis distingue desormais les deux, et le dit :

note : serveur_icingaweb2 declare une sortie vers serveur_openldap, absent de ce
       site — aucune regle emise (le repli aurait ouvert le port vers l'Internet).

ET TROIS REGLES DU MEME DEFAUT ETAIENT DEJA POSEES. Le devis corrige les signale perimees :

- opt9  TCP  24     SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES   (vers serveur_dovecot)
- opt9  TCP  12345  SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES   (vers serveur_dovecot)
- opt9  TCP  636    SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES   (vers serveur_openldap)

Le relais de courriel du site n'a ni Dovecot ni annuaire a joindre. Ces trois regles ne pouvaient porter aucun trafic utile — elles ne faisaient qu'elargir la frontiere. Les retirer est un RETRECISSEMENT.

Etat

Console deployee, verifiee sur la machine : nginx et php-fpm actifs, 401 sans identifiants, verify-full vers PostgreSQL, zero reference LDAP dans les trois .ini. vault_icingaweb2_admin pose dans la voute du site avec les memes gardes que la veille, et ajoute aux quatre gabarits d'ecosysteme.

LA FRONTIERE N'EST PAS ECRITE. Elle porte la production, et frontiere-appliquer exige CONFIRMER=true — ce garde-fou existe pour un humain. Le devis est lu, les quatre ajouts et les trois retraits sont compris ; l'ecriture attend un mot.

2026-09-14 (18) — La supervision du SITE etait morte depuis 11:10, et rien ne le disait

L'exploitant : « je ne vois pas de serveur icinga pour le site ? ». Il y en a un. Il tournait. Et il ne servait a rien.

La panne

icingadb.service : failed depuis 11:10:27

pq: aucune entree dans pg_hba.conf pour l'hote « 10.37.36.11 »,
    utilisateur « icingadb », base « icingadb », aucun chiffrement

icinga2 tournait, icingadb-redis tournait, les sondes poussaient — et rien n'atteignait la base. Les verdicts se calculaient dans le vide. Aucun deploiement n'a echoue, aucun service visible n'est tombe : le moteur de supervision etait mort et personne n'avait de quoi l'apprendre, puisque c'est precisement lui qui le dirait.

La cause : une seconde liste, tenue a la main

serveur_postgresql_tls_force pose hostssl dans pg_hba — toute connexion non chiffree refusee. Chaque consommateur avait alors SON interrupteur a allumer separement, dans les group_vars de chaque ecosysteme :

Chezlepro SITE
serveur_postgresql_tls_force true true
serveur_icinga_db_tls true absent
serveur_keycloak_db_sslmode verify-full absent

Chezlepro avait les trois. Le site avait le premier. Et le commentaire de site_inventaire.py cite deja cette erreur mot pour mot : le cote SERVEUR avait ete corrige le 2026-09-12 (reseaux_autorises), le cote CONSOMMATEUR jamais.

serveur_forgejo disait pire que rien : sslmode: "disable" ecrit en dur. Le consommateur affirmait le contraire de ce que le serveur imposait.

Le remede : le consommateur suit son serveur

resoudre_base expose desormais resoudre_base_db_tls_force, lu dans les hostvars de la machine qui PORTE la base. Un consommateur n'a plus a savoir qu'il doit chiffrer — il le deduit de ce que sert son serveur. Les trois interrupteurs en derivent.

default(false) : un serveur qui ne declare rien ne force rien, et le consommateur reste en clair. On ne casse pas un ecosysteme qui n'a pas bascule.

P78 refuse qu'un consommateur porte une valeur ECRITE : elle doit deriver. Une valeur ecrite est une seconde liste, et une liste qui suit une autre prend du retard. La preuve nomme aussi les deux consommateurs SANS reglage TLS — serveur_icingaweb2, serveur_nextcloud — plutot que de rendre un vert muet sur eux.

Apres

icingadb                        active
connexions vues par PostgreSQL  icingadb | t | TLSv1.3 | 10.37.36.11
en base                         16 hotes, 88 services

Et les cinq sondes ecrites hier pour les marqueurs du site etaient INCONNU : Icinga avait bien defini les services depuis leurs meta/supervision.yml, mais leurs roles n'avaient pas ete redeployes, donc aucun script n'etait pose. Les cinq roles appliques, un rapport force, et elles disent leur phrase :

site-backup-01  depot-locataires        OK  2 locataire(s) etanche(s), 374 Go libres
site-cache-01   cache-site-racine       OK  racine sans amont, et l'index se sert
site-dns-01     resolution-locataires   OK  3 supernet(s) de locataire admis
site-forge-01   genome-servi            OK  6 depots servis sous « genome », aucun vide
site-ops-01     pouvoir-materialiser    OK  carte en place, voute chiffree, cle en 600

Ce que la supervision, une fois vivante, a immediatement signale

site-mon-01  journaux-frontiere  CRITIQUE  LA FRONTIERE NE JOURNALISE PLUS
site-forge-01 / site-pki-01 / site-mon-01  sante  CRITIQUE  setops-verification-depot.service

Deux constats a instruire, et c'est exactement ce qu'on attend d'un moteur qu'on vient de remettre en marche : il ne rassure pas, il rapporte.

Ce qui manque encore, et c'est une decision

Le plan du site declare serveur_icinga et pas serveur_icingaweb2. Le site calcule 88 verdicts que personne ne peut LIRE. Grafana montre les series, pas les verdicts. Ajouter la console est une machine de zero (elle se co-localise) et un nom expose de plus.

2026-09-14 (17) — La garde regardait un seul cote de la cloture

L'exploitant, en cherchant son tableau : « grafana c'etait observatoire.genese.internal ». Il avait raison, et Grafana ne le savait pas.

Le defaut

Le plan du site expose observatoire.genese.internal. serveur_grafana devinait grafana.{{ domaine_interne }} — sa valeur par defaut. Grafana fabriquait donc son GF_SERVER_ROOT_URL, ses liens d'alerte et son URL de retour OIDC avec un nom que rien ne sert.

C'est MOT POUR MOT le defaut du 2026-09-10, quatre jours plus tard, de l'autre cote de la cloture. instancier derive <groupe>_hostname de l'exposition du plan depuis ce jour-la, et P67 le garde. Les deux ne connaissent que l'instance MONTEE — donc un locataire. L'inventaire du SITE, lui, n'en derivait aucun :

groupe expose le plan dit le role devinait
serveur_forgejo forge.genese.internal forge… — juste par convention
serveur_step_ca pki.genese.internal pki… — juste
serveur_resolveur dns.genese.internal dns… — juste
serveur_backup_site sauvegarde.genese.internal juste
serveur_grafana observatoire.genese.internal grafana.… — faux

Quatre devinettes sur cinq tombaient juste, et c'est precisement ce qui rend ce defaut invisible : tant que le plan suit la meme convention, la devinette tombe juste et personne ne voit qu'il y a deux sources. Le seul nom qui s'ecarte de la convention est le seul qui ment.

Ce qui est corrige

site_inventaire.py derive <groupe>_hostname de l'exposition unique du plan du site — le meme geste, au meme endroit, que chez un locataire. Les cinq noms viennent maintenant du plan.

P67 regarde les deux inventaires. Le site a un inventaire DYNAMIQUE : la preuve l'EXECUTE au lieu de lire un fichier — juger sa source reviendrait a relire le raisonnement au lieu du resultat. Eprouvee dans les deux sens : sans la derivation, elle nomme les cinq services fautifs ; avec, elle en compte 10 sur 2 inventaires.

Environment=GF_SERVER_DOMAIN=observatoire.genese.internal
Environment=GF_SERVER_ROOT_URL=https://observatoire.genese.internal/

La lecon, et elle est generale

Une garde posee la ou un defaut s'est montre ne couvre pas la ou il peut se montrer aussi. Le site et le locataire ont deux inventaires, et le depot en compte plusieurs autres qui ne regardent qu'un des deux. C'est la meme forme que « une liste qui suit une autre prend du retard », appliquee aux gardes elles-memes.

Et la console etait joignable depuis le debut

Le poste porte 10.37.0.17/24, et nftables accepte le port 3000 depuis 10.37.0.0/24. Il manquait une ROUTE : sans elle, le noyau envoyait les paquets a la passerelle du LAN avec l'adresse du LAN comme source, et le pare-feu du site voyait arriver un inconnu. Forcer la source ne sert a rien — c'est la route qui la choisit.

Les routes du poste pointaient aussi les deux locataires vers 10.17.0.1, une passerelle que la frontiere ne porte pas (elle route les tenants par 10.0.4.41 sur le vlan040). Cinq routes vers un routeur inexistant, dont les /16 des deux ecosystemes : c'est ce qui donnait « No route to host » en cherchant la console de Chezlepro. Routes refaites par l'exploitant ; les trois runners repondent.

2026-09-14 (16) — Le dernier maillon : l'exportateur PostgreSQL, et huit panneaux qui repondent

L'entree precedente laissait le tableau de PostgreSQL assemble, charge, et vide : serveur_postgresql saute son exportateur tant que vault_pg_exportateur n'est pas dans la voute. Il y est maintenant, et la chaine est complete de bout en bout.

Le secret, pose avec ses gardes

vault_pg_exportateur etait deja au gabarit de voute — scripts/voute.py lister le reclamait pour serveur_postgresql. Il manquait a la voute REELLE du site. Le gabarit disait quoi mettre, personne ne l'avait mis : c'est exactement ce que ce gabarit existe pour attraper, et il l'a attrape.

Quarante caracteres alphanumeriques, et ce n'est pas de la pruderie : ce mot de passe entre dans une URI de connexion PostgreSQL (DATA_SOURCE_NAME). Un « @ », un « : » ou un « / » y couperait l'URI en deux, et l'echec dirait « mauvais mot de passe » au lieu de « URI mal formee ».

LES GARDES, toutes passees : copie de surete avant toute ecriture ; dechiffrement vers un FICHIER et jamais vers un tube — ansible-vault ecrit sur stderr, et un tube ferme le fait echouer en silence ; relecture du YAML avant de rechiffrer ; en-tete $ANSIBLE_VAULT verifie apres ; empreinte comparee ; les quinze cles relues une a une ; copies en clair passees au shred, pas au rm.

UN DETOUR, CORRIGE AVANT DE NUIRE. Le premier rechiffrement, fait sous --encrypt-vault-id, a produit du format 1.2 avec une etiquette la ou la voute portait du 1.1 sans etiquette. Le runner du site ouvre cette voute avec un unique fichier-cle : une etiquette qu'il ne connait pas etait un risque pour rien. Rechiffree en 1.1, comme avant.

Le compte, et ce qu'il peut

setops_metriques   superuser=f  createdb=f  createrole=f   membre de : pg_monitor

pg_monitor donne les vues de statistiques et elles seules — aucune donnee applicative. Faire tourner un exportateur en postgres serait donner les cles de la base pour lire des compteurs.

Les huit panneaux, mesures

tableau panneau ce que Prometheus rend
client_metrique Memoire disponible 10 series, 20 % a 87 %
Espace libre (le plus serre) 10 series, 28 Go a 401 Go
Charge par coeur 10 series
Trafic reseau entrant 10 series, 65 o/s a 5,4 Mo/s
serveur_postgresql Connexions utilisees 1 serie
Taux de succes du cache 1 serie
Taille des bases 4 series, 7,5 a 10,9 Mo
Transactions par seconde 1 serie

DEUX PANNEAUX ONT MIS DEUX MINUTES A REPONDRE, et il fallait savoir pourquoi avant de conclure. Les deux emploient rate(...[5m]) : un taux exige au moins deux echantillons dans la fenetre, et l'exportateur venait de naitre. La verification n'a pas attendu en esperant — elle est allee lire les metriques BRUTES chez l'exportateur (4 series chacune) puis dans Prometheus (4 series chacune) : les noms etaient bons, seul le temps manquait. Un panneau vide parce qu'une metrique n'existe pas et un panneau vide parce qu'il est trop tot se ressemblent parfaitement, et n'appellent pas le meme geste.

2026-09-14 (15) — Le generateur de tableaux : les panneaux declares deviennent des tableaux

Les roles declaraient leurs panneaux dans meta/metriques.yml, Prometheus en derivait deja ses cibles, les expressions repondaient — et RIEN ne les assemblait. Un panneau declare que personne ne peut regarder n'est pas une observabilite, c'est une intention.

Ce qui a ete construit

serveur_grafana lit desormais les memes meta/metriques.yml que serveur_prometheus, et en assemble un tableau par role. Exactement le patron du voisin : le role declare, le moteur derive.

meta/metriques.yml  --exportateur-->  cible de scrutation   (serveur_prometheus)
                    --panneaux----->  tableau Grafana       (serveur_grafana)

UN TABLEAU PAR ROLE, pas un grand. La question qu'on se pose est « comment va PostgreSQL », pas « comment va la flotte » — celle-la est deja repondue par Icinga. Le role est l'unite qui declare, donc l'unite qui s'affiche.

LA raison DEVIENT LA DESCRIPTION DU PANNEAU. C'est le seul champ qui ne produit aucun pixel, et le plus important : Grafana l'affiche au survol du titre. Sans elle, celui qui regarde six mois plus tard voit une courbe sans savoir ce qu'elle annonce.

LA MOISSON, comme pour les sondes orphelines. Un tableau qu'aucun role ne declare plus est retire. Un tableau orphelin est moins grave qu'une sonde orpheline — il n'echoue pas, il MENT : il affiche « aucune donnee » pour un service disparu, et celui qui regarde croit a une panne. Le prefixe setops-role- protege journaux-flotte.json, ecrit a la main.

LE JSON EST VALIDE AVANT D'ETRE POSE. Grafana n'echoue pas sur un tableau illisible : il le SAUTE, en silence. Sans validate:, une expression mal repliee ferait perdre un tableau sans qu'aucune tache ne devienne rouge.

Un second rôle qui déclare, et il donne des données tout de suite

client_metrique declare quatre panneaux sans exportateur — le cas symetrique de PostgreSQL. Le job node est universel et ecrit une fois dans prometheus.yml.j2 ; le deriver le ferait exister deux fois.

Le panneau qui compte : memoire disponible en part du total. Depuis que le ballon est actif, l'hyperviseur reprend de la memoire a une VM qui n'en a pas besoin, 100 Mio par cycle. La VM ne voit pas son plafond bouger, elle voit sa marge fondre. Aucun verdict ne peut se poser la-dessus : il n'y a pas de seuil juste, c'est la PENTE qui dit si le plancher a ete pris trop bas.

Eprouve sur le site, de bout en bout

fichiers assembles setops-role-serveur_postgresql.json, setops-role-client_metrique.json
Grafana les a-t-il charges ? oui — setops-postgresql et setops-client-metrique dans le stockage unifie
moisson un setops-role-serveur_fantome.json pose a la main a ete retire, les deux autres conserves
les panneaux repondent-ils ? les 4 de client_metrique : 10 series chacun, donnees reelles

Le journal ne prouvait rien. finished to provision dashboards s'ecrit aussi quand Grafana saute un fichier. La verification est allee lire ce que Grafana a VRAIMENT enregistre — et Grafana 13 range les tableaux dans le stockage unifie (table resource), plus dans la table dashboard, qui est vide et le restera. Une premiere lecture y a vu « zero tableau » ; c'etait la mauvaise table.

Un panneau faux, trouve en le regardant

« Espace libre — le point de montage le plus serre » rendait 0 octet pour les trois hyperviseurs. Le coupable : /var/lib/lxcfs, un systeme FUSE virtuel qui rapporte toujours zero, et qui n'est pas en lecture seule — aucun filtre malin ne l'ecartait.

Un min() est impitoyable : un seul montage pathologique rend le panneau inutile pour toujours, et sa courbe plate ressemble a un disque plein. La pire sorte de faux — lisible, alarmant, et faux.

Corrige en liste blanche (ext4|xfs|btrfs|zfs) plutot qu'en liste noire. Une liste noire suit ce qui existe, donc prend du retard. Une liste blanche ignore par defaut : un montage inconnu MANQUE au graphe, ce qui se voit, au lieu de l'ecraser, ce qui ne se voit pas. Apres correction : min 28 Go, max 401 Go.

La garde

P77 exige de chaque panneau un titre, une expression, une unite et une raison — et que l'unite figure dans la table de traduction de serveur_grafana. C'est encore une liste qui en suit une autre : une unite inventee ne casse rien, le panneau retombe sur short, et un graphe d'octets gradue en unites brutes reste parfaitement lisible et parfaitement faux. Eprouvee dans les deux sens.

docs/supervision-conception.md porte desormais la moitie « metriques » a cote de la moitie « sondes ».

Et la fiche cachait ce que le role declarait

fiche_role.py imbriquait le tableau des panneaux DANS la condition de l'exportateur. Le premier role a declarer des panneaux sans exportateur affichait donc « aucun exportateur declare » — et cachait ses quatre panneaux.

Une fiche qui tait ce qu'un role declare est pire qu'une fiche absente : elle affirme que rien n'existe. Les deux moities sont desormais lues separement, et chacune dit ce qu'elle trouve — y compris « series collectees, aucun panneau declare : personne ne les regarde encore », qui est une carte de ce qui reste a faire.

Ce qui reste

Le tableau de PostgreSQL est assemble, charge, et vide : l'exportateur exige vault_pg_exportateur dans la voute du SITE, qui n'y est pas. Ajouter un secret a cette voute-la n'est pas un geste a poser sans le dire.

2026-09-14 (14) — L'echappatoire du wiki etait documentee cinq fois et n'existait pas

En publiant les huit fiches neuves, make wiki-publier a refuse : l'ecosysteme monte est OPS-Technolibre, qui lit son genome ailleurs et ne republie donc pas d'ici. C'est le comportement voulu, et le message propose le remede :

Ex: make wiki-publier WIKI_REMOTE=ssh://git@forge.<domaine>/<proprio>/<depot>.wiki.git

Le remede ne marchait pas. La cible lisait

remote="$$remote"

— c'est-a-dire $remote, une variable de SHELL que rien ne definit — au lieu de $(WIKI_REMOTE), la variable de make. L'option etait documentee cinq fois dans ce Makefile, dont dans le message d'erreur qui la proposait, et la cible ne l'a jamais lue.

CE QUI LA REND VISIBLE : WIKI_BRANCHE, deux cibles plus bas, est ecrit $(WIKI_BRANCHE) depuis le debut. Deux options soeurs, deux ecritures. Une option qui se lit autrement que sa voisine est l'endroit ou regarder.

Le wiki est publie sur eregion : 95 pages, et les huit fiches neuves portent bien leur sonde — verifie en clonant la forge, pas en lisant le temoin.

2026-09-14 (13) — Les huit derniers roles sans sonde, et deux defauts que l'epreuve a trouves

Les 33 roles serveur_* declarent desormais une supervision. Les huit qui manquaient n'avaient pas ete oublies par hasard : ce sont ceux dont la verite ne ressemble pas a « ce service repond-il ».

Quatre marqueurs du site, quatre verites qu'aucun installateur ne voit

Un marqueur n'installe rien. Il dit qu'une machine rend un service AUX LOCATAIRES, et tasks/main.yml le verifie — une fois, au deploiement. Ce qu'il verifie cesse d'etre vrai sans que rien ne tombe.

role sonde ce qui se degrade en silence
serveur_cache_site cache-site-racine la racine se retrouve chainee, ou ne remplit plus
serveur_forge_site genome-servi la forge repond et n'a plus rien dedans
serveur_resolveur_site resolution-locataires un locataire n'est plus admis a resoudre
serveur_backup_site depot-locataires l'isolation glisse, ou la place manque

genome-servi mesure exactement la panne du 2026-09-14 : le wiki du site avait zero commit apres une reconstruction, et la forge etait verte tout du long. Un depot vide ne fait pas echouer un clone — il rend un arbre sans fichiers.

Les quatre autres

serveur_ops_site ne sert rien : il DETIENT un pouvoir. pouvoir-materialiser verifie que la carte de la fabric est la, que la voute du site est chiffree et que sa cle est en 0600 — sans jamais lire le contenu d'aucun des trois. Une voute dechiffree en transit ne fait echouer aucun deploiement : Ansible la lit tres bien, elle expose simplement tout.

serveur_icingaweb2 porte la sonde la plus retorse du dispositif : la supervision surveille sa propre vitrine. Si la console meurt, le moteur collecte toujours, les verdicts restent verts, et l'exploitant est aveugle.

serveur_web_frontal et serveur_web_dorsal frappent chaque site et chaque app declares. Une webapp ne tombe presque jamais en failed — elle se coince : le processus vit, systemd la dit active, et plus une requete n'aboutit. Le rapport d'unites en echec ne verra jamais ca.

DEFAUT 1 — quatre gabarits qu'Ansible aurait refuse de rendre

${#tableau[@]} est la seule facon d'obtenir la longueur d'un tableau en bash. Elle contient {#, que Jinja lit comme un debut de commentaire :

TemplateSyntaxError: Missing end of comment tag

Rien ne le signalait : le fichier est valide en shell, valide a la lecture, et --syntax-check ne rend pas les gabarits. La panne serait arrivee sur la machine, pendant un deploiement.

Le depot connaissait deja le remede — une en-tete #jinja2: qui deplace le delimiteur — et l'appliquait trois fois, exactement la ou quelqu'un s'etait fait prendre. Nulle part ailleurs. Quatre sondes neuves l'ont refait d'un coup.

P76 est la garde qui manquait : elle REND chaque gabarit de role, avec les memes delimiteurs qu'Ansible en tirerait. Pas une recherche de motif — un rendu, qui attrapera aussi le prochain piege, quel qu'il soit.

DEFAUT 2 — la doctrine promettait des greffons que la flotte n'a pas

docs/supervision-conception.md annoncait « le paquet en fournit 54 » et citait check_pgsql. Mesure du 2026-09-14 : client_sante installe monitoring-plugins-basic — 53 greffons, et ni check_pgsql, ni check_dns, ni check_ldap n'en font partie. Ils sont dans monitoring-plugins-standard, qui traine samba, smbclient et une pile SNMP sur chaque machine de la flotte.

La premiere ecriture de resolution-locataires appelait check_dns. Elle sortait en 127 — qui n'est pas un code Nagios : la sonde serait devenue illisible au lieu d'echouer proprement. Elle emploie dig, comme la sonde voisine le faisait deja.

Le paquet n'est pas ajoute : le durcissement dit le contraire d'installer Samba partout pour trois greffons. C'est le document qui est corrige.

Un defaut de plus, trouve en eprouvant

genome-servi interrogeait d'abord /api/v1/repos/search?owner=<org>. Avec owner=organisation-qui-nexiste-pas, elle rendait les six depots de la forge et sortait VERTE : le parametre est ignore. Elle n'aurait jamais mesure le genome, seulement « cette forge a des depots ». La route /api/v1/orgs/<org>/repos, elle, rend 404.

Controles negatifs

Chaque sonde a ete mise en defaut sur la machine reelle avant d'etre ecrite dans son role — 21 cas au total, sur site-cache-01, site-forge-01, site-dns-01, site-backup-01 et site-ops-01. Les huit gabarits rendent et passent bash -n. ansible-lint profil production : 0 sur 91 fichiers. Harnais : 75 OK, 0 echec.

2026-09-14 (12) — La decision appliquee a TOUTE la flotte : une machine de moins par ecosysteme

L'entree precedente prend la decision. Celle-ci la pose partout, sur ordre de l'exploitant : « meme principe pour tous les tenants et pour tous les modeles, sauf celui qui doit avoir une forge ».

Ce qui est parti

plan avant apres ce qui est retire
OPS-Technolibre 14 machines 13 forge-01
OPS-Chezlepro 14 machines 13 forge-01
OPS-Chezlepro-lab 15 machines 14 forge-01
Modeles/origine 5 machines 4 forge-01, et serveur_artefacts avec elle
Modeles/identite 8 8 serveur_artefacts (colocalise, pas de VM)
Modeles/observabilite 8 8 serveur_artefacts
Modeles/collaboration 9 9 serveur_artefacts
Modeles/presence-web 8 8 serveur_artefacts

Retirer un service n'est jamais une ligne : c'est cinq points d'attache. Le service dans applications.yml, sa machine dans serveurs.yml, sa base dans bases-donnees.yml, son client SSO dans group_vars/serveur_keycloak.yml, et sa configuration de role. En oublier un laisse un inventaire qui se genere et un deploiement qui echoue plus tard, sur une machine qui n'existe plus.

origine compte doublement : tout ecosysteme neuf en descend. Y laisser ces deux services les ferait renaitre dans chaque enfant.

Les deux modeles qui gardent leur forge, et pourquoi c'est ecrit

forge et integral la gardent. Ce n'est pas une exception concedee, c'est une fonction differente : ils vendent une forge au code des gens — l'offre Atelier. La raison est desormais dans leur plan, pas dans la tete de celui qui l'a decidee.

Ce que ca vaut

Par ecosysteme : une VM de 80 Go a sauvegarder, superviser, durcir et reconstruire ; une organisation Forgejo ; ses depots ; un mot de passe d'admin en voute ; un miroir Debian a tenir a jour. Pour republier ce que le locataire vient tout juste de lire chez son site.

Preuve statique apres coup : 74 OK, 0 echec.

Reste a trancher

OPS-Patient0 n'est pas touche. Il est enregistre comme « l'ecosysteme d'origine, detenteur du genome — pas un locataire ordinaire », et le genome de la flotte y vit. Retirer sa forge est une decision d'un autre ordre que les autres.

Et les VM forge-01 de Chezlepro et de TechnoLibre tournent encore. Le plan ne les declare plus ; l'hyperviseur les porte toujours. Raser une machine est une action destructive : elle attend un ordre explicite.

2026-09-14 (11) — Le wiki suit le genome, et un locataire n'en est pas depositaire

Decision de l'exploitant : seuls les SITES portent la forge, le cache APT et les artefacts. Un locataire n'a pas besoin de sa propre forge pour le moteur — il le lit chez son hebergeur, comme il y prend ses paquets et son gabarit.

C'est le meme geste que le retrait de serveur_artefacts du plan d'un locataire, un cran plus loin, et la meme phrase le porte : le site fournit tout ce dont un tenant a besoin pour venir au monde.

Ce que la soiree avait deja chiffre

Une forge par locataire, c'est une organisation, des depots, un mot de passe d'admin en voute, un mecanisme d'amorcage, un wiki, un miroir a tenir synchrone, et une preuve qu'il ne derive pas. Mesure du 2026-09-14 sur TechnoLibre : forge servie en https=200, cle du runner acceptee, zero depot. La chaine n'existait pas — pour UN locataire.

La distinction qui reste vraie

« Un locataire n'a pas besoin d'une forge pour le genome » n'est pas « un locataire n'a jamais de forge ». L'offre Atelier vend precisement une forge a des equipes qui developpent : depots, tickets, revues, pour LEUR code. Ce n'est simplement pas l'endroit ou vit le moteur.

Ce qui a ete defait, et pourquoi

La generalisation de forge_amorcer.py a un locataire est revenue en arriere : elle implementait le modele ecarte. La garder en ferait du code mort au mieux, un piege au pire — quelqu'un finirait par le lancer.

ma_forge.py change de regle : il ne derive plus « MA forge » mais la forge qui porte mon genome. Le signal est deja au plan — serveur_ops_forge_externe: true dit « je lis mon genome ailleurs ». Un ecosysteme qui le declare ne publie pas : il consulte, la ou le genome vit.

OPS-Technolibre  ->  refus : lit son genome sur 10.37.33.11
SITE-Chezlepro   ->  forge.genese.internal

2026-09-14 (10) — Le temoin du wiki pointait une forge VIDE

En publiant les fiches de role, make wiki-publier a refuse : le wiki vise par le temoin — forge.genese.internal, la forge du SITE — n'a aucun commit.

Refus: ce wiki est VIDE (aucun commit).

Mesure des deux forges :

forge pages branche
eregion.chezlepro.ca 26 main
forge.genese.internal 0 —

Le wiki vivant est sur eregion. Le temoin, lui, affirmait depuis le 2026-09-12 avoir publie sur la forge du site — dont le depot wiki n'a pas survecu a sa reconstruction.

C'est exactement ce que P60 annonce d'elle-meme : « un temoin dit ce qui est PARTI, jamais ce qui est ARRIVE ». La preuve etait verte, la documentation n'existait nulle part a l'adresse qu'elle nommait. Elle avait raison sur ce qu'elle mesurait, et sa limite etait ecrite — encore fallait-il aller chercher.

Le garde-fou a tenu. Publier sur un wiki vide aurait invente un nom de branche — master depuis ce poste, alors que le wiki en service vit sur main. Deux wikis, deux branches, et une divergence silencieuse de plus.

Publie sur eregion : 95 pages, dont les 68 fiches et leur index. Le temoin nomme desormais la bonne forge.

2026-09-14 (9) — Une fiche par role, generee, et publiee au wiki

Soixante-huit roles, soixante-huit fiches : qui lui parle, ce qu'il rend a la supervision, ce qu'il expose en series, ce qu'il coute, qui entre. Un schema mermaid en tete, puis les tableaux — et chaque ligne porte la raison que le role a declaree.

Generees, jamais ecrites. Soixante-huit pages redigees a la main seraient perimees avant la fin du mois : c'est « une liste qui suit une autre prend du retard », et une soixante-neuvieme liste n'y echapperait pas. make fiches les relit depuis meta/flux, supervision, metriques, empreinte, authentification.

Une section vide est une information. Un role sans sonde l'affiche. L'index Rôles recompte a chaque generation ce que la flotte ne declare pas encore : 36 sans flux entrant, 40 sans sonde, 67 sans metrique. La carte de ce qui reste, tenue a jour toute seule.

Elles vivent au wiki, et la charte a du etre amendee

Home.md disait : « le detail du comment vit dans le depot — ce wiki y POINTE, ne le RECOPIE pas (pour eviter la derive) ». La regle reste juste ; son MOTIF ne s'applique pas a une page qui relit sa source a chaque generation. L'exception est donc nommee dans la charte plutot que prise en silence.

Trois gardes ont mordu, et toutes avaient raison

Un script qu'aucune cible n'appelle est du code mort. Le premier commit est parti sans make fiches ; la preuve l'a vu, pas moi.

La navigation exigeait une citation DIRECTE. Son propre motif dit pourtant « n'est lue par personne » — or une page citee par une page citee EST lue. L'exigence litterale interdisait toute page d'index : citer les soixante-huit fiches dans la navigation en ferait un mur ou plus personne ne trouverait les vingt-sept unites redigees. La garde forcait a degrader ce qu'elle protegeait. Elle suit desormais les liens de proche en proche. Eprouvee dans les deux sens : coupe le lien depuis Home, elle refuse les 69.

Au passage, son motif de lien ne contenait pas _ : un lien vers Rôle-serveur_postgresql n'etait NI suivi, NI signale casse. Un motif trop etroit ne rend pas une garde prudente, il la rend aveugle.

Le compte du wiki serait passe de 27 a 96. Une fiche generee n'est pas une unite d'apprentissage — celles-la suivent le moule en quatre temps. Le nombre aurait ete exact et l'affirmation fausse.

2026-09-14 (8) — La chaine des metriques, de la declaration au graphe

Premiere verticale complete du second versant, eprouvee sur TechnoLibre.

etape ce qui a ete verifie
declaration meta/metriques.yml : exportateur postgresql, port 9187, quatre panneaux
flux meta/flux.yml ouvre 9187 depuis l'observatoire seul
installation prometheus-postgres-exporter actif, compte pg_monitor en lecture seule
exposition 657 series servies, valeurs reelles par base
derivation Prometheus ecrit de lui-meme job_name: postgresql / ["10.23.18.11:9187"]
scrutation cible up
interrogation sum(pg_stat_activity_count) = 11 · cache = 0,9982 · bases = 84 Mo

Le crochet serveur_prometheus_cibles_supplementaires n'est plus vide pour la premiere fois : un role declare, le moteur derive. Les cibles ecrites au plan le completent toujours — un equipement tiers n'a aucun role Set-OPS pour se declarer.

Ce que la voute a rappelé au passage

Le runner a d'abord saute l'exportateur sans rien signaler : changed=0. Sa voute datait, parce que vault.yml est gitignore — aucun secret ne transite par la forge. Elle n'arrive chez un runner que par serveur_ops_tenant, depuis le poste qui la detient.

Ce n'etait pas un defaut : c'est la ligne de partage du pouvoir qui se rappelle a nous. Le geste d'armement n'est pas une formalite d'installation, c'est le seul transport de secret du systeme — et il se refait a chaque fois qu'un secret change.

Et une garde qui a servi

L'ecriture dans la voute a d'abord pendu : ANSIBLE_VAULT_IDENTITY_LIST n'etait pas dans le shell (le Makefile l'exporte, pas un appel direct), donc ansible-vault attendait une saisie qui ne venait jamais. Le filet a tenu : le clair faisait 0 octet, la voute etait inchangee, et la comparaison d'empreinte l'a dit avant qu'on ne croie au succes.

empreinte CHANGEE a403fd82165b0e0c -> 1577e3c1439a5aa9
33 cles -> 34

2026-09-14 (7) — meta/metriques.yml : le second versant de la supervision

Aucune metrique de SERVICE n'etait collectee. Prometheus ne scrutait que les node_exporter : du systeme, et rien de PostgreSQL, de l'annuaire, des boites ou du cache. Le crochet existait pourtant — serveur_prometheus_cibles_supplementaires, documente, et que personne ne remplissait.

Le pendant de meta/supervision.yml, et son contraire

fichier ce qu'il declare la question
supervision.yml une sonde rend un verdict avec un TTL est-ce casse ?
metriques.yml un exportateur expose une serie depuis quand, et vers ou ?

Ce n'est pas une frontiere inventee : docs/supervision-conception.md la pose deja dans l'autre sens — « une metrique a seuil appartient a Prometheus et Grafana ». Ce fichier est l'autre moitie de cette phrase.

Le critere qui choisit les panneaux

Une serie a sa place ici si elle PRECEDE un verdict, ou si elle n'en aura JAMAIS.

Le taux de succes du cache n'aura jamais de verdict, et c'est pourquoi il compte : quand les donnees depassent shared_buffers, la base va chercher sur disque de plus en plus souvent. Rien ne casse, rien n'alerte, tout devient lent. C'est la panne qu'un graphe voit et qu'une sonde ne verra jamais.

Ce que le moteur derive, et ce qu'il ne derive pas

Il derive la cible de scrutation — le nom du dossier du role est le nom du GROUPE, donc les cibles sont ses hotes actifs. Un role declare sans hote ne produit aucun job.

Il ne derive pas le flux : le port 9187 s'ouvre par meta/flux.yml, la ou vivent deja tous les flux de ce role. Deux fichiers pour un meme fait finissent par diverger.

Deux choses dites plutot que tues

Le compte de metriques est en lecture seule (pg_monitor), et ne lit que les vues de statistiques — pas une ligne de donnee applicative. Faire tourner l'exportateur en postgres serait donner les cles de la base pour lire des compteurs.

Le flux est en CLAIR, et c'est une dette inscrite au fichier. client_metrique sert deja ses metriques en TLS ; celui-ci pas encore. La dette est ecrite dans la raison du flux, avec son remede — --web.config.file + client_pki.

2026-09-14 (6) — Le site avait l'orchestration, pas le moyen de la lancer

playbooks/site.yml est genere par orchestrer.py et ordonne les couches pour n'importe quelle instance — son en-tete le dit. Mais deployer-tout ne vise que l'inventaire d'un LOCATAIRE, et le site n'avait que site-appliquer GROUPE=<un seul>.

Le site ne se deployait donc que groupe par groupe, a la main, dans un ordre qu'il fallait se rappeler. Un hebergeur qu'on ne peut remonter qu'en enchainant onze groupes de memoire n'est pas reconstructible : il est reparable par quelqu'un qui se souvient. C'est nommement l'une des trois limites du jalon de reconstruction autonome — « le SITE jamais reconstruit ».

site-deployer-tout comble le manque. Trois differences avec son equivalent locataire, aucune cosmetique : l'inventaire est un script (un site se derive de son underlay, il ne se fige pas dans un hosts.yml), la voute vit a cote de la carte (underlay.vault.yml, hors depot), et le perimetre reste hotes_actifs — les hyperviseurs et la frontiere sont dans l'inventaire mais ne se deploient pas.

Quatre gardes que --check rendait folles

Le Makefile CONSEILLE l'essai a blanc — « tester d'abord en idempotent ». Suivre ce conseil rendait sept machines en echec sur un site parfaitement sain. Quatre causes, une seule famille : une garde qui compare contre ce qu'une tache du meme play vient de produire n'a rien a dire tant que rien n'a ete ecrit.

role ce que --check cassait remede
hosts_statiques la lecture des deux fichiers etait sautee : ne portent pas le meme nombre d'entrees () check_mode: false sur la lecture
hosts_statiques l'assertion comparait l'AVANT a l'APRES : /etc/hosts=13 | tmpl=2 not ansible_check_mode sur l'assertion
serveur_icinga le telechargement simule, l'installation cherchait un fichier absent check_mode: false sur get_url
serveur_ops la version du controleur non lue : Le controleur tourne Python check_mode: false sur la lecture

Les parentheses vides et l'espace apres « Python » sont tout le diagnostic : il n'y avait rien a comparer. Un essai a blanc qui ment est pire qu'aucun — il apprend a ne plus le lancer.

Apres correction : 7/7, 0 echec, de 174 a 309 taches par machine.

2026-09-14 (5) — Le site ne savait pas se configurer lui-meme

Parti pour eprouver une sonde, arrive sur un defaut structurel : le runner du SITE ne pouvait entrer sur aucune machine du site. Il sait inseminer un locataire — c'est prouve — et il ne savait pas configurer l'hebergeur qui le porte. Tout le site avait donc ete deploye depuis le poste de l'exploitant, et rien ne disait que c'etait la seule voie.

Trois causes empilees, et un message qui accusait la mauvaise

  1. Le site n'avait aucun moyen d'autoriser une cle d'administration. Un locataire declare ssh_baseline_cles_admin ; cote site, rien ne transmettait cette liste. Ses machines n'autorisaient que la cle posee par cloud-init.

  2. Le rebond etait pose sans condition, y compris pour un controleur vivant DANS le site. Il devait alors s'authentifier aupres de la frontiere, ou sa cle n'est pas autorisee. OpenSSH rend alors :

    Host key verification failed.
    Connection closed by UNKNOWN port 65535
    

    — un message qui accuse les cles d'HOTE alors que l'echec est une AUTHENTIFICATION, et sur le SAUTEUR, pas sur la cible.

  3. cle_ssh designe le chemin de la cle SUR LE POSTE. Impose au runner, il nomme un fichier qui n'existe pas chez lui.

Le rebond et la cle decrivent tous deux comment on arrive, pas ce vers quoi on va. Ce qui depend de l'endroit d'ou l'on part n'a pas sa place dans la description d'un site.

La faute que j'ai ecrite en corrigeant, et qui merite d'etre gardee

La fonction qui repond « suis-je une machine du site » appelait underlay_mod — un nom qui n'existe pas dans ce fichier, le module y etant importe as U. Python levait un AttributeError a chaque appel, et le except Exception: return False le rendait muet.

La fonction repondait donc toujours non, le rebond etait toujours pose, et rien ne le disait. Une garde qui se tait ne garde rien — le defaut exact que ce depot traque ailleurs, ecrit ici. Le except ne couvre plus qu'un plan illisible ; une faute de programmation remonte.

Le critere lui-meme a demande trois formulations : « dans un RESEAU du site » (faux, le poste porte 10.37.0.17), « dans underlay.hotes » (faux, hotes ne contient que les equipements), puis par le NOM — le runner s'appelle site-ops-01, une cle du plan.

L'amorcage est circulaire, et il se rompt par le poste

Lui seul entrait : il pose la cle, le runner devient autonome. Verifie apres make site-appliquer GROUPE=serveur_debian — 7/7, 0 echec — puis 5/5 machines atteintes par le runner, qui a ensuite configure site-backup-01 lui-meme.

Et la sonde, enfin prouvee

verdict code
depot sain accepte l'ecriture — 4 depot(s), 374G libres 0
chemin non inscriptible refuse l'ecriture pour restic 2
chemin inexistant n'existe pas 2

Le vrai depot n'a pas bouge : zero fichier temoin residuel.

2026-09-14 (4) — Les greffons standard manquaient a la flotte

docs/supervision-conception.md dit qu'un greffon Nagios est une sonde valide, « sans la moindre colle », et compte sur les 54 que fournit monitoring-plugins. Mesure du 2026-09-14 sur une machine de la flotte :

check_http : ABSENT

Aucun des 54 n'etait installe. Le document decrivait une possibilite qui n'existait pas — et chaque role ayant besoin d'un controle HTTP n'avait d'autre choix que d'ecrire du shell, precisement ce que la doctrine refuse.

Pose par le PORTEUR, pas par chaque role

C'est l'affaire de client_sante de pouvoir executer des sondes. Les poser role par role en ferait autant de copies de la meme decision, et laisserait sans greffon les machines dont aucun role n'en reclame — alors qu'elles en auront besoin le jour ou on leur en ajoute un. 1,2 Mo par machine.

Trois sondes, trois enveloppes

role sonde ce qu'elle voit
serveur_collabora edition /hosting/discovery est l'adresse que Nextcloud interroge lui-meme pour savoir quels documents Collabora sait ouvrir. Sans elle, le bouton « ouvrir » meurt pour tout le monde — et Nextcloud reste vert.
serveur_rspamd filtrage Un filtre muet ne bloque pas le courrier : il le laisse passer. Postfix sans verdict delivre sans filtrer ou differe, et le service a l'air sain pendant que le pourriel entre.
serveur_oauth2_proxy passerelle Ce qui tombe avec elle n'est pas elle. Les services derriere restent debout et deviennent injoignables : on cherche la panne du mauvais cote.

Chacune delegue a check_http et rend son code tel quel. Chacune exige une chaine que seul le bon service produit — un 200 peut venir d'une page d'erreur ou d'un cache perime.

Le controle negatif, rejoue sur TechnoLibre

edition filtrage passerelle
sain 0 0 0
chaine introuvable 2 2 2

La chaine attendue est la mise en defaut : la remplacer rend CRITIQUE sans rien casser.

Couverture : 24 roles serveur_* sur 33 declarent leur supervision (21 avant ce lot).

2026-09-14 (3) — La sonde de Redis, et son controle negatif rejoue

Premier des treize roles serveur_* qui ne declaraient aucune supervision. Vingt sur trente-trois en avaient une ; celui-ci n'en avait pas, alors qu'il porte les sessions.

La panne qu'elle voit, et que rien ne voyait

Borne a maxmemory avec allkeys-lru, un Redis plein n'echoue jamais : il EVICTE. Les sessions disparaissent une a une, les gens sont deconnectes au hasard, et le service reste vert. La panne se presente comme un defaut d'application — personne ne regarde le cache, puisqu'il va bien.

Une seule sonde, parce qu'il n'y a qu'une cause d'action : le cache ne sert plus. Elle s'atteint par deux chemins, et le verdict appelle le meme geste.

Le compteur d'evictions de Redis est CUMULATIF depuis le demarrage ; la sonde en fait un debit entre deux passages, sinon une seule mauvaise journee laisserait le voyant rouge pour toujours.

Le controle negatif, rejoue sur data-sql-01 de TechnoLibre

verdict code
etat sain, premier passage Cache sain — premier releve 0
etat sain, second passage Cache sain — 0 eviction(s) 0
Redis arrete Redis n'est pas actif. 2
seuil impossible (CRIT=0) Redis evicte : … des sessions se perdent en silence. 2

Les donnees de performance sortent au format Nagios et portent le vrai plafond : evictions=0c memoire=808232B;;;0;268435456.

serveur_redis_sonde_evictions_crit est la mise en defaut par parametre — le poser a 0 rend CRITIQUE sans rien casser, ce qui rend la seconde preuve REJOUABLE. C'est le meme patron que les seuils de PostgreSQL.

Ce qui n'est PAS dans cette sonde, et c'est la doctrine

Occupation memoire, taux de succes du cache, latence : ce sont des series, elles appartiennent a Prometheus. Icinga repond a une seule question — est-ce casse ?

2026-09-14 (2) — Redis EST borne : le releve d'hier lisait le mauvais fichier

L'entree du 2026-09-13 (6) affirmait « aucun maxmemory configure ». C'etait faux, et la faute est de METHODE : le grep visait /etc/redis/redis.conf, alors que le role ecrit dans /etc/redis/setops.conf. Interroge la ou la verite vit — redis-cli config get — Redis repond :

maxmemory         268435456      (256 Mo)
maxmemory-policy  allkeys-lru

La conclusion ne change pas, sa raison si. Redis reste hors de la table des planchers, non plus parce qu'il serait sans limite, mais parce qu'il est BORNE a 256 Mo : il ne peut pas prendre la machine, et lui epingler plusieurs gigaoctets serait absurde.

Le commentaire porte desormais le bon seuil de vigilance : le jour ou ce plafond montera, le plancher devra le suivre — et c'est le maxmemory EFFECTIF qu'il faudra lire.

La lecon est celle de la veille, appliquee a moi-meme : verifier d'ou l'instrument mesure. Un fichier de configuration n'est pas la configuration ; c'est une de ses sources.

2026-09-13 (10) — Un runner TIRE ce qu'on pousse ; il ne le recoit pas

Trois fois dans une meme soiree, un genome perime a menace de rebatir un etat depasse :

  • le runner du SITE, treize commits en retard, s'appretait a materialiser un locataire avec son ancien plan — serveur de sauvegarde inutile, cache d'artefacts en trop, runner sans pouvoir de configurer, et aucune machine avec son plancher de memoire ;
  • le runner du LOCATAIRE, clone pendant l'insemination donc avant trois correctifs, s'appretait a reposer un plan d'administration pointant le WAN d'un AUTRE site — et a refermer derriere lui la porte que l'exploitant venait tout juste de rouvrir.

Pourquoi c'est le pire mode de defaillance de cette famille

Aucun des deux n'aurait echoue. Un deploiement depuis un genome perime REUSSIT : il applique fidelement un etat qui n'a plus cours. Il se presente en vert. Rien, dans la sortie, ne distingue « la flotte converge vers ce que tu veux » de « la flotte converge vers ce que tu voulais il y a trois heures ».

La garde

scripts/verifier_genome_a_jour.py, en tete de deployer-tout :

etat du depot verdict
en RETARD refus — deployer poserait un etat qu'on sait depasse
en AVANCE note, on continue — un mainteneur qui travaille localement est normal
DIVERGE note, on continue — c'est a l'humain de trancher, pas a une garde
forge injoignable note, on continue — une forge en panne ne doit pas bloquer une exploitation

Ce dernier point est delibere : le silence serait la faute, pas le passage. Une garde qui immobilise la flotte quand la forge tousse serait pire que le defaut qu'elle surveille.

FORCE=1 passe outre, pour le cas legitime ou l'on deploie sciemment un etat local.

Eprouvee dans les trois sens sur un clone recule de deux commits : elle mord, elle se tait, et FORCE=1 la leve.

2026-09-13 (9) — Le site expose sept intrants ; le controle n'en comparait que cinq

site_intrants --verifier rapportait CONFORME avec un aplomb complet sur un locataire dont le plan d'administration pointait encore les bouts de WAN d'un AUTRE site.

OU_LE_LOCATAIRE_LE_DIT couvrait dns_amorcage, artefacts_amorcage, setops_depot_binaires, serveur_ops_forge_amont et client_backup_cible. Pas nftables_admin_ssh. Pas passerelle_sortie.

Ce que le silence a coute

Quatorze machines materialisees, le runner insemine — et l'exploitant incapable d'entrer sur son propre runner pour l'armer. Connection timed out, sans qu'aucune garde n'ait rien eu a dire. La frontiere avait bien pose une regle d'administration, mais sur son interface WAN, puisque c'est ce que le plan declarait. Bonne source, mauvaise porte.

Ce qui change

Les deux entrees manquantes sont ajoutees. nftables_admin_ssh etant une liste, la comparaison passe par une normalisation : l'ordre d'une liste de sources ne porte aucun sens, et comparer str(['a','b']) a str(['b','a']) ferait crier une difference qui n'en est pas une.

Les deux ecosystemes passent de cinq a six intrants compares — le septieme, passerelle_sortie, n'est declare par aucun des deux : il se derive, et la garde le dit plutot que de l'inventer.

La regle, cinquieme occurrence

Une liste qui en suit une autre prend du retard. Le site expose ; la table compare. Deux listes, et la seconde ne suivait pas. La garde s'ecrit EN MEME TEMPS que la seconde liste, jamais quand on s'en sert.

2026-09-13 (8) — Le plancher traversait trois modules et se perdait au troisieme

Trouve en preparant la creation des quatorze machines de TechnoLibre, une commande avant de les creer. instancier derivait bien proxmox_memoire_min dans l'inventaire ; le playbook de clonage savait bien poser balloon: ; entre les deux, la table CHAMPS_PROXMOX de inventory_host.py ne transmettait rien.

Pourquoi rien n'aurait echoue

Trois silences qui s'enchainent :

  1. $SETOPS_MEMOIRE_MIN non defini vaut la chaine vide
  2. le Makefile ne passe alors pas -e proxmox_clone_memoire_min
  3. la garde when: de la tache saute proprement

Quatorze machines seraient nees avec le ballooning desactive, sans une seule erreur. C'est le patron d'une liste qui en suit une autre et prend du retard — deja rencontre quatre fois.

La garde, ecrite en meme temps que le remede

P75 lit la cible creer-vm du Makefile, releve chaque $SETOPS_X qu'elle consomme, et exige que la table qui les EMET le declare. Elle ne juge aucune valeur : elle refuse qu'un maillon manque. Eprouvee dans les deux sens — elle mord quand on retire le maillon, elle se tait quand il est la.

La regle qui en sort : quand une variable traverse trois modules, le troisieme s'ecrit en meme temps que le premier, pas quand on s'en sert.

test_inventory_host.py a signale le changement de contrat de son cote, comme il l'avait fait pour SETOPS_DOMAINE et SETOPS_CLES_AMORCAGE.

2026-09-13 (7) — Un hote retire du plan laissait son pare-feu derriere lui

En retirant backup-01 du plan de TechnoLibre, flux-genere/backup-01.nft est reste sur le disque : un ruleset complet, pour une machine qui n'existe plus, indiscernable des autres au premier coup d'oeil. La generation ECRIVAIT sans jamais RETIRER.

Ce que ca coute : flux-genere/ cesse d'etre une IMAGE du plan pour devenir le cumul de tous les plans successifs. Qui lit le dossier pour savoir ce qu'un ecosysteme expose lit alors un etat qui n'a jamais existe.

P53 le voyait deja — un ruleset perime n'a pas de refus audible — mais il designait le fichier, pas la cause. La generation supprime desormais les orphelins et le dit :

note : backup-01.nft retire — cet hote n'est plus au plan.

C'est le meme patron que les neuf resolutions d'instance : un dossier GENERE doit etre le reflet exact de sa source, donc la generation doit aussi supprimer.

2026-09-13 (6) — balloon: 0 ne desactive pas le ballooning : il retire le peripherique

Soixante-et-un gigaoctets de RAM etaient declares a vingt-et-une machines. Mesure a l'interieur des invites : douze reellement utilises. Les quarante-neuf autres etaient tenus sans etre lus, et l'hote ne pouvait pas en reprendre un seul octet.

Ce que la mesure a corrige, deux fois

La premiere table de planchers etait ecrite sur une crainte : 0,75 a PostgreSQL et a Keycloak « parce qu'ils se dimensionnent au demarrage ». Releve sur les machines reelles :

ce qu'on croyait ce qui est
shared_buffers derive de la RAM 128 Mo, le defaut Debian
une JVM qui reserve son tas 605 Mo sur 3 Go
Redis « tout en memoire » 16 Mo residents, borne a 256 Mo

Ces machines tiennent du cache de pages, pas un jeu de donnees. Le reprendre ralentit des lectures — sur du NVMe — ; il ne fait pas swapper. Leurs planchers sont redescendus au cas general. Ce qui reste haut le reste pour une raison mesurable : un collecteur garde ses series EN MEMOIRE et grossit avec le temps.

Le piege, et il n'etait pas dans la doctrine

balloon: 0 se lit « ballooning desactive ». C'est plus fort que ca : le peripherique n'est pas sur la ligne de commande QEMU. Le moniteur repond « No balloon device has been activated ». Donc qm set --balloon N ecrit la config et ne change rien tant que la VM n'a pas ete redemarree depuis sa config — un reboot a l'interieur de l'invite ne suffit pas, et le branchement a chaud est refuse : sur q35, pcie.0 n'est pas enfichable.

qm reboot fait l'arret propre puis le demarrage depuis la config. Les vingt-et-une machines y sont passees une par une, feuilles d'abord, DNS et PKI en dernier, chacune verifiee sur son balloon_min avant la suivante.

Comment l'hote decide, lu dans la source et non dans une doc

pvestatd vise 80 % de la RAM de l'hote (goal = memtotal × 0,80 − memused) et ne deplace au plus que 100 Mio par VM par cycle. Le filet ne se tend donc que quand la charge arrive — c'est le comportement voulu, et c'est pourquoi il ne s'est rien passe de visible au moment de poser les planchers.

Le resultat

declare plancher reprenable
locataire Chezlepro (14) 40 960 Mo 22 912 Mo 17,6 Go
site (7) 20 480 Mo 11 264 Mo 9,0 Go

asgard est passe de 46,1 a 36,0 Go utilises. Le moteur derive desormais le plancher sur les deux chemins de creation — instancier pour un locataire, site_machines pour le site — et le playbook de clonage le pose a cote de memory, de sorte qu'une machine neuve nait avec son plancher.

Un plancher ecrit au plan (memoire_min) prime toujours : la table est un defaut raisonne, pas une contrainte.

Ce qui reste ouvert, dit franchement

Les grosses VM hors Set-OPS — ERPLibre, ZIMBRA, Nextcloud, eregion — portent 92 Go declares avec balloon: 0 sur gandalf et vishnu, pour environ 48 Go reellement lus. C'est la que dort le reste de la marge. Elles n'ont pas ete touchees : elles ne relevent pas de ce depot.

2026-09-13 (5) — Le gabarit dore etait declare deux fois, et les deux clonaient

plan/10-intrants.yml disait 9006 (modeleSetOPS-minimal). underlay.yml disait 99998 — que le plan nomme lui-meme precedent.

Le Makefile derive VMID_MODELE de underlay.gabarit(), donc du PLAN : les locataires clonaient 9006. site_machines lisait l'autre : les machines du site clonaient 99998.

Pourquoi ca ne s'est jamais vu

Les deux VMID designent un gabarit valide. Les deux clonent, les deux demarrent, rien n'echoue. La flotte etait simplement issue de deux souches — et aucun message ne pouvait le dire, puisqu'aucune operation n'avait echoue.

Ce qui a ete fait

site_machines lit desormais underlay.gabarit(), la meme source que tout le monde. Le motif d'origine de la seconde declaration est preserve : cette source lit le PLAN DU SITE, jamais celui du tenant actif — elle ne depend d'aucun symlink instance.

materialisation.vmid_modele est retire des deux cartes. precedent: reste au plan : il dit d'ou l'on vient, et il n'est jamais clone.

P74 refuse toute redeclaration. Eprouvee dans les deux sens.

Au passage

SITE-Technolibre visait 99998 — l'ancien — parce que sa valeur avait ete reprise de la carte de Chezlepro plutot que de son plan. Corrige avant son premier clonage.

2026-09-13 (4) — La cle USB ne savait pas relire ce qu'on venait d'y ecrire

exporter_cles --support-chiffre ecrit un TAR CLAIR quand le support est deja chiffre au repos : empiler GPG par-dessus ajouterait une phrase de passe a perdre sans rien proteger de plus. Cette capacite a ete ajoutee cote EXPORT le 2026-09-12, et pas cote RESTAURATION.

restaurer_cles.py — le script qui rend la cle autonome — ne savait lire que du GPG. Constate en eprouvant l'export, pas en le supposant reussi :

gpg: aucune donnée OpenPGP valable n'a été trouvée.
ECHEC du dechiffrement.
  Phrase de passe erronee, ou archive abimee.

Le message accusait une phrase de passe qu'on n'avait jamais posee, sur une archive parfaitement saine. C'est le pire genre de diagnostic : il envoie chercher la faute la ou elle n'est pas, au moment ou l'on restaure — c'est-a-dire au moment ou l'on sait le moins.

tarfile.is_tarfile reconnait la forme ; les deux sont desormais lues. On ne demande pas un drapeau : ce serait une chose de plus a savoir le jour ou tout a brule.

Ce que l'export a aussi montre de bon

Le garde-fou a REFUSE d'ecraser l'archive du 12 — « on n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule ». La nouvelle est datee, l'ancienne reste.

Dix cles sortent desormais, dont setops-vault-site-technolibre, creee le jour meme.

2026-09-13 (3) — Un site expose ce dont ses locataires ont besoin pour l'habiter

Le constat

Un locataire ECRIT dans ses intrants les adresses des services de son site : ou resoudre, ou prendre ses paquets, ou cloner le genome, ou deposer son etat. Une copie se perime, et deux l'avaient fait en deux jours, avec exactement la meme forme :

  • serveur_ops_forge_amont: https://10.0.33.11 alors que la forge sert en 10.37.33.11 depuis que le site a pris son propre index. Le commentaire juste au-dessus expliquait encore pourquoi l'ancienne adresse figurait dans les SAN du certificat : le raisonnement etait intact, la valeur non.
  • ac-racine-site.crt versionne portait la racine du site d'AVANT sa reconstruction.

Aucune des deux n'etait relue par quoi que ce soit.

make site-intrants

Le site lit son propre plan et rend le contrat — sept valeurs, toutes DERIVEES :

dns_amorcage · artefacts_amorcage · setops_depot_binaires · serveur_ops_forge_amont
client_backup_cible · nftables_admin_ssh · passerelle_sortie

make site-intrants-verifier les confronte a ce que le locataire monte declare, et P73 en fait une preuve. Elle a trouve le defaut de la forge des sa premiere execution.

Le contrat se DERIVE du plan du site, pas d'une liste tenue a part : ajouter un service prete au site l'ajoute au contrat, sans qu'on ait a y penser.

P69 restreinte au couple monte

Elle balayait TOUS les depots OPS-* et les comparait au site MONTE. Elle avait raison tant qu'un seul site existait : une adresse « en 10.x.3z » ne pouvait designer que lui.

Deux sites decoupent leurs zones de la meme facon — c'est le but, un locataire doit pouvoir habiter l'un ou l'autre sans se renumeroter. Le troisieme octet a donc cesse de distinguer « mon site » d'« un autre site » : 10.31.34.11, parfaitement juste pour un locataire de TechnoLibre, etait declare faux parce que Chezlepro etait monte.

Un locataire n'appartient a aucun site — il en habite un, choisi par le symlink au moment du deploiement. La seule paire qu'on puisse juger est donc celle qui est montee.

Une marche payee en chemin

La premiere version de site_intrants ecrivait RACINE / "instance" / "inventories" / "principal". P41 a mordu : neuf modules avaient deja porte chacun leur copie de cette resolution, et cinq defauts en etaient sortis en cinq jours. La preuve a attrape la dixieme avant qu'elle serve.

2026-09-13 (2) — L'annuaire cesse de tout ouvrir avec la meme cle

Ce que la mesure a montre

Les quatre services qui interrogent l'annuaire — Keycloak, Dovecot, Postfix, Icinga Web 2 — s'y liaient tous avec cn=admin, le compte d'administration de la base. C'est le rootDN : slapd lui fait CONTOURNER TOUTES LES ACL. Un seul secret, quatre services, tous les droits sur l'arbre — pour ce qui est, trois fois sur quatre, une simple lecture.

Et les droits livres par Debian etaient intacts : to * by * read. Sur ldap://, sans s'authentifier, une machine du reseau enumerait tous les comptes et toutes les adresses.

L'indice etait deja dans le depot. validatePasswordPolicy existe parce que « slapd n'applique pas ses controles de qualite au rootDN ». La consequence etait compensee, la cause intacte.

Ce qui a ete fait

  • ou=services et un compte par consommateur, secret propre en voute.
  • Sept regles d'acces, posees en entier (state: exact) plutot qu'inserees : l'ordre est la regle, et inserer c'est parier sur ce que le paquet aura mis avant nous. Keycloak ecrit (mots de passe, creations), les trois autres lisent, les comptes de service ne se voient pas entre eux, et la lecture anonyme est fermee.
  • amorcage_acces garde le compte d'administration, et c'est nomme comme l'exception : il ne consomme pas l'annuaire, il le PROVISIONNE, depuis la socket locale.
  • La sonde passe de -x a -Y EXTERNAL. Elle lisait en anonyme : elle aurait annonce un annuaire VIDE — la panne la plus grave — sur un annuaire parfaitement sain.
  • La rotation du compte d'administration est enfin possible. Son mot de passe n'etait pose qu'a l'installation, par debconf : le faire tourner en voute ne descendait nulle part, et les deux divergeaient en silence. Un secret qu'on ne peut pas faire tourner est un secret qu'on ne fera pas tourner.

Quatre marches payees en chemin

  1. Un cinquieme appelant. amorcage_acces inclut aussi le role partage. Oublie, il echouait — et no_log, qui protege la valeur, masquait aussi la RAISON. La garde refuse desormais dans une tache SANS no_log : elle nomme la cle absente, jamais son contenu.
  2. Une garde qui surveillait deux champs sur trois. La federation Keycloak ne reecrivait son bindDn que si l'URL ou le mode changeaient. Nouveau secret, ancien nom : error code 49 - Invalid Credentials, qui accuse les identifiants sans dire lequel des deux a bouge.
  3. Une rotation placee trop tard. Les taches qui se lient en administrateur passaient AVANT la mise a jour du mot de passe. Un secret qui tourne doit descendre avant que quoi que ce soit s'en serve.
  4. ansible-vault et son tube. Sortie non bloquante = echec silencieux ; la voute paraissait tournee et etait identique a l'octet. La garde qui compare l'empreinte du FICHIER avant et apres l'a rattrape — c'est la lecon deja ecrite dans ce depot, et elle vient de se repayer.

La garde

P72 exige que tout role incluant resoudre_annuaire NOMME son compte de service, et qu'aucun sauf amorcage_acces ne nomme admin. Eprouvee dans les deux sens.

Rotation

vault_openldap_admin et vault_ldap_bind_postfix ont ete renouveles : les deux avaient transite en clair par une session d'exploitation. Verifie sur l'infrastructure — les anciennes valeurs rendent Invalid credentials (49), les nouvelles ouvrent, et les quatre services repondent.

2026-09-13 — Le genome ne nait plus chez un locataire

Ce que la console montrait

Chezlepro-17 contenait vingt et une VM : les quatorze du locataire ET les sept du genome, melangees. OPS-Chezlepro et OPS-Technolibre, crees a la main, etaient vides. Le gabarit vivait seul dans un pool Set-OPS.

La cause, et pourquoi le rangement n'aurait pas tenu

site-creer appelle cloner-vm, qui derive son pool par --pool-actif — c'est-a-dire le pool du tenant lie. Les machines du site heritaient donc du locataire courant.

Range a la main, ca se serait defait au prochain site-creer, sans un mot : la VM est bien creee, bien nommee, bien adressee. Seule son appartenance est fausse, et rien ne la regarde.

Ce qui a ete fait

  • devis_proxmox_pools.py --pool-site rend le nom invariable du pool du genome. Une option DISTINCTE, pas un drapeau sur la premiere : le site et un tenant ne repondent pas a la meme question, et une fonction qui repond aux deux finit par se tromper d'appelant.
  • cloner-vm accepte une surcharge POOL= ; site-creer la nomme. La substitution se fait au niveau make, pas shell — POOL arrive du sur-make comme variable make, et $${POOL} ne l'aurait jamais vue.
  • P71 exige que site-creer nomme son pool. Eprouvee dans les deux sens : elle passe sur le Makefile sain, elle tire des qu'on retire l'argument.

Applique au cluster

pool avant apres
Site-OPS — 9 (7 du site + 2 gabarits)
OPS-Chezlepro 0 14
OPS-Patient0 — 5
Chezlepro-17, Patient0-29, Set-OPS 21 / 5 / 1 supprimes, vides

Les pools anterieurs a Set-OPS (Prod.Chezlepro, Prod.TechnoLibre, Dev.TechnoLibre, Lab.TechnoLibre) et les quinze VM hors pool n'ont pas ete touches.

2026-09-12 (6) — Le site tient aussi les binaires, pas seulement les paquets

Ce qui manquait

Le cache du site couvre apt depuis longtemps : 1,34 Go tires de l'amont, 6,13 Go servis a la flotte pendant la reconstruction d'un locataire — un rapport de 4,58. Mais quatre artefacts arrivent autrement, parce qu'ils ne vivent dans aucun depot apt : Forgejo, Keycloak, Nextcloud, oauth2-proxy. Le CONTROLEUR les tire (delegate_to: localhost) puis les pousse par SSH.

Mesure du 2026-09-12 : /opt/setops/.cache/setops est ABSENT sur le runner du site. Un second locataire monte depuis ce runner sortait donc chercher 570 Mo sur codeberg.org, github.com (deux fois) et download.nextcloud.com — alors que le meme ecosysteme ne demandait plus un seul paquet a Debian. Le poste du mainteneur, lui, a ces 1,6 Go depuis toujours : c'est pourquoi personne ne l'avait vu.

Pourquoi pas un relais transparent

Les Remap-* du cache font deja passer quatre fournisseurs HTTPS. Mesure des quatre amonts avant de decider :

amont reponse
codeberg.org 200, aucune redirection
download.nextcloud.com 200, aucune redirection
github.com (x2) 302 vers release-assets.githubusercontent.com, URL signee valable une heure

L'URL finale de GitHub change a chaque requete. Un cache qui la prend pour cle ne fait jamais mouche, et ce qu'il garderait serait perime avant d'etre relu. Le relais marche pour deux amonts sur quatre : ce n'est pas un mecanisme, c'est une coincidence.

Ce qui a ete fait

Un vrai depot de fichiers, dans le service qui existe deja. LocalDirs d'apt-cacher-ng publie un repertoire du disque sous un prefixe — eprouve sur site-cache-01 AVANT d'ecrire le role : 200, 90 158 octets, identiques a l'octet. Donc aucun service, aucun port, aucun certificat, aucun flux nouveaux : l'ingress 3142 pair: flotte deja declare couvre exactement ce chemin.

  • serveur_artefacts publie /var/lib/setops/artefacts-directs sous setops-binaires, et le remplit depuis les amonts — une seule machine sort, pour tout le site et pour les ecosystemes qui naitront demain.
  • Les versions ne sont pas recopiees. Le role lit les defauts des quatre roles consommateurs (include_vars SANS name: — enfermees dans un dictionnaire, les valeurs qui se citent entre elles ne se resolvent plus ; le devis des certificats avait deja paye cette marche).
  • Les quatre roles recoivent une tache ajoutee, placee avant leur stat de cache. Si le depot sert le fichier, le stat le voit et la tache de telechargement amont se saute d'elle-meme : aucune tache existante n'a change.
  • Les deux signatures detachees passent aussi par le depot. Elles pesent 228 octets, mais elles sont demandees a chaque passage : une sortie reste une sortie.
  • setops_depot_binaires est derive, jamais ecrit deux fois — de artefacts_amorcage chez le locataire, du plan du site dans site_inventaire.py.

La garde, ecrite le meme jour que la liste

P70 exige que tout dest: ecrit sous un <role>_cache_local figure au depot. Sans elle, une version montee dans un role sans l'etre dans le depot produirait exactement le defaut que ce depot traque : le runner ne trouve pas le fichier, retombe sur Internet, et tout fonctionne — sans que rien ne le dise.

Une liste qui suit une autre prend du retard. Celle-ci est nee avec son garde-fou.

Une erreur corrigee en cours de route

La premiere version des gardes de signature s'appuyait sur failed_when: false puis testait is not succeeded. failed_when: false REECRIT le verdict : la tache n'est plus jamais failed, donc la garde etait toujours vraie et ne gardait rien — la faute exacte que client_artefacts documente depuis le 2026-08-31. Les gardes mesurent le fichier desormais, pas le verdict de la tache.

Ce que ca ne couvre pas encore

Le cache du contrôleur reste per-runner : un runner tout neuf demande le depot, et si le depot ne l'a pas encore, il sort. Le depot se remplit au deploiement du site — donc un locataire monte avant que le site n'ait ete redeploye sortira une derniere fois.

2026-09-12 (5) — Une reprise conditionnelle ne reprend rien

Cinq roles telechargeaient une cle de signature avec une boucle until exigeant une taille non nulle. Des que le fichier existe — meme a zero octet — get_url emet une requete CONDITIONNELLE ; l'amont repond 304 Not Modified ; la tache reussit sans rien ecrire ; et la boucle retente cinq fois contre un serveur qui repondra toujours 304 :

HTTP Error 304: Not Modified   size: 0   attempts: 5

La boucle de reprise se battait contre elle-meme. force: true sur les cinq (client_pki, client_journal, serveur_collabora, serveur_grafana, serveur_loki) — le when: qui precede garantit deja qu'on ne retelecharge pas une cle valide, donc force ne concerne que le cas ou l'on a DECIDE d'aller chercher.

2026-09-12 (4) — Pools nommes comme les depots, et les cles sortent du poste

Le nom d'un pool est celui de son depot

Chezlepro-17 devient OPS-Chezlepro. Le seed se lisait dans le nom, ce qui etait juste mais obligeait a connaitre le codage — et surtout le nom CHANGEAIT si l'index changeait : la renumerotation du site l'a montre le jour meme. Ce qu'on lit dans la console est desormais ce qu'on tape dans un terminal.

L'unicite ne vient plus de l'index mais du nom de dossier, deja unique par construction.

Site-OPS ne derive de rien, et c'est le point. Les machines du genome — cache, forge, AC, noms, depot, supervision — ne dependent d'aucun index : elles sont l'infrastructure SUR laquelle les index vivent. Un nom invariable dit cela, et il reste le meme d'un hebergeur a l'autre. Sans ce bloc, les sept restaient hors de tout pool : sept orphelines a cote de trois flottes rangees.

CONFORME : 4 pool(s) Proxmox, 41 VM placee(s), aucun nom ni VMID en collision.

P69 — l'amorcage d'un tenant designe-t-il le site REEL ?

dns_amorcage et artefacts_amorcage sont ecrits A LA MAIN dans les intrants d'un tenant, et c'est voulu : au moment ou ils servent, la premiere machine ne resout aucun nom. Une adresse, pas un nom.

Mais ces adresses designent des machines DU SITE. Le site a change d'index ; elles n'ont pas suivi. La reconstruction du locataire s'est arretee sur infra-pki-01 avec « Failed to update apt cache » — a quinze couches de sa cause.

La preuve ne juge que les valeurs qui PRETENDENT designer le site : un tenant peut legitimement s'amorcer sur un resolveur public, et OPS-Technolibre comme OPS-Patient0 visent 9.9.9.9. C'est un choix, pas un oubli.

Les cles sortent du poste, en clair, et c'est raisonne

Les neuf fichiers (1 644 o) sont sur la cle USB LUKS. En tar clair, par --support-chiffre, option ajoutee ce jour et explicite — le defaut reste GPG.

Deux menaces, deux reponses :

le support est perdu ou vole  ->  LUKS s'en charge
le poste est compromis        ->  la 2e couche n'aide pas : les originaux sont
                                  dans ~/.config sur ce meme poste

La couche GPG n'ajoutait donc presque rien, et coutait une phrase de passe STOCKEE NULLE PART — le seul point que la procedure elle-meme ne couvre pas. On echangeait un risque de divulgation deja couvert contre un risque de PERTE qui ne l'etait pas.

Pourquoi ce n'est pas le defaut : un support non chiffre est le cas le plus frequent, et le silence ne doit jamais pencher du cote de la divulgation.

Et la note qui disait les cles sorties depuis le 2026-09-05 etait fausse : le support ne portait que le depot hors site du 1er. Verifie avant d'ecrire, pas apres.

2026-09-12 (3) — Trois reconstructions : trouver, verifier, prouver

La premiere reconstruction avait trouve seize defauts. Deux tours de plus ont ete faits, et c'est le second qui compte le plus : un runbook ecrit d'apres un recit de depannage contenait une erreur qu'aucune relecture n'aurait montree.

Tour 1    8 passages   ~2 h 30      16 defauts trouves
Tour 2    3 passages   53 min        2 defauts trouves
Tour 3    2 passages   37 min 24     0

Ce que le tour 2 a corrige dans le runbook

J'avais annonce DEUX passages. Il en fallait TROIS, et pour une raison instructive : les deux blocages connus — la cle de Forgejo et la forge vide — sont en serie. On ne peut pas amorcer une forge qui ne tourne pas, et elle ne tourne qu'apres la convergence de la cle. Je les croyais franchissables dans le meme passage.

Il fallait SUIVRE le runbook pour le voir.

Deux murs de plus, invisibles au tour 1

#17 — site-creer rend la main avant que les machines repondent. Mesure : 3 min 20 au tour 3. Le chemin locataire a _attendre-flotte ; le site n'a pas d'equivalent. Un enchainement automatique echouerait sur UNREACHABLE qui n'est qu'une impatience.

#18 — les cles d'hote changent a chaque reconstruction. known_hosts porte l'ancienne, git refuse — a juste titre — et sans terminal il ne peut rien demander. Le message qu'il rend parle de droits d'acces et de depot inexistant.

accept-new accepte une PREMIERE rencontre, jamais un CHANGEMENT : il ne suffit donc pas pour une machine reconstruite sur une adresse deja connue. forge-amorcer purge desormais l'entree perimee — celle de l'hote que git va contacter, lu dans git remote get-url, et non celle de l'API : quand l'amorcage passe par un tunnel, les deux different et purger la mauvaise ne se verrait qu'au push.

Le cycle de la cle, denoue

client_pki posait les droits de la cle d'hote pour le groupe git, que le paquet de Forgejo cree dans une couche POSTERIEURE. Aucun ordre de couches ne denoue ce cycle : les certificats doivent venir tot, tout le monde en depend.

C'est la PROPRIETE DU GESTE qui a change de main. serveur_forgejo revendique la cle au moment ou il cree le groupe ; client_pki garde la sienne et la repose a chaque passage, parce que step reecrit la cle a chaque renouvellement. Les deux se recouvrent, et c'est voulu : l'une amorce, l'autre entretient.

Gain mesure : un passage entier, et 300 secondes d'attente perdue.

Ce que mon propre outil masquait

forge-amorcer n'affichait que la DERNIERE ligne de stderr — or git termine toujours par « assurez-vous que le depot existe », quelle que soit la cause. Un aller-retour de diagnostic pour une cle d'hote. Il rend maintenant les quatre premieres lignes.

C'est le meme defaut que ceux trouves dans le moteur, commis par moi : un message qui pointe ailleurs que sa cause.

La sequence est desormais mesuree, pas reconstituee

docs/runbooks-exploitation.md §9 porte les six etapes du tour 3, avec leurs durees reelles et les dix-huit murs. Le troisieme tour n'a rien trouve de nouveau : c'est le premier ou reconstruire le site est une PROCEDURE et non une enquete.

2026-09-12 (2) — Le site est reconstruit depuis zero, et il a fallu seize corrections

SITE-Chezlepro n'avait jamais ete rase. Il avait ete monte par ajouts successifs, sur des semaines, avec un service deja debout a chaque etape. La limite qu'on repetait partout — « l'infrastructure d'accueil n'a jamais ete reconstruite depuis zero » — se lisait comme de la prudence.

7 machines   0 echec   0 injoignable   46 couches
16:31 -> 19:00   creation 12 min 08 s, puis HUIT passages de deploiement

Ce qui rend un site different d'un locataire

Un locataire naît dans un monde deja peuple : le site lui fournit les paquets, les noms, le genome, l'heure et le depot de sauvegarde. Un site n'a personne au-dessus de lui, sauf sa frontiere. Tout ce qu'un locataire recoit, un site doit se le donner — et pendant qu'il se le donne, il ne l'a pas.

Onze des quinze murs viennent de la.

Deux capacites qui n'existaient pas

make site-raser. raser.py ne visait que l'instance active — un locataire. Rien ne detruisait les machines du site. Ce n'est donc pas que personne n'avait essaye de le reconstruire : l'outil n'en offrait pas le moyen. La limite etait un trou d'outillage, pas une fatalite. Les quatre verrous de raser.py sont repris tels quels.

make forge-amorcer. Le runner clone le genome depuis SA PROPRE forge, et serveur_forgejo ne cree ni organisation ni depot. A froid la forge naît vide. Le geste part du POSTE, et c'est structurel : a cet instant il est le seul endroit qui detienne le genome. Un amorcage vient toujours de l'exterieur de ce qu'il amorce.

Trois gardes qui verifiaient la forme au lieu du resultat

C'est la famille la plus couteuse : elles donnent l'apparence d'une verification.

Le resolveur. « Le resolv.conf nomme-t-il deja le resolveur declare ? » — juste en regime etabli. A froid, les sept machines naissent avec nameserver <site-dns-01>, qui est l'une des sept et n'a aucun resolveur. La garde concluait « elle pointe deja au bon endroit » et protegeait l'etat casse. Elle mesure desormais si la resolution ABOUTIT.

L'administration reconnue a son port. elif "22" in _ports(fl) — juste tant que le seul flux administratif etait SSH. L'exploitant declare admin sur le 443 de sa forge, la machine accepte, la frontiere refuse, et les deux couches se croient d'accord.

Un flux a deux paires n'obtient qu'une branche. La forge declare pair: [flotte, admin]; la chaine de elif rangeait le flux dans le premier cas et la part administrative disparaissait en silence. La portee administrative s'AJOUTE desormais.

Un ecart de securite, que seule la construction pouvait montrer

Le PostgreSQL du SITE servait le certificat AUTO-SIGNE du paquet Debian, pas celui de son AC — serveur_postgresql_tls_actif vaut false par defaut et le site ne le declarait nulle part, quand le locataire le met a true. Ni reseaux_autorises, ni tls_force.

Personne ne l'avait vu parce qu'aucun client n'exigeait la verification. Le premier a l'exiger — l'import du schema Icinga DB — a echoue sur certificate verify failed. Le defaut n'a pas casse la construction : la construction a revele le defaut.

L'acces de l'exploitant etait accidentel

L'ancien plan d'administration 10.17.0.0/24 vivait DANS 10.17.0.0/16, que les expositions du site acceptent deja pour les locataires. Sortir le site de ce supernet a revele qu'aucune declaration n'avait jamais accorde cet acces.

C'est l'explication complete d'une mesure du matin meme — « le site a une console deployee et sans chemin d'acces ». Ce n'etait pas Grafana : c'etait tout le site. La decision de separer les index n'a pas cause le probleme, elle a retire le hasard qui le masquait.

Deux passages, au minimum

client_pki pose les droits d'une cle pour un consommateur qu'une couche ULTERIEURE cree. Au premier passage le groupe git n'existe pas, la cle reste root:root — donc FERMEE, jamais plus ouverte — et Forgejo ne demarre qu'au second.

C'etait d'abord un blocage CIRCULAIRE : l'echec arretait le play avant la couche qui aurait cree le groupe. Rejouer n'y changeait rien. La tache constate desormais l'absence et reporte, au lieu d'echouer.

La sequence est ecrite

docs/runbooks-exploitation.md §9 — « Le premier jour d'un site », avec les seize murs et ce que chacun enseigne. Le seizieme s'est montre en ECRIVANT cette entree : make wiki-publier refuse, parce que Forgejo ne cree <depot>.wiki.git qu'a la premiere page. Sans cette section, le prochain site les rencontrerait tous.

2026-09-12 (1) — Un site prend son propre index, et la garde les compte enfin

Decision de l'exploitant : sites et locataires se partagent la classe A, chacun avec son propre index. L'exception du guide — « un site derive du meme index que son tenant » — disparait. Il reste une seule regle : tout prend un index.

Ce que l'exception cachait

Chez l'hebergeur de reference, le plan d'administration du site vit dans 10.17.0.0/24, a l'interieur du supernet du locataire OPS-Chezlepro. Ce n'est pas dangereux — les zones d'un tenant commencent a l'octet 16 par construction, mesure faite — mais 10.17.0.0/16 designait alors deux choses : un locataire, et le plan d'administration de celui qui l'heberge. Deux sens pour une adresse.

Les zones du site, elles, ne derivaient de rien : 10.0.31 a 10.0.36, tapees a la main dans underlay.yml. Le site etait la seule partie du systeme qui n'avait pas de seed.

La seconde liste, et sa garde ecrite le meme jour

Tant que les sites n'avaient pas d'index, ils ne pouvaient heurter personne. Depuis cette decision, un site et un locataire peuvent reclamer le meme nombre — et make instances ne voyait que les depots OPS-* :

for chemin in sorted(FRERES.glob("*/plan/nomenclature.yml")):

Un site n'a pas de nomenclature.yml : il etait invisible a la garde des collisions, celle qui refuse deux instances aux memes VLAN et VMID. La decouverte lit desormais aussi SITE-*/underlay.yml et son champ index. Un site qui n'en declare pas reste hors du compte — il y entre le jour ou il en prend un.

OPS-Chezlepro       17   1171-1176
OPS-Technolibre     23   1231-1236
OPS-Patient0        29   1291-1296
SITE-Technolibre    31   —

SITE-Technolibre

Squelette du deuxieme site, pour la visite du 14 septembre. Un seul noeud Proxmox, en version 9, derriere un OPNsense. Meme forme que le site de Chezlepro — sept machines, six zones, memes services — adresse depuis l'index 31 : gestion en 10.31.0.0/24, zones en 10.31.31 a 10.31.36 (le 3e octet reste le numero de VLAN).

Trois differences ecrites dans son README plutot que decouvertes sur place : le SPOF est total (gabarit et clones sur la meme machine), les clones sont COMPLETS (un clone lie n'achete rien sans second noeud), et rien n'a jamais tourne contre un PVE 9.

COLLECTE.md liste les 35 valeurs a relever, dont la question de forme : ce qu'il y a entre le noeud et l'OPNsense decide du mode de routage.

Ce qui reste a faire, et qui appartient a l'exploitant

OPS-Chezlepro passe a l'index 37, ce qui libere 17 pour le site qui l'utilise deja. C'est un renumerotage de quatorze machines : il se fera quand l'exploitant le decidera. SITE-Chezlepro ne declare donc pas encore son index — il entrera dans le compte ce jour-la.

2026-09-11 (5) — Les correctifs de securite reviennent au quotidien, sans personne

Decision de l'exploitant, et elle corrige un mauvais jugement de ma part. J'avais decrit la situation correctement — « la seule operation de la flotte qui exige un humain » — puis propose un minuteur MAISON pour appliquer le socle, plutot que le mecanisme que Debian fournit exactement pour ca.

Ce qui etait desarme, et pourquoi ca ne tenait plus

Depuis le 2026-08-25, common_packages masquait apt-daily.timer, apt-daily-upgrade.timer et unattended-upgrades.service sur toute la flotte.

LE MOTIF ETAIT REEL : apres six redemarrages et des heures sans reseau, le rattrapage d'unattended-upgrades a tenu le verrou dpkg 48 minutes et fait echouer trois deploiements de suite.

MAIS LA CONTREPARTIE ETAIT ECRITE ICI SANS ETRE MESUREE — « les correctifs ne s'appliquent plus qu'au passage de Set-OPS ». Mesure le 2026-09-11 : les sauvegardes tournent seules, les certificats se renouvellent seuls, la sante se rapporte seule. Les correctifs, non. Une absence de trois jours devenait une exposition sur vingt et une machines.

Ce qui rend la coexistence tenable, et qui n'existait pas en aout

_attendre-hote        attend apt-daily* ET le verrou avant tout deploiement
lock_timeout: 300     pose en module_defaults sur chaque playbook de groupe
Origins-Pattern       `-security` SEULEMENT — pas un `upgrade: full`
Automatic-Reboot      false : aucun demon ne redemarre un service seul

La course de premier demarrage reste. Elle est desormais ATTENDUE plutot que supprimee — c'est la difference entre subir un concurrent et le connaitre.

Rearmer ne se deduit pas de ne plus desarmer

Passer le drapeau a false SAUTE la tache de desarmement ; il ne defait rien. Une machine deja masquee le serait restee pour toujours, et le drapeau aurait dit le contraire de l'etat reel. Une tache de rearmement explicite est donc posee le jour meme ou la decision s'inverse — masque retire, unite activee, unite demarree.

On arme, on ne declenche pas — et la nuance a coute dix machines

La premiere version du rearmement faisait state: started sur les trois unites. Demarrer unattended-upgrades.service apres des semaines de masquage lance son RATTRAPAGE immediatement — en plein deploiement :

'apt-get autoremove' failed: E: Could not get lock /var/lib/dpkg/lock-frontend.
It is held by process 185135 (unattended-upgr)

Exactement la course qui avait motive le desarmement en aout, rejouee par la tache censee le defaire, et malgre lock_timeout: 300. Dix machines sur vingt et une.

LE TRAVAIL QUOTIDIEN NE PASSE PAS PAR CE SERVICE : apt-daily-upgrade.timer appelle apt.systemd.daily, qui invoque unattended-upgrade lui-meme. Le .service ne sert qu'aux passages de demarrage et d'extinction. L'ACTIVER suffit — il partira au prochain redemarrage, sur une machine au repos. Armer un minuteur, en revanche, ne declenche pas sa cible : il la programme.

Ce que la sonde correctifs devient

Elle ne mesure plus un geste manquant mais un mecanisme en panne. Son seuil de 72 h cesse d'etre une tolerance pour devenir une alarme : avec un passage quotidien, trois jours de retard veulent dire que quelque chose ne tourne plus.

2026-09-11 (4) — L'heure vient de la frontiere : la derniere dependance vivante tombe

L'exploitant a presume qu'un ecosysteme fonctionnel n'avait plus besoin d'Internet pour ses operations internes. Mesure sur les quatorze machines : presque.

sorties TCP etablies vers une adresse publique   aucune, sur 14/14
apt                                             par le cache du site
noms internes                                   autoritaires, en local
unattended-upgrades / apt-daily                 masked, masked, masked

pool 2.debian.pool.ntp.org iburst               QUATRE pairs publics, sur les 14

Le role chrony posait le fuseau et INSTALLAIT le demon sans jamais toucher a ses sources. Le defaut de Debian tenait depuis le premier jour : herite, jamais choisi.

Pourquoi c'etait la mauvaise a laisser trainer

step-ca emet des certificats a duree courte ; TLS refuse un certificat pas encore valide ; Loki rejette une ligne trop loin dans le futur ; les silences d'Icinga reposent sur des ttl. Une flotte dont les horloges divergent tombe en panne DE L'INTERIEUR, avec des symptomes qui ressemblent a tout sauf a une horloge.

Et la derive est lente : couper Internet ne casse rien le premier jour. Le systeme parait souverain jusqu'a ce qu'il ne le soit plus, sans signal entre les deux.

L'autorite est la frontiere — decision de l'exploitant

Elle etait deja stratum 2, synchronisee, et ecoutait en 123 sur chaque patte. Il ne manquait que le passage : son pf est en refus par defaut et aucun flux NTP n'etait declare vers elle.

a creer : 14   |  a retirer : 9

Les NEUF retires sont les anciennes regles port 123 -> !SETOPS_INTERNES : le 123 etait autorise vers N'IMPORTE QUELLE adresse d'Internet. Le changement resserre autant qu'il centralise. La destination est desormais nommee, alias SETOPS_FRONTIERE.

Deux chemins, parce que la topologie en a deux

Un tenant n'atteint PAS la frontiere par sa passerelle de zone : 10.17.x.1 est tenue par le SDN de Proxmox. Il sort par le lien de transit — 10.0.4.1, patte libellee TENANTS, qui manquait a opnsense_if_zones pour la meme raison que grappe-controle la veille. Une machine du site, elle, a la frontiere pour passerelle directe.

La valeur est DERIVEE des deux cotes, jamais ecrite : instancier prend la patte sur le reseau de transit (via reseau_transit(), qui le derive de passerelle_sortie — le reconnaitre a son prefixe d'adresse aurait marche ici et menti chez le prochain hebergeur) ; site_inventaire prend la patte de la zone de la machine.

14 machines du tenant   ^* 10.0.4.1      publiques=0
7 machines du site      ^* 10.0.3x.1     ecarts en nanosecondes

Le drop-in ajoute, il ne retire pas

/etc/chrony/conf.d/ est lu EN PLUS du fichier principal. Y declarer notre serveur sans neutraliser le pool de Debian aurait laisse cinq sources, dont quatre sur Internet — l'horloge centralisee en apparence, la dependance intacte.

La sonde horloge, ecrite en meme temps

Elle mesure DEUX choses, et la seconde est celle qui manquait : la machine est-elle synchronisee, ET contre la source DECLAREE ? Une machine peut etre parfaitement a l'heure en interrogeant quatre serveurs publics — c'est exactement l'etat d'avant, et mesurer la seule derive l'aurait laisse invisible.

Le seuil d'ecart (500 ms) est tres large, et c'est voulu : il doit crier sur une horloge qui DECROCHE, pas sur la respiration d'un chronyd. L'autre moitie, elle, ne tolere rien.

Ce qui aura toujours besoin d'Internet, et c'est normal

Les correctifs de securite, la construction d'un ecosysteme neuf au-dela de ce que le cache detient, la resolution des noms externes, le courriel sortant. Aucun n'est une operation interne.

2026-09-11 (3) — make expositions-etat : le certificat SERVI, pas celui du disque

Renommer une exposition touche cinq choses. Quatre suivent au deploiement ; la cinquieme, non — et c'est celle que le navigateur regarde.

serveur_powerdns   la zone publie le nouveau nom          OK
serveur_keycloak   le client OIDC accepte le retour       OK
le service         il fabrique ses URL avec le bon nom    OK   (P67)
serveur_nginx      le vhost repond sur le nouveau nom     OK
client_pki         le SAN du certificat porte le nom      NON  il faut le rejouer

Mesure DEUX FOIS le 2026-09-10, sur grafana -> observatoire puis icinga -> vigie. Le symptome est trompeur : le site repond, la page s'affiche, et c'est le NAVIGATEUR qui refuse — avec une erreur de certificat que personne ne relie a un renommage fait la veille.

Il regarde dans les deux sens

Un nom RESTE dans le SAN apres avoir quitte le plan est un nom que le certificat continue d'authentifier. C'est exactement ce qu'avait laisse le premier renommage.

Le piege de ce second sens, trouve en l'ecrivant : client_pki met TOUJOURS le FQDN et le nom court de la machine dans le SAN. Les compter comme vestiges faisait crier le controle a chaque execution — et un controle qui crie toujours ne se lit plus.

Deux formes de terminaison, une seule question

Le locataire termine son TLS sur un edge ; sans_exposition y designe le porteur. Le SITE n'a pas d'edge — site-forge-01 ecoute lui-meme sur 443 — et la variable n'y existe donc pas. Le porteur se derive alors de l'application : son hote dit quelle machine la sert.

OPS-Chezlepro  6 expositions   toutes servies, certificat conforme, rien en trop
SITE           5 expositions   INJOIGNABLE depuis ce poste

Le second resultat n'accuse pas le service, et le controle le dit : les zones du SITE ne sont pas routees depuis le plan d'administration du locataire. Le mur est la frontiere.

Pourquoi pas une preuve

make prouver est STATIQUE — il lit le depot, zero appel reseau, et c'est ce qui le rend rejouable partout. Rien de statique ne peut lire un certificat SERVI : il faut ouvrir la connexion.

Les quatre controles a la demande n'etaient nommes nulle part comme famille — routes-fabric-etat n'apparaissait dans aucune documentation. Ils ont maintenant leur section (§8 des runbooks). Une garde que personne ne sait lancer ne sert a rien.

2026-09-11 (2) — La piste d'audit sort de la machine auditee

auditd tournait, onze regles etaient armees, et rien de tout cela ne servait a grand-chose. Quatre manques, mesures avant d'etre combles.

1. Rien ne sortait de la machine

C'etait le trou decisif. La configuration d'Alloy contenait zero reference a l'audit, et les cinq greffons d'auditd etaient tous active = no. La piste vivait uniquement sur la machine auditee : qui la compromet possede la preuve de l'avoir fait, et le fichier est precisement ce qu'on efface en premier.

Alloy lit maintenant /var/log/audit/audit.log et le pousse vers Loki. Aucune permission a poser — mesure, pas suppose :

uid=999(alloy) groupes=989(alloy),4(adm),999(systemd-journal)
drwxr-x--- 2 root adm /var/log/audit

ON A ECARTE L'AUTRE VOIE, et pour une raison precise : activer le greffon syslog.conf ferait passer les evenements par journald, deja collecte — mais journald applique une LIMITE DE DEBIT. Une rafale d'evenements d'audit, c'est-a-dire exactement le moment qui compte, serait tronquee en silence.

L'activation DERIVE de l'appartenance au groupe (serveur_durci dans group_names), pas d'une liste par instance : une liste de plus qui suit une autre prendrait du retard.

2. Aucune sonde, alors que le depot racontait lui-meme la panne

roles/auditd/ n'avait pas de meta/ du tout. Et son propre defaults/main.yml documentait l'incident du 2026-08-30 : onze regles armees dans le noyau sur quinze machines ou auditd etait mort, auditctl -l les affichant toutes.

La sonde audit mesure donc la COLLECTE — service actif et journal qui a bouge. Le nombre de regles part en perfdata, jamais en verdict : c'est exactement le chiffre qui restait bon pendant la panne.

Declaree dans roles/serveur_durci/meta/ (le GROUPE) et deposee par roles/auditd/ : la derivation de supervision ne traverse pas les roles-taches. Meme lecon que la sonde correctifs, deplacee la veille.

3. La retention n'etait declaree nulle part

Mesure sur infra-edge-01 au repos, deploiement termine :

3 380 octets / 60 s        ->  ~4,6 Mo/jour
Debian : 8 Mo x 5 = 40 Mo  ->  ~9 jours

Sauf qu'une reconstruction a elle seule en brule 4 Mo. Portee a 16 Mo x 8 = 128 Mo (~28 jours), et le raisonnement a change en route : depuis (1), l'archive n'est plus ici, elle est chez le collecteur. Le local n'est plus qu'un TAMPON pour traverser une panne de Loki.

4. Les regles ignoraient le materiel qui signe tout le reste

Elles surveillaient passwd, sudoers, sshd_config, /etc/apt/ — les fichiers par lesquels on prend un COMPTE. Aucune ne regardait /etc/step/certs/, la cle privee de l'hote : qui la copie parle ensuite au nom de la machine devant toute la flotte, sans toucher a un mot de passe ni declencher une seule des regles precedentes.

-w /etc/step/ -p wa -k pki
-w /etc/step-ca/ -p wa -k pki-autorite     (autorite seulement)

LA SECONDE EST CONDITIONNELLE, ET CE N'EST PAS UN DETAIL. auditctl REFUSE un -w vers un chemin absent, et augenrules fait alors echouer le chargement ENTIER — auditd ne demarre plus, faute de sa dependance. Poser cette ligne sur les treize machines qui ne sont pas l'autorite rejouerait exactement la panne du 2026-08-30. Verifie sur l'autorite d'abord, seule : 13 regles armees, audit-rules non en echec, sonde a 0.

Un effet de bord assume

Creer roles/serveur_durci/ pour porter la declaration en fait un role a part entiere : il lui faut son README et sa meta/authentification.yml, comme serveur_debian. Trois preuves l'ont exige (P29, P31, P48) — elles ont fait exactement leur travail.

2026-09-11 (1) — Reconstruction a froid : les noms derives naissent justes

Quatrieme reconstruction depuis zero de Chezlepro, demandee pour une raison precise : trois roles dependent desormais d'une variable que le GENERATEUR pose. En incrementiel ils avaient marche parce que la configuration existait deja. A froid, si instancier ne posait pas <groupe>_hostname sur un chemin quelconque, le defaut du role reprendrait la main en silence — et il tomberait juste pour forge et cloud (la convention coincide), faux pour observatoire.

14/14 VM rasees puis recreees      32 min 14 s
un seul echec, et il n'a rien a voir avec les noms

Le certificat ne A FROID porte observatoire et vigie, et aucun ancien nom. Les deux retours SSO derivent juste, sans reprise :

observatoire  redirect_uri=https%3A%2F%2Fobservatoire.chezlepro.internal%2Flogin%2Fgeneric_oauth
vigie         redirect_uri=https%3A%2F%2Fvigie.chezlepro.internal%2Foauth2%2Fcallback

En incrementiel, le SAN avait demande un second passage de client_pki. A froid, il naît juste du premier coup — la sequence penible n'existe qu'en incrementiel.

Une reussite n'est pas un contenu

L'echec unique, sur infra-mail-01 et sur elle seule : get_url a rendu 0 octet sans erreur. Le cache a servi un 200 au corps vide. Le role a continue, satisfait.

Le defaut ne s'est pas lu la. Il s'est lu deux cents lignes plus loin, dans un apt qui accusait la SIGNATURE :

Missing key 35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3, which is needed to verify signature

Un message qui envoie chercher une cle revoquee chez le fournisseur, alors que le fichier local faisait zero octet. Meme famille que l'index tronque du cache la veille : l'octet manquant se denonce toujours ailleurs qu'ou il manque.

Et la garde posee hier ne mordait pas. « Retirer une ressource VIDE avant de la redemander » ne vaut qu'au passage SUIVANT : au premier, le fichier n'existe pas encore, il n'y a rien a retirer. Elle repare le second essai, elle ne protege pas le premier.

Les cinq roles qui telechargent une cle la MESURENT maintenant dans la meme execution — le until exige une taille non nulle (cinq tentatives), puis une assertion nomme la vraie cause. P68 garde le motif. Les roles qui deposent une cle embarquee (copy depuis files/) n'entrent pas dans le compte : rien de reseau ne s'interpose.

infra-mail-01 redeployee : la cle fait 1022 octets, comme les treize autres.

Les trois bruits connus

Revenus comme prevu, et ce ne sont pas des regressions : auditd livre sans regles au gabarit, la course sur le verrou dpkg, l'index d'apt.grafana.com absent du cache hors-ligne.

2026-09-10 (18) — vigie pour Icinga, et la quatrieme liste tombe

Suite du renommage precedent, decide par l'exploitant : vigie est le nom de la console de supervision. Le mot avait ete ecarte pour Grafana precisement parce qu'il connote la guette du danger — c'est le metier d'Icinga, pas celui d'un tableau de bord.

icinga.chezlepro.internal       ->  vigie.chezlepro.internal
icinga.technolibre.internal     ->  vigie.technolibre.internal
icinga.lab.chezlepro.internal   ->  vigie.lab.chezlepro.internal

Les trois locataires en meme temps : deux vocabulaires entre ecosystemes, c'est le defaut qu'on vient de corriger.

La quatrieme liste

serveur_oauth2_proxy_redirect_url etait posee A LA MAIN dans les group_vars de chaque instance, avec le FQDN recopie du plan. Quatrieme recopie du meme nom, apres les SAN (deja derives), les clients Keycloak (P66) et les noms publics des roles (P67).

Elle derive maintenant de serveur_oauth2_proxy_hostname, que instancier pose depuis expose:. Le repli reste VIDE et non fabrique : l'assertion du role doit refuser un deploiement sans exposition, pas inventer un nom que personne ne resout. La ligne a ete retiree des trois instances.

Ce qu'il faut savoir pour le prochain renommage

Changer une exposition ne suffit pas a refaire le certificat de l'edge. Deployer serveur_nginx pose le vhost mais laisse le SAN en arriere ; c'est client_pki qui reemet. L'ordre eprouve deux fois aujourd'hui :

serveur_powerdns  ->  serveur_keycloak  ->  le service  ->  serveur_nginx  ->  client_pki

Le certificat servi a ete VERIFIE apres coup, pas suppose : il porte observatoire et vigie, et plus aucun des deux anciens noms. Aucune garde ne compare encore le SAN SERVI aux expositions du plan — ce serait un controle a la demande, pas une preuve statique.

2026-09-10 (17) — La console d'observabilite s'appelle pareil des deux cotes

Deux ecosystemes, deux noms pour le meme service : grafana.chezlepro.internal chez le locataire, tableaux.genese.internal au site. Le second etait de notre fait, pose le jour meme en montant la pile d'observabilite du site.

grafana.chezlepro.internal   ->  observatoire.chezlepro.internal
tableaux.genese.internal     ->  observatoire.genese.internal

Pourquoi pas grafana

Un nom de produit dans une URL se grave ailleurs qu'a l'ecran : dans les SAN du certificat, dans les URI de redirection du SSO, dans les signets de l'exploitant. Remplacer Grafana obligerait alors a renommer le service — donc a refaire le certificat et le client Keycloak pour une raison qui n'a rien a voir avec eux.

Le plan du locataire nommait deja quatre services par leur FONCTION (auth, cloud, bureau, forge) et deux par leur PRODUIT (grafana, icinga). Celui-ci rejoint le registre majoritaire. icinga reste, pour l'instant : c'est un autre geste.

Ce que le renommage a revele

Les URI de redirection des clients Keycloak repetent a la main les FQDN que plan/applications.yml declare dans expose:. Rien ne les relie. Renommer l'exposition regenere hosts.yml, le certificat et la zone DNS — et laisse le client OIDC viser l'ancien nom. Keycloak ne proteste pas : c'est l'utilisateur qui le decouvre au retour du SSO.

P66 (preuve_clients_oidc_vises_sur_une_exposition) : chaque redirect_uris et chaque web_origins doit viser un FQDN qu'une application expose. Ecrite EN MEME TEMPS que le renommage, parce que c'est le seul moment ou l'on sait encore que les deux listes existent.

P66  4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose

Et une TROISIEME liste, que le deploiement a revelee

Le renommage deploye, nginx servait le nouveau nom, le certificat le portait, les deux zones le publiaient — et Grafana fabriquait toujours son URL de retour OIDC avec l'ancien :

redirect_uri=https%3A%2F%2Fgrafana.chezlepro.internal%2Flogin%2Fgeneric_oauth

Quatre roles — grafana, forgejo, nextcloud, keycloak — portaient en defaut une DEVINETTE du nom sous lequel ils sont servis (grafana.{{ domaine_interne }}). Tant que le plan suit la meme convention, la devinette tombe juste et rien ne revele qu'il y a deux sources. Keycloak avait deja son remede, dans le role, en relisant le plan a l'execution — un remede par role, donc trois roles sans remede.

instancier derive desormais <groupe>_hostname de l'exposition declaree, quand elle est UNIQUE (deux expositions ne designent aucun nom canonique : le role garde alors la main). Six services en heritent : collabora, forgejo, grafana, keycloak, nextcloud, oauth2_proxy.

P67 garde la derivation. Les quatre instances ont ete regenerees.

Une erreur de lecture, au passage

En cherchant pourquoi tableaux.genese.internal ne resolvait pas depuis le poste, on a conclu que le nom n'etait pas dans la zone. Il y etait — PowerDNS le publiait correctement, comme sauvegarde.genese.internal. Ce qui ne les connaissait pas, c'est le RESOLVEUR DU POSTE (192.168.10.10), qui porte trois entrees faites a la main et aucune delegation de genese.internal. L'index consulte n'etait pas la source.

2026-09-10 (16) — La frontiere journalise a nouveau, et une garde veille cette fois

Doctrine posee par l'exploitant : des lors que le site a son Loki, la journalisation de la frontiere et des hyperviseurs doit y etre dirigee.

46 681 lignes recues de la frontiere en 30 min
site : 68 services, tous OK

Ce qui etait casse, et depuis quand

La destination syslog d'OPNsense pointait 10.17.20.11:3100 — une adresse de TENANT, sur le port de Loki, en UDP, ce que Loki ne sait pas lire. La VM a ete rasee ; une cible morte a arrete TOUTE la journalisation d'OPNsense pendant dix jours. La destination a ete ETEINTE pour reparer, et jamais remplacee.

Deux fautes en une : la frontiere ne journalisait plus nulle part, et sa cible etait chez un locataire — ce que D-87 refuse dans l'autre sens.

L'intention, elle, etait juste : les bonnes facilites, info et au-dessus. On l'a REPRISE telle quelle et change uniquement la destination — ces choix avaient ete faits, ce n'etait pas a nous de les redecider.

Un traducteur, parce que Loki ne parle pas syslog

loki.source.syslog dans l'Alloy qui tourne DEJA sur le collecteur — un second agent n'aurait servi qu'a tenir deux configurations en phase. Port 1514 et non 514 : au-dessus de 1024, donc ecoute sans privilege. Un collecteur qui aurait besoin des droits du systeme pour entendre un equipement serait un mauvais echange.

Une regle de relabel sans garde n'ignore pas : elle EFFACE

Trois quarts d'heure sur un symptome absurde — la configuration deposee portait host = "bifrost-1", visible a l'oeil dans le fichier, et le flux arrivait sans etiquette.

rule { source_labels = ["__syslog_connection_ip_address"]
       target_label  = "host" }          # sans regex

Sans regex, la regle correspond toujours — meme quand sa source n'existe pas — et pose host = "". Loki jette une etiquette vide, et celle de l'ecouteur disparaissait avec elle. Les deux autres regles etaient gardees ; celle-la ne l'etait pas.

Et la verification finale a corrige une seconde erreur, la mienne : label/host/values rendait une liste en CACHE. La requete directe {host="bifrost-1"} rendait bien un flux. Un index qui ne montre pas une chose ne prouve pas qu'elle n'existe pas.

La garde, qui manquait depuis l'incident

journaux-frontiere demande a Loki ce qu'il a RECU, pas a la frontiere ce qu'elle croit avoir envoye — la destination est le seul juge, et un emetteur qui parle a un trou noir se porte tres bien.

La fenetre est DERIVEE du debit observe, pas choisie : ~1 500 lignes/minute mesurees. Une frontiere qui filtre ne se tait jamais dix minutes.

Trois etats, tous eprouves sur une copie :

frontiere muette          rc=2   « LA FRONTIERE NE JOURNALISE PLUS »
Loki injoignable          rc=3   « la sonde ne peut rien affirmer »
debit normal              rc=0   « 973 ligne(s) recue(s) en 10m »

Le 3 compte autant que le 2 : « je ne peux rien affirmer » n'est pas « c'est casse ». Une sonde qui confondrait les deux accuserait la frontiere d'un silence qui serait le sien.

2026-09-10 (15) — Une source vide n'est pas « tout le monde »

Les journaux de la fabric, et le defaut de classe que leur mise en place a revele.

journaux    10 hotes dans Loki   (7 VM du site + 3 hyperviseurs)
site        67 services, tous OK (51 avant)

Metriques TIREES, journaux POUSSES — et ce n'est pas un caprice

Un scrape part du collecteur et doit REVENIR : il exige un chemin symetrique, que la route par defaut gelee (D-57) interdit. Un push part de la source et n'attend qu'un accuse : la route SPECIFIQUE vers les zones du site suffit — celle qu'on venait justement de completer.

Trois trous dans les generateurs, du meme jour

Le devis de la frontiere ne connaissait fabric qu'en DESTINATION. Un flux entrant depuis la fabric tombait dans « rien d'autre n'entre » et n'emettait AUCUNE regle, sans rien dire. Meme forme que le trou des integrations universelles, trouve le matin meme.

La regle etait sur la mauvaise patte. D-61 fait raisonner le devis en ARRIVEE ; la regle etait posee sur l'interface de la DESTINATION. opt7 — la patte face a la fabric, libellee PROXMOX — manquait a la table des zones, qui ne recensait que le site. Elle y est, et l'interface se derive desormais du reseau ou vivent les hyperviseurs.

Et le plus grave : fabric etait un mot RECONNU mais NON RESOLU. Le generateur de pare-feu d'hote l'acceptait comme valide, ne trouvait aucune adresse, et une source vide produit une regle sans saddr :

tcp dport 3100 accept        # ouvert a tout le monde

Un mot reconnu mais non resolu est pire qu'un mot inconnu : celui-ci serait refuse a la validation, celui-la produit une porte grande ouverte qui a l'air d'un flux precis.

La classe entiere, refermee

Le defaut n'etait pas propre a fabric. Toute paire nommant un ensemble et ne resolvant rien ouvrait le port. Trouve en lisant les fichiers generes :

tcp dport 5665 accept        # serveur_icinga, sur le mon-01 d'un TENANT

L'API de supervision ouverte a tous, parce que pair: serveur_backup ne resout rien — cet ecosysteme depose son etat chez le site et n'a pas de depot a lui.

expositions et externe, EUX, veulent bien dire « tout le monde » : ce sont des services publies, et leur ouverture est une INTENTION. Ailleurs : pas de source, pas de regle — et le generateur le DIT, parce qu'un flux tu en silence est une porte qu'on croit fermee.

Trois regles se sont refermees. Chacune verifiee AVANT d'appliquer :

regle verdict
site-forge-01:3000 rien n'ecoute — la forge sert 443
mon-01:5665 (tenant) deux autres regles couvrent les pousseurs
site-mon-01:3000 Grafana ecoute — a corrige avant de fermer

Grafana : edge ET admin

Chez un tenant, le nginx d'edge termine le TLS : edge resout, c'est le seul chemin. Le SITE n'a PAS d'edge — chaque service s'y sert lui-meme. edge n'y resolvait donc rien, et la console etait ouverte a l'Internet PAR ACCIDENT, sous un commentaire qui parlait d'un edge inexistant.

Ajouter admin rend explicite ce qui etait accidentel. Et la mesure a montre autre chose : Grafana etait deja injoignable depuis le poste — la frontiere ne laisse pas passer le 3000. La regle ouverte ne servait a rien, sauf a rester ouverte si la frontiere s'ouvrait un jour.

Le site a donc une console deployee et sans chemin d'acces. Ce n'est pas corrige ici, mais c'est dit.

2026-09-10 (14) — Le site surveille enfin sa fabric

La supervision du site voyait ses sept VM et rien d'autre. Mesure de depart, depuis site-mon-01 : les trois hyperviseurs, les neuf pattes de la frontiere et sa propre passerelle par defaut rendaient tous 100 % de perte au ping.

16 hotes UP / 16      dont 9 pattes de frontiere en controle ACTIF
11 cibles Prometheus  dont 3 hyperviseurs, job `fabric` separe

Le partage : ce qui peut porter un agent, et ce qui ne le peut pas

Les hyperviseurs portent node_exporter — D-48 l'autorise. Chacun expose 6 600 a 7 900 lignes de metriques, dont 1 005 unites systemd avec leur etat : soit exactement ce que la sonde sante mesurait, plus la charge, le disque et l'horloge.

Ils n'entrent PAS dans le socle, et c'est le point delicat. Un hyperviseur Proxmox n'est pas une VM de la flotte : lui appliquer serveur_durci reecrirait son pare-feu, son SSH et ses sysctl — sur la machine qui tient tout le reste. Ils vivent dans leur propre groupe, hors de GROUPE_SOCLE et de hotes_actifs.

La frontiere n'accueille aucun agent : controle ACTIF, une entree par PATTE. La frontiere est un seul boitier, mais chaque zone depend de SON interface — une interface eteinte coupe une zone pendant que les autres vont bien, et on en a deja vu (les routes creees disabled le 2026-09-02). Un ping vers une seule adresse dirait « la frontiere est debout » et manquerait ce cas.

On TIRE, on ne pousse pas — et c'est la route gelee qui le decide

J'avais propose du passif, et il avait ete valide. La mesure a dit non :

asgard -> 10.0.36.11   via 192.168.11.254   (le routeur du site)
route par defaut       via 192.168.11.254   GELEE (D-57)

Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site : le porteur de sante y expirait en 20 s. Le remede evident — router 10.0.0.0/8 par la frontiere — touche la route par defaut d'une machine EN SERVICE, ce que D-57 interdit. On tire donc, dans le sens que la frontiere route deja.

Le vrai defaut : une liste qui n'a pas suivi

Le mecanisme de routage EXISTAIT sur vmbr0, avec un commentaire du 2026-08-26 tenant exactement le raisonnement qu'on venait de refaire. Sa liste s'arretait a 10.0.34.0/24 :

le site declare      6 zones (31 -> 36)
les hyperviseurs     4 routes (31 -> 34)

Les zones sauvegarde (35) et supervision (36) sont nees, les routes n'ont pas suivi. Le symptome ne ressemblait pas a une route manquante : il ressemblait a un pare-feu, puis a un probleme de reseau chez l'exploitant.

make routes-fabric-etat compare desormais TROIS choses : les zones declarees, les routes declarees dans /etc/network/interfaces, et les routes vivantes dans le noyau. Le cas le plus traitre est le troisieme — vivante mais non declaree : tout fonctionne, la supervision est verte, et la panne attend la prochaine maintenance. Une garde qui ne comparerait que le vivant ne le verrait jamais. Eprouvee dans les deux sens.

Deux defauts trouves en construisant

bifrost-2 est un nom reserve, pas un boitier. L'underlay le disait — « pour que les noms soient reserves » — mais en PROSE, illisible par le moteur. Le surveiller aurait donne deux CRITICAL permanents pour un equipement absent. Il porte desormais etat: reserve ; le jour ou le second boitier arrive, retirer la cle le fait entrer dans la supervision.

Un service passif n'existe que pour un hote qui peut POUSSER. Les hyperviseurs sont dans client_metrique — c'est par ce groupe qu'on leur deploie node_exporter — et la derivation en tirait asgard!metriques. Icinga refusait la configuration ENTIERE. Meme en definissant l'hote, le service serait reste UNKNOWN pour toujours. L'appartenance a un groupe sert deux choses qui ne coincident pas toujours : a qui l'on deploie, et de qui l'on attend un rapport. La seconde se lit sur client_sante, et nulle part ailleurs.

Les commutateurs restent dehors

Choix de l'exploitant, coherent avec D-48 : ils sont hors flotte, et rien ne les rend interrogeables sans leur ouvrir un acces qu'on ne veut pas leur ouvrir.

2026-09-10 (13) — D-88 : le noeud du gabarit, point unique de la REPRODUCTION

Question de l'exploitant : « le modele vit sur vishnu, les clones sont sur asgard — qu' arriverait-il si vishnu tombait ? ». Mesuree, la reponse se coupe en deux.

Les donnees survivent.

pool CephNVMe   size=3   min_size=2
OSD sur asgard, gandalf, vishnu
base-9006-disk-0, base-9006-disk-1   repliquees

L'image reste lisible avec un noeud en moins, et les quatorze VM d'un ecosysteme tournent ailleurs sur leurs propres disques Ceph : elles ne s'apercoivent de rien.

La reproduction, non. La configuration du gabarit porte le nom du noeud dans son chemin meme — /etc/pve/nodes/vishnu/qemu-server/9006.conf — et le clonage appelle nodes/vishnu/qemu/9006/clone. Noeud eteint, API muette, aucune VM nouvelle ne peut naitre. Or la reproduction est ce que ce depot existe pour garantir.

La decision est d'ASSUMER la dependance et de la rendre COURTE

Pas de la supprimer. Depuis que le disque du gabarit vit sur un stockage partage (migration du matin), la remise en route est un deplacement de fichier de configuration — quelques minutes, aucun mouvement de donnees — suivi de la declaration gabarit.noeud. Sur un stockage local, il aurait fallu recopier 16 Go ou refabriquer le gabarit.

Un benefice de la migration sur Ceph qu'on n'avait pas cherche : elle a raccourci une panne qu'on n'avait pas encore nommee.

Trois endroits, parce qu'un seul ne suffit pas

  • D-88 dans les decisions : la dependance est nommee et son perimetre borne ;
  • runbooks-exploitation.md §7 : la manoeuvre, dans l'ordre, avec ce qui casse si on l'inverse — deplacer sans declarer laisse gabarit_etat en ecart, declarer sans deplacer fait echouer le clonage ;
  • le plan du site, dans le bloc gabarit lui-meme : c'est la que l'exploitant lit noeud: vishnu, et c'est donc la que l'avertissement doit vivre.

Ce qui n'est PAS fait, et qui est dit

Rien ne MESURE cette dependance. gabarit_etat compare le declare au reel ; il ne demande pas si le noeud du gabarit heberge autre chose que le gabarit. Deux remedes de fond restent ouverts : deplacer le gabarit la ou vivent deja les VM (ce qui ne supprime pas le point unique mais cesse d'en avoir DEUX), ou une garde qui refuse quand la reproduction depend d'un noeud qui ne porte rien d'autre.

Une dependance qu'on documente sans la mesurer reste une dependance qu'on decouvrira au mauvais moment.

2026-09-10 (12) — Un fichier vide existe, et une sonde pour les correctifs

La garde « fichier entier » — et pourquoi il a fallu DEUX corrections

Un telechargement interrompu laisse un fichier de zero octet, qui existe. Toutes les gardes de ce depot demandaient « ce fichier est-il la ? » :

- name: Cette ressource est-elle deja recuperee ?
  ansible.builtin.stat: ...
- name: Telecharger la cle
  when: not ..._present.stat.exists

infra-mail-01 a garde une cle smallstep de 0 octet apres l'epreuve hors ligne. Treize machines portaient 1022 octets, elle portait le vide — et apt refusait le depot.

La premiere correction n'a pas suffi. Ajouter le controle de taille a bien fait s'executer la tache (ok: [infra-mail-01]) — et le fichier faisait toujours 0 octet au passage suivant. get_url sur une destination existante emet une requete CONDITIONNELLE : l'amont repond « non modifie », le module rend ok, la ruine reste. Le play etait vert et ne reparait rien.

Il faut donc effacer avant de redemander. state: absent ne mord que sur un fichier vide : une cle valide n'est jamais retiree.

Controle negatif : fichier vide volontairement, role rejoue → 0 → 1022 octets, 0 erreur apt. Cinq roles portent le patron complet.

Cette etape intermediaire est la lecon de la journee en miniature : un failed=0 ne dit pas que quelque chose a ete fait.

La sonde correctifs — 23e sonde

Set-OPS desarme unattended-upgrades (masque sur les vingt et une machines) et applique les correctifs par upgrade: full au passage du socle. Choix defendable — un minuteur de fond qui se dispute le verrou dpkg avec un deploiement est un tirage au sort, et il a fait decrocher infra-mail-01 d'une reconstruction entiere le matin meme.

Mais rien ne disait quand le geste etait du. Vingt-deux sondes, aucune sur le retard de securite : une flotte pouvait deriver des mois en restant verte.

Elle mesure les paquets en attente venant d'un depot de SECURITE, et depuis quand — le retard seul ne dit rien, c'est sa duree qui transforme un correctif publie en exposition acceptee. Trois etats eprouves sur une copie : rc=0 a jour, rc=1 des le premier correctif, rc=2 au-dela du seuil.

Deux choix dits franchement. Elle ne lance PAS apt-get update : une sonde qui rafraichit l'index toutes les quinze minutes deviendrait la cause de la panne qu'elle surveille. Et le seuil de 72 h est un CHOIX D'EXPLOITATION, pas une derivation — les autres seuils se deduisent d'un mecanisme (le certificat vit 24 h, donc on alerte a 6 h) ; ici le mecanisme est un geste humain, il n'y a rien a en deduire.

P64 refusait une declaration correcte

Declaree dans common_packages, la sonde etait invisible d'Icinga : la derivation croise les GROUPES, et ce role n'en est pas un. Pire, client_sante l'aurait retiree comme orpheline au passage suivant — la garde ecrite le matin meme.

Mais la declarer dans serveur_debian faisait echouer P64, qui exigeait declaration et depot dans le MEME role. Or serveur_debian et serveur_durci sont des roles de declaration pure : ils portent flux.yml, authentification.yml, supervision.yml, et pas une tache. Le travail est fait par les roles que leur playbook applique.

Une garde qui force a contourner ce qu'elle protege est un defaut. P64 suit desormais le playbook du groupe : ce qu'il applique compte comme depose. Elle ne s'affaiblit pas — elle apprend ou le depot a le droit de vivre. Controle negatif refait : depot desactive → refus, restaure → 65 OK.

Etat mesure

sonde `correctifs`   21 / 21 machines vertes
prouver              65 preuves, 23 sondes, 0 echec
ansible-lint         0 defaut

2026-09-10 (11) — Le trou reste ouvert derriere la porte qu'on croyait fermee

Troisieme reconstruction complete de Chezlepro : 32 minutes, un seul echec — le mien, introduit par le passage au cache et revele par la naissance.

clonage                    3 min 52
placement                  {'asgard': 14}
`fatal:` dans le journal   0, pas meme un ignore
servi par le cache         +963 Mo
tire de l'Internet         +183 Mo   -> 81 % servis localement

get_url IGNORE la configuration d'apt

Le mandataire pose dans /etc/apt/apt.conf.d/ ne vaut que pour apt. Les six cles de signature sortaient donc TOUJOURS en direct, malgre tout le travail sur le remap.

Et ce n'etait pas theorique. Depuis collab-01 :

en direct     grafana 200      collabora TIMEOUT
via le cache                   collabora 200

La route directe vers Collabora ne passe pas depuis cette zone. Trois cles sur quatre avaient reussi PAR CHANCE — parce que leurs fournisseurs, eux, etaient joignables. Le meme geste corrige les deux : plus rien ne sort, et la machine qui n'avait pas de route en trouve une.

Pourquoi seule une NAISSANCE pouvait le montrer

Mes deploiements de convergence rendaient failed=0 — parce que les cles etaient deja sur disque et que la tache etait sautee. Le defaut existait depuis le premier commit du remap, invisible a tout deploiement sur une flotte existante.

C'est l'argument de la reconstruction depuis zero, applique a moi-meme : un correctif qu'on ne verifie que sur une machine deja construite n'est pas verifie.

La derive s'est effacee toute seule

apt-cacher-ng tournait encore sur forge-01, que plus aucun plan ne declarait. Je proposais de l'arreter a la main. La reconstruction l'a fait :

apt-cacher-ng : absent     paquet : 0     sondes : les 4 legitimes

Ce qui n'est pas au plan n'existe pas apres une naissance. La propriete centrale du depot, verifiee sur un cas qu'on n'avait pas provoque pour elle.

Etat mesure

14 / 14 machines   0 source en HTTPS direct
14 / 14 machines   0 erreur `apt-get update`
prouver            65 OK, 0 echec
ansible-lint       0 defaut sur 79 fichiers

Les trois reconstructions de la journee

1re (matin)          3 echecs   1 h 08
2e (confirmation)    0 echec      34 min
3e (avec le cache)   1 echec      32 min   — le mien, corrige

2026-09-10 (10) — Plus rien ne sort chercher ses paquets, et un tenant de moins a nourrir

Deux mouvements d'une seule doctrine : le site fournit tout ce dont un tenant a besoin pour venir au monde.

1. Les depots tiers passent enfin par le cache

Set-OPS tire ses paquets applicatifs de fournisseurs qui ne publient qu'en HTTPS — Grafana, Icinga, Smallstep, Collabora. apt-cacher-ng ne relaie pas un tunnel, et le socle pose donc deliberement Acquire::https::Proxy "DIRECT" : chaque machine sortait elle-meme sur Internet. Mesure du matin : 58 references de depot, quatorze machines sortant chacune de son cote.

Le remede tient en deux moities, et une seule ne sert a rien :

  • le cache DECLARE un Remap-* par fournisseur — le client demande en http://, le cache va chercher en https:// ;
  • chaque role DEMANDE en {{ ..._depot_schema }}://, qui vaut http des qu'un cache d'amorcage est declare, https sinon. Degrader, jamais deviner.

Le TLS n'est rompu nulle part : il est termine au cache, qui est notre machine. Et l'integrite ne vient pas du transport mais des signatures du depot — le raisonnement deja tenu pour deb.debian.org depuis toujours.

Trois choses que la mesure a apprises :

Le remap appartient au cache qui SORT. Pose aussi sur le cache du tenant — chaine vers celui du site — il tentait le HTTPS a travers son amont, ce qui exige un CONNECT que l'amont ne fait pas :

remap sur le cache du tenant (chaine)  ->  503
remap sur le seul cache du site        ->  200

Un cache qui relaie n'a rien a remapper : il passe la demande a qui sort. Meme forme que la filiation elle-meme.

apt_repository AJOUTE, il ne remplace pas. Passer une source de https a http y ecrivait une SECONDE ligne ; apt interrogeait les deux, et l'ancienne sortait toujours. Le symptome le disait sur les quatorze machines : « La cible Packages est specifiee plusieurs fois dans grafana.list:1 et :2 ».

La liste des fournisseurs ne se devine pas. J'en avais recense trois ; l'audit des sources en a revele un quatrieme — Collabora, sur une seule machine. P65 refuse desormais tout role visant un depot RELAYE en https:// ecrit en dur. Sa limite est dite : elle empeche une regression sur ce qui est connu, elle ne decouvre pas l'inconnu.

avant :  4 fournisseurs en HTTPS direct, 14 machines sortant seules
apres :  0 source en HTTPS direct, 0 erreur apt sur 14 machines

2. Le cache d'artefacts quitte le plan du tenant

La ligne portait son propre retrait depuis toujours :

« c'est un service MUTUALISABLE — un ecosysteme au premier age peut aussi bien pointer sur celui de son hote »

Retiree. client_artefacts le voit tout seul : sans hote portant serveur_artefacts, il n'ecrit rien et laisse en place le plancher d'amorcage qui vise site-cache-01.

Ce qu'on perd, dit franchement : les quatorze machines interrogent desormais le cache du SITE a travers la frontiere, au lieu d'un cache local a la zone. Plus de trafic inter-zone, plus de charge sur site-cache-01 — contre un service de moins a poser, superviser et reproduire dans chaque ecosysteme.

3. Un role qu'on retire doit pouvoir DEFAIRE ce qu'il a fait

Retirer le role a revele que rien ne nettoie derriere lui. Deux fois :

  • le fichier apt 00-setops-artefacts continuait de viser un cache eteint, en ecrasant le plancher qui, lui, fonctionnait — exactement le defaut deja paye (« quinze machines ont perdu apt d'un coup »). Le SOCLE le retire desormais, parce qu'il est le seul a tourner dans les deux cas ;

  • les sondes cache-apt et cache-apt-volume restaient sur la forge, et le porteur poussait pour des services qu'Icinga ne definit plus :

    ECHEC du rapport Icinga pour « cache-apt » : {"error":404,"status":"No objects found."}
    

    client_sante derive maintenant les sondes ATTENDUES sur chaque hote — ses groupes croises avec les meta/supervision.yml — et retire celles dont plus aucun groupe ne repond. Meme derivation que celle de serveur_icinga, du cote du porteur.

Etat mesure

sources apt en HTTPS direct    0 / 14 machines
erreurs `apt-get update`       0 / 14 machines
mandataire                     site-cache-01 seul, partout
Icinga                         87 OK | 9 UNKNOWN sur 96 services
                               (les 9 = `sauvegarde`, minuteur nocturne)
prouver                        65 OK, 0 echec
ansible-lint                   0 defaut

Reste, et c'est dit : apt-cacher-ng tourne toujours sur forge-01, que plus aucun plan ne declare. La prochaine reconstruction ne l'installera pas ; sur la machine actuelle, il subsiste. Un service qu'aucun plan ne reclame est une derive, meme benigne.

2026-09-10 (9) — La reconstruction de confirmation : 0 echec, 34 minutes

Seconde reconstruction complete de Chezlepro dans la journee, cette fois pour EPROUVER les quatre correctifs livres entre les deux. Rien de nouveau n'a ete construit : c'est une mesure.

rc=0     0 echec     0 injoignable     14 / 14 machines
34 min   contre 68 le matin meme

Les quatre points, et leur verdict

ce qui etait a eprouver verdict
gabarit sur CephNVMe phase de clonage 4 min 30 contre ~30 min
placement declare (noeud: asgard) {'asgard': 14} — les quatorze au bon endroit
hosts_statiques conditionne a cloud-init changed au 1er passage, skipping au 2e
monitoring-plugins-basic au role ping4 14/14 OK sur une mon-01 nee neuve

Le troisieme est le plus instructif : les deux branches sont exercees dans la meme execution. infra-pki-01 recoit le gabarit maitre de cloud-init au premier passage (cloud-init est encore la), puis la tache est SAUTEE au second (le durcissement l'a retire). Aucune preuve statique ne pouvait montrer cela — il fallait une naissance.

Le facteur 6,6, et pourquoi ce n'est pas 45

Un clone isole sur Ceph prend 8 s contre 352 a 480 s sur TrueNAS : facteur 45. En conditions reelles il tombe a 6,6 — quatre clones se disputent le meme pool, et le redimensionnement du disque s'ajoute a la copie. On retient la mesure en conditions reelles : celle qui compte est celle qu'on paie.

Ce que les echecs du matin sont devenus

  • /etc/cloud — corrige, prouve dans ses deux branches.
  • Verrou dpkg — ne s'est pas reproduit. C'etait une course, pas un defaut de structure ; rien ne dit qu'elle ne reviendra pas, et le dire vaut mieux que la declarer reglee.
  • Cache apt — n'etait pas un defaut. Les deux seuls fatal: de cette execution sont suivis de ...ignoring : ce sont les sondes de client_artefacts, qui cherchent le cache du tenant, ne le trouvent pas, et laissent la machine servie par le plancher du SITE. C'est la filiation en train de fonctionner. Le matin, j'avais compte ces deux lignes comme des echecs sans lire la suivante.
  • auditd sans regles dans le gabarit — toujours la, mais invisible : aucune machine n'ayant decroche avant le socle, le CRITICAL transitoire a ete repare partout avant d'etre mesure. Un defaut qui ne se voit que lorsqu'autre chose casse reste un defaut.

Etat mesure

Icinga     89 OK | 9 UNKNOWN sur 98 — aucun WARNING, aucun CRITICAL
les 9      tous des `sauvegarde` : le minuteur nocturne n'a pas encore visite
           une flotte nee il y a vingt minutes

La condition posee avant de retirer les disques unused0/unused1 du gabarit sur TrueNAS est levee : une flotte complete est nee du nouveau gabarit, sans un echec.

2026-09-10 (8) — Une garde qui survit a ce qu'elle gardait

Le defaut le plus couteux de la reconstruction, corrige. Il n'arretait rien de visible : il faisait taire la supervision de deux machines.

hosts_statiques pose /etc/cloud/templates/hosts.debian.tmpl — le gabarit maitre dont cloud-init regenere /etc/hosts a chaque demarrage. Or D-85 fait retirer cloud-init par serveur_durci. Le repertoire part avec lui :

Destination directory /etc/cloud/templates does not exist

L'echec n'a aucun sens : ce gabarit ne sert qu'a survivre a une reecriture qui n'a plus lieu. Plus de cloud-init, plus de reecriture — le plancher tient tout seul, ce qui est le resultat recherche par D-85.

Ce que ca a coute, et pourquoi c'etait invisible

_amorcer-socle monte infra-pki-01 et infra-dns-01 EN ENTIER d'abord, durcissement compris. Cloud-init y est donc deja parti quand la flotte rejoue hosts_statiques. La tache echouait, l'hote sortait du play — et tout ce qui suivait n'etait jamais pose, dont icinga-ca.crt. Leur porteur de sante s'installait ensuite normalement, tournait, et chaque rapport echouait sur curl: (77) error setting certificate file.

Deux machines ont supervise dans le vide sans que rien ne le dise. Un echec bruyant au bon endroit avait produit une panne muette ailleurs.

Et le correctif evident aurait deplace l'echec d'un cran

Sauter la tache ne suffisait pas : la garde qui SUIT compare le nombre d'entrees du plancher a celui du gabarit maitre. Sans gabarit, elle compare 21 a 0 et echoue — elle accuserait une divergence la ou il ne reste qu'un seul fichier.

Une garde qui survit a ce qu'elle gardait ne mesure plus rien : elle invente. Les trois taches — pose, releve, assertion — suivent desormais la meme condition : le repertoire des gabarits maitres existe-t-il encore ?

Verifie sur les deux machines memes qui echouaient : failed=0, cloud-init absent, plancher a 21 entrees.

2026-09-10 (7) — D-86 mis a l'epreuve : Chezlepro rasee et refaite depuis zero

Quatorze machines detruites, quatorze refaites. 1 h 08, 0 injoignable. La reconstruction a confirme D-86 — et revele quatre defauts qu'aucune preuve statique n'aurait pu voir, parce qu'ils n'existent QUE pendant une naissance.

D-86 tient, et voici la mesure

L'ordre declare est l'ordre reel : socle → step_ca → client_pki → observabilite (postgresql, prometheus, loki, grafana, icinga) → agents (metrique, journal, sante) → services → apps.

Mais l'ordre ne prouve pas que quelqu'un REGARDAIT. Ce qui le prouve :

30 resultats de controle recus a 12:20:41
fin de la reconstruction        12:35:29

Trente services rapportaient leur etat pendant que Keycloak, Forgejo et Nextcloud montaient encore. Prometheus scrutait 14 cibles sur 15. Ce qui se deploie ensuite l'est bien sous l'oeil de la supervision.

La limite, dite franchement : le plancher n'a pas ete eprouve

D-86 affirme que le DNS peut rester en couche 6 grace au plancher /etc/hosts. Ce n'est pas ce qui s'est passe : _amorcer-socle monte infra-dns-01 EN ENTIER d'abord — serveur_powerdns puis serveur_resolveur, bien avant l'observabilite. Le DNS etait debout ; le plancher n'a rien eu a porter. La partie la plus audacieuse de la decision reste non testee, et l'eprouver demanderait de retirer l'amorcage DNS — une autre decision.

Le placement des VM n'etait declare NULLE PART

Les quatorze machines vivaient sur asgard depuis toujours. SETOPS_NOEUD valait le VIDE : ce placement n'existait que dans l'etat d'execution de Proxmox. Sans cible, le clone reste sur le noeud du gabarit — vishnu, qui a 14 Go libres et heberge eregion et site-forge-01. Quarante Go de VM neuves y auraient atterri.

Le defaut avait survecu a la reconstruction du 2026-09-02 : les VM existaient deja, et le clone les sautait. Un etat qui n'est declare nulle part ne se reproduit pas — il faut un from-zero VRAI pour le voir. noeud: asgard est desormais au plan.

Le clonage etait 45 fois trop lent, et la cause n'etait pas le reseau

Mesure sur le vrai gabarit :

disque sur `TrueNAS` (lvm sur iSCSI)   352 a 480 s par clone
disque sur `CephNVMe` (rbd)              8 a   9 s par clone

Source ET destination etaient sur le meme stockage : rien ne traversait d'hyperviseur a hyperviseur. La cause est que le LVM epais interdit a Proxmox tout clone autre que complet — 16 Go copies par machine, a travers iSCSI.

Le clone LIE descend a 1 s, mais enchaine chaque VM a l'image de base pour toujours. Pour 7 s gagnees sur 480, l'echange ne vaut pas la dependance : on garde les clones complets.

Gabarit deplace par qm move_disk sans --delete — les anciens disques restent attaches en unused, le retour arriere tient en une commande.

Un champ stockage: au gabarit, branche en quatre points

Declarer sans consommer, c'est decorer. Le champ vit donc partout ou il compte :

ou quoi
plan/10-intrants.yml la declaration, avec la mesure qui la justifie
underlay.py --gabarit l'expose
Makefile STOCKAGE_PROXMOX en derive — le clone ne l'HERITE plus en silence
gabarit_etat.py compare le declare au reel, eprouve dans les deux sens

Et le remede devient contextuel : le conseil « NE PAS CONVERTIR… REFABRIQUER le gabarit » s'imprimait pour TOUT ecart, y compris un deplacement de disque qui se repare en une commande. Un remede plus lourd que le mal se fait ignorer, puis le vrai avec lui.

Icinga ne pouvait pas faire ses propres controles

ping4  UNKNOWN  execvpe(/usr/lib/nagios/plugins/check_ping) failed: No such file

icinga2 n'apporte AUCUN greffon. Toute la supervision de Set-OPS etant PASSIVE, le manque etait masque : les services qui rapportent d'eux-memes etaient verts, et personne ne regardait les autres. Quatorze ping4 muets d'une seule cause.

monitoring-plugins-basic entre au role — -basic et non le metapaquet : -standard ajoute des greffons LDAP/SMTP/PostgreSQL que Set-OPS n'appelle jamais.

Le site l'avait deja — pose A LA MAIN plus tot dans la journee, jamais declare. Meme defaut que le placement, troisieme occurrence du jour : un etat qui ne vit que sur la machine ne survit pas a une reconstruction.

Trois defauts trouves, laisses ouverts

  1. infra-pki-01 et infra-dns-01 attendent forge-01:3142 pendant l'amorçage — le cache apt naitra bien plus tard. Un ordre qui se mord la queue.
  2. Regression D-85 : Destination directory /etc/cloud/templates does not exist. Un role ecrit encore la ou cloud-init a ete supprime. C'est ce defaut qui a fait decrocher deux hotes de la tache deposant icinga-ca.crt — leur porteur de sante tournait, et chaque rapport echouait sur curl: (77), en silence.
  3. auditd dans le gabarit, sans regles : audit-rules.service echoue au premier demarrage de CHAQUE VM neuve, jusqu'au passage du socle. Le CRITICAL ressemble alors a un probleme de la machine alors qu'il vient du modele.

Etat mesure, apres correction

Icinga Chezlepro   93 OK | 1 WARNING | 1 CRITICAL | 3 UNKNOWN  sur 98
Icinga du site     51 / 51 OK
Prometheus         15 / 15 cibles
prouver            64 OK, 0 echec
ansible-lint       0 defaut, profil production

Les quatre non-OK restants sont tous des sauvegarde : le minuteur nocturne n'a pas encore visite une flotte nee il y a deux heures.

2026-09-10 (6) — La frontiere passe, et trois sondes disaient faux

Application de ce que l'entree precedente avait prepare, puis correction de ce que l'application a revele.

La frontiere

make frontiere-appliquer : 49 regles creees, 5 retirees. Effet mesure immediatement :

cibles Prometheus   2/8  ->  8/8
hotes dans Loki     1/7  ->  7/7

Trois defauts, tous de la meme famille : un reglage qui ne suit pas son interrupteur

1. La sonde de Loki interrogeait en HTTPS un Loki servant en clair. serveur_loki_sonde_url disait https en dur alors que serveur_loki_tls_actif vaut false par defaut. Chez le tenant, qui l'active dans ses group_vars, l'accord etait fortuit ; au site, qui ne l'active pas, la sonde rapportait « Loki ne repond pas » sur un service en parfaite sante. Une fausse alarme est pire qu'une sonde absente : elle apprend a ne plus lire la sonde. Le schema se derive desormais du commutateur.

C'est exactement le meme defaut que GF_AUTH_DISABLE_LOGIN_FORM chez Grafana, corrige la veille au soir. Deux occurrences en douze heures.

2. La sonde des journaux criait une perte deja reparee. Elle comparait au passage precedent sans dire QUAND ce passage avait eu lieu. Elle a annonce PERTE EN COURS sur deux machines alors que le delta reel etait nul — les lignes avaient ete perdues avant la reparation. Mesure de controle, deux lectures a 90 s :

jetees   11340 -> 11340   (delta 0)
envoyees    68 ->    86   (delta +18)

Un ecart sans sa fenetre n'est pas une mesure, c'est un nombre. L'etat retient maintenant l'horodatage, et le message porte la duree.

3. Et surtout : elle alertait sur la cicatrice, pas sur la plaie. Le compteur d'Alloy est cumulatif — il ne redescend qu'au redemarrage. Les six machines qui avaient perdu des lignes pendant que la frontiere etait fermee les portaient donc pour toujours, condamnees a l'orange permanent. Ce qui alerte est la CROISSANCE. Le total reste dans le texte et dans les metriques, la ou il sert au diagnostic sans crier.

En echange, un cas qui ne levait rien le fait desormais : envoyees == 0 et jetees == 0 n'est pas « tout va bien », c'est « Alloy ne fait rien du tout ».

Controles negatifs, sur des COPIES des sondes :

port d'Alloy inexistant        -> CRITICAL  rc=2
compteur de pertes en hausse   -> CRITICAL  rc=2
port de node_exporter ferme    -> CRITICAL  rc=2

Neuf services qu'Icinga attendait et que personne ne lui envoyait

Le temoin declarait 51 services et n'en recevait que 42. Les neuf muets avaient une sortie vide : jamais un seul resultat. setops-sondes.conf derive les services de tous les meta/supervision.yml — Icinga savait donc les attendre bien avant que les roles n'aient ete rejoues pour deposer les scripts.

Une declaration suffit a creer l'attente ; il faut un deploiement pour creer la reponse. Huit roles rejoues au site : serveur_artefacts, serveur_resolveur, serveur_powerdns, serveur_forgejo, serveur_postgresql, serveur_postfix, serveur_step_ca, serveur_ops.

Grafana

vault_grafana_admin depose dans la voute du site. Verifie sur la machine : formulaire local offert, zero ligne GENERIC_OAUTH. Sonde tableaux verte.

Note d'exploitation : ansible-vault refuse de tourner quand stdout n'est pas bloquant — il faut le faire passer par un tube. Un premier essai a tronque la voute de 11,8 K a 873 o parce que le chiffrement s'est execute apres une lecture qui avait echoue. Restauree depuis la copie prise avant, identique a l'octet pres. Le remede est dans l'ordre des gardes : lire, VERIFIER le nombre de cles, ecrire vers un fichier neuf, verifier ce fichier, et seulement alors remplacer.

Etat mesure

cibles Prometheus 8/8
hotes dans Loki 7/7
sonde journaux 7/7 vert
sonde metriques 7/7 vert
services Icinga 51, dont 42 OK avant redeploiement des huit roles
make prouver 64 OK, 0 echec
ansible-lint 0 defaut, profil production

Suite : le runner ne pouvait plus cloner, et trois sondes de plus disaient faux

Redeployer serveur_ops au site a echoue sur le clonage du genome. Mesure depuis les sept machines :

10.0.33.11:443   injoignable de PARTOUT sauf depuis la forge elle-meme

Y compris depuis site-cache-01, qui est dans le MEME sous-reseau — ce qui excluait la frontiere et designait le pare-feu de l'hote. Le diff de flux-genere/ l'a confirme : la ligne fautive etait la AVANT nos changements, qui n'ajoutaient que les regles des sondes. Defaut preexistant, revele par le redeploiement, pas cause par lui.

La cause : generer_nftables(site=True) lisait le plan du TENANT. ports_plan = _ports_du_plan(), sans condition. derive se resolvait donc contre OPS-Chezlepro/plan/applications.yml, ou Forgejo vaut 3000 — le port qu'il ecoute DERRIERE un edge. Le plan du site dit 443, et le disait explicitement :

# 443, ET NON 3000. [...] garder 3000 aurait grave `:3000` dans ROOT_URL

La regle d'hote de la forge ouvrait donc un port que personne n'ecoute et laissait 443 ferme a toute la flotte. Un seul registre interroge pour deux verites opposees.

Trois sondes de plus corrigees, toutes de la meme famille — un parametre qui ne suit pas l'interrupteur dont il depend :

sonde ce qu'elle disait la cause
runner 2 depots divergent comparait les 6 depots a UNE branche, quand le plan en declare une PAR depot (master pour deux d'entre eux)
forge reponse illisible http:// en dur sur un port 443 servant du TLS
forge ne repond pas curl -s sans -k : cert step-ca emis pour le FQDN, interroge sur 127.0.0.1

La sonde runner est l'exemple le plus net : elle contredisait la declaration qu'elle etait censee verifier. Elle n'accusait pas la machine, elle s'accusait elle-meme.

Avec Grafana, Loki et Forgejo, cela fait cinq occurrences du meme defaut en une journee. Le motif merite d'etre nomme : un reglage corrige a moitie ne dit rien tant que le deploiement ne change pas de camp. Le tenant restait juste dans les cinq cas — c'est le site, qui deploie ces roles autrement, qui les a tous reveles d'un coup.

Etat final mesure

Icinga     51 / 51 services OK
Prometheus  8 / 8  cibles up
Loki        7 / 7  hotes presents
prouver    64 OK, 0 echec
lint        0 defaut sur 82 fichiers, profil production

Controles negatifs, sur des COPIES : port d'Alloy, compteur de pertes, node_exporter, forge — les quatre rendent CRITICAL.

2026-09-10 (5) — Le site prend sa propre pile d'observabilite

Suite directe de D-87. La decision disait : l'hebergeur n'a pas le droit de voir les journaux de ses locataires. Elle avait une face cachee — a force de refuser de voir ceux des autres, le site s'etait prive des siens. Ses sept machines n'expediaient nulle part.

Le remede n'est pas d'assouplir la frontiere, c'est de donner au site sa pile : prometheus, loki et grafana sur site-mon-01, pour les machines du site, sans aucun lien avec ceux d'un tenant.

Ce qui bloquait etait deja documente, dans le plan lui-meme

10-intrants.yml exemptait toutes les machines du site de client_metrique et client_journal. La prose de l'exemption portait sa propre condition de levee :

« Le jour ou le site prend un Loki et un Prometheus, on retire ces deux lignes. »

Ce jour-la etant venu, l'exemption s'est refermee toute seule. Elle aura tenu cinq jours.

Grafana au site n'a pas de SSO, et le role l'ignorait

Le site n'a ni Keycloak ni domaines.yml — l'identite ne monte pas dans le site, c'est D-87. serveur_grafana reclamait pourtant l'IdP avant de regarder s'il en voulait un : le deploiement echouait sur un registre absent, pour deriver une URL qu'aucun gabarit n'allait ecrire. L'interrupteur serveur_grafana_oidc_actif existait, il n'etait pas honore.

Trois corrections, dont deux depassent le site :

  • resoudre_idp n'est appele que si le SSO est actif ;
  • le role refuse SSO eteint et formulaire local eteint — la combinaison deploie un Grafana en parfaite sante ou personne ne peut entrer ;
  • GF_AUTH_DISABLE_LOGIN_FORM sort du {% if %} du SSO. Il y disparaissait quand le SSO etait eteint, et c'est le defaut amont qui decidait en silence. Un reglage d'authentification qu'aucun fichier n'ecrit est un reglage que personne ne peut relire.

Deux manques se cachaient l'un l'autre dans le devis de la frontiere

Prometheus voyait 1 cible sur 7. Les regles d'hote etaient justes ; c'est le devis OPNsense qui avait deux trous, et le second masquait le premier.

1. Le devis ne connaissait pas les integrations universelles. Il derivait les groupes d'une machine du site de applications.yml seul — qui declare les services. Il ne voyait donc ni client_metrique, ni client_journal, ni client_pki, ni client_sante, alors que ces groupes portent des flux et ouvrent des ports d'ecoute. La source est desormais l'inventaire du site, seule autorite sur ce qu'une machine porte vraiment.

2. Une sortie vers un role du site visait « l'exterieur ». Symetrique du correctif du 2026-09-02, qui n'avait traite que l'entree. !SETOPS_INTERNES est la bonne destination quand le pair est lointain ; quand il nomme un role du site, elle dit exactement l'inverse du flux declare — elle exclut la seule machine visee :

client_journal -> !SETOPS_INTERNES  port 3100

Le devis autorisait a expedier les journaux du site a n'importe quel Loki du monde, et a nul autre endroit qu'a celui-la. Neuf flux etaient dans ce cas : PKI, sante, resolveur, sauvegarde, courriel de la forge, base d'Icinga.

Et les deux declarations ne font qu'une regle. appliquer_opnsense pose tout en direction: in (D-61) : deux regles qui ne different que par leur sens sont le meme filtre pose deux fois. Dedoublonnage sur ce que la frontiere applique vraiment — la forme ingress gagne, pour ne pas retirer-puis-recreer des regles deja justes.

Devis : 49 regles a creer, 5 a retirer — les cinq etant exactement les sorties trop larges dont la jumelle etroite existe deja.

Les deux agents se supervisent enfin eux-memes

client_metrique et client_journal etaient parmi les groupes sans sonde. Ils en ont une.

metriques interroge l'endroit que Prometheus interroge, pas le gestionnaire de services : un node_exporter actif mais muet est vert pour systemd. Elle declare aussi les dix collecteurs qui cherchent du materiel qu'une VM n'a pas (zfs, mdadm, infiniband…) — ils echouent identiquement sur les sept machines, et une sonde rouge partout est une sonde qu'on cesse de lire.

journaux ne demande pas si Alloy tourne : elle lit ce qu'il a du jeter, et le compare au passage precedent — ce qui augmente est une perte en cours, ce qui stagne est une cicatrice.

Elle a fait ses preuves le jour meme. Sur six machines :

Alloy: active (running)     /-/ready: 200     dropped_entries_total: 50

Trois indicateurs verts, cinquante lignes perdues. La regle de frontiere manquait encore.

Etat mesure

metriques 7/7 vert
journaux 1/7 — les six autres attendent la regle de frontiere, et le disent
cibles Prometheus 2/8 pour la meme raison
make prouver 64 OK, 0 echec
ansible-lint 0 defaut, profil production

Reste a la main de l'exploitant : make frontiere-appliquer CONFIRMER=true, et le secret vault_grafana_admin a deposer dans underlay.vault.yml.

2026-09-10 (4) — Ce que l'hebergeur n'a pas le droit de VOIR (D-87)

Question posee : « quels roles ne dois-je pas embarquer dans le site ? »

La table de mutualisation de filiation-emancipation.md existait deja et repondait presque. Mais mise a l'epreuve, une de ses lignes contredisait ce qui tourne.

La ligne qui se contredisait

| observabilite | oui | l'hebergeur surveille ses locataires |

Or chaque ecosysteme a son propre Icinga, et celui du site ne voit que ses sept machines (mesure). L'implementation avait raison, la doctrine avait tort.

Et surtout : cette case autorisait ce que la ligne du dessous interdit. Les journaux contiennent du CONTENU — un mot de passe dans un message d'erreur, une donnee metier dans une trace, qui a fait quoi et quand. Un hebergeur qui ingere les journaux de son locataire en sait plus que s'il detenait son annuaire : l'annuaire dit qui existe, les journaux disent ce qu'ils font.

Decoupee en trois :

disponibilite (est-ce debout ?)  oui             une VM tombee est un fait de la fabric
metriques (charge, disque)       oui, reserve    disent quand et combien, pas quoi
journaux                         NON             contiennent le contenu

Et la ligne PKI, « a trancher », est tranchee : non

Elle notait qu'une AC intermediaire signee par l'hote est possible. Elle l'est techniquement — et c'est precisement ce qu'il ne faut pas faire.

Une intermediaire signee par l'hote lui donne le pouvoir d'emettre des certificats valides pour les noms du locataire. Il peut alors se presenter comme n'importe lequel de ses services, devant les propres machines du locataire — qui les accepteront, puisque c'est exactement ce que la chaine de confiance leur demande de faire.

Meme pouvoir que l'annuaire, sous une forme moins visible : rien dans la configuration du locataire, aucune trace de son cote, et la verification passe.

Une PKI par ecosysteme, jamais derivee de l'hote. C'est deja ce qui tourne ; la ligne cesse de laisser la porte entrouverte.

Le revers, mesure et assume

Le site ne porte ni client_journal ni client_metrique, et aucun loki ni prometheus. Ses sept machines n'expedient rien nulle part : l'hebergeur ne peut pas lire ses propres journaux.

C'est la consequence directe de la frontiere — a refuser de voir ceux des locataires, il s'est prive des siens. Le remede n'est pas d'assouplir la regle, c'est de lui donner sa propre pile, sans aucun lien avec celle d'un tenant.

Ce que la mesure a confirme par ailleurs

Rien d'intime n'est au site aujourd'hui. openldap, keycloak, oauth2_proxy, loki, nextcloud, collabora, dovecot, rspamd, web_frontal, web_dorsal : tous tenant seulement. La frontiere etait tenue en pratique avant d'etre ecrite au net.

2026-09-10 (3) — Les cinq sondes qui manquaient le plus

20 sondes sur 19 roles. 23 services distincts, 70 instances, 67 au vert.

D'abord une correction de compte

J'avais annonce « cinq groupes sans sonde ». Mesure : vingt-sept. J'avais compte ceux que j'avais en tete, pas ceux que le depot contient. Il en reste vingt-deux.

Les cinq posees, par ordre de degat silencieux

autorite (step_ca) — la plus urgente de l'ecosysteme. Nos certificats vivent 24 h : une AC muette ne casse rien aujourd'hui, elle casse TOUT demain, d'un coup, sur les vingt-et-une machines a la fois. Elle surveille aussi l'expiration de la RACINE, que personne ne regarde jamais parce qu'elle vit des annees — releve : 3642 jours. Le jour ou elle expire, toute la confiance interne tombe d'un bloc, et aucun renouvellement de certificat d'hote n'y change rien.

base (postgresql) — une VRAIE requete, pas pg_isready. Celui-ci ouvre une connexion et la ferme : il dit que le port repond, pas que la base sert. Une base en recuperation, en lecture seule ou a court de connexions le passe et refuse tout travail. La sonde compte aussi les connexions : a saturation, chaque application tombe en meme temps sans que la base ait l'air morte.

annuaire (openldap) — elle COMPTE les entrees. Un annuaire vide repond success a tout, et plus personne ne s'authentifie nulle part : c'est exactement le mensonge des sauvegardes vides, vert et sans contenu.

zones (powerdns) — un autoritatif sans zone repond NXDOMAIN a tout, ce qui se lit comme « ce nom n'existe pas ». La panne la plus trompeuse du DNS.

edge (nginx) — elle valide la configuration SUR DISQUE. nginx garde la derniere configuration valide et continue de servir ; une configuration cassee ne se voit qu'au prochain demarrage, c'est-a-dire au pire moment, souvent des mois plus tard.

Les cinq eprouvees vertes sur le sain, puis rouges PAR PARAMETRE — port ferme, seuil impossible, port qui n'ecoute pas — sans toucher a un seul service.

Ce qui reste, et pourquoi ce n'est pas le meme genre

Vingt-deux groupes. Ils ne sont pas de la meme nature :

  • couverts ailleurs : client_metrique (par collecte chez Prometheus), client_backup (par sauvegarde), client_sante (sa fraicheur EST son ttl) ;
  • chemins de report : client_smtp, client_artefacts, client_journal, client_resolveur — leur panne se voit deja par le silence de ce qu'ils portent ;
  • du site : serveur_cache_site, serveur_forge_site, serveur_backup_site, serveur_resolveur_site, serveur_ops_site — a poser depuis l'instance du site ;
  • applications et socle : redis, rspamd, collabora, web_frontal, web_dorsal, oauth2_proxy, icingaweb2, backup, debian, durci.

2026-09-10 (2) — cache-apt scindee, et le defaut que la scission a revele

La scission

cache-apt fondait deux causes dont les DELAIS different : « ne repond pas » arrete tout apt de l'ecosysteme — on agit dans la minute — tandis que « volume a 90 % » est un billet pour demain. Les fondre obligeait soit a reveiller quelqu'un pour un disque, soit a traiter une panne comme un billet.

Deux sondes desormais, chacune avec son etat, son historique et son acquittement. Prouve qu'elles sont INDEPENDANTES, et c'etait tout l'enjeu :

port ferme      -> cache-apt [2] CRITIQUE   cache-apt-volume [0] OK
seuil impossible-> cache-apt [0] OK         cache-apt-volume [1] AVERTISSEMENT

Ce que la verification a revele : moteur aurait alarme PARCE QUE tout allait bien

En verifiant que les deux services arrivaient bien dans Icinga, un resultat pousse et accepte (code 200) n'apparaissait pas en base. Hypothese testee et confirmee : IcingaDB n'ECRIT service_state QUE SUR CHANGEMENT D'ETAT. Un OK identique repete ne produit aucune ecriture ; un AVERTISSEMENT pousse ensuite est ecrit en 13 secondes.

Or la sonde moteur, ecrite quelques heures plus tot, lisait exactement max(last_update) de service_state pour juger que « l'etat est frais ». Elle mesurait donc le CHANGEMENT, pas la fraicheur.

Consequence : sur un ecosysteme parfaitement stable — celui qu'on veut — plus rien ne change, last_update vieillit, et la sonde serait passee en avertissement a 15 minutes puis en critique a 90. Une alarme qui se declenche PARCE QUE tout va bien, avec un delai qui l'aurait rendue difficile a rattacher a sa cause.

C'est le piege que docs/supervision-conception.md interdit — une alarme toujours allumee apprend a ne plus regarder — sous sa forme la plus sournoise : differee.

La bonne source existait : icingadb_instance porte le battement du synchroniseur, ecrit en continu qu'il y ait ou non des changements. Releve a 1 seconde sur un systeme sain. Les seuils passent de 15/90 minutes a 1/5 minutes : sur un battement, une minute de silence est deja anormale.

Controle negatif rejoue par parametre.

Etat

Dix-huit services, tous verts sauf sauvegarde a 7/9 — deux noeuds sans donnee a emporter, deja connu et confirme.

2026-09-10 — Chaque role porte desormais sa sonde

Quatorze sondes, declarees dans meta/supervision.yml, deposees par le role qui possede la verite, derivees en objets Icinga sans qu'une ligne soit ecrite a la main :

boites (dovecot)        certificat (client_pki, 14/14)   collaboration (nextcloud)
cache-apt (artefacts)   collecte (prometheus)            file-courriel (postfix)
forge (forgejo)         identite (keycloak)              ingestion (loki)
moteur (icinga)         resolution (resolveur)           runner (serveur_ops)
tableaux (grafana)      voute (ops_tenant)

Les dix-neuf lignes surveillance: ecrites en prose et jamais executees commencent a devenir des mesures.

Deux principes que la premiere sonde a imposes

Une sonde doit pouvoir etre mise en defaut PAR PARAMETRE. Cible et seuils sont des variables du role : on prouve le rouge avec un port ferme ou un seuil impossible, sur une machine reelle, sans rien casser, aussi souvent qu'on veut. Une sonde qu'on ne peut prouver qu'en cassant un service ne sera prouvee qu'une fois. Eprouve sur cache-apt : vert, CRITIQUE par port ferme, AVERTISSEMENT par seuil, retour au vert.

La sonde vit la ou vit la verite. « Ce noeud est-il collecte ? » est une sonde de serveur_prometheus, pas de client_metrique : une seule y voit les N noeuds, et surtout elle voit le cas silencieux — celui qui a cesse d'etre collecte ne peut pas s'en plaindre lui-meme.

On demande au service ce qu'il pense de lui-meme

Quand il sait le dire : /api/healthz de Forgejo (base + cache), /api/health de Grafana, /ready de Loki, status.php de Nextcloud, la decouverte OIDC de Keycloak. C'est plus juste que tout critere invente de l'exterieur — une forge dont la base est tombee sert encore ses pages et repond a /api/v1/version.

Et quand il ne sait pas : on va chercher la verite de terrain. moteur ne regarde ni le service ni le port — il demande a la base depuis combien de temps elle n'a pas ete rafraichie. C'est la lecon des sauvegardes appliquee a la supervision elle-meme : un moteur vert dont la synchronisation est tombee affiche eternellement le dernier etat connu.

Quatre fois j'ai ecrit la sonde avant de mesurer, quatre fois elle a eu tort

La forge : port 443 et chemin des depots INVENTES. Elle ecoute en 3000 derriere l'edge et n'a legitimement AUCUN depot — elle est neuve. Deux cris sur un service sain.

Loki : conclu « panne persistante » sur deux lectures prises a quelques secondes d'intervalle, juste apres un redemarrage. L'anneau etait ACTIVE et la reponse est passee a ready moins d'une minute plus tard. Deux mesures rapprochees ne distinguent pas un etat d'un instant. Le delai de stabilisation est desormais un AVERTISSEMENT nomme.

Keycloak : vise en 8443, il ecoute en 8080 derriere l'edge.

Le runner : git en root refuse les depots d'un autre proprietaire — la sonde aurait rendu un echec qui parle de git au lieu de parler du depot.

À chaque fois le remede est le meme : lire la verite du role, ne pas la supposer.

Et le meme piege Jinja, une seconde fois

${#tableau[@]} contient {#. Le remede etait deja au depot depuis client_sante ; je l'ai reecrit au lieu de le chercher. La correction balaie desormais tous les gabarits de sonde.

Ce qui reste

client_smtp, client_artefacts, client_journal, serveur_icingaweb2 et serveur_ops_site n'ont pas encore la leur. Le premier lot couvre les services dont la chute arrete l'ecosysteme ; ceux-la sont des chemins de report, dont la panne se voit deja par le silence des sondes qu'ils portent.

2026-09-09 (9) — La supervision passe juste apres la PKI (D-86)

On n'allume pas la lumiere une fois la maison finie.

L'observabilite et le moteur de supervision etaient en couches 4 et 5 sur six — donc deployes APRES presque tout ce qu'ils surveillent. Une reconstruction depuis zero est pourtant le moment ou l'on a le plus besoin de voir, et c'etait le seul moment ou l'on ne voyait rien.

1. socle                serveur_debian, serveur_durci
2. pki_racine           serveur_step_ca
3. pki_client           client_pki
4. observabilite        postgresql, prometheus, loki, grafana, icinga   <- NOUVEAU
5. agents_supervision   client_metrique, client_journal, client_sante   <- NOUVEAU
6. services             openldap, powerdns, resolveur, nginx, postfix...
7. apps                 keycloak, forgejo, icingaweb2, nextcloud...
8. agents               client_smtp, client_backup, client_resolveur, client_artefacts

Deplacer les serveurs sans les agents n'aurait rien change

C'est le point qui a decide de la forme : ce sont les AGENTS qui rapportent, et ils etaient en derniere couche. Monter prometheus et icinga en laissant client_metrique et client_sante a la fin aurait produit une supervision allumee et aveugle. Les deux couches vont donc ensemble.

Des la couche 5, chaque hote expedie ses metriques, ses journaux et l'etat de ses unites systemd. Tout ce qui se deploie ensuite est mesure PENDANT qu'on le construit.

Ce que ca a coute, et ce qui reste tard a dessein

serveur_postgresql monte aussi : icinga l'exige, et lui n'exige rien. C'est le seul entrainement.

Restent en couche 7 : icingaweb2 et oauth2_proxy, qui reclament LDAP et Keycloak. C'est la CONSOLE, pas la mesure — l'interface humaine peut attendre, l'etat non.

Ce qui rend ce deplacement possible

Le DNS (powerdns, resolveur) reste en couche 6, donc APRES la supervision. Or icinga joint sa base par un NOM, et client_sante pousse vers un NOM.

Ca tient parce que le plancher /etc/hosts, pose des la couche 1 par hosts_statiques, porte deja les 34 entrees de l'ecosysteme — verifie sur mon-01. C'est exactement ce pour quoi il existe : « il ne s'installe pas, il rend installable ». Sans lui, cette decision serait impossible.

La limite, dite franchement

Les notifications dependent de client_smtp, encore en derniere couche. Pendant une reconstruction, l'etat est mesure et consultable — mais rien ne part par courriel avant la fin. Le deplacer demanderait de monter postfix et son relais, ce qui entrainerait bien plus que PostgreSQL.

Et ceci : l'ordre est valide, pas eprouve. P08 accepte (aucune arete en arriere), le graphe des dependances accepte, playbooks/site.yml est regenere et passe le --syntax-check sur ses 782 lignes de plan. Mais la seule preuve reelle d'un ordre de reconstruction, c'est une RECONSTRUCTION — et celle-la se decide, elle ne se glisse pas dans une soiree.

2026-09-09 (8) — L'hote de supervision saturait par sa propre demonstration

« mon-01 tape dans l'fond. » Il tapait, en effet.

load average: 5,10  sur 4 coeurs
8 processus check_disk, 70 a 99 % de CPU chacun, jusqu'a 28 minutes de vie

check_disk 2.4.0-3+deb13u1 ne rend jamais la main sur cet hote (etat R, il boucle). Icinga en relancait un a CHAQUE intervalle pour le service disk de son hote de DEMONSTRATION, et aucun ne mourait. L'hote de supervision etait sature par la configuration d'exemple livree avec le paquet.

Ce qui a ete retire, et pourquoi l'hote plutot que les services

conf.d/hosts.conf definit object Host NodeName — un localhost de demonstration avec vars.disks, vars.http_vhosts, vars.os. Les apply Service s'y accrochent : disk, http, swap, apt, load, procs, users, ssh, icinga. Aucun ne decrit cet ecosysteme, et trois etaient rouges en permanence — swap sur une VM sans swap, http sur un port ou rien n'ecoute, apt pour un paquet a mettre a jour.

On retire l'HOTE, pas les services : sans lui les apply ne s'accrochent a rien, et on ne touche pas a un fichier que le paquet remplacera a la prochaine mise a jour.

Et ca garde ce qui servait : apply Service "ping4" vise tout hote ayant une adresse, donc les notres. Il est vert 14/14 au tenant. Retirer services.conf l'aurait emporte avec le reste.

avant : load 5,10 — 8 check_disk — 3 alarmes rouges permanentes
apres : load 0,77 — 0 check_disk — certificat 14/14, sante 14/14, ping4 14/14

Trouve en verifiant : la supervision du SITE croyait tout mort

hotes UP : 1/7  (site)        14/14  (tenant)

Nos object Host sont verifies par hostalive, c'est-a-dire un ping. Le site est decoupe en zones separees ; l'ICMP inter-zones n'etait declare nulle part, donc la frontiere l'avalait — 100 % de perte, mesure. Et Icinga SUPPRIME les notifications des services d'un hote DOWN : une supervision qui croit tout mort n'alerte plus de rien, tout en ayant l'air de fonctionner.

Le flux est desormais declare des DEUX cotes (serveur_icinga en egress, serveur_debian en ingress) — et le registre a refuse la premiere moitie seule, ce qui est exactement sa raison d'etre. CODES_ICMP apprend echo-request (un TYPE, pas un code de destination-unreachable). Les regles d'hote sont posees sur les 21 machines.

Le generateur de la frontiere : corrige, et il cachait un second manque

La cause exacte. devis_opnsense.py traitait tout port non numerique comme SYMBOLIQUE — a resoudre par le plan. C'est vrai pour derive, le port d'un service que seul le plan connait. C'est FAUX pour l'ICMP : echo-request et frag-needed sont des litteraux, pas des inconnues. Ils tombaient dans la branche « le plan ne resout pas », et aucune regle n'etait emise.

Une ligne de condition, et le silence de toute une flotte.

Ce que la correction a revele en plus. Huit regles a creer, zero a retirer — et SIX d'entre elles sont du PMTUD, absent lui aussi pour la meme raison. Le depot dit de ces deux messages ICMP : « Bloques, la connexion s'etablit, les petites requetes passent et les grosses reponses restent suspendues — la panne la plus couteuse a diagnostiquer. »

CORRECTION (meme jour, apres mesure). La premiere redaction de ce paragraphe disait que cette panne « etait la, silencieuse, sur les sept machines de l'hebergeur ». C'est faux, et la mesure ne le soutient pas :

site-mon-01 -> autre zone du site : MTU de chemin 1500
site-mon-01 -> Internet           : MTU de chemin 1500
ops-01 (tenant, overlay EVPN)     : MTU 1450, chemin vers Internet 1450

Le site est a 1500 de bout en bout. Rien n'y etait donc casse entre zones : aucun lien du chemin ne plafonne plus bas, donc aucun message « fragmentation necessaire » n'avait lieu d'etre emis. La contrainte a 1450 vit chez les TENANTS, sur l'overlay — pas chez l'hebergeur.

Les six regles restent un vrai manque : elles couvrent le site parlant vers l'EXTERIEUR, ou un lien distant peut tres bien plafonner plus bas, et elles seraient necessaires le jour ou une zone du site passerait sur l'overlay. Mais c'etait une protection absente, pas une panne active — et decrire l'un comme l'autre est exactement le genre d'exageration que ce fichier ne doit pas contenir. Une correction qu'on ne mesure pas se raconte toujours plus grande qu'elle n'est.

Ce que la regle emise autorise, exactement — et pourquoi on ne pretend pas mieux. appliquer_opnsense n'envoie destination_port que pour TCP et UDP : une regle ICMP ouvre le PROTOCOLE entre deux pairs, pas le seul type declare. Le type reste porte par la regle d'HOTE, que resoudre_flux emet precisement (icmp type echo-request accept). La frontiere dit qui peut parler a qui ; l'hote dit ce qu'il accepte d'entendre. Emettre un icmptype a la frontiere aurait ete plus fin — et aurait demande de traduire frag-needed (un CODE de destination-unreachable) dans le vocabulaire de pf, au risque de casser le PMTUD pour gagner de la precision sur une barriere qui n'est pas la derniere.

Mesure apres application :

ping site-mon-01 -> les six autres zones : 6/6 repondent
hotes UP : 1/7  ->  7/7
ping4 : 1/7     ->  7/7      certificat 7/7, sante 7/7

2026-09-09 (7) — Le contrat des sondes ETAIT celui de Nagios, sans le savoir

Question posee : « tu connais le paquet monitoring-plugins ? »

Oui — et le contrat que docs/supervision-conception.md decrivait quelques heures plus tot EST le sien, mot pour mot : une ligne sur stdout, 0/1/2 en code de sortie. Je l'avais donc reinvente sans le nommer, alors que positionnement.md dit exactement l'inverse : adopter aux seuils, ne pas reimplementer.

Le document nomme desormais l'API des greffons Nagios, ajoute 3 = INCONNU et la partie | metriques qui manquaient, et dit la consequence : un greffon standard EST une sonde valide, sans la moindre colle. Le paquet en fournit 54 — check_disk, check_load, check_procs, check_ntp_time, check_smtp, check_pgsql... On n'ecrit du shell que lorsque la verite a mesurer est propre a Set-OPS, comme client_pki/certificat, qui compare l'empreinte SERVIE a celle du disque : aucun greffon ne sait ca.

Le porteur separe maintenant le texte des metriques (performance_data), parce que c'est ce que le contrat porte et qu'Icinga sait les tracer.

Essayer un vrai greffon a revele DEUX defauts du porteur

1. Un envoi refuse faisait taire toutes les sondes suivantes. rapporter sortait en exit 1 : une sonde deposee mais non declaree — donc refusee par le filtre d'API — supprimait le rapport de toutes les autres, sante comprise. Le tableau ne devenait pas rouge, il devenait VIDE, et le ttl le perimait des heures plus tard sans dire pourquoi. Un rapporteur qui ne peut pas dire UNE chose doit quand meme dire les autres.

2. Aucun delai de garde sur les sondes. Et ce n'est pas theorique :

check_disk 2.4.0-3+deb13u1 sur mon-01 : etat R, ne rend jamais la main
quels que soient ses arguments (-p /, sans -p, seuils en % ou en G)

Un greffon STANDARD, sur une machine saine, qui boucle. Sans delai de garde il figeait le rapport entier, toutes les quinze minutes, indefiniment. Chaque sonde tourne desormais sous timeout 20 ; au-dela, on rapporte INCONNU en le disant.

La lecon n'est pas « les greffons standards sont mauvais ». C'est qu'adopter un standard ne dispense pas de l'eprouver — et que le porteur doit survivre a une sonde qui se comporte mal, standard ou maison.

Trois voyants rouges permanents, sur l'hote de supervision

Constate en cherchant : la configuration d'exemple livree par Icinga surveille localhost et crie en permanence sur mon-01 —

swap : SWAP CRITICAL - 0% free   (une VM sans swap)
http : connect to 127.0.0.1:80   (rien n'ecoute la)
apt  : 1 package upgradable

Trois alarmes qui ne peuvent que rester rouges, dans le seul endroit qui doit rester lisible. Non corrige — c'est une decision d'exploitation, pas une correction de code.

2026-09-09 (6) — La supervision se DECLARE dans le role, comme les flux

64 preuves (P01-P64). Nouveau document : docs/supervision-conception.md. Premiere sonde : client_pki/certificat, 14/14 OK sur la flotte.

Le constat qui a decide

Mesure du depot sur lui-meme, au 2026-09-09 :

39 roles declarent leurs flux        -> nftables + OPNsense, derives
32 declarent leur empreinte          -> ressources des VM, derivees
32 declarent leur authentification   -> habilitations, derivees
19 groupes declarent `surveillance:` -> RIEN

Les dix-neuf lignes surveillance: de docs/dependances-groupes.yml sont ecrites, versionnees, relues — et aucune n'etait executee. Icinga surveillait deux choses.

C'est la classe d'echec de ce depot, en version documentaire : la carte dit ce qui est surveille, et personne ne surveille. Une intention ecrite n'est pas une mesure.

Le mecanisme

Un role declare ses sondes dans meta/supervision.yml (nom, ttl, raison) et depose lui-meme son script dans /usr/local/lib/setops/sondes/ — il connait ses chemins, ses seuils, son consommateur. Le porteur (client_sante) fait tourner tout ce qui vit dans ce repertoire et pousse un resultat passif par sonde ; il ne sait pas ce qu'elles mesurent, et c'est le but. serveur_icinga derive les object Service ET le filtre de permission d'API des memes declarations.

Ajouter une sonde ne demande de toucher ni au porteur, ni a Icinga. Comme un flux declare devient une regle nftables.

La sonde du certificat, et ce qu'elle mesure VRAIMENT

Nos certificats vivent 24 heures. Une machine dont le renouvellement s'arrete ne casse pas tout de suite : elle casse le LENDEMAIN, et tout ce qui parle TLS avec elle tombe ensemble.

Ce qu'on ne mesure pas, et c'est delibere : « le minuteur est-il actif ? » — un minuteur vert qui echoue chaque nuit est exactement le mensonge deja paye avec les sauvegardes. « le certificat existe-t-il ? » — un certificat perime existe.

Ce qu'on mesure : les heures restantes sur le certificat REELLEMENT pose, la chaine verifiee contre notre racine, et — quand un service le consomme — l'empreinte SERVIE comparee a celle du disque.

Trois obstacles, et le premier est le plus instructif

La sonde a rendu 14 machines sur 14 en CRITIQUE — sur une PKI qui se portait tres bien. Elle utilisait openssl verify -CAfile racine ; or nos certificats d'hote sont signes par un INTERMEDIAIRE, et seule la racine vit sur le disque. step certificate verify, l'outil que le role installe deja, repond VALIDE.

Une alarme toujours allumee ne vaut pas mieux qu'une alarme jamais allumee : elle apprend a ne plus regarder. Une sonde se prouve donc DEUX FOIS — verte sur le sain, rouge sur le casse. Le document de conception porte la regle.

Le filtre d'API etait ecrit avant la lecture des declarations. Les services existaient, et Icinga aurait refuse leurs resultats avec un « 404 No objects found » sur des objets bien presents — une heure perdue sur ce message le 2026-09-02. La lecture est remontee avant le compte d'API, avec le pourquoi ecrit la ou l'on serait tente de la redescendre.

Mon controle negatif a casse un service reel. Substituer le certificat d'hote par un auto-signe a fait virer la sonde au rouge, comme voulu — et le script de synchronisation a propage ce certificat vers node_exporter, dont la clef etait restee l'ancienne : tls: private key does not match public key. Un service tombe pour eprouver une sonde. La regle qui en decoule est au document : un controle negatif se fait sur une copie, jamais en substituant l'artefact que d'autres consomment.

Les quatre etats, tous eprouves

certificat absent            -> [2] CRITIQUE
signe par une autre autorite -> [2] CRITIQUE  (24 h restantes : c'est la CHAINE qui parle)
sous le seuil d'avertissement-> [1] AVERTISSEMENT
etat sain                    -> [0] OK

Puis relu dans IcingaDB : certificat : 14/14 OK, sante : 14/14 OK.

P64 tient les deux bouts

Une sonde DECLAREE dont le script n'est pas depose donnerait un service qui n'a jamais de resultat. Un script DEPOSE que rien ne declare serait refuse par le filtre d'API. Les deux se rompent seuls, la preuve tient les deux — plus la presence d'une raison et d'un ttl. Trois controles negatifs rejoues.

Ce qu'elle ne fait pas, et qu'aucune lecture statique ne fera : juger qu'une sonde MESURE quelque chose. Ca reste au controle negatif de chacune, exige par le document et trace ici.

Et une QUATRIEME fois la meme lecon : les seuils criaient trop tot

Deployee sur le site, la sonde a rendu 5/7 — deux machines en AVERTISSEMENT, sur des certificats parfaitement sains.

Le seuil etait a 12 h. Or cert-renewer@.service porte ExecCondition=step certificate needs-renewal, qui dit oui au TIERS RESTANT : 8 h pour un certificat de 24 h, reessaye toutes les 15 minutes. La sonde criait donc AVANT que le mecanisme ne soit cense agir.

Les seuils sont desormais SOUS le point de renouvellement — 6 h (le renouvellement est du depuis deux heures, huit fenetres manquees : ce n'est plus un hoquet) et 3 h. Un seuil au-dessus du point de renouvellement ne previent pas : il ment.

Un seuil ne se choisit pas a l'intuition : il se DERIVE du moment ou le mecanisme surveille est cense agir. C'est la meme faute que openssl verify quelques heures plus tot, sous une autre forme — et c'est encore la flotte reelle qui l'a dite.

Etat final : 14/14 au tenant, 7/7 au site, sur certificat comme sur sante.

Reste

Dix-huit groupes attendent encore leur sonde. Le mecanisme, lui, ne demande plus rien.

2026-09-09 (5) — Retrait d'une garde qui ne pouvait pas se declencher

client_sante portait une branche « aucun serveur_icinga : rien a poser », et un when sur tout son bloc. Ni l'une ni l'autre ne pouvait s'executer.

Eprouve sur le modele public, dans une copie jetable : instancier ne pose une integration UNIVERSELLE que si son serveur existe dans l'ecosysteme. Sans Icinga au plan, le groupe client_sante est absent de l'inventaire genere — exactement comme client_metrique et client_journal le sont deja. Le role n'est donc jamais appele sans destinataire, et sa branche defensive etait du decor.

Une garde qui ne peut pas se declencher n'est pas une garde : elle rassure sans rien tenir. Et elle coute deux fois — a la lecture, puis le jour ou l'on cherche pourquoi rien n'a alerte.

Ce qui la remplace se declenche vraiment, et les deux cotes sont eprouves :

-e client_sante_icinga_hote=''        -> FAILED (assertion sur l'hote)
-e client_sante_icinga_motdepasse=''  -> FAILED (assertion sur le secret)

L'echec dit LEQUEL des deux manque. Le bloc, prive de sa condition, est aplati : douze taches a plat au lieu de onze indentees sous un when toujours vrai.

Rejoue sur les deux flottes : zero changement sur zero hote. make prouver : CONFORME, 63 OK, 0 echec, 0 saute.

2026-09-09 (4) — systemctl --failed entre dans la supervision

63 preuves. make prouver : CONFORME, 63 OK, 0 echec, 0 saute. Nouveau role client_sante, integration universelle (67 roles).

Ce qu'il ferme

Le defaut trouve quelques heures plus tot : openipmi.service echouait a CHAQUE demarrage sur les quatorze machines depuis le 2026-09-02, et systemctl --failed rendait ZERO partout — non parce qu'elles allaient bien, mais parce qu'AUCUNE n'avait redemarre depuis. Il a fallu qu'un humain redemarre une machine pour que le defaut existe aux yeux de quelqu'un.

Un controle qui ne peut echouer qu'au demarrage ne mesure rien tant que rien ne demarre.

PASSIF, et a duree de vie

Un controle ACTIF ne voit pas la machine MUETTE : si elle ne repond plus, la sonde echoue et on met ca sur le compte du reseau. Ici c'est le NOEUD qui parle, et le ttl de son envoi fait la fraicheur — sans nouvelle, Icinga perime le service tout seul. Le silence alerte autant que l'echec, et le silence est precisement ce qui n'a alerte personne.

Le minuteur declenche au demarrage (OnBootSec=2min) autant que toutes les 15 min : les echecs de cette famille NAISSENT au boot, et attendre le premier quart d'heure laisserait une fenetre pendant laquelle la machine est en panne et le tableau au vert.

CRITIQUE des la premiere unite, jamais un seuil. Une unite en echec est soit un vrai probleme, soit du bruit a retirer : dans les deux cas il faut agir. Un seuil ferait vivre le bruit indefiniment — exactement ce qu'on vient de corriger.

Les tolerances se nomment une par une (client_sante_unites_tolerees, vide par defaut). Jamais un motif large : un filtre qui cache une unite en cache d'autres, et on ne s'en apercoit que le jour ou l'on cherche pourquoi rien n'a alerte.

Le controle negatif

Une garantie qu'on n'a jamais vue echouer n'est pas une garantie. Unite factice posee sur obs-01, rapport rejoue, etat relu dans IcingaDB :

obs-01      sante  CRITICAL  1 unite(s) systemd en echec : setops-controle-negatif.service
(13 autres) sante  OK        Aucune unite systemd en echec.

Le verdict NOMME l'unite. Apres nettoyage : 14/14 OK.

Un conflit evite de justesse

setops-sauvegardes.conf definissait les object Host. Un second fichier de controle aurait redefini les memes, et Icinga refuse un objet en double : la configuration entiere aurait ete rejetee, donc AUCUNE supervision — en voulant en ajouter. Les hotes vivent desormais dans setops-hotes.conf, definis UNE fois ; les fichiers de controle n'attachent que des services. Verifie : 14 hotes, 14 services, zero doublon, icinga2 daemon -C valide.

Trois obstacles, et ce qu'ils apprennent

${#tableau[@]} contient {#, que Jinja lit comme un debut de commentaire — le rendu echouait sur « Missing end of comment tag ». Le shell a besoin de ${#...} ; c'est donc a Jinja de s'ecarter (#jinja2: comment_start_string:... en tete du gabarit).

Ma premiere sonde a traduit un refus en « 0 service ». Le compte setops-depot ne peut que POSER un resultat, pas lire — moindre privilege voulu. L'API rendait {"error":403,"status":"Missing permission: objects/query/service"}, et mon script, qui cherchait une clef results, a affiche « 0 service(s) sante ». Encore un « echec » qui ecrasait « permission ». L'etat se lit dans IcingaDB, pas par ce compte.

Et un echec apt transitoire sur mon-01 : 503 unexpected range puis « Message has been manipulated » sur la signature de debian-security. Alarmant a la lecture, local a une machine (13 autres propres), et disparu au second essai — un hoquet du cache apt-cacher-ng, pas un incident de signature. Mesure avant conclusion : les deux mandataires servaient le meme contenu, au meme condensat.

Le meme compte d'API, elargi de deux noms a trois

Un second compte serait plus pur — un secret par usage. Il exigerait une clef de voute de plus dans CHAQUE ecosysteme, donc un geste manuel a chaque nouvel ecosysteme, pour une portee identique : ce compte ne peut deja que poser un resultat passif, sur des services NOMMES. Le filtre passe de sauvegarde: * / sauvegarde a ces deux-la plus sante. Aucun pouvoir nouveau.

Le SITE : fait, et il a fallu deux corrections pour que ca MARCHE

7 machines, 0 echec au deploiement — et cinq rapports sur sept en TimeoutError.

Un playbook vert ne prouve pas qu'une chose fonctionne. make site-appliquer GROUPE=client_sante rendait 7/7, 0 echec, sur un flux qui ne passait pas. Seul l'essai de bout en bout l'a dit.

1. Le pair de la frontiere. Le role client declarait son egress 5665, la politique de sortie etait accept, la regle d'entree de l'hote autorisait bien la source — et les paquets mouraient ENTRE les deux. La frontiere filtre le trafic inter-zones du site et ne connait que ce que le registre lui dit ; or elle ne resout que les roles que les machines PORTENT AU PLAN. Une integration universelle n'y figure pas : elle est derivee, pas declaree. pair: client_sante produisait donc une regle est-ouest correcte et AUCUNE regle a la frontiere.

Le pair est desormais serveur_debian — le vocabulaire du depot pour « tout noeud », que le generateur de frontiere traite deja comme tel, et qui est exact au sens strict : tout noeud rapporte sa sante. Six regles creees, zero retiree, une par patte de zone. Le meme piege explique pourquoi le flux client_backup juste au-dessus ne suffisait pas seul : c'est serveur_backup, role REEL, qui ouvrait la porte pour le depot.

2. Le rapporteur s'accusait lui-meme. Pendant l'heure ou le pare-feu bloquait, setops-sante.service a echoue — et une fois debloque, cinq machines ont rapporte CRITIQUE en citant... leur propre rapporteur. Le blocage corrige, l'accusation restait : systemd garde l'etat failed jusqu'a un reset-failed. Un rapporteur qui trebuche une fois s'accuserait indefiniment.

Sa propre unite est donc exclue du compte, et ce n'est pas se menager : sa sante est deja mesuree, et mieux, par la FRAICHEUR de ses envois. S'il ne peut plus parler, le ttl perime le service — ce qui se voit precisement quand il ne peut PAS ecrire, alors que sa propre unite en echec ne se voit que quand il le peut. Se compter soi-meme, c'est mesurer deux fois la meme chose, dont une fois mal.

Etat final : 7/7 au site, 14/14 au tenant, tous OK. Controle negatif rejoue sur les deux flottes.

2026-09-09 (3) — Le redemarrage a tenu, et il a montre autre chose

Ce que le redemarrage prouve

obs-01 a redemarre avec son disque cloud-init retire de Proxmox — donc sans paquet ET sans source de donnees. Elle est revenue avec son adresse (10.17.20.11/24), sa passerelle (10.17.20.1) et son resolveur (10.0.34.11). C'est la confirmation que les mesures annoncaient : /etc/network/interfaces.d/50-cloud-init n'appartient a aucun paquet, il survit, et ifupdown le relit au demarrage.

Le retrait de cloud-init est desormais prouve par un demarrage reel, pas seulement par inference.

Ce que le redemarrage a REVELE, et qui n'a rien a voir

Une unite en echec : openipmi.service.

openipmi[655]: Starting ipmi drivers ipmi failed!
systemd[1]: Failed to start openipmi.service

La chaine : prometheus-node-exporter recommande prometheus-node-exporter-collectors, qui recommande ipmitool, qui recommande openipmi. Trois recommandations en cascade, chacune raisonnable sur du metal. Dans une VM il n'y a pas de BMC — /dev/ipmi* n'existe pas — et le script d'init echoue a chaque demarrage.

LE POINT N'EST PAS LE PAQUET, C'EST POURQUOI PERSONNE NE L'AVAIT VU. openipmi est arrive le 2026-09-02, avec la reconstruction. systemctl --failed rendait pourtant ZERO sur les quatorze machines — non parce qu'elles allaient bien, mais parce qu'aucune n'avait redemarre depuis. Six jours et vingt heures pour obs-01 (last -x reboot : boot du 2026-09-02 19:24, jusqu'a aujourd'hui 16:10).

Un controle qui ne peut echouer qu'au demarrage ne mesure rien tant que rien ne demarre. C'est la meme famille que le cheque vert sur un perimetre vide, avec le temps comme perimetre.

Et le degat n'est pas cosmetique : une unite en echec permanent use le seul signal qui devrait alerter. Le jour ou une vraie unite tombe, systemctl --failed rend « 2 » au lieu de « 1 », et personne ne fait la difference.

La correction, a la source et conditionnelle

client_metrique retire ipmitool et openipmi — mais seulement dans une VM (ansible_facts.virtualization_role == 'guest'). Sur du metal le collecteur IPMI est legitime : c'est la raison meme de la recommandation. Le role s'appuie sur ce qu'Ansible SAIT de la machine, pas sur une supposition.

Ils ne sont que RECOMMANDES, donc les retirer n'emporte pas prometheus-node-exporter-collectors, dont les collecteurs textfile servent vraiment.

Un handler efface l'etat failed laisse derriere : retirer le paquet ne l'efface pas, systemd le garde jusqu'au prochain demarrage. Sur une flotte qui ne redemarre pas, ce serait conserver par inadvertance exactement le bruit qu'on vient de supprimer.

Applique aux quatorze : ipmi=0, collectors=1, node_exporter=active, echecs=0 partout. Second passage : zero changement sur zero hote — idempotent.

Ce qui reste ouvert

Rien dans le harnais ne regarde systemctl --failed sur la flotte. Ce defaut-ci a ete trouve parce qu'un humain a redemarre une machine, pas parce qu'une mesure l'a dit. Les treize autres portaient la meme unite condamnee, silencieuses.

2026-09-09 (2) — Le wiki d'origin : une garde qui bloquait au lieu de proteger

Les deux forges servent desormais le meme wiki, 26 pages, contenu identique au fichier pres (diff -rq : aucune difference).

Ce que je disais, et qui etait faux

« Il n'y a rien sur origin » — non. Le depot y est, et a jour : git ls-remote rend exactement notre HEAD. C'est le WIKI qui etait vide. L'ecart entre « le depot est absent » et « son wiki n'a pas de branche » est tout l'ecart entre une panne et une formalite, et je l'avais recopie d'une session precedente sans le remesurer.

La garde avait raison de refuser, et tort de s'arreter la

make wiki-publier refuse un wiki VIDE : sans commit il n'a aucune branche, et publier en inventerait une — master sur ce poste, alors que le wiki en service vit sur main. Le message disait « creer une premiere page dans l'interface Forgejo ». Or l'interface n'est pas joignable depuis ce poste, et ce n'est pas une panne : la forge du site n'accepte le 443 que des machines qui DECLARENT le flux.

Forgejo declare pourtant la reponse. GET /api/v1/repos/genome/set-ops-public rend wiki_branch. Interroge depuis ops-01 — une machine qui a le flux — il repond main. On ne devinait donc pas : on ne demandait pas.

WIKI_BRANCHE= a ete ajoute a la recette. Le refus reste le DEFAUT ; qui a la reponse peut la donner. Les deux cotes sont eprouves : un wiki vide sans la variable est refuse, avec WIKI_BRANCHE=main il est initialise sur main exactement.

Au passage, une lecon d'instrument

Trois sondes fausses avant la bonne. Un connect() direct sur 10.0.33.11:443 depuis le poste rend TimeoutError — route presente, paquets avales : une POLITIQUE, pas une route manquante. Un tunnel par la frontiere ne repondait pas davantage. Et pendant ce temps git ls-remote fonctionnait tres bien, parce que ~/.ssh/config passe par un ProxyJump ansible@10.17.0.1 que la sonde ignorait.

L'instrument juste etait ops-01 : la machine dont le flux vers la forge est DECLARE. Elle repond HTTP 200 en 443, et 22 muet — l'exact inverse du poste. Verifier d'ou l'instrument mesure, encore une fois.

2026-09-09 — cloud-init nait avec la VM et ne lui survit pas

63 preuves (P01-P63). make prouver : CONFORME, 62 OK, 0 echec, 1 saute.

Le second maitre

cloud-init n'est pas un logiciel d'installation : c'est une source de verite externe. Il ne s'arrete pas apres la premiere seconde — il se reveille a CHAQUE demarrage et relit le lecteur cloud-init attache par l'hyperviseur, lequel peut redefinir les comptes, les cles SSH autorisees, les mots de passe et le reseau.

Sur une machine que le plan possede desormais, c'est un second maitre : le plan ne le decrit pas, make valider ne le mesure pas, et il parle en premier.

Sa tache est pourtant finie a la premiere seconde. C'est precisement parce qu'il a REUSSI a poser l'adresse, le nom et les cles d'hote qu'Ansible a pu entrer.

Trois moities, et elles se defont separement

  1. le gabarit le garde. Sans lui, un clone n'a ni adresse ni nom : il ne nait pas.
  2. le socle ne l'installe plus. Le garder produisait un va-et-vient a chaque deploiement — le socle installe, le durcissement retire, deux changed par passage, l'idempotence perdue et make valider bruyant pour rien.
  3. le durcissement le retire (roles/cloud_init_retrait, dernier role de serveur_durci, apres que tout le reste soit pose).

Une seule des trois qui bouge et la decision devient son contraire en silence : un gabarit sans cloud-init donne des VM mortes ; un socle qui le reinstalle rend le pouvoir a chaque passage ; un durcissement qui ne le retire plus laisse le second maitre en place. P63 garde les trois, plus le contenu du role — un role vide passerait les trois premiers controles sans rien fermer. Les quatre controles negatifs ont ete rejoues.

Ce qui rend le retrait sur est MESURE, pas suppose

L'adresse d'une VM vit dans /etc/network/interfaces.d/50-cloud-init. Le risque evident etait que le retrait l'emporte — une machine qui perd ce fichier ne se plaint pas : elle repart au prochain demarrage sans adresse, et plus personne ne peut entrer pour le constater. Mesure du 2026-09-09 sur obs-01 :

dpkg -S /etc/network/interfaces.d/50-cloud-init   -> aucun paquet ne le possede
/var/lib/dpkg/info/cloud-init.postrm              -> ne nomme jamais interfaces.d

Le fichier survit donc, meme en purge, et interfaces fait toujours source interfaces.d/*. Son en-tete annonce que les modifications « ne persistent pas au redemarrage » : c'etait vrai TANT QUE cloud-init pouvait le reecrire. Le paquet parti, plus personne ne le reecrit.

Le role le verifie quand meme, avant et apres, et n'accuse que si le retrait l'a emporte — une machine qui n'a jamais eu ce fichier ne doit pas faire echouer le durcissement. Essai a blanc sur obs-01 : cloud-init* a retirer, configuration reseau intacte, drapeau pose.

Deux choix dits franchement

cloud-guest-utils reste. C'est growpart : ni service, ni port, ni source de donnees. Le retirer ne fermerait aucune surface, et il faudrait le reinstaller au premier agrandissement de disque.

Les orphelins ne sont pas retires par defaut. Le retrait laisse ~29 paquets qui n'etaient la que pour cloud-init (python3-jsonschema, python3-jinja2, python3-oauthlib...). Ce sont des bibliotheques : elles pesent, elles n'ouvrent rien. autoremove calculerait exactement quoi retirer — a partir des drapeaux « installe manuellement » de dpkg, et une machine dont ces drapeaux ont derive y perdrait autre chose. Un durcissement ne doit pas pouvoir surprendre. cloud_init_retrait_autoremove existe pour qui veut, en connaissance de cause.

Et un drapeau, pour le retour par dependance

cloud-init peut revenir en recommandation d'un autre paquet. /etc/cloud/cloud-init.disabled est lu par cloud-init lui-meme au demarrage et l'arrete avant qu'il ne lise la moindre source de donnees.

Ce que cette decision NE ferme PAS

Ajoute le meme jour, apres la question « cloud-init est-il vraiment une valeur ajoutee ici ? ». La premiere redaction de D-85 se lisait comme une emancipation. Elle n'en est pas une, et il valait mieux le dire que de laisser un lecteur presse en conclure trop.

Retirer cloud-init n'ote AUCUN pouvoir a l'hebergeur. qemu-guest-agent est au gabarit — il doit y etre (P56 : c'est par lui que creer-vm confirme la materialisation sans entrer chez le tenant). Releve du 2026-09-09 sur edge-mta-01, avec le jeton d'API du site, les sous-chemins que l'API expose sur son dos :

exec · exec-status · file-read · file-write · set-user-password · shutdown · ...

C'est un pouvoir STRICTEMENT PLUS GRAND que le lecteur cloud-init, et il est deja la, sur chaque VM vivante de la flotte.

Ce que D-85 ferme est donc precis et etroit :

  1. une reapplication AUTOMATIQUE, a chaque demarrage, depuis un support que le plan ne possede pas et qu'aucune preuve ne lit ;
  2. le code de cloud-init lui-meme — un interpreteur Python complet execute en root au demarrage, et ses ~29 dependances.

La mainmise d'un hyperviseur sur ses invites est une propriete de la VIRTUALISATION, pas de cloud-init. Elle appelle sa propre decision, qui n'est pas prise.

Le seuil ou le remplacer deviendrait juste

L'alternative existe et sa piece est deja au gabarit : ecrire l'adresse et la cle par agent/file-write + agent/exec depuis l'hyperviseur, sans reseau. Aujourd'hui ce serait REIMPLEMENTER UN STANDARD — ce que positionnement.md interdit — et echanger un mecanisme eprouve contre du code maison au moment le plus fragile, dont le mode de panne est le pire : une VM injoignable.

Le jour ou une premiere seconde ne pourra plus etre amorcee par Proxmox — autre hyperviseur, metal nu, hebergeur sans API — ce chemin cesse d'etre une reimplementation et devient LE CHEMIN PORTABLE. C'est l'axe emancipation que le depot suit deja. Le seuil est nomme ici pour ne pas etre franchi sans le voir.

Deploye le 2026-09-09 — 14/14

Applique a la flotte de Chezlepro apres confirmation explicite. serveur_durci : 14 hotes, 0 echec, 0 injoignable. Ordre suivi : obs-01 seul d'abord, verifie, puis les treize autres.

Releve sur les quatorze machines apres coup :

paquet=0  unites=0  reseau=oui  drapeau=oui  networking=enabled  echecs=0

et, sur chacune, ifquery rend EXACTEMENT l'adresse vive (10.17.20.11, 10.17.21.11...). C'est le point : ifquery ne lit pas la memoire du systeme, il relit le fichier que le retrait aurait pu emporter — donc il repond ce que le prochain demarrage fera.

Second passage sur obs-01 : changed=1, et ce seul changement est la tache nftables_baseline qui se declare toujours modifiee, anterieure a ce chantier. Le retrait est idempotent.

make valider apres deploiement : 0 echec, 0 injoignable sur les quatorze.

CE QUI N'A PAS ETE PROUVE, ET IL FAUT LE DIRE. Aucune machine n'a ete REDEMARREE. Le redemarrage etait le controle que je voulais faire — la garde de securite du poste l'a refuse, et on ne contourne pas une garde. Le chemin de demarrage a donc ete prouve AUTREMENT, sans redemarrer : networking.service (ifupdown) est le service actif, systemd-networkd est desactive, NetworkManager absent ; ifquery relit le fichier et rend la bonne adresse ; zero unite cloud-init subsiste dans systemctl list-dependencies multi-user.target ; zero unite en echec ; le nom d'hote et les trois cles d'hote SSH persistent hors de cloud-init.

C'est fort, ce n'est pas un redemarrage. La confirmation finale tient en une ligne, a lancer par un humain sur une machine de son choix.

Le SITE : fait le 2026-09-09 — 7/7

make site-appliquer GROUPE=serveur_durci : 7 hotes, 0 echec, 0 injoignable. Configuration reseau intacte sur les sept, ifquery rend l'adresse attendue sur chacune (10.0.31.11, 10.0.33.11...), zero unite cloud-init restante, zero unite en echec.

Le site HEBERGE — c'est ce qui rendait l'operation plus lourde que chez le tenant. Verifie apres coup, depuis les machines qui ont le flux : la forge repond HTTP 200 et sert toujours ce depot au bon commit ; le cache APT repond HTTP 200 ; le DNS resout forge.genese.internal ; step-ca rend {"status":"ok"}.

Le site ne portait pas le piege openipmi : il n'applique pas client_metrique.

(Ce paragraphe remplace celui qui disait le changement « arme, pas applique » — et qui disait aussi, a tort, qu'il fallait passer par site-ops-01. make site-appliquer fonctionne depuis le poste.)

Trouve en verifiant : site-backup-01 n'a jamais recu client_pki

Elle est DANS le groupe client_pki — et elle n'a ni /etc/step, ni minuterie de renouvellement, ni meme la racine de l'AC dans /usr/local/share/ca-certificates/. Les six autres machines du site ont leur certificat et leur minuterie quotidienne, qui tourne.

L'appartenance au groupe dit « couverte ». La machine dit le contraire. C'est la meme famille que le reste : un perimetre declare n'est pas un perimetre mesure. Sans rapport avec le retrait de cloud-init — trouve en le verifiant.

Corrige le meme jour : make site-appliquer GROUPE=client_pki — 7 hotes, 0 echec. site-backup-01 a recu quatorze changements : depot apt Smallstep, step-cli, STEPPATH, mot de passe du provisioner, etablissement de la confiance dans l'AC, certificat d'hote emis, unite et minuteur de renouvellement, actives.

Verifie apres coup sur les sept :

chaine = VALIDE          (`step certificate verify` contre la racine locale)
racine = /etc/step/certs/root_ca.crt presente partout
timer  = cert-renewer@<fqdn>.timer  [enabled/active], prochaine passe ce soir

site-pki-01 est la seule sans ancre dans /usr/local/share/ca-certificates/, et c'est juste : elle PORTE l'autorite, elle ne s'enrole pas aupres d'elle-meme.

Trois de mes sondes ont menti avant la bonne : is-enabled sur un nom d'unite devine, puis la colonne « service active » lue a la place de la minuterie — un service declenche par minuteur est normalement disabled, ce qui se lit comme une panne quand on regarde la mauvaise ligne. La question etait pourtant serieuse : sans minuteur, les certificats du site expiraient dans 24 h.

Et le changed=2 que les six autres machines rapportaient n'etait pas une non-idempotence : c'etait le depot initial de step-cli. Rejoue sur site-forge-01 : changed=0.

Ce qui restait a faire (historique)

SITE-Chezlepro est une autre instance de ce depot — meme playbooks/groupes/serveur_durci.yml, memes roles. Ses sept machines actives (site-ops-01, site-cache-01, site-forge-01, site-pki-01, site-dns-01, site-backup-01, site-mon-01) sont durcies depuis le 2026-09-02 : leur prochain passage de serveur_durci retirera cloud-init chez elles aussi, sans que personne ne le decide a nouveau.

Ce n'est pas un oubli, c'est un fait a connaitre : le site se deploie depuis SON runner (site-ops-01), pas depuis ce poste, et son inventaire n'est meme pas genere ici. Y aller est un acte distinct, sur une machine distincte, et le site porte la forge qui sert ce depot et le cache qui nourrit apt — un rayon d'action different.

2026-09-08 (4) — Les SIX registres ont un formulaire genere

62 preuves. make prouver : CONFORME, 61 OK, 0 echec, 1 saute. CHAMPS_ECRITS_A_LA_MAIN est vide : plus un seul champ recopie a la main.

L'epreuve qui compte

Ouvrir chaque vue et enregistrer sans rien toucher doit renvoyer au serveur exactement le plan qu'on vient de lire. C'est ce qui separe un formulaire genere d'un formulaire qui a l'air genere : un champ visible a l'ecran et perdu en silence a l'enregistrement serait le pire des deux mondes.

/api/serveurs        14 entite(s)   IDENTIQUE
/api/applications    25 entite(s)   IDENTIQUE
/api/domaines         2 entite(s)   IDENTIQUE
/api/bases            4 entite(s)   IDENTIQUE

test_rendu_gui.py le mesure desormais a chaque make prouver.

Trois defauts trouves en chemin

Le formulaire annoncait des defauts inventes. « 2048 » pour la memoire, « 2 » pour les coeurs, « 16G » pour le disque. Il n'existe aucun defaut fixe : deriver_ressources calcule la taille depuis les ROLES que l'hote porte — 1024 Mo et 1 coeur pour infra-pki-01, 5632 et 4 pour collab-01. Un repere faux est pire qu'aucun : il fait croire qu'on connait la valeur. Le schema nomme maintenant le champ derive (x-defaut-derive), et le formulaire affiche la valeur REELLE de cet hote. De meme, l'option vide d'un <select> dit le defaut qu'elle produira : « (défaut : asgard) ».

Une SECONDE occurrence du defaut d'hier dormait. sourceDeValeurs lisait encore data.nomenclature — le meme data qui n'existe pas. Elle n'avait jamais leve parce que la vue Serveurs, seule a emprunter cette source, avait encore un formulaire ecrit a la main. Elle a leve a la seconde ou le generateur l'a prise, et c'est le banc de rendu qui l'a dit.

Le banc ne voit que les chemins VIVANTS. verifier_gui.py fait donc desormais une verification STATIQUE : une fonction qui lit data. sans le declarer ni le recevoir est refusee. Elle voit aussi ce qui dort. Son premier essai a signale dessinerReseau() a tort — data y est declare en second declarateur d'un const multiple ; corrige, parce qu'un faux positif dans une garde finit toujours par la faire desactiver.

La validation client s'accrochait a data-v, pose a la main sur trois champs. Le formulaire genere l'aurait perdu, et querySelectorAll aurait rendu une liste vide : la validation serait passee au vert sur ZERO champ. Le generateur marque maintenant chaque controle (data-champ), et la sauvegarde REFUSE si elle n'inspecte aucun champ — un controle qui ne trouve rien ne dit pas « tout va bien ».

Deux champs gardent leur editeur, et le schema le dit

integrations et liens ne sont PAS generes, volontairement. La matrice des integrations montre aussi les universelles (non decochables) et les exemptions sauf_role : un champ texte genere ferait lire un plan silencieux comme « cet hote n'est pas supervise », l'inverse exact de la politique. L'editeur de liens contraint le role a ce que le groupe porteur accepte (meta/liens.yml). Le schema porte donc x-editeur, et le generateur s'efface — au lieu de remplacer un editeur qui en sait plus que lui.

La limite

Je n'ai toujours pas ouvert ces pages dans un navigateur. Le banc prouve qu'elles rendent sans lever et qu'un aller-retour ne perd rien ; il ne dit rien de leur lisibilite.

2026-09-08 (3) — Quarante lignes de memoire que chaque « Sauvegarder » detruisait

62 preuves (P01-P62). make prouver : CONFORME, 61 OK, 0 echec, 1 saute. Quatre registres sur six ont desormais un formulaire GENERE.

Ce que j'ai trouve en voulant generer deux formulaires de plus

Avant de basculer les vues Bases (serveurs de BD) et Domaines sur le generateur, j'ai confronte le schema a ce que les VALIDATEURS acceptent — pas seulement a ce que les plans contiennent. Trois ecarts, et un quatrieme trouve en chemin.

edge designe un GROUPE, pas un hote. Le schema declarait source_valeurs: serveurs. instancier compare pourtant cette valeur aux GROUPES d'un hote (e.get("edge") in groupes) pour lui deriver ses SAN de certificat. Un formulaire genere aurait offert web-frontal-01, qu'aucun hote n'aurait reconnu : aucun SAN, donc un certificat correct sur un nom que personne ne peut appeler. C'est mot pour mot la panne du 2026-08-25, qu'une liste mal choisie aurait reintroduite.

exposition manquait au schema. valider_domaines le valide entierement — une liste de {nom, cible, type} — et le schema l'ignorait. Aucun plan ne s'en sert : P61 etait au vert. Le formulaire genere n'aurait donc jamais pu offrir une fonctionnalite que le moteur possede deja.

applications.liens etait decrit items: {type: object} — une liste d'objets sans forme. Le moteur exige vers et role.

mail faisait l'inverse : offert par la vue Domaines depuis sa creation, decrit ici comme un booleen, saisi la-bas comme du texte, et lu par RIEN. Retire.

D'ou P62 : les champs qu'un validateur lit sur l'entite qu'il valide doivent tous etre decrits. Elle separe l'entite du reste mecaniquement — un validateur lit son entite par des variables LOCALES, et les autres registres par ses PARAMETRES (srv.get("fonction") contre nomenclature.get("fonctions")). Aucune liste a tenir. Controle negatif rejoue.

Et le degat qui etait deja la

En verifiant que le nouveau formulaire n'abimerait pas domaines.yml, j'ai mesure les quatre ecrivains de registre sur les fichiers REELS :

domaines.yml        6 lignes de commentaire ->  3   (PERD 3)
applications.yml   27                       ->  5   (PERD 22)
serveurs.yml       18                       ->  3   (PERD 15)
bases-donnees.yml   4                       ->  4   (intact — il n'en portait pas)

Quarante lignes, detruites par n'importe quel clic sur « Sauvegarder » dans les vues Serveurs, Applications ou Domaines. Parmi elles, celle qui explique pourquoi backup-01 a ete retire — « une supervision creuse est pire qu'aucune : elle est verte » — et celle qui dit dans quel ORDRE les deux roles du runner s'appliquent.

C'etait l'incident du 2026-08-18 (94 lignes perdues dans les fichiers d'intrants), jamais corrige pour les registres du plan : _fusion_chirurgicale avait ete ecrite pour les intrants seuls, et les quatre ecrire_* etaient restes au safe_dump. Ils passent tous par _ecrire_registre maintenant. Aller-retour a vide : les quatre fichiers sont identiques a l'octet. Une modification reelle ne touche que ses lignes. scripts/tests/test_ecriture_plan.py le mesure sur les vrais fichiers, controle negatif compris.

Les deux formulaires

Serveurs de BD et Domaines sont generes, chargement et sauvegarde compris. Le generateur a appris une forme de plus : la liste d'objets (exposition, liens), avec son bouton d'ajout et le retrait par ligne. L'ajout passe par le meme setter que les champs — on lui remet le tableau entier, serialise dans l'attribut — plutot que par une fonction globale a resoudre au clic : ce qui marche sous le banc marche dans le navigateur.

La limite

Quatre registres sur six. Restent serveurs et applications, les deux plus gros. Et je n'ai toujours pas ouvert ces pages dans un navigateur.

2026-09-08 (2) — La vue Nomenclature, et deux fautes que mes bancs ne pouvaient pas voir

61 preuves. make prouver : CONFORME, 60 OK, 0 echec, 1 saute. couverture_gui verifier : les 28 champs des plans reels sont editables — le dernier trou est ferme.

La vue

La nomenclature etait le seul registre que le GUI ne savait pas ecrire DU TOUT : ajouter une fonction exigeait d'ouvrir le YAML. Elle a maintenant sa vue, et son formulaire est GENERE depuis le schema — deuxieme registre sur six.

Elle n'est pas un registre comme les autres : elle ne decrit pas des objets, elle decrit la REGLE dont VMID, VLAN, adresse et passerelle se derivent. D'ou trois partis pris :

  • chaque fonction affiche ce qu'elle derive (zone, VLAN, sous-reseau, bloc d'hotes) et les VM qui la portent — sans ca, changer un chiffre est un geste a l'aveugle ;
  • index est montre mais PAS editable ici : le fichier dit lui-meme qu'il est RECU du site et non decide par le tenant ;
  • valider_nomenclature refuse de retirer une fonction encore portee par une VM, ou de designer une zone non declaree — deriver_nomenclature rendrait None EN SILENCE.

Les deux fautes, et pourquoi mes bancs ne les voyaient pas

1. Le formulaire des bases, livre la veille, etait casse dans un navigateur. Il lisait data.schema — or il n'existe aucun data global dans cette page : c'est une const locale de charger(). ReferenceError a l'ouverture. Pire dans sauvegarderBases, ou const data est declare PLUS BAS dans la meme fonction : zone morte temporelle, l'enregistrement jetait avant meme d'envoyer.

Je l'avais « eprouve » sous node — en PASSANT data a la fonction. Le banc reproduisait la fonction, pas sa PORTEE. verifier_gui.py, lui, ne verifie que la syntaxe.

Remede : un global schemaPlan, et surtout scripts/tests/test_rendu_gui.py, qui charge le JS entier dans un DOM simule, appelle charger() sur la VRAIE reponse de l'API, et dessine les douze vues. Son controle negatif remet une reference absente et exige que le banc la voie.

2. Le schema decrivait reservations comme une table de zones. Le fichier reel est un bloc plat, et underlay.py lit reservations.passerelle a plat. P61 ne voyait rien : elle comparait des NOMS aplatis, et passerelle existe des deux cotes — a des profondeurs differentes. Un formulaire genere depuis cette description aurait offert « ajouter une zone » et ecrit une forme que le moteur ne lit pas.

P61 compare desormais aussi la FORME : scalaire, bloc a clefs fixes, table a clefs libres. Controle negatif rejoue, elle echoue.

Ecrire dans la nomenclature sans deplacer un commentaire

Le fichier porte vingt-deux lignes qui disent pourquoi index est recu, et laquelle des fonctions est le poste d'exploitation. _fusion_chirurgicale les gardait toutes — mais elle remplace le BLOC entier des qu'une valeur change. Mesure : ajouter une fonction transformait quinze entrees compactes en quarante-deux lignes, et HISSAIT le commentaire du poste d'exploitation en tete du bloc, ou il affirmait que collab etait le poste d'exploitation.

Un commentaire deplace n'est pas laid : il est FAUX.

_fusion_table edite donc les tables LIGNE A LIGNE. Le diff d'un changement reel fait desormais trois lignes, le style compact survit, et le commentaire garde son voisin. scripts/tests/test_nomenclature_ecriture.py le fige, son controle negatif compris.

Au passage

sort_keys=True triait le schema genere alphabetiquement — donc l'ordre des cases a l'ecran. reserve_max passait avant reserve_min. L'ordre de REGISTRES est délibéré et un dict Python litteral est deja stable : le tri ne servait a rien et desservait les deux formulaires.

Et _ecrire_index_nomenclature ecrivait encore par write_text : oubliee au passage des ecritures atomiques du 2026-09-06. Une coupure y laissait le seed a moitie ecrit, c'est-a-dire tout l'adressage de l'ecosysteme.

La limite, dite franchement

DEUX registres sur six sont generes. Les quatre autres formulaires restent ecrits a la main. Et je n'ai toujours pas ouvert cette page dans un navigateur : le banc prouve qu'elle rend sans lever, pas qu'elle est lisible.

2026-09-08 — La forme des registres cesse d'etre recopiee : elle est derivee

61 preuves (P01-P61, dont une conditionnelle). make prouver : CONFORME, 60 OK, 0 echec, 1 saute.

Le probleme

La forme du plan etait ecrite TROIS FOIS : dans les constantes du moteur (ETATS_SERVEUR, PORTEES_BD, AUTORITES_DNS), dans les formulaires du GUI, et dans la documentation. Rien ne les tenait ensemble. Ajouter une portee de base de donnees demandait trois gestes, et le troisieme s'oubliait sans qu'aucun controle ne s'en apercoive.

Ce qui change

scripts/schema_plan.py (nouveau) derive un JSON Schema des SIX registres du plan — serveurs, applications, bases_donnees, serveurs_bd, domaines_publics, nomenclature — en IMPORTANT les enumerations du moteur plutot qu'en les recopiant. Le resultat est versionne dans docs/audit/schema-plan.json : 42 champs, regenerable par make schema. Il est versionne, et non recalcule a chaud, pour qu'une divergence se voie dans un diff.

Le schema porte ce que JSON Schema seul ne dit pas : x-source-valeurs (liste fermee alimentee a l'execution depuis l'inventaire), x-source-selon (liste dependant d'un autre champ), x-clef (l'attribut qui identifie l'entite), x-requis.

Le formulaire des bases de donnees du GUI n'est plus ecrit a la main. Il est construit au chargement depuis le schema servi par /api/inventaire. Le chemin de SAUVEGARDE aussi derive du schema : les champs ecrits sont ceux que le schema declare, avec ses valeurs par defaut — plus une liste de champs recopiee dans le JS.

P61 garde l'ensemble : le schema doit couvrir tout ce que les plans REELS contiennent. Son controle negatif : retirer un champ du schema le fait echouer.

La limite, dite franchement

UN registre sur six est genere. Les cinq autres formulaires restent ecrits a la main.

Et le trou connu reste ouvert : couverture_gui.py verifier echoue toujours sur nomenclature.categorie et nomenclature.service — deux champs presents dans les plans reels que le GUI ne sait pas ecrire. Le schema les DECRIT deja ; c'est le passage de la vue nomenclature au generateur qui fermera le trou, par construction.

Enfin : le formulaire genere a ete eprouve en rendant son HTML avec la vraie reponse de l'API, pas dans un navigateur.

2026-09-06 — Tournee des 74 documents : ce que le depot disait de lui-meme avait vieilli

57 preuves (P01-P57, dont une conditionnelle). make prouver : CONFORME, 56 OK, 0 echec, 1 saute. Aucun comportement ne change. Ce qui change, c'est que les documents cessent de decrire un depot qui n'existe plus.

La lecon de methode, d'abord

La revision a commence par un BALAYAGE PAR MOTIFS — chemins morts, cibles make absentes, comptes derives. Il a trouve une trentaine d'ecarts, et il a rate presque tout le reste. Un motif ne voit que ce qui s'exprime en motif.

make hote-planifier en est l'exemple exact : la cible EXISTE, donc le controle passait au vert. C'est une cible DEPRECIEE qui refuse et sort en 2 — recommandee par AGENTS.md, et contredisant la REGLE D'OR du meme fichier trois ecrans plus haut. Il a fallu lire pour la voir. D'ou la tournee : les 74 documents, un par un.

Les affirmations franchement fausses

AGENTS.md — la source d'autorite — annoncait « pas encore execute contre des VM reelles ». La flotte a ete rasee et remontee depuis zero le 2026-08-13, puis DEUX FOIS le 2026-09-02. docs/ecosysteme-chezlepro.md, qui est le document montre a un client, portait la meme phrase : il se sous-vendait gravement.

docs/courriel-conception.md s'ouvrait sur « Statut : CONCEPTION. Aucun role n'est encore ecrit » — au-dessus de son propre §1 qui nomme les trois roles, deployes et prouves.

docs/autorisation.md se terminait sur « Rien n'est construit », alors que le meme document rapporte des mesures DATEES prises sur le role en fonctionnement.

docs/hebergeur-exploitation.md disait « Rien n'est fait » d'un depot qui existe : SITE-Chezlepro, avec son plan, ses sept VM et le symlink en place.

docs/filiation-emancipation.md se contredisait a deux ecrans de distance : une section decrivait make emancipation-prouver, une autre affirmait que cet instrument n'existait pas.

Les modeles decrits d'apres un monde anterieur

  • Le resolveur. dns-interne.md, integrations-vm.md, deux unites du wiki et intrants-communs.md decrivaient un Unbound par VM, en opt-in. Depuis le 2026-08-24, client_resolveur N'INSTALLE PLUS RIEN et son integration est UNIVERSELLE. Trois documents le donnaient meme en exemple d'integration facultative — l'inverse exact.
  • L'adressage. nomenclature-vm.md decrivait un reseau unique 10.0.0.0/16, des VLAN 11 a 15 et des VMID a cinq chiffres : le modele d'avant le multi-instance.
  • Le nommage SDN. sdn-evpn.md annoncait CHEZ17 / chez174 ; le code produit t17 / t17serv. C'est le wiki qui avait raison.
  • La bascule D-77. Trois documents la donnaient « en cours » ; 10.0.0.0/24 n'existe plus depuis le 2026-08-22.
  • Le mecanisme de voute. Cinq documents — dont le runbook de REPRISE — designaient un ANSIBLE_VAULT_PASSWORD_FILE unique. Une voute, une cle depuis le 2026-08-28.

Ce qui casse au premier essai

Le nom du gabarit dore etait faux a quatre endroits, dont la procedure qui le FABRIQUE (debian13-template) et le critere de reussite R2 de l'epreuve de l'operateur independant. Le defaut du code est modeleSetOPS, et le clonage cherche sa source PAR CE NOM.

preparer-un-site-hebergeur.md avertissait qu'une VM creee a la main serait detruite par l'outil. C'est l'inverse : raser derive sa liste du plan, il ne la detruira JAMAIS — elle survit sans DNS, sans certificat, sans sauvegarde, et son VMID n'est garde par aucune preuve.

Un mot de passe d'essai en clair dans wiki/Courriel.md, dans un depot public.

Le nom de l'inventaire n'en est pas un

Douze chemins ecrivaient instance/inventories/production/hosts.yml, la REGLE D'OR d'AGENTS.md comprise. Cet inventaire n'existe pas ici : la flotte dit principal. Mais le modele public dit bien production, et le moteur CHERCHE le nom au lieu de l'imposer : ecrire l'un des deux en dur etait faux pour la moitie des lecteurs.

Deux preuves etendues, et une qui se trompait elle-meme

P57 (comptes en prose) couvre desormais les GROUPES. Elle a signale dans la seconde qui a suivi que catalogue-services.md annoncait « 30 groupes classes » la ou il y en a 40, et « les 29 groupes » au-dessus d'un tableau qui en cite 40 — une contradiction A UNE LIGNE DE DISTANCE.

P29 confronte desormais le tableau de docs/authentification.md aux declarations reelles. La ligne sans-auth-humaine annoncait 12 roles ; il y en a 21. La preuve lisait ces declarations depuis le debut sans jamais regarder ce que le document en disait.

Et P57 imposait un chiffre faux. Elle mesurait len(PREUVES) = 56, mais le depot porte 57 preuves : P16 est conditionnelle et vivait DANS main(), hors de tout comptage. Un garde-fou qui fait respecter une erreur est pire qu'aucun garde-fou — il ajoute l'assurance a l'erreur.

Ce que la tournee laisse en place, et qu'aucune preuve ne tient

Deux comptes trouves a la main : le README annoncait cinq portes et en ouvrait six ; implanter-un-tenant-sur-un-site.md renvoyait aux « huit lignes » d'une fiche qui en compte dix. Et une lacune reelle, nommee dans autorisation.md : rien ne garde les meta/acces.yml — ni qu'un service web-sso en porte un, ni que le groupe qu'il nomme existe. P29 garde les POSITIONS d'authentification ; personne ne garde les HABILITATIONS.

2026-09-05 (2) — Rouvrir les cles : la cle USB doit se suffire a elle-meme

56 preuves. Les cles sont sorties du poste. Restait la moitie qui compte : savoir les REMETTRE. Une sauvegarde qu'on ne sait pas rouvrir n'est pas une sauvegarde.

Le piege, et pourquoi la procedure ne peut pas vivre dans le depot

Le jour ou l'on s'en sert, le poste est mort. Le depot Set-OPS est replique trois fois — eregion, la forge du site, patient 0 — mais le CLONER demande la cle SSH, qui est justement dans l'archive qu'on essaie d'ouvrir. Une procedure de restauration rangee dans le depot serait donc inaccessible exactement quand elle sert.

exporter_cles.py depose donc, A COTE DE L'ARCHIVE : restaurer_cles.py et un LISEZ-MOI-RESTAURATION.txt. La cle ne demande plus que gpg, python3 et la phrase de passe.

Ce que le script fait de plus qu'un tar -x

  • il remet chaque fichier a sa place selon son NOM, pas selon un chemin enregistre — on restaure souvent sur une machine neuve, parfois sous un autre compte ;
  • il repose les droits a 0600. ssh REFUSE une cle privee que d'autres peuvent lire, et son message ne dit pas qu'il s'agit d'un droit — une archive extraite depuis un support FAT arrive toujours dans cet etat ;
  • il refuse d'ecraser une cle deja presente, et regarde AVANT d'ecrire : refuser a mi-parcours laisserait la moitie des cles en place et l'autre non, un etat que personne ne sait diagnostiquer.

Et si meme ce script ne tourne pas

Le LISEZ-MOI porte les trois commandes manuelles — gpg, tar, chmod. C'est le vrai filet : un outil peut avoir un defaut, gpg et tar seront la.

EPROUVE, PAS SUPPOSE

Cycle complet sur des fichiers factices : export vers une cle, poste neuf entierement vide, restauration depuis la cle seule, puis comparaison.

IDENTIQUES — 4 empreintes sur 4
700 .ssh   600 .ssh/id_git_ed25519   600 .config/setops-vault-…
REFUS : ces cles existent deja ici — Rien n'a ete ecrit.

Le filet manuel a ete passe au meme test, separement : identiques, 4 sur 4.

Le test le moins cher, a refaire souvent

make cles-restaurer ARCHIVE=/media/…/setops-cles-*.tar.gpg

Il refusera, puisque les cles sont en place — et ce refus est la preuve que l'archive s'ouvre et que la phrase de passe est la bonne. A refaire apres chaque changement de cle.

2026-09-05 — 1 644 octets valent l'installation, et ils n'existaient qu'a un endroit

56 preuves. En cherchant par quoi reprendre, une mesure a change l'ordre des priorites : le CODE de Set-OPS est replique trois fois — eregion, la forge du site, patient 0 — et les voutes chiffrees y sont aussi. Le coffre est solide.

Les CLES qui l'ouvrent vivaient dans neuf fichiers de ~/.config et ~/.ssh, 1 644 octets au total, sans aucune copie ailleurs. C'est le pire rapport valeur/fragilite de l'installation : six cles de voute qui ouvrent tous les secrets — jetons Proxmox et OPNsense, mots de passe de step-ca, des bases, et les vault_restic_password — plus les cles SSH par lesquelles on entre partout.

Ce qu'on perdait avec le poste, sans exagerer la gravite :

  • le poste seul : les mots de passe restic restent lisibles sur les machines vivantes (/etc/setops/restic.pass) — recuperable, mais douloureux, et plus aucun deploiement possible entre-temps ;
  • le poste et une machine : l'etat de cette machine devient illisible ;
  • le poste et le site : terminal.

make cles-recenser et make cles-exporter

Le recensement montre ce qui sortirait sans rien ecrire — nom, taille, empreinte, jamais le contenu. L'export met le tout dans une archive chiffree (AES256, phrase de passe symetrique) sur un support choisi.

A LANCER SOI-MEME. gpg demande une phrase de passe : elle ne doit passer ni par un journal, ni par le contexte d'un assistant. La cible le dit dans son propre commentaire.

ECRIRE PUIS RELIRE (D-68), applique a ce qui compte le plus : l'outil REDECHIFFRE ce qu'il vient d'ecrire et compare les empreintes une a une. Une sauvegarde de cles qu'on n'a pas rouverte n'est pas une sauvegarde, c'est un fichier dont on espere quelque chose.

Trois refus, eprouves en les faisant echouer

  • destination dans l'infrastructure — ces cles ouvrent les sauvegardes ; les y ranger ferait un coffre dont la cle est a l'interieur. ~/Espace Chezlepro et /srv/restic sont refuses nommement ;
  • archive existante — on n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule ;
  • archive illisible — l'archive est SUPPRIMEE. Elle donnerait le sentiment d'etre protege sans l'etre.

Les trois ont ete essayes sur des fichiers factices avant d'approcher les vraies cles — et le premier essai du premier refus etait FAUX : le shell developpait $HOME avant que je le remplace, l'instrument mesurait donc ailleurs que la cible. Reteste correctement, le refus tire. Encore une fois : verifier d'ou l'instrument mesure.

Ce que l'outil ne couvre pas, et qui reste a l'humain

La phrase de passe (perdue, l'archive est du bruit) et la seconde copie dans un autre lieu. Un support unique dans un tiroir unique, c'est le probleme qu'on vient de fermer, deplace de quelques metres. Ces deux gestes sont ecrits dans la sortie du script et dans docs/sortir-les-cles-du-poste.md — pas en note de bas de page.

2026-09-03 — Les paquets non Debian passent par le cache : le dernier obstacle au hors-ligne

56 preuves. Trois depots tiers etaient configures sur la flotte — Smallstep, Icinga, Grafana — tous en HTTPS. Or client_artefacts pose Acquire::https::Proxy "DIRECT", sans quoi le cache du site refuse les tunnels HTTPS (« 403 CONNECT denied ») et aucun depot tiers n'est joignable.

Cette ligne est juste, et elle a ete posee pour une bonne raison. Sa CONSEQUENCE n'avait pas ete vue : ces trois depots contournent le cache par conception, et chaque VM neuve allait les chercher sur Internet a sa naissance.

step-cli (Smallstep) et alloy (Grafana) sont poses par des integrations universelles : sans lien, une machine neuve n'obtenait ni son client d'autorite ni ses metriques — elle n'entrait dans aucun flux chiffre. C'etait le dernier obstacle a une reconstruction hors ligne, et il tenait dans un mot : DIRECT.

Ce qui change

make cacher-paquets lit paquets-tiers.yml, va chercher l'index de chaque depot, tire les 21 .deb aux versions epinglees et verifie l'empreinte SHA256 que l'index annonce. Tout atterrit dans ~/.cache/setops/paquets/, a cote de Forgejo, Keycloak et Nextcloud — meme patron depuis toujours : telecharger une fois, verifier, deposer ensuite.

Le role partage paquets_tiers les depose et les installe en un seul appel a apt : apt sait resoudre un ensemble de fichiers locaux qui se dependent mutuellement, la ou une installation paquet par paquet echouerait sur l'ordre. Icinga en apporte dix-sept dans ce cas.

Six roles y sont branches — client_pki, client_journal, serveur_icinga, serveur_icingaweb2, serveur_grafana, serveur_loki. Chacun retombe sur le depot distant pour ce que le cache n'a pas fourni, et rien d'autre : reinstaller ce que le cache vient de poser ferait un update_cache inutile qui, hors ligne, echouerait APRES un travail deja fait.

La coupure, eprouvee pour de vrai

Sur web-dorsal-01 : packages.smallstep.com renvoye vers 127.0.0.1, step-cli desinstalle, cache local efface. Le role rejoue :

step-cli : 0.30.6-1
installe depuis : step-cli_0.30.6-1_amd64_...deb
certificat present : 2

Zero echec. Le depot etait injoignable et la machine a obtenu son certificat.

Deux defauts de mon propre outil, trouves en le construisant

Smallstep sert son index NON COMPRESSE. Icinga et Grafana servent Packages.gz ; Smallstep sert Packages en clair et rend 404 sur le reste. La premiere version ne demandait que .gz : le depot echouait, et step-cli — le paquet de CHAQUE machine — n'a jamais ete mis en cache.

Et le script rapportait « 20 tires, 20 declares ». Un depot injoignable faisait continue AVANT d'incrementer le total : il disparaissait du decompte, et l'echec se lisait comme un succes. Une garde qui ne peut pas echouer ne garde rien — troisieme occurrence cette semaine, cette fois dans un outil ecrit le jour meme.

Corrige, puis eprouve en le faisant echouer : depot rendu injoignable, le script rend INCOMPLET et sort en 1 ; tout present, il sort en 0.

Ce qui reste hors ligne

Deux dependances mineures, nommees pour qu'on sache : le temps (NTP externe, la derive est lente) et l'expedition des alertes (le relais livre directement aux MX). La supervision continuerait de VOIR sans pouvoir le DIRE.

2026-09-02 (6) — Deuxieme reconstruction de validation : une course que la premiere n'avait pas montree

56 preuves. Reconstruction : 14/14 hotes, 0 echec. make valider : 0 echec sur 13 hotes. Chezlepro a ete rasee une seconde fois et refaite depuis le gabarit minimal, pour eprouver tout ce qui avait change depuis la veille.

Ce que ce cycle a VALIDE

Les corrections de la veille ont toutes tenu en conditions reelles :

  • La garde de clonage — edge-mta-01, infra-mail-01 et ops-01 ont clone « AVEC AVERTISSEMENT ». Les trois auraient ete declarees perdues l'avant-veille ; le clonage continue, et l'avertissement s'affiche au lieu d'etre avale.
  • La degradation de client_artefacts — infra-pki-01 et infra-dns-01, premieres machines debout, sont restees sur le cache du SITE et l'ont dit. Elles ont bascule sur celui du locataire dans la passe principale.
  • enabled a la frontiere, la delegation de zone, le durcissement du site — aucun incident.

LE DEFAUT QUE SEULE UNE SECONDE RECONSTRUCTION POUVAIT MONTRER

forge-01 bouclait :

migration[v14a_add-foreign-keys-collaboration] ... failure to delete inconsistent
records before foreign key sync: la relation « collaboration » n'existe pas

La base contenait 5 tables avec version=305 deja inscrite. A moitie faite.

C'est une course. Le role DEMARRE Forgejo, qui entame son initialisation de premier lancement — creation du schema et migrations, plusieurs dizaines de secondes. Puis flush_handlers le REDEMARRE, parce qu'app.ini vient de changer. Redemarre au milieu, il laisse la version cible inscrite et le schema absent.

Il ne s'en releve jamais seul : ORM engine initialization attempt #1/10, #2/10… indefiniment. Le service reste active — il n'a pas plante, il reessaie — et n'ecoute jamais son port. Une machine verte qui ne sert rien.

Pourquoi la premiere reconstruction ne l'avait pas vu. Aux deploiements suivants la base est deja migree, le premier demarrage est instantane, et le redemarrage ne tombe au milieu de rien. Le defaut n'existe que sur une base VIERGE, et meme la il depend du timing. Il a fallu deux reconstructions completes.

La correction attend la fin du premier demarrage AVANT tout redemarrage. Le schema a ete remis a zero — il ne contenait rien — et Forgejo est monte du premier coup.

La chaine de sauvegarde, prouvee sur une flotte entierement neuve

Neuf detenteurs d'etat ont depose sur le depot du SITE, puis chacun a verifie SON depot distant et l'a rapporte a l'Icinga de l'ecosysteme :

collab-01      OK : instantane il y a 0 h, 108 fichier(s)
data-sql-01    OK : instantane il y a 0 h, 1 fichier(s)
edge-mta-01    OK : instantane il y a 0 h, 145 fichier(s)
forge-01       OK : instantane il y a 0 h, 29 fichier(s)
idm-01         OK : instantane il y a 0 h, 1 fichier(s)
infra-mail-01  OK : instantane il y a 0 h, 7 fichier(s)
infra-pki-01   OK : instantane il y a 0 h, 12 fichier(s)
web-dorsal-01  N'EMPORTE RIEN — a confirmer
web-frontal-01 N'EMPORTE RIEN — a confirmer

Le cycle complet — deposer chez l'hebergeur, verifier avec la cle qu'on est seul a detenir, rapporter a sa propre supervision — tourne sur des machines nees il y a une heure.

2026-09-02 (5) — La verification suit la cle : chaque noeud constate SON depot

56 preuves. make valider : 0 echec sur 13 hotes, test de restitution compris.

backup-01 est retire du plan de Chezlepro et sa VM detruite. Elle ne gardait plus rien : son /srv/restic ne contenait que les fichiers de demarrage du compte restic, aucun depot, aucun instantane — et sa verification rapportait consciencieusement « tout va bien ». Une supervision creuse est pire qu'aucune : elle est verte.

Pourquoi la verification a change de main

serveur_backup verifiait pour tout le monde, et c'etait juste : un noeud sait qu'il a LANCE sa sauvegarde, il ne sait pas qu'elle a ABOUTI — le depot etait le seul a voir ce qui arrivait vraiment.

Depuis que les ecosystemes deposent chez leur HEBERGEUR, ce n'est plus vrai. Le site heberge des octets chiffres COTE CLIENT, avec un mot de passe qui ne quitte pas la voute du locataire : il ne peut ni les lire, ni les ouvrir, ni dire s'ils valent quelque chose. C'est la propriete qui rend la mutualisation acceptable, et elle deplace la verification chez le seul qui detient la cle — le noeud lui-meme.

Il verifie donc SON depot distant, pas le fait d'avoir lance sa sauvegarde. La nuance est tout : une unite verte sur un depot vide est exactement ce qui a menti pendant un mois (2026-07-03 -> 2026-08-11).

Deux modeles, et le role sait desormais dans lequel il est

serveur_icinga exigeait un hote serveur_backup dans l'ecosysteme et attachait les services sauvegarde: <noeud> a cet hote. Sans depot local, il refusait de se deployer.

Il se branche maintenant :

  • depot local — il rapporte pour tous, les services vivent sur SON hote, et leur nom dit de quel noeud on parle : sauvegarde: idm-01 ;
  • pas de depot — chaque noeud rapporte le sien, le service vit SUR LUI, et s'appelle simplement sauvegarde. Repeter le nom donnerait « idm-01 / sauvegarde: idm-01 ». Et c'est plus juste : la sauvegarde d'idm-01 est un attribut d'idm-01.

Ce qui reste exige, c'est le secret d'API — sans lui, personne ne peut rien rapporter.

Le 404 qui n'etait pas une absence

Les noeuds recevaient {"error":404,"status":"No objects found."} — le message d'un objet ABSENT. icinga2 object list montrait pourtant idm-01!sauvegarde charge et vivant.

Le filtre du compte d'API portait match("sauvegarde: *", service.name) : la premiere forme de nom seulement. C'etait la PERMISSION qui refusait, et elle le disait avec les mots d'une absence. Le filtre accepte desormais les deux formes, sans s'elargir au-dela : ce compte ne peut poser un resultat que sur un service de sauvegarde.

serveur_icinga declare aussi ingress 5665 depuis client_backup — le pair ne nommait que serveur_backup, qui n'existe plus chez ce locataire.

Ce que la supervision dit maintenant, chez le locataire

collab-01      OK : instantane il y a 20 h, 108 fichier(s)
data-sql-01    OK : instantane il y a 20 h, 1 fichier(s)
edge-mta-01    OK : instantane il y a 20 h, 138 fichier(s)
forge-01       OK : instantane il y a 20 h, 29 fichier(s)
idm-01         OK : instantane il y a 20 h, 1 fichier(s)
infra-mail-01  OK : instantane il y a 20 h, 7 fichier(s)
infra-pki-01   OK : instantane il y a 20 h, 12 fichier(s)
web-dorsal-01  N'EMPORTE RIEN : instantane sans aucun fichier — a confirmer
web-frontal-01 N'EMPORTE RIEN : instantane sans aucun fichier — a confirmer

Les deux avertissements sont honnetes : ces repertoires sont reellement vides, aucune webapp n'est deployee. La machine ne peut pas distinguer « les donnees ont disparu » de « il n'y en a pas encore » — c'est a un humain de trancher, mais il doit le VOIR.

Constat non corrige

Retirer une VM du plan ne la detruit pas : make raser ne connait que les hotes DU plan, donc plus celle-ci. Il a fallu la detruire a la main sur l'hyperviseur. Un hote retire du plan devient un orphelin qu'aucune cible ne ramasse.

Et Prometheus a continue de scruter son exportateur jusqu'a ce que serveur_prometheus soit rejoue — make valider l'a attrape, ce qui est exactement son role.

2026-09-02 (4) — Le site a un temoin ; et une panne dormait depuis des semaines dans un mot

56 preuves. L'hebergeur a desormais sa propre supervision : site-mon-01 (VLAN 36, zone site-supervision) porte PostgreSQL, Icinga et un relais de courriel. Le depot de sauvegarde lui rapporte, et son premier verdict a ete un vrai defaut — site-mon-01 n'avait jamais depose son propre etat. Corrige, les trois sont au vert :

sauvegarde: site-forge-01  ->  OK : instantane il y a 5 h, 835 fichier(s)
sauvegarde: site-pki-01    ->  OK : instantane il y a 5 h, 13 fichier(s)
sauvegarde: site-mon-01    ->  OK : instantane il y a 0 h, 1 fichier(s)

serveur_backup_verification_locale repasse a true sur le depot du site. Ce drapeau ne dit plus « on renonce » mais « verifie ce que tu peux ouvrir » : serveur_backup_noeuds_attendus derive de l'inventaire OU TOURNE LE ROLE, donc des seules machines du site, dont il detient le mot de passe restic. Il reste aveugle aux locataires, et c'est le but.

serveur_postfix gagne un mode relais

Le role est le serveur de courriel d'un ecosysteme : il exige un bind LDAP et un magasin Dovecot. Un site n'heberge aucune boite — il lui faut EXPEDIER, et rien d'autre. Plutot qu'un role jumeau (qui aurait duplique le certificat, sa resynchronisation, la resolution dans le chroot et la validation), un mode : complet par defaut, relais pour le site.

Le site expedie DIRECTEMENT par sa frontiere. Emprunter le MTA d'un locataire ferait dependre l'hebergeur d'un ecosysteme qu'il peut outvivre — une emancipation emporterait son alerte avec elle.

LA PANNE QUI DORMAIT DANS UN MOT : disabled au lieu de enabled

Le modele de routes d'OPNsense lit enabled. appliquer_opnsense lui envoyait disabled: "0" — un champ qu'il IGNORE. enabled restait donc a son defaut, c'est-a-dire ETEINT. Chaque route creee par Set-OPS depuis l'origine l'etait desactivee.

Pourquoi personne ne l'avait vu : une route eteinte EXISTE dans le modele. frontiere-plan la comptait « posee » et annoncait « 15 routes inchange ». Elle n'etait simplement pas installee dans la table de routage — et tant que le boitier n'avait pas ete recharge depuis sa creation, le noyau gardait les routes ajoutees a la main lors de la mise en place. Tout fonctionnait.

Le rechargement complet lance pour activer la patte du VLAN 36 a fait reprendre au noyau sa table depuis le modele : quatorze routes sur quinze ont disparu, et onze machines de Chezlepro sont devenues injoignables — le lendemain de sa reconstruction. Le devis, lui, restait vert.

Trois corrections, parce qu'il y avait trois defauts empiles :

  1. La cause : enabled: "1".
  2. La cecite : le plan lit desormais la TABLE DU NOYAU (/api/diagnostics/interface/getRoutes, en GET, reponse en liste nue — sondee en POST puis en {"rows": ...}, elle rendait « impossible de lire » sur une frontiere qui repondait tres bien) et signale toute route declaree mais absente, ainsi que toute route presente mais eteinte.
  3. L'inaction : la reconfiguration n'etait declenchee que if routes_creer or routes_retirer. Aucun changement, donc aucune reparation : appliquer rendait « OK » sur un routage casse. Elle se declenche maintenant aussi quand le noyau a perdu quelque chose.

Et « rien a faire » ne s'affiche plus quand le noyau, lui, a du travail : le message disait vrai du modele et faux du service.

Un role du site peut enfin en appeler un autre

serveur_icinga declare ingress 5665 depuis serveur_backup, et serveur_backup l'egress en face. Les deux etaient justes, et la regle de frontiere n'existait pas : le devis traitait tout pair nomme comme « l'exterieur » et sautait le flux. C'etait vrai tant qu'un pair designait quelque chose de lointain — mais un pair peut nommer un role DU SITE, pose dans une AUTRE ZONE, et depuis le decoupage en zones deux machines du site ne se parlent qu'a travers la frontiere. Le depot pouvait joindre un Icinga du monde entier sauf celui de son propre site.

Le plan d'administration se DERIVE, il ne se recopie pas

L'intrant nftables_admin_ssh du site listait a la main les pattes de la frontiere, sous ce commentaire : « oublier une seule de ces adresses rend une zone entiere inadministrable ». Une zone a ete ajoutee le meme jour, la liste ne l'a pas suivie, et site-mon-01 est devenue injoignable des l'application de son pare-feu — rouverte par l'agent invite de l'hyperviseur. La liste LIT desormais la carte ; l'intrant ne sert plus qu'aux voies qu'elle ne peut pas connaitre.

Ce qui reste ouvert

Le destinataire des alertes est encore root@localhost — le defaut d'Icinga. La supervision voit et sait ; elle ne sait pas encore a QUI parler.

2026-09-02 (3) — Le site sauvegarde son propre etat, et la preuve le lui demande

56 preuves. L'hebergeur protegeait l'etat de tous ses locataires et pas le sien. Deux choses qu'il detient et que personne ne peut regenerer :

  • la racine de son autorite de certification (/etc/step-ca) — compromise, elle forge tout nom ; perdue, il faut redistribuer la confiance a chaque machine de chaque ecosysteme ;
  • la forge du genome (/var/lib/forgejo) — les quatre depots dont tout descend. Ils vivent aussi sur les postes et les runners, mais la forge est le seul endroit ou ils se rejoignent.

Tout le reste est reconstructible par le code : le cache se re-remplit, la zone DNS se regenere depuis le plan, les depots du runner vivent dans git.

Preuve faite, pas annoncee. Sauvegarde reelle sur les deux hotes, puis restic check et restitution : 13 fichiers / 152 Ko pour l'AC — secrets/root_ca_key et secrets/intermediate_ca_key compris — et 835 fichiers / 31 Mo pour la forge.

Son propre compte, sur son propre depot

Le site depose avec SON identite (backup_pubkey au plan, moitie privee dans underlay.vault.yml), sur le compte restic que serveur_backup lui cree — celui dont le home est /srv/restic/site, a cote des comptes des locataires et separe d'eux par les memes permissions. Celle des locataires ne lui sert a rien et ne doit pas lui servir.

La cible est DERIVEE du expose de l'application qui porte serveur_backup_site — le nom du SERVICE, la meme source qu'un locataire utilise. Une adresse gravee dans client_backup_repo rendrait le depot indeplacable.

P36 lisait le plan de l'instance montee, donc jamais celui du site

La preuve qui exige qu'un detenteur d'etat porte client_backup ne regardait que l'ecosysteme. L'hebergeur y echappait entierement — et c'est lui qui detient le plus.

Elle lit desormais les deux plans, avec le meme catalogue et la meme regle : ce qui se lit statiquement se prouve statiquement, sinon on l'apprend le jour de la restauration (D-75). 9 hote(s) de l'ecosysteme et 2 du site.

Verifiee en la faisant echouer : l'integration retiree de site-pki-01, P36 tire et le harnais passe NON CONFORME. Une garde qui ne peut pas echouer ne garde rien — cette semaine en a deja produit deux.

Ce qui reste ouvert, et qu'il faut savoir

Personne ne verifie les sauvegardes du site. serveur_backup rapporte l'etat reel des instantanes a Icinga ; le site n'a pas de supervision, et son depot tourne avec serveur_backup_verification_locale: false — pose pour les locataires, dont il ne peut pas ouvrir les depots. Pour SES propres depots il le pourrait, mais il n'a personne a qui le dire. La sauvegarde existe et se restaure ; c'est son SILENCE qui n'alerte pas encore.

2026-09-02 (2) — Les machines du site sont durcies : le commentaire disait vrai, le code n'en faisait que la moitie

56 preuves. Les six machines du SITE — racine de l'AC, forge du genome, cache d'artefacts, resolveur, depot de sauvegarde, runner — ne recevaient QUE serveur_debian. serveur_durci ne leur etait jamais applique : ni auditd, ni fail2ban, ni apparmor, ni sysctl, ni pare-feu. Un inventaire de tenant met chaque machine dans les DEUX groupes (inventory_host.py) ; celui du site n'en mettait aucune dans le second.

Le commentaire au-dessus de GROUPE_SOCLE affirmait pourtant : « elle veut le meme durcissement SSH, les memes horloges, LE MEME PARE-FEU que n'importe quelle machine de la flotte ». Personne ne pouvait voir l'ecart, puisque la documentation disait le contraire de ce que le code faisait. Deuxieme fois en deux jours qu'un commentaire juste couvre une implementation qui ne l'est pas.

Trouve en cherchant autre chose : un depot de sauvegarde refusait une cle valide, et le fichier de durcissement SSH fautif datait d'une version abandonnee le 2026-08-09. Rien ne l'avait jamais remplace parce que rien n'appliquait le role qui le remplace.

Etat obtenu, mesure sur les machines : auditd, fail2ban, apparmor et nftables actifs sur les six, chacune avec son jeu de regles resolu, aucun service en echec.

Le pare-feu du site n'avait aucune des trois pieces qui le rendent applicable

1. Aucune regle n'etait generee pour le site. resoudre_flux.py nftables lit un hosts.yml ; le site a un inventaire DYNAMIQUE. Ses machines etaient donc invisibles pour la generation. Deux absences se cachaient l'une l'autre : pas de regles, et pas de pare-feu pour s'en apercevoir. D'ou nftables-site, qui traduit l'inventaire dynamique vers la forme attendue plutot que de dupliquer la generation, et ecrit a cote du plan du site. Branche sur make flux, pour que ca ne se demode pas.

2. Le chemin du jeu de regles sortait du depot. nftables_baseline derive son chemin de inventory_dir — juste pour un tenant, faux pour un inventaire dynamique dont le inventory_dir est scripts/. Le role serait tombe sur son repli : une politique drop sans les regles resolues.

3. Aucun plan d'administration. nftables_admin_ssh est la seule chose qui empeche un deploiement de couper la main qui le lance. Pour le site, il fallait comprendre que l'exploitant administre PAR REBOND : la connexion est ouverte par la frontiere et arrive avec l'adresse de sa patte DANS LA ZONE DE LA MACHINE VISEE, pas avec celle du poste. Oublier une seule de ces adresses rend une zone entiere inadministrable. Le generateur REFUSE desormais de produire des regles si cet intrant manque.

Le defaut que la lecture a attrape avant l'application

Le jeu de regles produit pour site-dns-01 autorisait 10.23.0.0/16 et 10.29.0.0/16 sur le port 53 — et PAS 10.17.0.0/16. _supernets_voisins() retire l'instance montee : juste chez un tenant, ou nul n'est son propre voisin ; faux du cote du site, ou ca retire le locataire qu'on pilote au moment ou l'on genere, c'est-a-dire presque toujours celui a qui l'on tient le plus.

Chezlepro resout encore sur le resolveur du site. L'appliquer aurait prive de DNS les quinze machines reconstruites la veille. C'est le meme piege que site_inventaire documente deja pour les ACL du resolveur : il s'est retendu ici parce que la regle est portee par la FONCTION, pas par le lieu qui l'appelle.

Une valeur qui dependait de l'ordre des plays

serveur_backup_site desserrait MaxStartups a 60:30:200 — un depot de site voit la somme des locataires. La valeur vivait dans son meta, donc ne portait que dans SON play. Le jour ou serveur_durci a apporte ssh_hardening a toutes les machines du site, son passage — le dernier — a remis le depot au defaut, sans rien signaler. Passee en host_var : tout play qui applique le role la voit. Un reglage qui depend de l'ordre des plays est un reglage qui reviendra en arriere.

Verifie apres coup, pas seulement dans le journal

Depuis un locataire, a travers le nouveau pare-feu : le cache repond, la forge repond, le depot rend 2 snapshots, le resolveur du site resout. L'AC du site, elle, refuse — et c'est correct : son flux declare pair: flotte, un locataire a la sienne.

2026-09-02 — Reconstruction de Chezlepro depuis zero : quatre defauts que seule une flotte rasee pouvait montrer

56 preuves. make valider : 0 echec sur 13 hotes, test de restitution compris.

Les quinze VM de Chezlepro ont ete detruites, disques compris, puis refaites depuis le gabarit minimal — make reconstruire : creation des VM, AC et DNS montes completement d'abord, puis toute la flotte par couches. Resultat : 15/15 hotes, 0 echec, 0 injoignable, et les neuf detenteurs d'etat redeposent sur le depot du site.

Aucun des quatre defauts rencontres ne venait de la flotte ni du gabarit. Tous venaient de gardes ou de derivations que rien n'avait jamais eprouvees, faute d'avoir jamais tout reconstruit.

1. Un avertissement n'est pas un echec

Cinq VM sur quinze declarees perdues. Le journal Proxmox disait pourtant transferred 16.0 GiB of 16.0 GiB (100.00%), suivi de :

can't deactivate LV 'vm-9006-disk-1': Logical volume in use.
WARN: volume deactivation failed

Proxmox desactive le volume DU GABARIT apres un clonage, et n'y arrive pas tant qu'un autre clonage parallele s'en sert — la consequence NORMALE de cloner un meme gabarit en parallele, ce que flotte-creer fait justement pour aller vite. La garde n'acceptait que exitstatus == 'OK', or Proxmox rend trois formes : OK, WARNINGS: n pour une tache ABOUTIE qui signale quelque chose, et un texte d'erreur pour un vrai echec.

Son message trompait en plus : « etat stopped » designe l'etat de la TACHE (terminee), pas celui de la VM. On cherche un probleme de demarrage qui n'existe pas. Le message le dit desormais. Et l'avertissement est AFFICHE au lieu d'etre avale.

2. Une garde qui se contredisait elle-meme

_amorcer-socle monte l'autorite en premier, comme il se doit. infra-pki-01 est donc la premiere machine debout — a un moment ou le cache du locataire, qui vit DANS la flotte qu'on reconstruit, n'existe pas encore. client_artefacts arretait tout la.

Le plus parlant : le commentaire du role decrivait deja le bon comportement — « en laissant le plancher en place, elles restent servies par le site : degrade, mais debout, et reparable par un deploiement ». Le code faisait une assert qui arretait. Or ce que la garde protege, c'est le plancher d'amorcage : il suffit de NE PAS l'ecraser. Arreter en plus ne protege rien et rend la reconstruction impossible — aucun ordre de deploiement ne pouvait la satisfaire, la contradiction etait dans la garde.

Elle degrade desormais, et le journal le DIT. La convergence a fonctionne dans la meme passe : les quinze machines ont fini sur le cache de leur ecosysteme.

3. La racine nie notre TLD, et Unbound etend ce « non » — la vraie cause

internal. n'est pas delegue dans la racine, qui est SIGNEE : elle rend une preuve NXDOMAIN validee pour ce TLD. harden-below-nxdomain (actif par defaut) tient ce « non » pour prouve et repond NXDOMAIN pour TOUT nom sous internal. DEPUIS SON CACHE — sans jamais interroger la stub-zone ni la forward-zone.

Le declencheur : n'importe quelle question sur un nom inexistant sous internal., y compris la zone d'UN AUTRE ecosysteme, que ce resolveur ne sert pas et va donc chercher a la racine. Sur un resolveur partage par plusieurs locataires, ca arrive en permanence.

Pourquoi ca a pris deux jours. La panne parait intermittente : au redemarrage le cache est vide, tout fonctionne, on conclut que c'est regle. Une seule requete l'eteint ensuite pour des heures. Pire, le cache contenait EN MEME TEMPS la bonne reponse et un message negatif pour le meme nom — c'est le message qui etait servi. Il a fallu lire le cache.

Le remede a d'abord ete mal identifie : aggressive-nsec: no avait semble marcher, parce que le REDEMARRAGE qu'il imposait vidait le cache. C'est le vidage qui soignait. Mesure qui tranche, cache vide puis une requete empoisonnante :

harden-below-nxdomain: no  seul -> repond
aggressive-nsec: no        seul -> ne repond pas

Ce qu'on perd : une protection anti-usurpation qui suppose que la racine dit vrai sur nos noms. Elle ne le peut pas — nos zones n'y sont pas.

4. Un locataire doit savoir a qui demander la zone de son hebergeur

Un locataire depend de services du SITE par leur NOM : cache, forge, depot de sauvegarde, autorite. Tant que ses machines pointaient sur le resolveur du site, ca marchait — par accident. Reconstruites proprement, elles utilisent LEUR resolveur, qui ignorait cette zone : make valider a echoue sur la RESTITUTION d'une sauvegarde. La sauvegarde etait intacte ; c'est le chemin pour la NOMMER qui manquait.

D'ou serveur_resolveur_zones_deleguees, derive par instancier. La premiere version prenait dns_amorcage pour l'adresse du resolveur du site — vrai chez Chezlepro, FAUX chez Technolibre, dont l'amorcage pointe sur 9.9.9.9. On aurait delegue la zone souveraine de l'hebergeur a Quad9. P03 l'a attrape avant tout deploiement, en comparant l'inventaire de CHAQUE instance a son plan. L'adresse vient desormais du plan du site : la machine qui y porte serveur_resolveur.

Vide par defaut, et c'est le comportement d'un EMANCIPE : sans la carte de l'hebergeur, on ne delegue plus rien, donc on ne nomme plus ses services. C'est ce qu'on veut CONSTATER d'une emancipation, pas une panne a reparer.

Au passage

instancier tentait encore ~/.config/setops-vault-pass, le mot de passe UNIQUE d'avant la separation des voutes du 2026-08-28 : comparer echouait en exit 4 sur un message qui ne parlait que d'ansible-inventory. On ne pouvait donc plus voir ce qu'on changeait avant de l'appliquer. Il derive desormais ses identites de voutes.py.

2026-09-01 — Le depot de sauvegarde du SITE : l'etat d'un locataire quitte enfin sa propre flotte

56 preuves. Chezlepro rangeait ses instantanes sur backup-01, une VM DE SA PROPRE FLOTTE. Une sauvegarde rangee dans ce qu'elle protege ne protege rien : raser l'ecosysteme pour le reconstruire, c'etait raser le filet avec. La reconstruction ne pouvait donc pas etre tentee — le blocage n'etait pas technique, il etait structurel.

Le site a desormais son propre depot (site-backup-01, VLAN 35), et les neuf detenteurs d'etat de Chezlepro y deposent. Une restitution est sortie : l'annuaire LDAP est revenu lisible, dc=chezlepro,dc=internal, depuis un depot hors de la flotte.

L'isolation entre locataires est celle du noyau, pas une convention

serveur_backup a un compte et une cle : juste pour un depot d'ecosysteme, faux pour un depot de site. Une cle partagee aurait donne a chaque locataire la lecture — et l'effacement — des instantanes des autres.

Un compte Unix par locataire, home en 0700, sa cle et rien qu'elle (exclusive: true : le site est autorite sur qui entre chez lui). La racine partagee est a root en 0711 : traversable, non listable — un locataire ne peut pas apprendre QUI sont ses voisins. Les deux refus ont ete constates, pas supposes.

Ce que le site voit : des octets. restic chiffre CHEZ LE CLIENT, avec un mot de passe qui reste dans la voute du locataire. Le site ne peut ni lire, ni ouvrir, ni reconstituer — c'est ce qui rend la mutualisation acceptable, la meme frontiere que pour les voutes. D'ou serveur_backup_verification_locale: false : la verification n'est pas supprimee, elle est DEPLACEE chez celui qui detient la cle et sait ce qu'il a envoye.

Quatre defauts que seul un deuxieme usage pouvait reveler

1. La racine des depots ne peut etre le home de personne. /srv/restic en 0700 au compte restic : sshd a refuse restic-chez par StrictModes, qui exige que chaque repertoire menant au home appartienne a root ou a l'utilisateur. Le message rendu etait Permission denied (publickey) — celui d'une mauvaise cle. La cle etait la bonne ; c'est le CHEMIN qui etait refuse, et rien dans ce message ne pouvait le dire.

2. La racine nie notre TLD, et Unbound etend ce non a tout ce qui est dessous. internal. n'existe pas dans la racine, qui rend un NXDOMAIN signe (drapeau ad). harden-below-nxdomain tient ce non pour prouve et fabrique un NXDOMAIN pour tout nom sous internal., sans jamais interroger la stub-zone. La delegation etait correcte, l'autoritatif repondait juste, et pas une requete ne lui parvenait.

Ce que ca coutait : aucun locataire ne pouvait nommer un service du site. Chacun gravait donc des ADRESSES dans sa configuration — le cache, le resolveur, la forge — et le site devenait indeplacable un intrant a la fois. Le defaut ne s'est pas vu parce que les machines du site ont le plancher /etc/hosts : le nom marchait partout ou on le testait, et nulle part ou on en avait besoin. Remede : local-zone transparent sur notre zone — le meme que pour les zones inverses, la meme cause.

3. Le gabarit transporte des fichiers de durcissement perimes. Dont 20-chezlepro-hardening.conf, celui-la meme que ssh_hardening_fichiers_perimes existe pour retirer : fige dans l'image, donc herite par chaque VM clonee. Et les machines du SITE ne recoivent jamais ssh_hardening — leur socle est serveur_debian seul, la ou un locataire recoit aussi serveur_durci. Rien ne venait donc jamais le corriger.

4. MaxStartups compte les connexions NON AUTHENTIFIEES. Un depot d'ecosysteme en voit une poignee ; celui d'un SITE en voit la somme de tous ses locataires, dont les minuteurs se reveillent a la meme heure. Au-dela du seuil, sshd coupe AVANT la banniere : le client rapporte kex_exchange_identification: Connection reset by peer, qui accuse le reseau pour un refus applicatif. Douze sauvegardes simultanees, une perdue. Portee a 60:30:200 sur le depot ; les douze repassent ensemble.

Une patte de plus a la frontiere, et ce qu'elle a appris

Ajouter un segment au site n'avait aucune procedure ecrite. Il a fallu, dans l'ordre : le declarer dans underlay.yml, creer le VLAN et l'interface sur OPNsense, ajouter le VLAN au trunk du commutateur, une route sur le poste — et inscrire la nouvelle patte dans opnsense_if_zones. Sans cette derniere ligne, le devis posait les regles du site sur une patte ou son trafic ne passe pas : 16 regles creees, aucune ne correspondant jamais, et rien ne le signalant.

Constat non corrige, a decider

Les machines du site ne sont pas durcies. Pas d'auditd, pas de fail2ban, pas de nftables_baseline, pas de sysctl_hardening, pas d'unattended_upgrades — alors qu'elles portent la racine de l'AC, la forge du genome, et desormais les sauvegardes de tous les locataires. ssh_hardening a ete pose sur le depot seul, la ou il bloquait. Etendre serveur_durci a tout le site est un changement a mener pour lui-meme.

2026-09-01 — q35 n'est pas un reglage : c'est la raison de la procedure manuelle

56 preuves. L'exploitant a explique pourquoi Set-OPS n'utilise pas l'image cloud officielle de Debian — et c'est un constat paye en anomalies, qui n'etait ecrit nulle part.

genericcloud est livree configuree pour i440fx, le defaut de Proxmox. La convertir en q35 apres coup ne change pas un parametre : ça remplace le materiel virtuel sous un systeme qui croit connaitre le sien.

i440fx = PCI          q35 = PCIe
la topologie des bus change, donc :
  les noms d'interfaces predictibles suivent le chemin PCI et changent
  les chemins de disques bougent
  l'ordre d'enumeration des peripheriques n'est plus le meme

La conversion n'est pas une correction — c'est une transplantation. D'ou la regle : une machine nait q35, ou elle ne le sera jamais proprement, et c'est l'installation depuis l'ISO qui le garantit.

Ces deux lignes n'existaient que comme une ligne de tableau dans la procedure. Elles portent desormais leur pourquoi, et le SITE les declare comme donnees — plus seulement comme prose.

make gabarit-etat

Il compare le gabarit reel a ce que le site declare de lui. Controle negatif verifie : declarer i440fx le fait echouer.

On verifie la SOURCE, pas chaque copie. Ma premiere version gardait le clonage — mais la propriete s'herite : verifier chaque clone coute a chaque creation sans rien dire de plus que verifier le gabarit une fois.

Et trois fois j'ai devine la forme de la reponse au lieu de la regarder — regex_search a groupe qui rend None, proxmox_vm_info sans config: current qui ne rend que l'etat. La garde a declare « ? » sur une VM parfaitement conforme. Une garde qui crie toujours est pire qu'aucune : on apprend a l'ignorer, et le jour ou elle a raison plus personne ne la lit.

2026-09-01 — Le gabarit refabrique : minimal, et fabrique chez le SITE

56 preuves. Le gabarit dore est refait — VMID 9006, modeleSetOPS-minimal, quatre roles au lieu de dix-sept.

Il se fabriquait dehors

« Les ressources du SITE font autorite pour tous ses artefacts ; elles servent les tenants jusqu'a ce qu'ils s'emancipent » (decision de l'exploitant). Le gabarit en est un — c'est de lui que descend chaque VM de chaque tenant.

Or -i "<ip>," ne porte aucun group_vars : la fabrication n'avait ni mandataire ni resolveur. L'ancien gabarit allait chercher ses paquets chez Debian et resolvait chez l'ancien LAN (192.168.10.10, lu sur la VM 99998). Le site avait son cache et son resolveur, et son propre artefact les ignorait.

La fabrication derive desormais ses ressources du plan du site et tourne sur le reseau du genome. Prouve : dix requetes de 10.0.33.31 servies par site-cache-01.

L'identite du gabarit vient du site, plus du tenant

proxmox_clone_vmid_modele vivait dans les group_vars de l'ecosysteme. Deux tenants pouvaient donc cloner deux gabarits differents sans que rien ne le dise, et un tenant decidait d'un objet qui n'est pas a lui.

Meme mouvement que l'INDEX, tranche le 2026-08-25 : le site ALLOUE, le tenant RECOIT.

Deux pieges fermes en chemin

Une valeur vide qui ecrasait la derivation. creer-vm passait VMID_MODELE="$SETOPS_VMID_MODELE" a cloner-vm — or inventory_host.py n'emet pas cette variable. Elle valait le VIDE, et ce vide reprenait la main sur le site sans bruit.

Le chemin du controleur contient une espace, et lookup('pipe', …) le decoupait. Ce depot l'a paye une troisieme fois — ansible-galaxy le 08-23, git bundle le 08-26, ici le 09-01.

Eprouve de bout en bout

VM clonee du gabarit minimal   nom, adresse, machine-id neuf,
                               cle d'hote regeneree, agent invite actif
socle applique dessus          changed=5, 0 echec, auditd compris

VM d'essai retiree. L'ancien gabarit (99998) est garde et declare comme precedent : le retirer est un geste, pas un effet, et il attend une reconstruction complete sur le neuf.

2026-09-01 — Le gabarit etait un cache du socle, et il perimait sans le dire

56 preuves. Question de l'exploitant : le gabarit dore et cloud-init gagnent-ils leur place, vu tout ce que Set-OPS fixe par ailleurs ? La mesure a repondu avant le raisonnement.

Le gabarit portait dix-sept roles — exactement ceux du socle et du durcissement, que le deploiement rejoue ensuite a l'identique. C'etait un cache, et comme tout cache il perimait :

derniere recapture   2026-08-09
roles changes depuis  common_packages · cloud_init · ssh_baseline
                      ssh_hardening · auditd · nftables_baseline

Rien ne le signalait. Aucune preuve du harnais ne regardait sa fraicheur, et le deploiement masquait la derive en reappliquant tout — donc personne ne pouvait la voir. C'est le defaut que D-81 a corrige pour la forge du genome, jamais traite ici.

Il ne garde que ce qui doit exister avant qu'Ansible puisse agir

qemu_guest_agent   l'agent repond AVANT SSH — c'est par lui que P52 confirme
cloud_init         le seul chemin vers la premiere seconde : adresse, nom, cles
sudo_ansible       le compte technique et son sudo — la porte par ou tout entre
ssh_baseline       le serveur SSH, meme raison un cran plus bas

Ce ne sont pas des choix d'efficacite, ce sont des conditions d'existence.

cloud-init, lui, ne peut pas etre retire : sans lui une VM neuve n'a ni adresse ni cle, donc personne ne peut l'atteindre pour lui en donner une. Il reste le seul chemin vers la premiere seconde — au prix connu de reecrire /etc/hosts a chaque demarrage, ce que le depot a deja du contourner.

Ce que la reduction coute, et qui le couvre

Une VM neuve n'est plus durcie a la naissance. Elle nait cependant derriere le pare-feu de l'hyperviseur — policy_in=REJECT, arme au clonage (verifie sur obs-01) : seuls les flux declares l'atteignent, avant meme son premier paquet. La fenetre d'exposition est fermee par la fabric, pas par le gabarit — mon objection initiale tombait devant la mesure.

P56 garde les deux moities

Qu'il ne regrossisse pas : un role ajoute recree le cache, donc la peremption invisible. Et que rien de retire ne soit perdu : un role absent du gabarit et du socle disparaitrait de toutes les machines neuves — sans erreur, sans trace, et la panne arriverait des mois plus tard sur une machine qu'on croyait durcie.

Verifie : 14 retires, 14 repris, zero orphelin. Deux controles negatifs.

2026-09-01 — make emancipation-prouver : couper, pas sonder

55 preuves. docs/filiation-emancipation.md decrivait quatre temps et n'en outillait que trois. Le quatrieme est celui qu'on oublie — « une emancipation non prouvee est une emancipation non faite ».

Sonder ne prouve rien. Verifier que le service local repond ne dit pas si l'amont sert encore ; le depot le disait deja du cache : « tant qu'internet repond, un apt update qui reussit ne dit pas d'ou vient l'octet ». L'instrument coupe donc l'amont, et refait marcher la chose.

Le meme essai rend les deux verdicts

coupe, la fonction marche  ->  ÉMANCIPÉ, et c'est prouvé
coupe, la fonction casse   ->  PAS ÉMANCIPÉ, dépendance prouvée RÉELLE

Le second n'est pas un echec de l'outil : c'est son controle negatif, rendu par la meme commande. Une preuve d'emancipation incapable de montrer la dependance qu'elle mesure ne prouverait rien le jour ou elle passerait au vert.

Un temoin precede la coupure — la fonction marchait-elle seulement avant ? Sans lui, une panne preexistante se lirait comme une dependance.

La coupure est garantie reversible

Une table nftables dediee, jamais une regle glissee dans une table existante : elle se retire d'un geste et ne peut pas laisser d'etat partiel. Le bloc always la retire meme si la mesure echoue ou si le play est interrompu, et une tache verifie ensuite qu'elle a bien disparu.

Mesure le jour de sa naissance

obs-01   / resolveur   PAS ÉMANCIPÉ — plus aucune resolution des la coupure
forge-01 / artefacts   ÉMANCIPÉ — apt installe, cache du site coupe

Ce second verdict a ete doute avant d'etre cru : apt aurait pu reussir en rejouant des listes deja fraiches. Refait avec un dossier de listes neuf, amont coupe — il reussit quand meme. Le cache sert vraiment son contenu.

2026-08-31 — D-82 : patient 0 n'est le parent de personne

55 preuves. Le dilemme ouvert le 2026-08-28 est referme, et ce sont les faits qui l'ont tranche plus que le raisonnement. Trois lui ont retire ce role un par un — il fallait les regarder ensemble pour le voir :

  • D-81 a donne l'autorite du genome a la forge du SITE. Son dernier lecteur, Chezlepro, a ete corrige le 08-26 ; Technolibre le 08-31. Il ne sert donc le genome a personne.
  • Le denominateur commun qu'il portait vit dans les modeles depuis le 08-24, sous le nom origine. Ce n'est plus lui qu'on copie.
  • L'ancetre etait locataire de son enfant : index 29 sur la fabric de SITE-Chezlepro, qui descend de lui. Il ne peut pas etre le chemin de reprise de son propre hote.

Des trois issues posees mercredi, la deuxieme l'emporte — non parce qu'elle etait la plus elegante, mais parce que les deux autres avaient cesse d'etre disponibles. Et le depot l'avait deja suivie sans le declarer : serveur_forge_site, puis serveur_cache_site, puis serveur_resolveur_site. Trois services pretes, un seul patron.

Ce que ca ne regle pas, et qui est ecrit

Patient 0 existait pour eliminer un point unique de defaillance — eregion, hors flotte, que Set-OPS ne deploie ni ne prouve. Il ne l'a pas elimine : il a ete promu. Le poste y pousse, la forge du site en tire.

La dette a change de proprietaire, pas de nature ; elle appartient au SITE. Un objectif qu'on abandonne sans le dire devient un objectif qu'on croit atteint.

Ce qu'il garde : sa place de pair dans la famille du genome, et sa forge de travail — un ecosysteme garde la sienne meme quand il ne detient plus le genome de personne. Ce qu'il perd : le rang.

Une panne dormante, trouvee en reglant le cas

Technolibre chainait encore son cache sur patient 0. Le meme defaut a coute quinze machines sans apt chez Chezlepro le jour ou ses VM ont ete eteintes ; chez Technolibre, qui n'a pas encore de VM, il etait dormant et se serait reveille au premier montage.

2026-08-31 — La recette dependait de l'heure : un depot occupe n'est pas une sauvegarde cassee

55 preuves. make valider : rc=0. La reconstruction de Chezlepro est validee de bout en bout — 16 cibles Prometheus UP, 6 vhosts HTTPS, un courriel reellement remis (envoi → LMTP → Maildir), et six depots restic restaures et verifies, dont l'annuaire dont on prouve qu'il se rejoue (slapadd -u).

Un seul verdict rouge au premier passage : data-sql-01, la base. Et la meme commande, rejouee a la main, restaurait ses cinq fichiers.

restic verrouille son depot pendant qu'il ecrit. Une recette qui croise la fenetre de sauvegarde lit donc un echec de RESTAURATION la ou il n'y a qu'une attente — verdict juste sur l'instant, faux sur le fond, et dependant de l'heure a laquelle on l'a lancee.

On reessaie, mais on ne masque pas. Trois tentatives espacees couvrent un verrou d'ecriture ; au-dela l'echec est reel, et la recette rend desormais la raison que restic a donnee au lieu d'un -1 muet. Controle sur la machine — cle de dechiffrement retiree :

ECHEC — la RESTAURATION a échoué après 3 tentatives (snapshots=0)
      — restic dit : Fatal: Resolving password failed

Deux fautes a moi, en chemin

[ "$f" -lt 0 ] && printf ... : la derniere commande d'un script decide de son code de sortie, et un test faux rend 1. La tache echouait donc exactement sur les machines ou la restauration avait REUSSI — trois hotes verts declares en echec.

Puis la ligne raison= n'etait emise qu'en cas d'echec : le gabarit cherchait un champ absent, rendait None, et explosait sur les dix machines saines. Elle est desormais toujours emise, vide en cas de succes. Un champ toujours present coute un octet et supprime un cas.

2026-08-31 — Deux filets : un cache mort ne rend plus un tenant irreparable

55 preuves. Le cache d'un tenant est un maillon dont depend sa propre reconstruction. Toute la flotte y prend ses paquets ; s'il pointe vers un amont mort, apt echoue sur les quinze machines, donc le socle echoue, donc le deploiement n'atteint jamais la couche qui poserait le bon amont.

Le correctif se retrouve dans la couche que la panne empeche d'atteindre — et il faut une main pour en sortir. C'est exactement ce qui s'est passe : l'amont pointait sur le cache de patient 0, eteint la veille pour liberer de la RAM.

apt rendait 503 Connection timeout en citant l'adresse du cache LOCAL, jamais celle de l'amont manquant. La panne accusait le maillon visible.

Deux filets, symetriques

serveur_artefacts sonde l'amont avant d'ecrire, et refuse s'il est muet : mieux vaut un cache qui garde sa configuration precedente qu'un cache qu'on vient de rendre inutilisable pour toute la flotte.

client_artefacts sonde la source avant de detourner apt vers elle. Ce fichier ecrase le plancher d'amorcage pose par le socle — le poser sur un cache mort prive la machine du cache du SITE, qui lui fonctionnait. En refusant, le plancher reste : la flotte est degradee mais debout, et reparable par un deploiement.

ignore_errors et non failed_when: false — la difference est toute la garde

failed_when: false reecrit le verdict : la tache n'est plus jamais failed, donc is not failed est toujours vrai, donc l'assertion qui suit ne peut pas tirer. Ecrite ainsi, ma premiere version a declare « joignable » un amont mesure muet la seconde d'avant.

Une garde qui ne peut pas echouer ne garde rien. Deux controles verifies sur la machine : amont eteint → refuse ; amont reel → pose.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

2026-08-31 — Trois unites en echec sur chaque machine, et personne ne les voyait

55 preuves. Le deploiement rendait 15/15, failed=0. Les machines portaient chacune trois unites systemd en echec. Le rapport d'Ansible n'est pas l'etat d'une machine.

auditd : onze regles armees, personne pour collecter

Deux fichiers de regles identiques cohabitaient — 99-chezlepro.rules et 99-setops.rules, vestige du renommage du role. augenrules concatene tout rules.d/, et le noyau refuse la seconde occurrence :

Error sending add rule data request (Rule exists)
There was an error in line 16 of /etc/audit/audit.rules

audit-rules echoue, et auditd ne demarre pas — c'est sa dependance. auditctl -l affichait pourtant onze regles, ce qui donne toutes les apparences d'un audit qui fonctionne. L'audit etait arme dans le noyau et personne ne l'enregistrait.

Meme mue que ssh_baseline, meme registre — auditd_fichiers_perimes. Et failed_when: false cachait le reste : un service de securite qui ne demarre pas doit se voir.

Les timers apt-daily : un etat d'echec residuel

Masquer le service pendant que son timer tourne lui fait perdre sa cible ; systemd le note et le garde. state: stopped n'efface pas un etat failed — seul reset-failed le fait.

Ce n'est pas cosmetique. Une supervision qui compte les unites en echec compte ces deux-la pour toujours, et la vraie panne s'y noiera. Meme defaut que le journal de la frontiere noye sous 982 000 entrees : ce qui ment le plus n'est pas ce qui se tait, c'est ce qui crie sans raison.

Un diagnostic faux, annule avant d'etre livre

J'avais lu masked enabled dans list-unit-files comme un etat contradictoire, conclu que masquer empechait de desactiver, et bati une reparation pour le defaire. Ces colonnes sont ETAT puis PRESET : masque, avec un prereglage constructeur active, est parfaitement normal. Mesure sans ambiguite ensuite : is-enabled=masked, is-active=failed. Le masquage etait juste ; seule la trace restait.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

2026-08-30 — Vingt-et-un plays : le resolveur ouvre la chaine, deux defauts tombent

55 preuves. Le deploiement depuis ops-01 passe de 3 plays a 21. Douze machines sur quinze sont deployees completement (ok=91 a 137, failed=0). Le resolveur du site a debloque tout ce qui suivait.

Trois echecs restants, deux causes — et les deux etaient de vrais defauts du moteur.

Un port symbolique ecrit tel quel dans le pare-feu

/etc/nftables.conf:36: Error: Could not resolve service: Servname not supported
    ip saddr { ... } udp dport derive accept   # serveur_powerdns

port: derive dit « ce port depend du deploiement » — Forgejo ecoute 3000 derriere un edge et 443 quand il sert son propre TLS ; PowerDNS 53 seul sur son hote et 5300 en loopback derriere le resolveur. Seul le plan sait lequel. Le devis de la frontiere le resout depuis le 2026-08-25 ; le generateur nftables, lui, ecrivait le mot.

Il resout desormais par le plan, et n'emet rien quand il ne peut pas — en le DISANT : un flux tu en silence est une porte qu'on croit ouverte. Verifie que ca ne ferme rien : infra-dns-01 garde son 53, ouvert par serveur_resolveur ; l'omission de PowerDNS est juste, puisqu'il ecoute en loopback derriere lui.

Et la validation ne pose pas la meme question que l'emission. Ma premiere garde refusait tout port non numerique — elle a fait echouer P09, qui valide les roles hors instance, la ou derive est parfaitement legitime. PORTS_SYMBOLIQUES nomme le vocabulaire : le mot passe a la validation, jamais dans un fichier, et un mot inconnu reste refuse des deux cotes. Confondre les deux questions coute des deux cotes : un fichier casse d'un cote, un role correct declare fautif de l'autre.

Recharger n'applique pas un changement d'ecoute — deuxieme fois

warning: ignoring inet_protocols parameter value change
warning: to change inet_protocols, stop and start Postfix
fatal: :::submission: Address family for hostname not supported

main.cf porte inet_protocols, que Postfix refuse de changer a chaud. Le master garde all, tente d'ouvrir :::submission en IPv6, et meurt — apres avoir accepte une configuration valide. postfix check ne dit rien, parce que la configuration EST valide : c'est la TRANSITION qui ne l'est pas.

Meme famille que nginx : restart si changement d'ecoute. Ce qui vit dans le processus maitre — protocoles, adresses, ports — exige qu'il reparte. main.cf notifie desormais le redemarrage.

Diagnostic corrige en chemin : j'ai d'abord accuse postfix@-.service, dont l'assertion echouait sur /etc/postfix-/main.cf. C'etait MOI qui l'avais declenche en tentant un demarrage manuel — postfix.service le declare en Conflicts. Le vrai journal etait ailleurs.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

2026-08-30 — La cle de signature est versionnee : une lignee qui se reproduit sans Internet

55 preuves. Le deploiement de Chezlepro depuis son propre runner a franchi le socle et le durcissement sur les quinze machines, et s'est arrete sur une seule :

infra-pki-01 : Request failed: <urlopen error [Errno -3]
               Echec temporaire dans la resolution du nom>
url: https://packages.smallstep.com/keys/apt/repo-signing-key.gpg

Un tenant n'a pas de DNS sortant, et c'est voulu. Il resout chez lui, sa requete ne traverse jamais la frontiere. apt s'en sort par le mandataire du cache — qui resout a sa place ; get_url n'a pas de mandataire. Mesure sur la machine :

resolv.conf        9.9.9.9, 149.112.112.112
DNS                BLOQUE
443 sortant        OK        (par adresse)
apt via le cache   OK

Les cinq reprises du role ne pouvaient rien : elles etaient ecrites pour un serveur intermittent (mesure du 2026-08-23), pas pour une resolution fermee. Une reprise ne repare que ce qui est passager.

Une dette nommee, appelee

C'etait ecrit dans client_artefacts : « les depots tiers en HTTPS continuent d'aller en direct […] la seconde voie est propre et reste a faire — et le dire vaut mieux que le laisser croire ». Elle a ete appelee le jour ou un ecosysteme s'est reconstruit derriere une frontiere qui fait son travail.

La cle est desormais versionnee, avec son empreinte et sa procedure de rafraichissement. Meme idiome que les collections Ansible et les roues Python : le depot porte, la cible n'ouvre rien. C'est ce qui rend une lignee reproductible sans Internet.

Ce que ca coute, et qui est ecrit : une rotation amont n'est plus recuperee toute seule. Elle ne passe pas inapercue pour autant — apt refuse alors le depot, bruyamment. Mieux vaut un refus lisible qu'un telechargement qui reussit depuis n'importe ou.

Trouve en la recuperant : l'URL publique redirige vers pkgs.infra.smallstep.com. Sans suivre les redirections, on obtient un fichier VIDE que rien ne signale — un curl -f sans -L rend zero octet et sort en succes. La procedure de mise a jour le dit, parce que ce piege-la ne se voit qu'au deploiement suivant.

Le correctif du pare-feu a tenu

Plus une seule suspension : les quinze machines ont franchi le durcissement (ok=66 chacune, 0 unreachable) et repris la main apres s'etre armees. C'est la vraie nouvelle de ce passage — le defaut d'hier soir ne se reproduit plus.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

2026-08-30 — Le terrain etait inoccupable : la cle du tenant nait avec ses machines

55 preuves. Le deploiement lance depuis ops-01 s'est arrete au premier geste, sur les quinze machines a la fois :

ops-01 -> toutes : Permission denied (publickey)

Les VM neuves n'acceptaient qu'une cle : celle de l'exploitant, posee par cloud-init. La cle du runner est declaree au plan (ssh_baseline_cles_admin) — mais c'est le SOCLE qui la depose, et le socle doit etre applique par quelqu'un qui peut deja entrer. Boucle fermee : le tenant recevait un terrain qu'il ne pouvait pas occuper.

Deux cles, deux portees, et la difference est toute l'architecture

la cle du SITE     -> sur le SEUL runner du tenant     l'insemination
la cle du TENANT   -> sur TOUTES ses machines          il va les configurer

Poser une cle au clonage n'est pas entrer chez le tenant. C'est un parametre de creation, au meme titre que l'adresse ou le disque : le site ecrit les conditions de NAISSANCE, il n'ouvre aucune session. La distinction n'est pas rhetorique — le site n'obtient aucun acces sur ces machines, seul le runner du tenant en obtient un.

C'est la forme que l'exploitant a tranchee : le site renseigne le seul runner, qui se charge ensuite de toute sa flotte. Le plancher /etc/hosts suit le meme chemin — ops-01 a le sien depuis son insemination, il resout ses quinze voisines par leur nom (verifie : obs-01.chezlepro.internal:22 ouvert), et c'est lui qui posera le leur en appliquant le socle. Le SITE n'a jamais a toucher une machine de tenant.

La revocation est honoree a la naissance

Une entree passee a etat: absent n'est pas reposee sur les VM neuves. Sans cette lecture, une cle retiree de toute la flotte serait ressuscitee sur chaque machine creee ensuite — une panne lente, silencieuse, et invisible au plan.

P55 — le pouvoir se lit dans les cles, pas dans les intentions

Une VM recoit ses cles a la naissance, et personne ne relit un authorized_keys pose il y a six mois. Si celle du site partait sur toute une flotte, l'hebergeur y gagnerait un acces que rien ne declare. P55 verifie les deux moities : la cle du site ne nait que sur un porteur de serveur_ops_tenant, et aucune machine ne reste sans celle de son tenant. C'est le pendant exact du flux d'insemination — une frontiere tenue a une seule couche n'est pas tenue. Deux controles negatifs verifies.

make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.

2026-08-30 — Le demarrage declarait en panne ce qui fonctionnait

54 preuves. Quinze VM de Chezlepro creees trois a la fois. L'une d'elles a depasse le delai du module pendant que Proxmox generait encore son ISO cloud-init :

fatal: Reached timeout while waiting for starting VM.
       Last line in task before timeout: 'generating cloud-init ISO'

Elle a demarre juste apres. Le module avait renonce, pas l'hyperviseur — et flotte-creer a rendu « au moins une VM n'a pas ete creee » alors que les quinze tournaient.

Ce faux echec arrete une reconstruction. make reconstruire enchaine flotte-creer puis deployer-tout : la sequence se serait arretee la, sur une flotte complete et saine.

Un delai reste un pari — on n'en fait plus un verdict

Deux corrections, et la seconde est la vraie. Le delai devient genereux, parce que la generation d'ISO sur un stockage partage se met en file quand trois clonages tombent ensemble. Mais son expiration ne conclut plus rien : c'est l'hyperviseur qui juge, en repondant ce qu'il fait tourner.

C'est exactement le principe de P52, applique un cran plus tot — la, l'agent invite jugeait la materialisation a la place d'une reponse SSH ; ici, l'API juge le demarrage a la place d'un chronometre.

Logique verifiee sur quatre cas : seul running passe ; VM arretee, VM introuvable et reponse malformee echouent toutes. Une reponse vide ne passe pas en silence. Rejoue sur une VM deja en marche : idempotent.

Ce que la creation de cette nuit n'a PAS prouve

Les quinze VM ont ete materialisees depuis le poste, pas depuis le runner du SITE. Le chemin existait — make inseminer etait ecrit la veille — et il n'a pas servi. Rien de cette nuit ne demontre donc que le runner du site sait materialiser une flotte : il a cree une VM l'avant-veille, pas celles-ci.

Ce n'est pas une faute de pouvoir : le poste detient legitimement la voute du site. C'est une demonstration qui manque, et il faut le dire plutot que de laisser croire le contraire.

make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.

2026-08-28 — Le puits avalait le plan d'administration

54 preuves. Le poste de l'exploitant ne joignait aucune machine de tenant. La chaîne, mesurée saut par saut :

1. le poste        10.17.0.17 -> 10.17.19.41:22           route et source correctes
2. la frontiere    rule 31/0(match): pass out vlan040     ELLE LAISSE PASSER
3. asgard          vlan40 In  ... 10.17.19.41.22          IL RECOIT
4. la VM           (rien)                                 IL N'ARRIVE JAMAIS

Et le noyau dit pourquoi — la source est le discriminant, pas l'interface :

ip route get 10.17.19.41 from 10.0.31.11 iif vlan40  ->  dev vrf_t17
ip route get 10.17.19.41 from 10.17.0.17 iif vlan40  ->  Invalid cross-device link

Parce que le retour est impossible : depuis le VRF du tenant, 10.17.0.17 tombe dans blackhole 10.17.0.0/16.

Le puits a ses raisons — c'est sa largeur qui était fausse

Sans lui, une adresse non attribuée du supernet sort par le défaut, revient par la frontière dans la table principale et repart vers le réseau de gestion (mesuré le 2026-08-09). On n'y touche pas.

Mais il couvre la bande basse, là où D-77 place justement l'underlay d'un site. Deux règles justes séparément, contradictoires ensemble.

La correction est dérivée, pas écrite

Pour chaque tenant, les réseaux d'administration qu'il déclare (nftables_admin_ssh) et qui tombent dans son propre supernet reçoivent une route vers la frontière, plus spécifique que le puits. Ceux qui vivent dehors n'en ont pas besoin.

vrf_t17   ip route 10.17.0.0/24    ...     l'admin de Chezlepro
vrf_t23   (rien)                           le sien est hors de son supernet
vrf_t29   ip route 10.29.19.41/32  ...     l'admin de patient 0 : son propre ops-01

Ce que ça répare au-delà de l'accès

Les règles administration → tenant de la frontière étaient vraies et inapplicables à la fois. Elles correspondaient, elles laissaient passer, et le paquet mourait un saut plus loin. Un devis vert sur un chemin qui ne pouvait pas aboutir — exactement le « périmètre vide » que ce dépôt traque partout ailleurs.

Trois instruments m'ont menti avant d'y arriver : une capture qui ne tournait pas, un ping vers un ICMP que rien n'autorise, et un test TCP depuis la frontière dont la source n'est pas dans l'IPSet de la VM. La mesure qui a tranché est ip route get ... iif, comparée entre une source qui marche et une qui échoue.

make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.

2026-08-28 — make inseminer : le geste sort de mes mains et entre dans le depot

54 preuves. L'insemination a d'abord ete conduite A LA MAIN depuis le runner du site — donc hors du depot, donc sans preuve, ce que ce projet refuse. Elle a maintenant sa cible :

make inseminer TENANT=OPS-Chezlepro          # l'hote se DERIVE

TENANT= plutot que le symlink instance. Le runner du SITE materialise et amorce PLUSIEURS locataires ; pointer un lien global sur l'un d'eux le ferait se prendre pour ce tenant. Il en nomme un par commande. C'est aussi ce qui borne le couplage que creer-vm imposait en silence : rien ne disait sur quels tenants ce lien avait le droit de pointer, ni jusqu'a quand.

L'hote se derive, il ne se tape pas : c'est celui qui porte serveur_ops_tenant. Meme critere que le flux d'insemination et que la cle SSH du runner — le meme mot borne les trois pouvoirs. Un ecosysteme qui n'en declare aucun est refuse, avec la raison : « l'insemination vise LE RUNNER d'un tenant, jamais une machine ordinaire ».

P54 — la ligne de partage, gardee la ou elle glisserait sans bruit

COUCHES_INSEMINATION vaut serveur_debian serveur_ops, et ce n'est pas une commodite : ce sont les deux seules couches qui ne reclament aucun secret. Le site ne detient pas la voute d'un tenant, et ne doit jamais la detenir.

Le jour ou l'on ajouterait une couche « pour aller un peu plus loin », le deploiement echouerait chez le tenant sur une valeur vide — un message qui ne dit pas qu'un pouvoir a ete franchi. P54 lit les roles reellement appliques et refuse toute citation de vault_*. Controle negatif eloquent : ajouter client_pki, la couche suivante, la fait echouer, parce qu'elle reclame le secret de l'autorite du tenant.

Un garde-fou existant a intercepte une insemination mal dirigee

Premier essai sur un second tenant :

REFUS : SETOPS_INVENTAIRE designe un inventaire HORS de l'instance demandee

Le make parent exporte SETOPS_INVENTAIRE, derive de l'instance montee ; ma resolution en heritait et visait l'inventaire d'un AUTRE ecosysteme. Le refus vient d'inventory_rules, pas de la cible — et c'est exactement l'erreur qu'un runner de site, qui sert plusieurs locataires, commettrait en silence. env -u SETOPS_INVENTAIRE ferme la fuite.

La cible dit aussi ce qu'elle ne fait pas : la machine amorcee n'est PAS armee, ni voute ni cle, et l'armer est le geste d'un humain.

make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.

2026-08-28 — make sonder : rapporter ce qui distingue, au lieu d'« échec »

53 preuves. Trois faux diagnostics en une journée, tous dus à l'instrument, aucun au composant. curl, bash /dev/tcp et un ping nu écrasent quatre causes incompatibles dans le même mot. L'information était là chaque fois ; l'outil la jetait.

ouvert, ~0 s                le flux est déclaré et le service écoute
refusé (RST)                une POLITIQUE interne refuse — immédiat
EHOSTUNREACH, ~3 s          LA MACHINE N'EXISTE PAS (l'ARP a abandonné)
silence jusqu'au délai      la FRONTIÈRE, muette par conception

Validé contre le réel depuis ops-01 — trois des quatre cas, le quatrième attendant deux machines vivantes dans un même tenant.

Le même code d'erreur dit deux choses selon le délai

C'est la distinction qui a coûté le plus cher : EHOSTUNREACH immédiat veut dire « pas de route » ; le même code après trois secondes veut dire « il n'y a pas de machine » — c'est l'ARP qui renonce. Les confondre a fait appliquer un pare-feu pour réparer un vide. interpreter est une fonction pure, donc gardée par un test qui exige que les deux ne se lisent jamais pareil.

La garde qui échouait du côté silencieux

L'outil dit par quelle source le paquet part, et refuse de conclure quand c'est une passerelle anycast — le piège qui m'a fait déclarer muet un REJECT qui émettait bien ses RST.

Ma première version rendait « pas anycast » quand inventory_rules était introuvable. C'est-à-dire exactement sur un hyperviseur où l'on a copié le seul fichier pour diagnostiquer — là où le piège se produit. Constaté à l'essai : l'avertissement s'est tu sur 10.17.21.1, qui est une passerelle anycast. Une garde qui échoue doit crier, pas se taire : sans le moteur, elle retombe désormais sur une heuristique franchement étiquetée PASSERELLE PROBABLE (module absent).

Et « non concluant » est un résultat

Quand la source est anycast et que la mesure est un silence, l'outil ne tranche pas — il dit quoi faire à la place : compter les paquets du côté qui refuse, ou sonder depuis une VM. C'est ce compteur qui a prouvé, lui, que le refus était instantané.

TCP seulement, et c'est dit : UDP n'a pas de poignée, un silence y confond « ouvert » et « filtré », et prétendre trancher serait le défaut qu'on corrige.

make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.

2026-08-28 — P53 : l'interne refuse à voix haute, la bordure se tait

53 preuves. Décision de l'exploitant : block vers l'Internet, reject à l'intérieur. « Parce que c'est prudent. »

Ce n'est pas le refus qui informe, c'est ce qu'il fait au silence. Sous drop partout, un timeout voulait dire trois choses incompatibles — aucune machine, aucune route, ou une politique. Quand la politique parle, il n'en reste qu'une.

nftables par hôte    policy drop  +  reject with icmpx type admin-prohibited
pare-feu Proxmox     policy_in = REJECT   (POLITIQUE_VM, source unique)
frontière OPNsense   block                 — INCHANGÉ, et c'est la condition

admin-prohibited et non tcp reset : un RST est indiscernable d'un port fermé sans service. Ce message-ci dit qu'une politique a refusé — la seule forme qui distingue « on ne veut pas de toi » de « il n'y a rien ».

La chaîne forward reste muette, et ce n'est pas un oubli : elle porte le trafic qui traverse l'hôte. Y répondre ferait parler cette machine au nom d'une destination qui n'est pas elle — le défaut qu'on corrige dans input, déplacé d'un cran.

Pourquoi c'est prudent, et pas seulement commode

L'obscurité était déjà nulle à l'intérieur. Chaque machine porte un /etc/hosts généré qui liste toutes ses voisines avec leurs adresses. Se cacher de pairs qui ont déjà notre adresse ne protège de rien — le drop coûtait du diagnostic sans rien acheter.

Et la bordure protège le reject. Rien d'indéclaré ne franchit le périmètre : ce reject ne répond donc jamais à l'Internet. Mesure du 2026-08-27 à la frontière, qui explique l'autre moitié du choix : 982 000 entrées par jour, dont 82 % un balayage contre le port VNC. Y répondre serait un vecteur d'amplification, source usurpée comprise.

P53 garde les deux moitiés ensemble, parce que l'une sans l'autre est fausse : une bordure bavarde s'expose, un interne muet ment. Trois contrôles négatifs vérifiés.

La mesure a corrigé la mesure — deux fois

Ma preuve était trop grossière. Elle interdisait le littéral DROP dans le pare-feu est-ouest, et a fait échouer un code juste : dc["politique"].upper() in ("DROP", "REJECT") détecte une politique posée au datacenter, où elle vaudrait pour tout le parc — un garde-fou, pas un réglage. Une preuve qui interdit un mot au lieu de mesurer une propriété finit par accuser ce qu'elle devrait protéger. Elle cherche désormais une assignation en dur, pas une occurrence.

Et l'absence parlait déjà. Mesuré depuis ops-01 après application :

cache du site, déclaré           OUVERT                            0,00 s
machine INEXISTANTE              OSError errno 113 EHOSTUNREACH    3,05 s
autre tenant, frontière          TimeoutError                      6,01 s

La passerelle anycast dit EHOSTUNREACH ; seule la frontière se tait. Mes deux erreurs de diagnostic de la journée ne venaient donc pas du drop, mais de ma sonde — curl et bash /dev/tcp écrasent EHOSTUNREACH et timeout dans un même « échec ». L'information était là ; l'instrument la jetait. Encore une fois, vérifier d'où l'on mesure avant d'accuser ce qu'on mesure.

Le reject garde toute sa valeur pour ce qu'aucun errno ne dit : la politique d'hôte et est-ouest. Sa démonstration attend deux machines vivantes dans un même tenant — Chezlepro n'en a qu'une.

Appliqué : 6 VM passées en REJECT (Chezlepro ops-01, les cinq de patient 0), 0 créée, 0 retirée. Le changement ne ferme rien — REJECT et DROP refusent tous deux, l'un répond.

Le REJECT est instantané — et c'est mon instrument qui disait le contraire

J'avais présenté « 3,05 s » comme le signal d'un refus. C'était faux, et l'exploitant a eu raison de le contester : un REJECT répond en microsecondes. Ces 3 secondes étaient l'ARP qui abandonne pour une machine inexistante — aucun pare-feu n'était impliqué.

Puis, sondé depuis l'hyperviseur, un port non déclaré a expiré à 8 s. La mesure qui tranche n'est pas le délai mais le compteur :

compteur tcp-reset AVANT : 17
sonde vers ops-01:9999 (non déclaré) → TimeoutError en 4,001 s
compteur tcp-reset APRÈS : 21

Quatre RST émis en quatre secondes, un par retransmission du SYN. Le refus part immédiatement, chaque fois. Ce qui manquait, c'était le retour :

ip route get 10.17.19.41  ->  dev vrf_t17  src 10.17.21.1

10.17.21.1 est une passerelle anycast. Le RST revient au nœud qui porte cette adresse dans le VRF, pas à la socket émettrice. Troisième instrument fautif de la journée, et celui-ci était déjà documenté — « anycast n'est pas une source de test ».

Entre deux VM d'un tenant — le cas réel — la source est une adresse propre, et le routage inter-zone ne la réécrit pas : le refus arrive instantanément. L'artefact ne vaut que depuis un hyperviseur.

Ce qui manque n'est donc pas dans le pare-feu, c'est une sonde qui rapporte l'errno et le délai au lieu de les écraser dans un « échec ».

make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.

2026-08-28 — L'insémination aboutit : un runner de tenant, né d'un runner de site

52 preuves. ops-01 existe, configurée de bout en bout par le runner du SITE, qui n'a détenu aucun secret de Chezlepro :

/opt/setops        Set-OPS-public  OPS-Chezlepro  SITE-Chezlepro  venv
symlinks (D-80)    instance -> OPS-Chezlepro   underlay.yml -> SITE-Chezlepro/…
moteur             au commit courant
sa paire SSH       setops@ops-01.chezlepro.internal
voûte / clé        AUCUNE

Il s'arrête exactement là où il n'a pas la clé. Ce n'est pas une règle qu'on lui impose, c'est un fait : la voûte de Chezlepro ne vit dans aucun dépôt, et son mot de passe n'est sur aucune de ses machines. L'armer reste le geste d'un humain, depuis son poste, par son chemin — le site ne peut pas le faire à sa place, même s'il le voulait.

Trois murs, et chacun a nommé une pièce manquante du terrain

Le socle bloquait sur apt, puis serveur_ops sur pip, puis sur le clonage du génome. Trois échecs, trois couches du terrain que le SITE doit préparer et qui n'étaient pas déclarées.

apt → la source d'artefacts d'amorçage. Le DNS sortant d'un tenant est fermé par conception ; un mandataire résout ce que le client ne peut pas résoudre. Aucun flux à ouvrir : le cache du site acceptait déjà les supernets des tenants.

pip → la roue hors-ligne, dernière dépendance qui sortait par elle-même. Les collections suivaient l'idiome du dépôt depuis longtemps — le contrôleur télécharge, la cible n'ouvre rien — pip non. --no-index est le point : sans lui, la réussite dépendrait un jour d'un flux que ce tenant n'a pas le droit d'avoir, et une lignée autonome deviendrait une lignée qui a l'air autonome.

Le génome → le marqueur serveur_forge_site, qui manquait. Le site rendait deux services à ses locataires, les paquets et le génome ; seul le premier était déclaré. Une dépendance écrite chez le consommateur, sans flux chez le fournisseur — invisible, parce que la garde de matrice ne vérifie que les paires rôle→rôle.

Les clés SSH suivent la même règle que les voûtes

Chaque runner a son jeu de clés ; sa publique vit dans l'authorized_keys des machines de son tenant, et d'aucun autre. Le runner du SITE n'entre que sur ops-01, par le flux d'insémination. Le pouvoir d'entrer se borne au périmètre du runner qui le détient, comme le pouvoir d'ouvrir se borne à sa voûte.

Le véhicule existait déjà — ssh_baseline_cles_admin, avec son etat par entrée, donc révocable sur toute la flotte. Chezlepro n'en déclarait aucune parce que son runner n'existait pas encore.

Ce que la mesure a corrigé, deux fois

ops-01 est la seule VM de Chezlepro qui existe — les quatorze autres ne sont sur aucun hyperviseur. Mes sondes vers le résolveur, le cache et la PKI du tenant mesuraient donc une absence, que j'ai d'abord lue comme un pare-feu. Un port muet ressemble à un refus.

Et l'accès que je croyais avoir cassé en appliquant le pare-feu est-ouest : aucune machine de Chezlepro n'était joignable depuis le poste par ce rebond. ops-01 l'était parce qu'elle n'avait aucun groupe de pare-feu. Je n'ai pas créé une panne, j'ai retiré une exception.

make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute.

2026-08-28 — Nourrir la première machine : le cache du site pendant la jeunesse

52 preuves. ops-01 est la première machine de la reconstruction de Chezlepro, et le socle y restait bloqué sur son premier apt update, sans message qui parle de réseau. La mesure, depuis la machine :

sortie Internet TCP 443    OK
DNS UDP 53 -> 9.9.9.9      MUET
plancher /etc/hosts        15 machines du tenant, AUCUNE du site

Le DNS sortant est fermé par conception — un tenant résout chez lui, son DNS ne traverse jamais la frontière. Or apt vise deb.debian.org par son nom, et le cache de l'écosystème n'existe pas encore.

C'est le cas étroit que le plan du SITE nommait déjà, descendu d'un niveau : la première machine d'un écosystème n'a ni résolveur ni cache, et c'est elle qui doit les construire.

Un mandataire résout ce que le client ne peut pas résoudre

apt envoie au mandataire l'URL absolue ; c'est lui qui traduit le nom. La machine n'a donc besoin d'aucun DNS pour installer ses paquets — seulement d'une adresse. D'où artefacts_amorcage, symétrique exact de dns_amorcage, à la même place dans le socle.

Aucun flux à ouvrir, et c'est la bonne surprise. serveur_cache_site accepte déjà le 3142 depuis voisins_site, qui résout les supernets des tenants — donc toute machine d'un locataire, pas seulement son cache. Vérifié depuis ops-01 : HTTP 200 en 0,158 s, la résolution faite par le cache.

Ce n'est pas une rustine, c'est de la filiation

L'hébergeur nourrit la première machine de son locataire le temps qu'il monte son propre cache. L'emprunt est déclaré — l'intrant nomme l'amont — et il s'efface : client_artefacts écrit 00-setops-artefacts, qui trie après ce fichier-ci, donc gagne dès que l'écosystème a sa source. Le plancher reste dessous, comme /etc/hosts reste sous le DNS.

Retirer cette ligne le jour où le cache du tenant est debout est une émancipation — un geste, pas un effet. C'est le premier service à recevoir cette forme après la forge, et le patron vaudra pour les sauvegardes, l'observabilité, le relais courriel.

Deux diagnostics faux, corrigés par la mesure

J'ai lu « BLOQUÉ » comme un pare-feu. Les sondes vers le résolveur, le cache et la PKI du tenant échouaient toutes — j'y ai vu un filtre, puis j'ai appliqué le pare-feu est-ouest en croyant corriger. La vraie cause était une absence : ops-01 est la seule VM de Chezlepro qui existe, les quatorze autres ne sont sur aucun hyperviseur. Un port muet ressemble à un refus.

Et j'ai cru avoir cassé mon propre accès. Après l'application du pare-feu, ops-01 est devenue injoignable depuis le poste — j'y ai vu une régression que j'aurais causée. Mesure : aucune machine de Chezlepro n'est joignable depuis le poste par ce rebond. ops-01 l'était parce qu'elle n'avait aucun groupe de pare-feu. Je n'ai pas créé une panne, j'ai retiré une exception — et le seul chemin qui reste vers elle est le chemin déclaré, celui de l'insémination.

Le nœud 192.168.11.42 est injoignable, à traiter séparément.

Le garde-fou du génome a refusé une histoire réécrite

--amend sur un commit déjà poussé : le runner a refusé l'avance non rapide, exactement comme il doit. Les arbres étant identiques et seul le message différant, c'est la version de la forge qui fait foi (D-81) — et l'explication vit ici, où elle a de toute façon sa place.

make verifier : vert.

2026-08-28 — Armer un runner est un acte, pas un réglage

52 preuves. La séparation faite, la distribution devient sûre. serveur_ops_site et serveur_ops_tenant savent désormais déposer la clé de la voûte qu'ils déposaient déjà chiffrée — et le runner du site l'a reçue :

/opt/setops/.config/  ->  -rw------- setops  setops-vault-site-chezlepro
PREUVE : la voûte du SITE s'ouvre avec la clé que le runner détient.

Une seule clé chez lui, et c'est tout ce qu'il peut ouvrir. La fabric, pas un tenant.

false par défaut, et ce n'est pas de la prudence décorative

Armer est le moment où un humain remet la clé — le seul geste que la reproduction exige de lui. Un défaut à true armerait des machines sans que personne ne l'ait décidé. Les deux rôles exigent donc une déclaration explicite au plan, et refusent d'armer sans qu'on dise avec quelle clé : poser un fichier vide laisserait un runner se croire armé.

Écrire, puis relire — comme pour la voûte, mais contre la faute symétrique. Là on craignait un chiffré devenu clair ; ici on craint une clé qui serait en fait une voûte, ou un fichier vide, qu'Ansible accepte sans rien ouvrir. Le rôle mesure existence, taille et mode après avoir écrit.

Ce que ça change pour l'exploitant

Le mot de passe se tapait à chaque déploiement. Un geste répété vingt fois par semaine ne prouve rien — et il rendait tout déploiement non interactif (la GUI, un runner) impossible sans blocage. Il se remet désormais une fois, et c'est un événement daté.

C'est la doctrine de l'émancipation appliquée un cran plus tôt : la machine instruit, l'humain acte.

P32 a trouvé la moitié manquante

Le rôle du tenant exigeait serveur_ops_tenant_cle_source ; le plan de Chezlepro ne la fournissait pas. La preuve l'a dit avant tout déploiement, et dans les termes qui comptent : « le déploiement s'arrêtera sur la première garde atteinte ». ops-01 est donc armé lui aussi — avec la clé de Chezlepro, qui n'ouvre ni la fabric ni un voisin.

make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute.

2026-08-28 — Une voûte, une clé : la séparation avant la distribution

52 preuves. Décision de l'exploitant : chaque runner est maître de sa voûte et en détient la clé. C'est ce qui rend un runner autonome — et donc ce qui rend l'émancipation atteignable. Mais elle exigeait un préalable, mesuré ce matin :

UN SEUL mot de passe ouvrait les SIX voûtes de la flotte
  SITE-Chezlepro (la fabric)  ·  OPS-Chezlepro  ·  OPS-Chezlepro-lab (×2)
  OPS-Patient0                ·  OPS-Technolibre

Distribuer avant de séparer aurait été strictement pire que le statu quo. Poser « la » clé sur chaque runner, c'était rendre chaque runner capable d'ouvrir les autres : compromettre le plus petit locataire donnait les secrets de l'hébergeur. L'ordre n'est pas un détail de mise en œuvre, c'est ce qui décide si la mesure protège ou expose.

Cinq clés, une par écosystème, sous ~/.config/setops-vault-<dépôt-en-minuscules>. Vérification croisée après rechiffrement — la diagonale, et rien qu'elle :

voûte \ clé        chezlepro  lab   patient0  site  technolibre   ANCIENNE
ops-chezlepro        OUVRE      .       .       .        .          refusée
ops-chezlepro-lab      .      OUVRE     .       .        .          refusée
ops-patient0           .        .     OUVRE     .        .          refusée
site-chezlepro         .        .       .     OUVRE      .          refusée
ops-technolibre        .        .       .       .      OUVRE        refusée

Un seul mot de passe ne pouvait plus suffire, et pas pour la raison qu'on croit

cloner_vm_debian.yml charge deux voûtes dans la même exécution : celle du tenant, puis celle de l'underlay — les identifiants Proxmox appartiennent à l'hébergeur, le reste au locataire. ANSIBLE_VAULT_PASSWORD_FILE n'en porte qu'une.

ANSIBLE_VAULT_IDENTITY_LIST en porte plusieurs et les essaie toutes sur un bloc chiffré sans étiquette. Un seul export dans le Makefile suffit donc pour les 28 appels à ansible-playbook, sans en toucher un seul. La liste se dérive de l'instance montée, de son site et des dépôts frères (scripts/voutes.py).

Ce qui borne le pouvoir n'est pas cette liste mais la présence des fichiers. Sur le poste de l'exploitant, toutes les clés sont là — c'est l'humain qui les détient toutes, et des preuves comme P03 lisent les inventaires de tous les écosystèmes frères. Sur un runner, une seule clé existe, et la même fonction n'en nomme qu'une. Le code est identique, le pouvoir ne l'est pas.

Deux preuves ont immédiatement dit ce qui manquait

P03 (diff-vide de toutes les instances) est tombée dès la séparation : elle lit les inventaires des écosystèmes voisins, donc il lui faut leurs clés. C'est elle qui a établi que la liste devait couvrir le voisinage sur le poste — je l'avais d'abord limitée à l'instance montée et à son site.

P16 s'est mise à sauter : sa garde ne connaissait que ANSIBLE_VAULT_PASSWORD_FILE. Une preuve sautée se lit beaucoup trop facilement comme une preuve passée — c'est le même défaut que le vert sur un périmètre vide, en plus discret.

Ce que ça coûte, et qui est assumé

Le chiffré et sa clé cohabiteront sur la même machine dès que les runners recevront la leur. Voler le disque d'un runner suffira, là où ça ne donnait rien. Le pari tient parce que le périmètre est borné : le runner d'un tenant ne peut ouvrir que ce tenant. Trois contreparties en découlent — mode 0600, la clé hors du chemin de sauvegarde ordinaire, et un runner durci comme un détenteur d'état.

Les six voûtes ont été sauvegardées telles quelles avant rechiffrement. L'ancienne clé maîtresse n'ouvre plus rien et reste sur le poste : la retirer est une décision, pas un nettoyage.

make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute — sans ANSIBLE_VAULT_PASSWORD_FILE.

2026-08-28 — L'insémination aboutit, et trois tests qui ne gardaient rien

52 preuves. Le flux était déclaré et appliqué ; il manquait l'identité. Le runner du SITE pouvait atteindre la porte de ops-01 et n'avait pas de clé :

ansible@10.17.19.41: Permission denied (publickey)

Le piège, et pourquoi il compte plus que le correctif. La clé s'injecte par cloud-init, au clonage — or creer-vm crée toutes les machines d'un tenant. L'injecter à chaque clonage aurait donné à l'hébergeur un accès SSH à la flotte entière de chaque locataire, en silence, sans qu'aucune règle ne le dise. Ça aurait défait à la couche identité ce que le pare-feu venait de borner à la couche réseau — et deux couches qui ne déclarent pas la même politique, c'est une politique qu'on ne peut plus lire.

Le même critère gouverne les deux : porter serveur_ops_tenant. inventory_host.py émet SETOPS_CLES_AMORCAGE pour cet hôte, et une chaîne vide partout ailleurs. La clé vient du plan du SITE, pas du disque local : on matérialise depuis le runner comme depuis le poste, et un lookup local rendrait deux clés différentes selon qui agit.

Elle s'ajoute, elle ne remplace pas : l'exploitant garde son accès à la machine qu'il vient de créer. C'est l'humain qui arme, pas le site.

Deux couches mangeaient les espaces d'une clé publique, en silence

Une clé porte des espaces — type, matériel, commentaire. Proxmox répondait :

500 Internal Server Error: SSH public key validation error

Un message qui ne parle ni de make, ni de shlex, ni d'espaces. Ce qui l'a isolé n'est pas la lecture du code mais un contrôle : rejouer cloner-vm sans la clé — la tâche passe. Puis la mesure de ce qui arrivait vraiment au module : "sshkeys": "ssh-ed25519". Le premier mot, rien d'autre.

Deux causes, et j'ai accusé la mauvaise d'abord. Une variable make passée à un sous-make est un chemin fragile pour une valeur à espaces — vrai, mais ce n'était pas ça. Le coupable est ansible-playbook -e clé=valeur, qui découpe la chaîne au shlex : tout ce qui suit le premier espace devient d'autres paires. D'où la forme retenue : l'environnement pour le transport, et -e '{"…": "…"}' en JSON pour l'entrée dans Ansible — le seul mode de -e qui ne découpe rien.

Un scalaire YAML plié (>-) ne produit pas non plus de saut de ligne : '\n' y reste deux caractères. Vérifié en base64 sur les trois formes candidates.

Trois tests qui ne gardaient rien

test_inventory_host déclare ses tests dans une liste explicite. Les deux que j'ai écrits pour la nouvelle garde y étaient absents : définis, verts en apparence, jamais joués. J'ai donc ajouté la garde qui manquait — la liste doit couvrir tout ce que le fichier définit.

Elle a trouvé un troisième cas le jour même : test_etiquette_vlan_repli_et_vide_explicite, jamais inscrit depuis sa création. Inscrit, il a levé un KeyError — il cherchait hotes_actifs dans une fixture qui range ses hôtes sous hotes_planifies. Il gardait le comportement SDN de l'étiquette VLAN, et il ne gardait rien. Un test non inscrit est pire qu'un test absent : on croit l'avoir.

La preuve, avec son contrôle négatif

runner du SITE -> ops-01          ops-01   10.17.19.41/24   ✔ entré
runner du SITE -> 10.17.19.21     Connection timed out      ✘ refusé

Le chemin est large d'exactement une machine — et la première ligne est une livraison, pas une poignée de main : ops-01 s'est nommée.

ops-01 est née avant ce mécanisme ; sa clé a donc été posée une fois à la main. Toute VM matérialisée à partir d'ici la reçoit de cloud-init, ou ne la reçoit pas — selon ce que le plan dit d'elle.

make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec.

2026-08-28 — Le lien d'insémination, déclaré — et un test rouge depuis trois jours

52 preuves. L'insémination avait un nom depuis ce matin ; elle n'avait pas de flux. Le runner du SITE doit joindre le runner d'un tenant pour l'amorcer — socle, moteur, plan, plancher de résolution — et ce chemin s'exerçait jusqu'ici sans être déclaré nulle part.

Deux déclarations, aux deux bouts, et rien d'autre.

serveur_ops_site     egress  22/tcp -> serveur_ops_tenant
serveur_ops_tenant   ingress 22/tcp <- runner_site          partage: true

Étroit par construction : il vise le groupe serveur_ops_tenant, qu'un écosystème ne pose que sur une machine. Déclaré au socle, il aurait ouvert le SSH du site vers toute la flotte du tenant ; déclaré sur ce rôle, sa portée est celle du groupe — et le groupe est écrit au plan du tenant.

L'en-tête de roles/serveur_ops_site/meta/flux.yml disait « ce rôle n'entre JAMAIS chez un tenant ». C'était une frontière qu'on ne pouvait pas tenir : creer-vm exige _instance-requise, et le runner du site avait déjà dû basculer son symlink instance sur OPS-Chezlepro pour matérialiser ses VM. Déclarer ne crée pas ce pouvoir — ça rend limitable un pouvoir qui s'exerçait sans borne.

Ce qui reste interdit n'est pas une règle mais un fait : le runner du SITE ne détient pas la voûte d'un tenant — elle vit hors dépôt — et n'en connaît pas le mot de passe. Il pose tout ce qui est public ; l'humain arme. Le pouvoir du site s'arrête là où il n'a pas la clé.

La règle est émise d'un seul côté, et ce n'est pas celui qu'on croit

Le paquet part du réseau de l'hébergeur vers la zone du tenant : il pénètre le pare-feu par la patte du site, pas par le lien de transit. La règle appartient donc au côté SITE du devis. L'émettre aussi depuis la déclaration ingress du tenant aurait produit une seconde règle attachée à la mauvaise interface — jamais évaluée, impossible à distinguer d'une règle utile, et comptée comme telle par tout ce qui audite ce devis.

La déclaration du tenant n'est pas perdue : c'est elle qui pose la règle nftables sur la machine visée, et elle seule. Une ligne, sur un seul hôte, dont la source dérive du plan du site :

ip saddr { 10.0.31.11 } tcp dport 22 accept   # serveur_ops_tenant: Insémination…

Écrire cette adresse à la main aurait survécu au prochain déplacement du runner sans que rien ne le dise — le site a déjà déplacé ses machines une fois, le 2026-08-25.

Plan de la frontière : 2 objets à créer, 0 à retirer, 126 inchangés. Rien n'est appliqué.

P41 appliquée au plan du site

resoudre_flux avait besoin du plan du SITE à son tour, que devis_opnsense portait en privé. Les trois lecteurs déménagent dans underlay — le module qui sait déjà où vit l'hébergeur — et l'appelant délègue. Deux recensements du même objet finissent toujours par diverger, et la divergence se lit « tout va bien ».

Deux gardes ont fait leur travail au passage, et méritent d'être nommées : P33 a refusé ingress 22 sur un hôte qui porte déjà le sshd du socle — la réponse était partage: true, le mot qui distingue ouvrir une écoute d'autoriser une source de plus sur celle d'un autre, exactement comme serveur_backup. Et le devis a refusé d'émettre une règle vers un alias vide plutôt que d'en poser une sans destination.

Un test rouge depuis trois jours, que personne ne pouvait voir

test_adressage_derive construisait un site avec un index. Or un SITE n'a pas d'index — add94f2 l'a retiré le 2026-08-25, remplacé par bande_basse_de, porté par chaque réseau et non par le site. Le test est resté rouge du 25 au 28.

Personne ne l'a vu parce que le geste quotidien est make prouver, qui ne joue pas les tests — make verifier le fait, et il n'avait pas été rejoué. Un test rouge que rien ne lit ne garde plus rien. Remis sur le contrat actuel, et sa contrepartie ajoutée : un réseau qui ne dit pas de quel tenant il occupe la bande basse n'a droit à aucun chevauchement — ne pas déclarer ne peut pas valoir permission.

make verifier : vert de bout en bout. make prouver : CONFORME, 52 OK, 0 echec.

2026-08-28 — L'insémination : la parenté est la seule exception au zéro-confiance

52 preuves. Entre tenants, la frontière refuse tout ce qui n'est pas déclaré. Mais un écosystème neuf ne peut pas s'amorcer lui-même : quelqu'un doit poser sa première machine. Ce geste n'avait pas de nom, et un geste sans nom ne se borne pas.

le SITE matérialise        VNets, VM, routes, flux — le terrain
le SITE amorce ops-01      la première machine, et elle seule
le TENANT achève           ses autres machines, ses rôles, ses intégrations

Le partage d'intelligence qui le rend possible, et qui est plus net que « le SITE ignore les rôles » : les deux runners portent le même moteur, et n'en consomment pas la même face. Le SITE lit roles/*/meta/flux.yml — ce que les rôles exigent du réseau — et en tire le SDN, le pare-feu Proxmox, la frontière. Le tenant lit tasks/, templates/, meta/integration.yml — ce qu'ils exigent de la machine. C'est ce partage qui permet au SITE de poser les bonnes règles sans jamais lire la configuration d'un tenant ni détenir sa voûte.

Le couplage existait déjà, sans être déclaré

make creer-vm exige _instance-requise. Pour matérialiser les VM de Chezlepro, le runner du SITE a donc dû basculer son symlink instance sur le dépôt de Chezlepro — alors que son plan pose serveur_ops_instance: "" et que la doctrine dit qu'un SITE n'a pas de plan monté par ce lien. Mesuré sur la machine :

/opt/setops/Set-OPS-public/instance -> ../OPS-Chezlepro   (27 août 21:52)

Rien ne borne sur quels tenants ce lien porte, ni jusqu'à quand. Le déclarer, c'est le rendre limitable ; le taire ne l'a jamais empêché.

L'émancipation exige la gouverne d'un humain

J'avais proposé une condition d'extinction mesurée : le lien se ferme quand la machine constate que le tenant se reproduit sans son parent. C'était l'erreur symétrique de celle qu'on corrigeait, et ce document disait déjà pourquoi — « chaque émancipation est une décision du client, jamais une rupture technique ».

Le fruit de l'émancipation est livré à quelqu'un. Sans quelqu'un pour le recevoir, il n'y a pas de livraison : seulement un lien qui tombe. Une émancipation qui se déclencherait toute seule ne serait pas une émancipation, ce serait une expulsion.

D'où quatre lignes qui ne se confondent pas : le moteur porte le lien, la machine produit le constat d'aptitude — elle instruit, elle n'agit pas —, l'humain pose l'acte sous CONFIRMER=true, et la machine prouve ensuite que le lien est coupé. C'est la forme des autres gestes lourds d'ici : raser exige son nom d'instance, le mot de passe de la voûte se tape à l'exécution. L'émancipation est le deuxième objet de cette liste.

Deux limites nommées plutôt que tues

Qui est le parent ? D-81 donne l'autorité du génome à la forge du SITE. Conséquence non écrite : patient 0, dont la raison d'être est « porter les dépôts qui fabriquent les écosystèmes, et les servir à ses enfants », n'a plus un seul lecteur — Chezlepro était le dernier, corrigé le 26. Et la machine hors flotte qu'il existe pour remplacer alimente désormais la forge qui fait autorité : le point unique de défaillance n'a pas été éliminé, il a été promu. Trois issues sont posées dans le document ; aucune n'est tranchée, et aucune ne bloque la reconstruction en cours.

La séparation des voûtes est organisationnelle, pas cryptographique. Les fichiers sont bien séparés — vérifié sur site-ops-01, qui ne détient que underlay.vault.yml. Mais un seul mot de passe ouvre la voûte du site et celles des tenants. « Deux pouvoirs, aucun omnipotent » décrit la répartition des fichiers, pas celle des clés.

Un document qui nomme ce qu'il n'a pas encore résolu vaut mieux qu'un document dont on découvrira le trou en s'appuyant dessus.

2026-08-27 — P52 : matérialiser n'exige pas d'entrer chez le tenant

52 preuves. creer-vm confirmait son succès en attendant une réponse SSH. Le runner du SITE matérialise le terrain de tous les tenants, mais la frontière lui refuse d'entrer chez eux — c'est le sens même de leur isolation. La première VM qu'il a créée a donc été déclarée en échec après 600 secondes alors qu'elle tournait, avec l'adresse exacte que le plan lui destinait :

Attente de SSH sur ops-01 ............ ECHEC: injoignable apres 600s.

La tentation était d'ouvrir le SSH du runner vers tous les tenants. Ça aurait réparé la mesure en détruisant ce qu'elle protège : une machine capable d'entrer chez chaque locataire est précisément ce que cette architecture refuse d'avoir.

L'agent invité répond sans rien ouvrir — l'API des hyperviseurs est déjà le flux par lequel la VM vient d'être créée, donc qui peut la créer peut la voir naître — et il prouve davantage. « Quelque chose écoute sur le port 22 » ne dit ni quel système a démarré, ni si cloud-init a posé la bonne adresse. Sur ops-01 :

10.17.19.41 portee sur asgard — Debian GNU/Linux 13 (trixie) 6.12.101

Ses échecs distinguent deux causes très différentes : « la machine démarre mais rapporte une autre adresse — cloud-init, ou le pont sur lequel elle est posée », et « introuvable sur la fabric ».

Attendre la disponibilité a changé de main. Guetter cloud-init et la libération de dpkg appartient à qui va configurer : deployer le fait désormais, là où il se contentait d'un ping unique. Il y gagne ce qu'il n'avait pas — attendre le verrou APT, faute de quoi la première couche échouait dessus. Contrepartie assumée : un nom d'hôte erroné patiente au lieu d'échouer vite ; échouer vite interdirait de chaîner création et déploiement, le geste central d'une reconstruction.

Une preuve qui exige un pouvoir que l'architecture refuse ne mesure pas le système : elle mesure ce qu'il faudrait casser pour la satisfaire.

2026-08-27 — P51 : quatre défauts de portabilité, révélés par le runner

51 preuves. Première matérialisation d'une VM de tenant depuis le runner du SITE. Elle a échoué quatre fois d'affilée, sur quatre dépendances que le moteur ne déclarait nulle part. Chacune fonctionnait chez le mainteneur pour une raison différente, et aucune de ces raisons n'existe sur une autre machine.

1. Versions des collections. requirements.yml les nommait sans les épingler. Le runner a reçu community.general 13.3.0 quand le poste porte la 10.3.0 — et la 11 a retiré les modules Proxmox de cette collection (ils vivent désormais dans community.proxmox).

ERROR! couldn't resolve module/action 'community.general.proxmox_pool'

Une dépendance non épinglée n'est pas une dépendance, c'est un pari sur l'état d'Internet à la date du déploiement.

2. Installation en avant. ansible-galaxy ne rétrograde pas : déclarer la 10.3.0 ne suffisait pas à défaire une 13.3.0 déjà posée. --force. Un poste peut être en avance, pas seulement en retard, et le dépôt doit faire autorité dans les deux sens.

3. Emplacement. Le Makefile pose ANSIBLE_HOME ?= $(CURDIR)/.ansible : sous make, Ansible ne lit que <moteur>/.ansible/collections. Le rôle déposait dans <racine>/.ansible/collections — à côté, jamais lu. Invisible chez le mainteneur, où les collections viennent du paquet système, toujours dans le chemin quel que soit ANSIBLE_HOME. Et le garde-fou d'installation suivait le dépôt des archives : changer la destination ne le déclenchait pas. Il mesure désormais la destination — même piège qu'au 2026-08-24, déplacé d'un cran.

4. Bibliothèques Python. Le venv recevait ansible-core et pyyaml, écrits en dur. Les modules Proxmox tournent delegate_to: localhost et exigent proxmoxer sur le contrôleur.

La bibliotheque Python proxmoxer est absente

Né d'ici : requirements-python.txt, avec sa frontière écrite — il ne déclare que ce qui tourne sur le contrôleur. python-ldap et psycopg2 s'exécutent sur leurs cibles ; le poste du mainteneur ne les a pas et la flotte se déploie, ce qui prouve que la ligne est au bon endroit.

P51 garde les trois faiblesses de cette famille : une dépendance utilisée sans être déclarée (le défaut du 2026-08-24, ansible.posix), une dépendance déclarée sans version, et proxmoxer absent alors que des playbooks Proxmox existent. La preuve ne lit que les fichiers de tâches : un defaults/main.yml porte net.ipv4.ip_forward, qu'un motif trop large prendrait pour un module.

Résultat : ops-01 matérialisée par le runner du site. L'agent invité rapporte eth0 10.17.19.41/24, Debian 13 trixie — l'adressage dérivé du plan, appliqué.

Migration connue, pas faite : passer les quatre modules Proxmox à community.proxmox permettra de suivre community.general au-delà de la 11.

Un seul exécutant masque ces écarts indéfiniment ; un second les révèle tous en une soirée. Même mécanique que le second tenant en août.

2026-08-27 — P50 : le devis de la frontière sait refuser SANS consigner

50 preuves. L'outil ne savait qu'autoriser — "action": "pass" était en dur dans l'émetteur. Une règle de silence ne pouvait donc pas naître du dépôt, et j'en avais posé deux à la main sur le boîtier : exactement ce que ce projet refuse.

Ce qui l'a motivé. Le journal de la frontière écrivait 982 000 entrées par jour, dont 82 % un balayage Internet contre le port VNC et le reste du bavardage de découverte du réseau local. Sa fenêtre utile était tombée à quarante-quatre secondes. J'y ai cherché la trace d'un flux du site vers les hyperviseurs, je n'ai rien trouvé, et j'en ai conclu à tort qu'aucune règle ne bloquait. Un journal noyé ment aussi sûrement qu'un journal mort. Mesuré après déclaration : ~20 700/jour.

Une règle de silence ne change aucun comportement : ce qu'elle vise était déjà refusé par le défaut. Elle ne supprime qu'une trace que personne ne lira.

Trois pièces. cle_regle accepte une action sans changer d'un octet la clé des règles pass déjà posées — l'ajout naïf d'un champ les aurait toutes détruites pour les recréer à l'identique, sur la frontière, en production ; le plan l'a confirmé : 7 à créer, 0 à retirer, 121 inchangées. _corps_regle lit l'action, la consignation et la séquence depuis le devis : la séquence est ce qui rend un block sûr, puisque OPNsense évalue en quick et qu'un blocage large émis avant les pass fermerait courrier, web et accès distant. devis_opnsense lit opnsense_silences et en fabrique règles et alias, motif compris — une règle block muette sans raison écrite est indiscernable d'un oubli.

P50 garde ces deux dangers : un silence en séquence 1 échoue, un silence sans motif échoue.

2026-08-26 — P49 : le registre des flux avait dérivé sans bruit

48 preuves. docs/registre-flux.md est généré depuis les roles/*/meta/flux.yml, et c'est le document qu'un humain lit pour savoir ce que le pare-feu laisse passer. Son existence était vérifiée depuis longtemps ; sa fraîcheur ne l'était pas.

Il avait dérivé : la garde d'administration y portait encore 10.0.0.0/24 alors que le réseau d'administration vaut 10.17.0.0/24, deux flux client_resolveur ajoutés depuis n'y figuraient pas, et un hôte manquait des listes de sources. Un lecteur y aurait lu un pare-feu qui n'existe plus.

L'inventaire avait déjà sa garde — P03, le diff-vide du plan. Le registre des flux est le même genre d'artefact : généré, versionné, lu par un humain. Il lui manquait la même. generer_registre étant une fonction pure, P49 la rejoue en mémoire et compare — une preuve qui répare ce qu'elle mesure ne mesure plus rien.

Contrôle négatif idéal, et il ne s'invente pas : la version commitée elle-même. Restaurée, la preuve échoue ; régénérée, elle passe.

Ce que cette découverte corrige aussi dans ma tête

J'avais d'abord décrit le symptôme comme « le runner salit ses propres clones » — une contradiction structurelle entre un dépôt-clone et un répertoire de travail. C'était faux, et la question de l'exploitant l'a mis au jour. Régénérer un artefact doit produire un diff quand les sources ont changé ; ce qui manquait n'était pas une architecture, c'était une garde. Un symptôme observé depuis un seul endroit ressemble toujours à une propriété de cet endroit.

2026-08-26 — serveur_ops_tenant : le runner d'un tenant reçoit enfin sa voûte

47 preuves. La doctrine des runners décrit trois portées depuis le 2026-08-22 :

calculer      plan → inventaire        aucune voûte        serveur_ops
configurer    rôles sur ses machines   voûte du TENANT     ← revendiquée, jamais reçue
matérialiser  créer/détruire des VM    voûte du SITE       serveur_ops_site

La deuxième ligne était un trou. Rien ne déposait jamais la voûte d'un tenant sur son runner. Un runner pouvait dériver son inventaire et ne rien pouvoir en faire : chaque rôle qui demande un secret échouait sur son assertion, et l'échec ne disait pas qu'il manquait un fichier, seulement que les valeurs étaient vides.

Découvert en préparant la reconstruction de Chezlepro. Le runner du site peut matérialiser ses quinze machines ; il ne peut pas les configurer, parce que Chezlepro a sa propre autorité de certification — donc client_pki y réclame un secret de Chezlepro. Le runner du site ne l'a pas, et ne doit pas l'avoir : c'est la ligne qui rend l'hébergement mutualisé défendable.

serveur_ops_tenant est le symétrique exact de serveur_ops_site : un marqueur, pas un installateur. Il dépose la voûte decrypt: false, puis relit l'en-tête du fichier déposé — une voûte déchiffrée par accident est une fuite silencieuse. Le dossier d'inventaire (principal ou production) se découvre sur la machine.

Pourquoi un rôle à part et non une option : donner sa voûte à un runner est un pouvoir, pas un réglage. Le déclarer au plan force à répondre à « cette machine a-t-elle le droit de configurer cet écosystème ? » — une option activée par défaut y répondrait à notre place.

Chezlepro lisait encore son génome chez patient 0

Trouvé au passage, et bloquant pour la reconstruction : serveur_ops_forge_amont pointait sur 10.29.16.11 — la forge de patient 0, l'amont d'avant que le site ait la sienne. D-81 a tranché depuis. Laissé tel quel, Chezlepro se serait reconstruit depuis un moteur périmé, sur un réseau que le découpage en zones a de toute façon déplacé.

Corrigé vers la forge du site, par son IP — forge.genese.internal appartient à la zone souveraine du site, et un tenant ne résout que la sienne ; le certificat servi porte bien IP Address:10.0.33.11. La racine de l'AC du site est désormais versionnée à côté de la carte de la fabric à laquelle elle appartient, et son chemin se dérive du symlink underlay.yml — il vaut donc depuis le poste comme depuis un runner.

2026-08-26 — Le runner du site travaille, et le génome remonte chez lui

46 preuves. serveur_ops et serveur_ops_site sont déployés sur site-ops-01 : le moteur, les plans des trois tenants, les collections hors ligne, et la voûte de l'underlay déposée chiffrée. Le site a son runner.

Deux formes de runner, et une seule était prévue

serveur_ops exigeait un dépôt role: instance et un serveur_ops_instance. C'est la forme d'un runner de tenant : il pilote un écosystème, monte son plan par le lien instance, détient sa voûte.

Un runner de site ne pilote rien — il matérialise le terrain que d'autres occuperont, et son inventaire est dynamique. Son plan déclarait donc ses dépôts de tenants en role: tenant, à dessein et avec ses raisons écrites, et le rôle le refusait au nom d'une exigence qui ne le concernait pas. Il exige désormais que le runner pilote quelque chose, sans prescrire quoi : un instance, ou un hebergeur.

Et il posait le lien quand même : serveur_ops_instance: "" donnait instance -> /opt/setops/, un répertoire qui n'est l'écosystème de personne. Un lien qui existe et ne désigne rien est pire qu'un lien absent : tout ce qui le suit croit avoir trouvé une instance.

Le certificat couvre les noms du service, pas seulement ceux de la machine

client_pki ne mettait dans ses SAN que le FQDN, le nom court et l'IP. Le clonage du génome a buté dessus : « certificate subject name (site-forge-01.genese.internal) does not match target hostname 'forge.genese.internal' ». Le nom déclaré expose: était publié partout — plancher, zone DNS — et couvert nulle part.

Les expositions que cet hôte sert réellement entrent maintenant dans le certificat. Un certificat qui revendiquerait le nom d'un service rendu ailleurs serait une usurpation, pas une commodité.

make genome-pousser — le runner pousse, le poste ne route pas

La forge du génome vit dans le site, sur un réseau que le poste de l'exploitant ne route pas. On ne perce pas un chemin pour lui : on lui retire le rôle. Le poste emballe les commits dans un git bundle — un fichier, vérifiable, qui ne demande aucun réseau — et le runner vérifie, avance en avance rapide seulement, puis pousse avec sa clé.

Ce qui est poussé se dérive de serveur_ops_depots : les dépôts que le runner porte et dont une copie existe à côté du moteur. Six, ici. DEPOT=<nom> en cible un seul.

Trois pièges rencontrés, tous inscrits dans le code :

  • l'outil ne peut pas être supposé présent sur le runner — il ne recevrait cette version qu'après la poussée qu'elle sert à faire. Le contrôleur le porte ;
  • la branche n'est pas toujours main — deux dépôts vivent sur master, et le plan le déclarait déjà ;
  • le dossier des dépôts frères contient un espace : argv et non cmd.

Le juge n'est pas le journal du playbook mais la forge : on lui demande son API ce qu'elle porte, et on refuse si ça diffère de ce que porte le runner.

Pourquoi ça comptait : la forge du site était restée quatre commits derrière le poste, sans que rien ne le signale — dont le correctif qui désarme le pare-feu Proxmox. Un écosystème qui se reproduit depuis une forge en retard reproduit ses défauts.

P48 — la carte d'orientation ne peut plus mentir

docs/carte-set-ops.md est l'index du mainteneur : l'ordre de lecture du corpus, et surtout le catalogue des mécanismes transverses avec, pour chacun, où il vit dans le code. Son but est écrit en toutes lettres : « ne plus re-déterrer ce qui existe ».

Elle n'avait aucune garde, alors que catalogue-services.md a la sienne depuis P38. Ses sept chiffres étaient faux — 54 rôles annoncés contre 60, 34 documents contre 38, 15 pièces d'audit contre 27, 70 décisions contre 78.

Le défaut coûteux n'est pourtant pas là. C'est le pointeur mort : la carte dit où vit un mécanisme, quelqu'un ne l'y trouve pas, et le réimplémente à côté — exactement la panne qu'elle existe pour prévenir. Aucun de ces nombres ne fait travailler personne ; mais un document dont les faits vérifiables sont faux cesse d'être consulté, et c'est alors ses pointeurs qu'on perd.

P48 vérifie les deux : 84 chemins cités existent, et 7 chiffres correspondent à la mesure. Quatre distinctions ont dû être écrites pour qu'elle ne soit pas fausse dans l'autre sens — un gabarit de nom (preuve-<date>.md) décrit une forme, pas un fichier ; un chemin hors dépôt (~/.config/setops-vault-pass) vit sur le poste de l'exploitant ; un fragment (meta/acces.yml) vaut comme suffixe ; et un artefact généré (hosts.yml) n'a pas à exister dans le moteur. Les quatre sont nommées dans le code plutôt que sautées en silence.

Le tableau « Le dépôt en chiffres » remplace les comptes en prose : ce qu'on n'entretient pas, on ne l'affirme pas — ou bien on le fait recompter.

D-81 — la forge du site fait autorité, et make genome-etat le vérifie

Décision de l'exploitant : la forge du SITE fait autorité pour le génome. Toute autre copie — y compris celle d'où le moteur a été poussé jusqu'ici — est un miroir.

Une autorité qu'on ne vérifie pas est une autorité qu'on suppose. make genome-etat confronte donc, dépôt par dépôt, ce que le poste porte à ce que la forge porte — et refuse en cas d'écart plutôt que de le signaler. Un écart connu et toléré redevient un écart oublié, et la commande qui le corrige tient en trois mots. Il dit aussi quand la copie locale n'est pas propre : des commits pas encore faits sont une autre forme de retard.

Mesuré au passage, et traité plutôt qu'ignoré : le premier contact avec la forge échoue une fois sur six — poignée TLS expirée, puis cinq réponses de suite. Ce n'est pas le chemin, qui est prouvé ; c'est l'acceptation TLS après un temps d'inactivité. Les deux cibles réessaient.

2026-08-25 — Quatre zones inverses, et rien de plus

47 preuves. Le site ne servait aucun PTR. serveur_powerdns_zone_inverse dérivait d'un supernet /16 — la forme d'un tenant, qui tire tout de son index. Un site ne dérive pas : il déclare plusieurs /24 et n'a pas de supernet unique, si bien que la dérivation rendait une chaîne vide et qu'aucune zone n'était générée.

Le site revendique désormais exactement ce qu'il occupe :

31.0.10.in-addr.arpa   32.0.10.in-addr.arpa
33.0.10.in-addr.arpa   34.0.10.in-addr.arpa

Revendiquer 0.10.in-addr.arpa d'un seul geste aurait été plus simple et faux : cette zone couvre aussi la frontière, le transit et les hyperviseurs, qui ne sont pas à lui. Une autorité qu'on s'attribue sans l'exercer est une panne différée.

La dérivation vit dans un filtre (zones_inverses) parce qu'elle a deux appelants : serveur_powerdns écrit ces zones, serveur_resolveur les délègue à l'autoritatif. Deux calculs séparés finiraient par diverger, et la divergence ne se verrait qu'au premier PTR interrogé. Le nom d'une zone dit sa profondeur — trois étiquettes numériques valent un /24, deux valent un /16 — et le modèle en déduit seul la forme du PTR.

Deux chemins morts trouvés en route

Les zones étaient servies, et personne ne les demandait. serveur_resolveur déléguait la zone directe par une stub-zone mais pas les inverses : dig -x rendait vide depuis les cinq machines alors que la même requête posée directement à l'autoritatif répondait juste. Un service correct derrière un chemin que rien n'emprunte.

Puis, les stubs posés, Unbound répondait toujours NXDOMAIN avec le drapeau aa — une réponse autoritaire, sans jamais consulter le stub. Il embarque des local-zone pour tout l'espace RFC1918 inversé. nodefault n'y change rien : ce mode n'agit que si le nom correspond exactement à une zone par défaut, et la sienne est 10.in-addr.arpa, le /8 entier. C'est transparent qui laisse la requête suivre son cours — pour nos quatre zones seulement, là où unblock-lan-zones aurait ouvert tout l'espace privé.

Rien ne distinguait ce blocage d'une absence : le même NXDOMAIN qu'un nom qui n'existe pas.

Vérification

Les cinq PTR résolvent depuis les cinq hôtes par le résolveur, les huit noms directs par le plancher et par le DNS, et les deux rôles sont idempotents (changed=0).

P47 évalue la dérivation sur cinq cas — un site à quatre zones, un tenant à une, deux machines d'un même /24 qui n'en font qu'une, aucune adresse, une adresse illisible.

2026-08-25 — Le plancher survit au redémarrage, et la zone dit les vraies adresses

46 preuves. Le découpage du site en quatre zones a déplacé cinq machines ; ni le plancher /etc/hosts ni la zone DNS n'avaient suivi. Quatre défauts, tous dans le moteur.

Le plancher était effacé à chaque démarrage — et le correctif d'hier n'en était pas un

hosts_statiques posait /etc/cloud/cloud.cfg.d/99-setops-hosts.cfg avec manage_etc_hosts: false, pendant que cloud_init posait 99_setops.cfg avec true. Dans cloud.cfg.d l'ordre est lexical et le dernier gagne : - vaut 0x2D, _ vaut 0x5F — le true l'emportait. On a donc d'abord retiré la clé de cloud_init (le rôle qui possède le fichier décide), puis renommé notre fragment zz- pour passer après le 99_chezlepro.cfg du gabarit doré.

Et ça ne suffisait toujours pas. Redémarrage d'épreuve : le plancher, encore effacé. La cause réelle est ailleurs — Proxmox inscrit manage_etc_hosts: true dans la user-data de son lecteur cloud-init, et la user-data prime sur cloud.cfg.d tout entier. Aucun fragment ne pouvait gagner ; renommer pour parler en dernier ne servait à rien, le dernier mot n'appartenant pas à ce répertoire.

Ce que cloud-init régénère, il le régénère depuis hosts.debian.tmpl — le gabarit le documente lui-même. hosts_statiques le pose donc désormais avec le même contenu que /etc/hosts, et une garde compare les deux à chaque passage. Redémarrage d'épreuve : les neuf entrées sont là.

La garde précédente affirmait « conforme » en mesurant l'ordre lexical — vrai, et sans rapport avec ce qui se passait. Une garde qui mesure la mauvaise chose est pire qu'aucune.

La zone DNS ne publiait pas les noms de service

forge.genese.internal et pki.genese.internal — des noms que les certificats portent et que les clients appellent — n'avaient aucun enregistrement. Seul le plancher savait les résoudre. Trois causes empilées :

  • le plan du site coupait serveur_powerdns_publier_expositions, au motif que « le site n'a pas d'edge » — ce qui confondait public et exposé ;
  • expositions_des_applications rendait domaine: None faute de domaines.yml, et le modèle de zone écarte les expositions dont le domaine n'est pas la zone. Le repli existe désormais : sans domaine public déclaré, le domaine est celui que porte le FQDN — symétrique du repli déjà écrit pour edge ;
  • serveur_powerdns exigeait les deux registres et échouait si domaines.yml manquait — le même tout-ou-rien que hosts_statiques avait corrigé le même jour.

Puis named-checkzone a refusé la zone : dns.genese.internal héritait d'un CNAME par défaut du rôle et d'un A par exposition. La garde a bien joué son rôle — elle a arrêté une zone cassée avant qu'elle soit servie. Le plan l'emporte désormais sur le défaut du rôle.

Un service ne doit pas dépendre d'un plancher pour être joignable : le plancher est un filet, pas le sol.

Vérification

Les huit noms — cinq machines et trois services — résolvent vers les bonnes adresses depuis les cinq hôtes, par le plancher et par le DNS, et le plancher survit au redémarrage.

Reste nommé, pas corrigé : la zone INVERSE. serveur_powerdns_zone_inverse dérive d'un supernet /16 — la forme d'un tenant. Un site déclare plusieurs /24 et n'a pas de supernet unique : aucune zone inverse n'est générée, et les machines du site restent anonymes à l'envers.

2026-08-25 — Le pare-feu Proxmox ne s'arme que dans le SDN

45 preuves. Le découpage du site en quatre zones a révélé un défaut qui dormait dans le moteur : firewall=1 sur l'interface d'une VM, posé sur un pont classique, jette le retour des flux en épingle.

Le mécanisme. firewall=1 fait passer tout le trafic ponté par conntrack. Sur un VNet SDN c'est sans conséquence : en EVPN le routage inter-VNet se fait dans le VRF, sur le nœud, et le flux ne quitte jamais l'hyperviseur. Sur un pont classique routé par une frontière externe, deux VM du même nœud dans deux VLAN différents ne se parlent qu'en épingle : la trame sort par le lien physique, la frontière la route, elle revient sur le même pont. La même table conntrack voit alors les deux moitiés de la connexion, classe le retour INVALID, et PVEFW-FORWARD le jette.

La mesure, et c'est elle qui a tranché :

ops(asgard)   -> pki(asgard)     0/8      dns(gandalf) -> cache(gandalf)   0/8
ops(asgard)   -> forge(vishnu)   6/8      dns(gandalf) -> pki(asgard)      6/8
ops(asgard)   -> cache(gandalf)  8/8      dns(gandalf) -> forge(vishnu)    8/8

Toutes les paires intra-nœud échouent, toutes les paires inter-nœuds passent. Douze tentatives faisaient monter le compteur ctstate INVALID de +112 sur asgard et +116 sur gandalf ; firewall=0 posé, il ne bouge plus — 0 sur les deux.

Ce qui rend le défaut coûteux, c'est son déguisement : la poignée TCP aboutit (SYN et SYN-ACK créent l'état), et ce sont les paquets de données qui disparaissent. L'AC le disait dans son propre journal — TLS handshake error … i/o timeout, elle accepte la connexion et attend un ClientHello qui n'arrive jamais. On accuse le MTU, la frontière, la zone, le port. Écartés un par un, par la mesure : règles identiques champ par champ, alias corrects, assignation des interfaces confirmée, ARP et routes saines, IPS désactivé, shaper vide, NAT source limité à wan, MTU à 1500 de bout en bout et la taille sans effet (100 octets se perdent comme 1460).

La règle, désormais dans le moteur : le pare-feu Proxmox ne s'arme que sur un VNet SDN. L'intention déclarée ne suffit pas — un tenant déclare proxmox_clone_parefeu_interface: true avec proxmox_clone_pont: vmbr1 comme valeur par défaut, chaque hôte la remplaçant par son VNet dérivé ; l'hôte qui retombe sur vmbr1 naîtrait armé sur un pont classique. cloner_vm_debian.yml croise donc l'intention avec le pont réellement utilisé, et le dit quand il désarme.

P45 évalue l'expression du playbook sur quatre cas plutôt que d'en lire le texte, dont un qui doit rendre vrai — sans lui, une expression constamment fausse passerait la preuve sans rien garantir.

Le site a révélé ce défaut parce qu'il a été le premier à porter plusieurs zones. Ce n'est pas une particularité du site : un tenant dérive ses zones du même principe.

2026-08-25 — Le SITE en quatre zones, et l'ordre inscrit dans les intégrations

44 preuves. Le site n'est plus un /24 plat : une zone par nature d'autorité, chacune son VLAN et sa patte sur la frontière.

opt3  vlan031  10.0.31.0/24  pilotage   site-ops-01     voûte underlay, API Proxmox + OPNsense
opt4  vlan032  10.0.32.0/24  autorite   site-pki-01     racine de confiance
opt5  vlan033  10.0.33.0/24  genome     site-forge-01, site-cache-01
opt6  vlan034  10.0.34.0/24  service    site-dns-01

L'inversion que ça corrige, mesurée : les cinq VM du site n'avaient aucun filtrage est-ouest — enable=None, policy_in=None, zéro règle — contre enable=1 policy_in=DROP sur une machine de tenant. La machine la plus autoritaire du système était la moins protégée. Le filtrage est nord-sud, par choix de l'exploitant : un seul point de police, un seul langage, un seul devis — plutôt que deux politiques à tenir d'accord.

Le devis passe de 90 à 117 règles, et c'est tout le sens du découpage : ce qui était gratuit devient policé. Chaque flux flotte produit une règle par zone source, avec une destination nommée — le rôle visé, jamais la zone entière.

L'ordre fait partie de l'intégration

client_pki s'exécutait sur tous les hôtes en parallèle. Sur celui qui porte l'autorité, le rôle réémet le certificat de step-ca et recharge le service ; les quatre autres l'interrogeaient dans cette fenêtre et échouaient ensemble sur TLS handshake timeout — un message qui accuse le réseau alors que la cause est une course de dix secondes.

Ce n'est pas propre à la PKI. Chaque intégration déclare désormais sa dépendance dans meta/integration.yml, les playbooks de groupe en sont le miroir généré, et P44 refuse l'écart. Quatre contrôles négatifs : play unique, second play qui n'exclut pas le serveur, any_errors_fatal retiré, déclaration absente.

serveur: ~ est une réponse, pas un oubli — client_metrique pose un exportateur que Prometheus vient lire. Toutes les intégrations ne dépendent pas d'un service debout.

Le cas dangereux n'est pas le certificat mais le résolveur : un hôte basculé sur un résolveur pas encore prêt devient muet, et le runner qui devrait le réparer tombe avec lui.

Le plancher avant le premier apt

hosts_statiques venait après common_packages. Or apt vise le cache par son nom — ce qui est voulu. Le jour où les adresses changent, la boucle se referme : apt échoue faute de résoudre, donc le socle n'atteint jamais le plancher, donc le plancher reste périmé. Cinq machines bloquées, /etc/hosts vide. Le plancher ne s'installe pas, il rend installable — il passe donc avant.

dns_amorcage se dérive

Écrit en dur, il a eu tort deux fois : la frontière d'abord, puis une adresse que le découpage a déplacée. Il se lit maintenant sur l'hôte qui porte serveur_resolveur.

Et l'ACL du résolveur dérivait de setops_supernet — juste tant que le site était plat. Découpé, il refusait trois zones sur quatre, et la panne ressemble à un DNS mort alors que c'est une autorisation.

Ce que je me suis trompé à croire

J'ai diagnostiqué un trou noir de MTU et déclaré 1450 sur les quatre zones. C'était faux. Il n'y a pas de VXLAN sur ce chemin — il sert aux zones EVPN des tenants — et tout est à 1500 de bout en bout, vérifié sur igb1, les vlan03x, bond3, vmbr3 et les cartes. Ma mesure (1500 → échec, 1300 → 200) était réelle, mon interprétation ne l'était pas : je venais de débrancher la carte à chaud en modifiant net0, et chaque ip link set mtu reconfigurait l'interface au passage. C'est la reconfiguration qui débloquait, pas la valeur. Les VM sont en mtu=1, héritant du pont, à 1500.

2026-08-25 — P43 : la frontière voit-elle les machines du site ?

43 preuves. Celle-ci couvre ce qui a failli coûter 36 objets ce soir : le devis de la frontière avait cessé de voir le site, et proposait de retirer tous ses alias et toutes ses règles. La frontière aurait laissé tomber la forge du génome, le cache racine et le runner du site, d'un seul CONFIRMER=true.

Rien ne l'avait signalé — harnais vert, ansible-lint vert. Seule la lecture manuelle du plan avant application l'a attrapé.

La première version était inutile, et c'est instructif

Elle lisait le plan par devis_opnsense._machines_du_plan_site() — la fonction même dont la panne était à détecter. Éprouvée sur la régression réelle, elle ne criait pas : elle se taisait. Les deux voyaient le vide, et la preuve concluait « rien à prouver ».

Une preuve qui partage la source de ce qu'elle vérifie ne vérifie rien.

C'est la même erreur d'instrument qui a coûté quatre faux diagnostics cette session : le VPN pris pour la frontière, ping pour du TCP, l'overview d'OPNsense pour ses assignations. Ici elle était logée dans la preuve elle-même.

La version retenue lit plan/serveurs.yml et plan/applications.yml directement, et confronte au devis produit. Éprouvée sur la régression réelle : elle refuse, et nomme la cause — « SETOPS_SITE est absent ou vide, alors que le plan déclare 5 machines ».

2026-08-25 — Le site résout chez lui, et le socle cesse de le défaire

Mise au point de l'exploitant : la frontière OPNsense n'a pas de service DNS actif et géré. Un Unbound y tourne — il répondait, ce qui m'a induit en erreur — mais un processus n'est pas un service. La délégation de zone que j'y avais posée est retirée : on ne règle pas ce que rien ne déclare ni ne prouve.

Et surtout, le site a son propre DNS. dns_amorcage pointait encore sur la frontière (10.0.3.1), valeur d'un moment où site-dns-01 n'existait pas. Elle a survécu à sa raison d'être. Il vaut désormais 10.0.3.51.

Reste un cas étroit, nommé plutôt que résolu : la machine qui PORTE le DNS ne peut pas résoudre chez elle avant de l'avoir installé. Un site bâti depuis zéro doit créer et déployer site-dns-01 en premier.

Le devis de la frontière ne voyait plus le site

En déplaçant le plan hors de underlay.yml, j'ai vidé machines() sans rebrancher devis_opnsense. Il proposait de retirer 36 objets — tous les alias et toutes les règles du site. Aucune preuve ne couvre le devis de la frontière du site, donc make prouver restait vert : c'est le plan avant application qui l'a attrapé, et rien d'autre ne l'aurait fait.

Le socle défaisait la bascule du résolveur

Sa garde ne protégeait que l'hôte du résolveur (127.0.0.1). Partout ailleurs il réécrivait /etc/resolv.conf avec dns_amorcage, annulant client_resolveur à chaque passage.

Invisible chez un tenant : dns_amorcage y vaut l'adresse du résolveur du tenant, donc réécrire remettait la même valeur. La coïncidence masquait le défaut. Sur le site les deux ont divergé quelques heures — et ça ne s'est vu qu'en retirant la règle de pare-feu vers la frontière : les cinq machines ont perdu la résolution d'un coup.

L'amorçage ne s'applique plus que si rien de sensé n'est en place : ni la loopback, ni un résolveur déclaré de l'écosystème. Vérifié — le socle repasse changed=0 et les cinq machines restent sur 10.0.3.51.

2026-08-25 — Le génome vit sur la forge du SITE

Six dépôts, 606 commits, chaque tête confrontée entre le poste et la forge : identique. Le génome ne dépend plus d'un écosystème pour se reproduire.

L'ancienne forge de patient 0 devient un second remote, nommé patient0 — elle n'est pas remplacée. Le README de patient 0 exige que le génome vive en au moins trois endroits sans coordination : « n'importe quel survivant réamorce les autres. Une famille, pas un maître. » La forge du site en fait un quatrième.

On vérifie l'état, on ne fait pas confiance au drapeau

Toute l'API de la forge rendait 403. Ni 401, ni un message de droits : jeton valide, identité reconnue, requête rejetée. On cherche un scope, une politique, une capacité — et la cause est un drapeau sur le compte, must-change-password.

Le rôle passait pourtant déjà --must-change-password=false, corrigé le 2026-08-23 sur la première forge de patient 0. Forgejo 16 a ignoré l'option : le compte est né avec le drapeau posé malgré elle. La correction d'il y a deux jours ne protégeait plus rien, et rien ne le disait.

D'où la forme du remède : le rôle ne demande plus à la création de bien se comporter, il constate l'état voulu et le rétablit — une tâche idempotente, sans effet quand la création a tenu parole. C'est la même leçon que le changed= d'Ansible qui ne prouve pas un service : vérifier l'état, pas l'intention.

Le jeton d'amorçage est créé localement par la CLI : un mot de passe d'administrateur n'a pas à circuler pour créer six dépôts.

2026-08-25 — Un SITE a bien un plan

J'ai passé la journée à répéter qu'un SITE n'est pas un plan. C'était faux, et le dépôt le démontrait à mesure : j'ai reconstruit un plan morceau par morceau dans underlay.yml — machines, services, variables, intégrations — sans jamais le nommer.

Ce qui est vrai, c'est qu'un site ne dérive pas. Un tenant tire tout de son index ; un site déclare ses adresses, parce qu'il est le terrain. Il ne passe donc pas par instancier et ne partage pas la superclasse du tenant. Mais « ne pas dériver » n'est pas « ne pas avoir de plan » — et faute d'avoir fait la distinction, chaque manque est arrivé par surprise au lieu d'être prévisible.

SITE-Chezlepro/
  plan/10-intrants.yml     domaine, organisation, rebond, clé, amorçage, exemptions
  plan/serveurs.yml        5 machines — adressage DÉCLARÉ, pas dérivé
  plan/applications.yml    7 services, leur placement, et leurs `expose:`
  underlay.yml             la fabric seule : ce qui est MESURÉ

underlay.yml décrit ce qui est mesuré, le plan ce qui est voulu. Les groupes viennent du registre des applications, comme chez un tenant. Les gardes ont suivi le plan — une garde loin de ce qu'elle garde finit par garder autre chose — et quatre contrôles négatifs les éprouvent, dont une collision d'IP avec la frontière.

Ce que le site a fait tomber, et qu'aucun tenant ne pouvait révéler

Le site est le premier écosystème qui n'a que le strict nécessaire : pas de plan (jusqu'à aujourd'hui), pas de Keycloak, pas de PostgreSQL, pas d'edge, pas de rôles colocalisés.

défaut pourquoi il était invisible
client_pki codait infra-pki-01 en dur la nomenclature d'un tenant nomme toujours sa PKI ainsi — le littéral avait raison par coïncidence
aucun moyen pour un service non-root de lire la clé nginx, Postfix, Dovecot lisent en root avant de déprivilégier ; Forgejo tourne en git d'emblée
le certificat — public par nature — restait en 0600 même cause
l'unité Forgejo n'avait pas d'ExecReload chez un tenant c'est nginx qui consomme le cert, et il sait se recharger
le rôle Forgejo ne savait pas servir TLS il y avait toujours un edge devant
le certificat ne couvrait pas le nom de service expose: n'existait pas, faute de plan
chargement des registres en tout-ou-rien domaines.yml existe toujours chez un tenant
le flux déclarait 3000 en dur vrai tant qu'il y avait toujours un edge

Le message d'erreur accusait presque toujours autre chose : le DNS quand c'était un nom faux, une permission de fichier quand c'était un port privilégié, rien du tout quand le plancher s'écrivait vide.

ROOT_URL est gravée dans les URL de clonage

La forge servait son TLS sur 3000 — le port qu'elle écoute derrière un edge. Coût réel : chaque écosystème descendant aurait porté :3000 dans son origin, pour toujours. Elle sert désormais sur 443, ROOT_URL = https://forge.genese.internal/, avec CAP_NET_BIND_SERVICE dans son unité — Forgejo tournant en git ne peut pas se lier à un port privilégié autrement, et l'échec parlait de « permission denied » sur le port.

Sans edge déclaré, le service se sert lui-même

expositions_des_applications résolvait l'edge par le domaine. Sans domaines.yml, elle rendait edge: None, le gabarit cherchait groups[None] et écrivait un plancher sans alias, sans erreur. Le repli dit une vérité générale, et corrige aussi un silence chez les tenants dont un domaine ne déclare pas d'edge.

Chaîne complète, vérifiée depuis le réseau : expose: → /etc/hosts → SAN du certificat → Verify return code: 0 (ok) → https://forge.genese.internal/ → 200.

2026-08-25 — Un SITE n'a pas d'index : le vestige est retiré

Le site portait index: 17. Ça n'a jamais voulu dire « voici mon adressage » — un site ne dérive rien, ses machines vivent sur un réseau de fabric (10.0.3.0/24), hors de toute dérivation. Ça voulait dire une seule chose : quel supernet de tenant est le mien, pour autoriser un réseau du site à en occuper la bande basse (D-77).

C'était un reste de la coïncidence hébergeur↔tenant chez Chezlepro, qui est les deux à la fois. Ailleurs, ça aurait induit en erreur : un hébergeur qui n'héberge pas son propre écosystème n'a aucun supernet à lui, et devait quand même inventer un index.

L'exception se déclare désormais sur le réseau qui en a besoin, et elle nomme le tenant dont elle occupe la bande basse :

- nom: management
  segment_physique: true
  bande_basse_de: OPS-Chezlepro
  sous_reseau: 10.17.0.0/24

Trois gains. C'est plus vrai — un seul réseau est concerné, pas le site entier. C'est plus lisible — le lecteur voit pourquoi ce /24 habite un /16 qui n'est pas le sien. Et c'est vérifiable : le nom se résout dans le registre tenants:, donc une faute de frappe est refusée au lieu de passer silencieusement.

Quatre contrôles négatifs, tous refusés : un tenant inconnu, l'exception absente alors que le chevauchement existe, un débordement sur la bande des zones, et — le cas subtil — le mauvais tenant nommé.

2026-08-25 — Le SITE devient un écosystème complet : PKI, DNS, forge

Le site portait trois services sur les sept que le modèle origine appelle le plus petit écosystème complet. Ce n'était pas un choix : j'ai bâti le strict minimum pour y déplacer la forge, signalé une fois que « le site n'a ni PKI ni DNS », puis continué. La décision était celle de l'exploitant, et je l'avais tranchée par omission.

Ce que ça coûtait : la forge servait le génome en clair — le code qui fabrique tous les écosystèmes — et aucune machine du site n'avait de certificat, donc aucun chiffrement est-ouest. La doctrine zéro-confiance l'interdit frontalement.

site-pki-01 (step-ca) et site-dns-01 (PowerDNS + résolveur, colocalisés) rejoignent les trois autres. Cinq machines, réparties sur les trois nœuds.

Quatre défauts que le site a fait tomber

Le site est le premier endroit qui n'a que le strict nécessaire — pas de plan, pas de Keycloak, pas de PostgreSQL, pas de rôles colocalisés. Chaque manque a révélé une dépendance implicite qu'aucun tenant ne pouvait exposer :

défaut où il se cachait
DNS bloqué par notre propre default-deny un tenant a son résolveur dans son réseau et ne traverse jamais la frontière
serveur_cache_site sans dépendance déclarée colocalisé avec serveur_artefacts chez patient 0
OIDC résolu même désactivé il y a toujours un Keycloak chez un tenant
plancher /etc/hosts vide hotes_actifs n'existait pas dans l'inventaire du site

Le DNS du site est dérivé, pas écrit à la main : le devis lit site.dns_amorcage et émet SETOPS_SITE → SETOPS_RESOLVEUR_SITE en 53/udp et 53/tcp. Destination déclarée, jamais any — ouvrir le 53 vers le monde aurait été une sortie DNS non policée.

La panne ne ressemblait pas à un pare-feu : resolv.conf correct, Unbound qui écoute sur toutes les interfaces, le port qui « répondait » à un test TCP naïf. On a cherché du côté du résolveur pendant que c'était le filtre.

serveur_cache_site déclare enfin sa dépendance. Il n'installe rien — il marque un cache comme racine de chaîne et lit pour ça les variables de serveur_artefacts. Or les défauts d'un rôle ne sont en portée que dans le play qui l'inclut, et chaque groupe est appliqué par son propre playbook. Déclarer les deux rôles sur la machine ne suffisait pas ; une dépendance de rôle règle l'ordre et la portée.

On ne résout pas un fournisseur d'identité qu'on n'utilisera pas. resoudre_idp partait inconditionnellement et exigeait un plan. serveur_forgejo_oidc_actif dérivait déjà de la présence de Keycloak : la résolution suit désormais l'usage.

Une machine du site peut se configurer

Un tenant configure ses services par son plan. Le site n'en a pas — c'est tout le sens de « un SITE n'est pas un plan » — mais ses machines ont quand même des choix à faire. D'où une section variables: par machine, appliquée en dernier : ce qu'une machine déclare d'elle-même prime sur ce que le site déclare pour toutes. La forge y déclare serveur_forgejo_bd: sqlite, le DNS serveur_powerdns_publier_expositions: false.

Vérifié, pas supposé

systemd disait active et la racine existait — mais rien n'écoutait sur 443 : step-ca sert sur 8443. Le changed= d'Ansible n'est pas une preuve de service. La zone souveraine résout (site-forge-01.genese.internal → 10.0.3.31) et la récursion marche, mesurées depuis la machine.

2026-08-25 — C'est le SITE qui détermine l'index d'un tenant

SITE et OPS sont deux classes distinctes. Le site décide de la fabric et du réseau et produit les intrants ; le tenant les consomme et n'a d'intelligence que sur ses applications, leur configuration et leurs intégrations. Une valeur réseau qu'un OPS décide est une valeur mal placée.

Or l'index est l'intrant réseau par excellence : de lui descendent le supernet 10.<index>.0.0/16, les sous-réseaux de zone, les VLAN, les VMID, les noms de VNet SDN. Un tenant qui le décide décide du plan d'adressage de la fabric.

Ce qui n'allait pas

Il était déclaré deux fois — dans plan/nomenclature.yml du tenant et dans l'underlay du site. Deux sources de vérité pour le même nombre, dans deux classes différentes. Et le site ne déclarait même pas qui il héberge : la clé tenants: existait dans le code, avec une docstring qui décrivait précisément le danger, mais n'était écrite nulle part — la découverte se faisait par balayage des dossiers frères, c'est-à-dire par accident du système de fichiers.

Le sens de la flèche change, pas les valeurs

tenants: devient un registre d'allocation : OPS-Chezlepro: 17, OPS-Technolibre: 23, OPS-Patient0: 29. Le site alloue, le tenant reçoit, et la nomenclature du tenant n'est plus que la copie vérifiable d'une décision prise ailleurs.

Quatre gardes neuves, chacune éprouvée par un contrôle négatif : une allocation qui contredit le plan du tenant, deux tenants sur le même index, une allocation qui n'est pas un entier, un tenant nommé qui n'existe pas. Elles vivent dans underlay.valider(), donc sous P23 — aucune preuve à ajouter.

La forme héritée (simple liste de noms) reste acceptée : elle dit « ces tenants sont ici » sans rien allouer, pour un site qui n'a pas encore migré.

Et l'émancipation

Un tenant qui part chez un autre hébergeur reçoit un index neuf de son nouveau site. L'index n'est pas une propriété du tenant : c'est une place sur une fabric.

2026-08-25 — La vue Réseau du panneau était vide : un KeyError deux couches plus bas

segment_physique: true — introduit le matin même pour dire qu'un réseau n'a pas d'étiquette VLAN — retire la clé vlan. Or devis_reseau.py écrivait r['vlan'] en une vingtaine d'endroits.

Le premier segment non étiqueté a levé un KeyError, /api/devis-reseau a rendu une 500, et la vue Réseau de l'interface est restée vide. Une panne qui ne ressemble à rien, à deux couches de sa cause — c'est le genre qu'on cherche partout sauf là où elle est.

Filtré à la source, une seule fois. Colmater les vingt appels aurait laissé le vingt-et-unième. devis_reseau définit désormais son propre reseaux_de_fabric qui écarte les réseaux sans étiquette, et les dix appels y passent : ce fichier ne configure que des commutateurs, et un commutateur n'a rien à dire d'un segment qui ne l'atteint pas.

Le bloc de gestion d'un switch échappait au filtre — il lit son réseau directement. Quand ce réseau n'est pas étiqueté, il n'existe aucune interface VlanN : l'adresse appartient à l'interface de gestion native du boîtier, dont le nom dépend du modèle. Le devis le dit plutôt que d'inventer une syntaxe — un devis qui promet une commande fausse est pire qu'un devis qui se tait.

Au passage, ça expose une incohérence de la carte : bifrost-3 et bifrost-4 déclarent leur adresse de gestion sur management, un segment qui ne les traverse pas. Ces deux adresses sont d'ailleurs celles de l'ancien plan et n'ont jamais répondu.

Les quatre devis du panneau — switches, frontière, SDN, pare-feu est-ouest — repassent.

2026-08-25 — Un renommage de rôle laissait son ancien fichier aux commandes

Mesuré sur forge-01, par l'agent invité — donc sans dépendre du réseau. Quatre drop-ins SSH coexistent là où il devrait y en avoir deux :

10-chezlepro.conf            10-setops.conf
20-chezlepro-hardening.conf  20-setops-hardening.conf

Les rôles se sont appelés chezlepro avant de porter le nom du moteur. Le renommage a changé le fichier déposé sans retirer le précédent. Et les paires ne disent pas la même chose : MaxSessions 2 contre 10, MaxStartups 5:30:20 contre 10:30:60.

C'est l'ancien qui gagne. sshd retient la première valeur rencontrée pour chaque mot-clé, et lit les drop-ins dans l'ordre lexical : 20-chezlepro-hardening.conf passe avant 20-setops-hardening.conf. Une configuration qu'on croit avoir remplacée reste donc aux commandes, en silence — et sa trace se lirait un jour comme une lenteur inexplicable pendant un déploiement parallèle, cherchée partout sauf là.

ssh_baseline et ssh_hardening retirent désormais chacun le fichier de la nomenclature qu'il a abandonnée, avant de déposer le sien, et notifient la même validation. La liste est déclarative (ssh_baseline_fichiers_perimes, ssh_hardening_fichiers_perimes) et ne contient que des noms qu'on a réellement déposés un jour : supprimer un fichier qu'on n'a jamais écrit serait effacer la configuration de quelqu'un d'autre.

Pas encore éprouvé. Les seules machines portant les anciens fichiers sont celles de patient 0, actuellement hors d'atteinte depuis le poste — leur écosystème est sain, c'est le chemin d'entrée qui est coupé. Le correctif est écrit et validé, il reste à le voir retirer un fichier pour de vrai.

2026-08-25 — Le clonage inter-nœuds : quatre défauts que la première VM ailleurs a révélés

Toutes les VM naissaient jusqu'ici sur le nœud du gabarit, puis migraient. site-cache-01 est la première à être placée ailleurs — et elle a fait tomber quatre défauts enchaînés du moteur de clonage. Aucun n'était visible avant, et aucun ne disait son nom.

1. Le clonage visait le nœud de destination. L'URL était nodes/{{ proxmox_clone_noeud }}/qemu/<gabarit>/clone, alors que l'API veut le nœud qui détient le modèle ; la destination se dit par target. Le gabarit vit sur asgard, la VM allait sur gandalf → 500 Configuration file 'nodes/gandalf/qemu-server/99998.conf' does not exist. Le nœud du gabarit se découvre désormais dans l'inventaire du cluster.

2. Le refus de l'API était avalé. failed_when: false + no_log: true protégeaient l'en-tête d'authentification, mais masquaient aussi le 500. Une assertion le relève maintenant, message de l'API compris — le masque reste, l'erreur sort.

3. L'attente cherchait la tâche au mauvais endroit. Elle interrogeait nodes/<destination>/tasks/<UPID> ; un clonage inter-nœuds s'exécute sur le nœud du gabarit. L'UPID n'existait pas là, l'API répondait en erreur, until n'était jamais satisfait : 60 tentatives × 10 s pour un clonage terminé en 87 s, puis la suite qui reprend sans un mot. Un UPID s'écrit UPID:<nœud>:<pid>:… — le nœud y est déjà, on le lit là plutôt que de le redemander à une variable.

4. La garde d'après-attente ne pouvait pas échouer. exitstatus | default('OK') faisait passer pour un succès une attente qui n'avait rien observé : sans json.data, pas d'exitstatus, donc « OK ». Elle exige désormais d'avoir vu la tâche s'arrêter, et bien s'arrêter.

La taille des disques porte toujours son unité

disque: 40 produisait un resize à « 40 » que Proxmox lisait comme un rétrécissement (shrinking disks is not supported). Le playbook tolère cette erreur — à raison, un disque déjà assez grand n'est pas une panne — donc la machine naissait avec les 16 Go du gabarit au lieu de 40, sans que rien ne le dise. Les plans des tenants écrivent 40G depuis toujours ; le site écrit pareil, et un entier nu se voit rattacher son unité.

Le site déclare ce qu'il matérialise

Le playbook prenait le gabarit et le stockage dans les group_vars du tenant actif : make site-creer aurait cloné depuis un modèle différent selon le symlink instance, sans rien dire. materialisation: (vmid du gabarit, stockage, clone_complet) vit désormais dans l'underlay du site, et site-creer les passe explicitement.

Ce que le chronomètre a dit

Le clonage réel : 59 s et 87 s pour 3,3 Gio effectivement alloués (le gabarit en déclare 16). Ce n'était donc pas le disque du modèle qui coûtait — c'était les dix minutes d'attente aveugle du défaut n° 3. Le clone lié reste le vrai levier (rbd clone depuis @__base__, instantané), mais il exige CephNVMe en destination : TrueNAS est du LVM épais, sans copy-on-write.

2026-08-25 — La frontière voit le site : fabric, alias et règles

Un mot manquait au vocabulaire des flux

Le runner de SITE déclarait son API Proxmox en pair: externe. Or « externe » se rend par !SETOPS_INTERNES — tout sauf les espaces privés — et les hyperviseurs sont en RFC 1918. La règle sortante les aurait exclus tout en ayant l'air d'ouvrir le flux. Un flux qui a l'air ouvert et qui ne l'est pas est pire qu'un flux fermé : il ne se cherche pas.

D'où fabric : le matériel de l'hébergeur — hyperviseurs, frontière, commutateurs. Ce n'est ni flotte (les machines d'un écosystème) ni voisins_site (les tenants d'à côté), c'est le socle sur lequel les uns et les autres reposent. Le seul à s'y adresser est le runner du site, et c'est tout son objet.

Deux flux qui n'existaient pas

Le cache racine ne pouvait rien aller chercher. serveur_cache_site ne déclarait qu'un ingress 3142 : la chaîne entière se terminait sur un cache vide. Ça ne se serait vu qu'au premier apt update d'un écosystème neuf — c'est-à-dire au pire moment.

Le runner de site n'avait que l'API. Déplacer un disque, poser un pont, lire une configuration réseau passe par le shell du nœud ; ouvrir les flux du tenant qu'on matérialise passe par l'API de la frontière. Trois pouvoirs distincts, déclarés à part.

Ce que le devis ignorait

Les machines du site ne sont dans aucun plan de tenant : la boucle du devis ne pouvait pas les voir, et il rendait zéro règle pour elles sans rien signaler. Un devis muet sur une machine qui existe n'est pas un devis.

Le devis émet désormais SETOPS_SITE (le réseau), SETOPS_FABRIC (ce que le runner pilote), SETOPS_ADMIN_SITE (seule source du SSH) et un alias par rôle du site. Leur trafic arrive par opt2, pas par le lien de transit — une règle posée sur la mauvaise interface ne correspond jamais, et c'est la panne la plus silencieuse de cette couche. Le SSH de gestion, lui, arrive par lan. Plus le NAT sortant du site : son réseau est directement attaché, mais le mode automatique d'OPNsense a été quitté en déclarant les tenants à la main — compter sur un mode qu'on a soi-même quitté se paierait au premier téléchargement.

Le socle s'applique aussi au site. site_inventaire.py range ses machines dans serveur_debian : même durcissement SSH, mêmes horloges, mêmes règles que n'importe quelle machine de la flotte. L'oublier aurait donné à la machine la plus puissante du site la protection la plus faible.

Patient 0 rend ses responsabilités de site

Corrigé le 2026-08-25. Cette section affirmait que « patient 0 est un tenant comme les autres : c'est même tout ce qu'il prouve ». C'est faux, et le texte est corrigé plutôt qu'effacé. Un tenant ordinaire existe pour ses gens ; patient 0 n'héberge que la lignée. Il est l'écosystème d'origine et le détenteur du génome, et il le reste. Le geste était juste, la raison ne l'était pas — et une doctrine juste appuyée sur une raison fausse finit toujours par se retourner.

Son plan déclarait encore serveur_ops_site et serveur_cache_site. Il les tenait non par nature mais faute d'un SITE capable de les porter : à l'époque, un site n'était qu'un fichier de carte, sans machines à lui.

Le SITE existe désormais comme objet à part entière — machines déclarées dans son underlay, sur leur propre réseau de fabric, avec leur voûte et leur runner. Matérialiser et servir le cache aux voisins lui reviennent. Porter la lignée reste chez patient 0.

Bilan au devis : 26 règles à créer, 5 à retirer (dont les trois périmées de patient 0).

2026-08-24 — Un SITE n'est pas un plan : ses machines vivent dans l'underlay

J'avais d'abord fait entrer le site dans le générateur des tenants, avec une branche « si l'adresse est déclarée, elle gagne ». Cette branche est retirée.

Le symptôme était visible tout de suite. Un tenant se dérive : de son seul index descendent son supernet, ses VLAN, ses VMID, ses VNet SDN. Un site ne dérive de rien — il n'a pas d'index, il est le terrain sur lequel les tenants dérivent. Les faire passer par la même moulinette donnait un nomenclature.yml de site réduit à une coquille vide, et un site exclu des devis par absence d'index plutôt que par nature. Une exclusion fondée sur un manque casse au premier ajout innocent.

Les machines de l'hébergeur se déclarent donc dans underlay.yml, à côté des switches et des hyperviseurs qui les portent : même carte, même fichier, mêmes secrets. Ce qui se partage entre un site et un tenant, ce sont les rôles, pas la forme du plan.

Six gardes neuves, chacune éprouvée par un contrôle négatif : réseau inconnu, réseau sans pont, nœud qui n'est pas un hyperviseur déclaré, IP hors sous-réseau, IP déjà prise, vmid absent ou en double, machine sans service.

Ce que la mesure a corrigé

Le réseau d'administration 10.17.0.0/24 n'a pas d'étiquette VLAN, et n'en a jamais eu. vlan: 10 était une supposition héritée, et elle était fausse : la frontière le porte sur igb0, un port physique à elle. Il existe bien un VLAN 10 sur vmbr1, mais c'est l'ancien plan 10.0.0.0/24, aujourd'hui vide — deux choses différentes que le même chiffre confondait. D'où segment_physique: true.

Aucun pont d'hyperviseur ne touche ce segment : trois sondes non persistantes depuis asgard (bond0 étiqueté 10, bond3 étiqueté 10, vmbr1 non étiqueté) sont restées muettes, témoin positif réussi dans le même script. Une VM y naîtrait sourde. Le validateur refuse désormais toute machine déclarée sur un réseau sans pont:.

Le premier jeu de sondes avait pour cible la frontière, et concluait faux : elle ne répond pas à une source qu'elle ne connaît pas — son default-deny travaillait. C'est le témoin qui l'a révélé, pas la sonde.

Les machines du site vivent sur site-services — VLAN 30, 10.0.3.0/24, pont vmbr3 (bond3, créé sur les trois nœuds), passerelle 10.0.3.1 portée par la frontière sur son interface OPT2. site-ops-01 en .11, site-cache-01 en .21.

Le repli intermédiaire était grappe-controle (192.168.11.0/24) : porté par un vrai pont, mais de l'hérité — des adresses sans avenir, derrière une passerelle qui n'est même pas la frontière. Ancrer le site là aurait figé les deux défauts dans la carte. Le VLAN 30 lève les deux : adressage de fabric cohérent avec le transit (vlan 40 → 10.0.4.0/24), et sortie par la frontière, donc policée.

Le chemin qui matérialise un site

site_machines.py traduit une déclaration d'underlay en les mêmes SETOPS_* que make cloner-vm consomme déjà : le clone reste le seul chemin éprouvé, il n'y en a pas deux. site_inventaire.py est un inventaire dynamique — un site ne dérivant de rien, sa déclaration est déjà sa forme finale, et un hosts.yml généré ne rendrait rien de plus inspectable. Les groupes sont les services : playbooks/groupes/<rôle>.yml trouve ses hôtes sans qu'on câble quoi que ce soit.

Quatre cibles, indépendantes de toute instance montée — le site existe avant tout tenant et doit pouvoir naître quand aucun n'est encore là : site-decrire, site-inventaire, site-creer (CONFIRMER=true), site-appliquer GROUPE=….

Le premier garde-fou de site-appliquer refusait un GROUPE vide — il ne pouvait jamais se déclencher, GROUPE ayant un défaut global. Le vrai risque, observé en le testant : un playbook de tenant lancé contre l'inventaire du site n'y trouve aucun hôte, n'a rien à faire, et sort avec 0 — un succès qui n'a rien fait. La cible refuse désormais tout groupe absent de cet inventaire-ci.

passerelle_amont, rôle d'hôte neuf : un routeur qui existe mais que nous n'administrons pas. Le déclarer n'est pas l'adopter — c'est distinguer une passerelle étrangère d'une passerelle fantôme.

2026-08-24 — Quinze invites pour un seul mot de passe

flotte-creer appelait make creer-vm une fois par hôte, et chaque appel ajoutait --ask-vault-pass. Quinze machines, donc quinze invites — quatre en parallèle, avec la sortie redirigée vers des journaux : l'exploitant est harcelé par des invites qu'il ne voit même pas.

Mesuré en reconstruisant Chezlepro. Ce n'est pas une fatalité de l'outil, c'est une faute de l'outil.

L'idiome existait déjà — je ne l'avais pas cherché

deployer-tout demande une seule fois depuis longtemps, et refuse proprement quand personne n'est au clavier. Ma première correction inventait une seconde façon de manipuler un secret, à côté de la première.

C'est exactement la faute que P41 combat : deux résolutions d'une même question finissent par diverger, et pour un secret la divergence ne se remarque qu'après.

Les deux cibles partagent désormais un seul bloc, VAULT_UNE_FOIS : lecture unique, fichier mktemp en 0600, trap au retrait, et refus explicite en entrée non interactive.

Une garde que ma première version n'avait pas

-t 0 : ne demander que si un humain est au clavier. Sans elle, un appel non interactif — CI, ordonnanceur, agent — resterait bloqué sur une invite que personne ne lit. Je m'en suis aperçu en vérifiant ma propre correction : la commande a pendu deux minutes.

Vérifié : flotte-creer sans mot de passe et sans terminal refuse maintenant en une ligne, au lieu d'attendre indéfiniment.

2026-08-24 — Le dénominateur extrait : origine devient un modèle

La hiérarchie se lit maintenant sans ambiguïté :

Set-OPS          le moteur — méthodes, rôles, preuves
SITE-xxx         le terrain — UN par hébergeur
Set-OPS-Modeles  les plans-types, dont `origine` est le dénominateur commun
OPS-xxx          les écosystèmes réels : un modèle, posé sur un SITE

Chezlepro et Technolibre sont frères — deux OPS au même niveau.

Patient 0 conflait trois rôles

La mesure l'a montré : il portait le dénominateur commun et un index: 29, une voûte réelle, une parenté, cinq VM en marche — quatre choses qu'un modèle n'a pas.

socle public   pki · edge · mail · dns
patient 0      pki · edge · dns · forge · ops
               + artefacts + cache_site + ops_site + resolveur

Trois rôles, dont un seul est générique :

rôle où il vit désormais
le dénominateur commun le modèle origine
l'écosystème de l'hébergeur de SITE-Chezlepro reste dans OPS-Patient0
le détenteur du génome reste dans OPS-Patient0

Ce que origine ne porte pas

serveur_ops_site et serveur_cache_site — les rôles de l'hébergeur. Un modèle qui les porterait donnerait à chaque client un pouvoir sur ses voisins.

Ni index réel, ni voûte, ni parenté. Un modèle est une recette : index: 1 est un gabarit que l'instanciation remplace par le seed dont tout l'adressage dérive.

C'est la même séparation que pour les runners et pour les voûtes — distinguer la recette de la machine qui l'applique.

Et le même défaut, trouvé une fois de plus

Les six modèles privés portaient encore setops_plan_dir: instance/plan — le lien du moteur, en dur. Corrigé chez les instances et le modèle public il y a deux jours, il avait survécu ici. Un déploiement lancé par SETOPS_INSTANCE y aurait lu le plan d'une autre instance, comme l'edge de patient 0 avait publié les noms de Chezlepro.

Les sept modèles génèrent leur inventaire.

2026-08-24 — Les deux pare-feu appliqués, et un derive qui n'était compris que d'un côté

frontiere    a creer : 0 | a retirer : 0 | inchange : 65 + 15 routes
est-ouest    a creer : 0 | a mettre a jour : 0 | a retirer : 0

Flotte vérifiée après coup, sur les cinq hôtes : DNS interne, Internet, apt sans erreur, et le cache d'artefacts joignable (HTTP 200).

Un port derive que seul un générateur sur deux savait lire

J'avais déclaré le port de PowerDNS derive — il vaut 53 seul sur son hôte, 5300 derrière le résolveur. verifier_ports traite depuis toujours un port non numérique comme « pas une écoute fixe ». Le générateur est-ouest, lui, l'envoyait tel quel à l'API Proxmox :

dport: derive  ->  400 « invalid format - invalid port 'derive' »   six règles refusées

Une même notion, comprise d'un côté et pas de l'autre. Elle l'est désormais des deux.

Et sauter est la bonne réponse, pas un contournement : depuis que le résolveur du tenant est la seule porte, l'autoritatif n'écoute que sur 127.0.0.1:5300. Aucune règle est-ouest n'a d'objet pour lui — les trois groupes t*-srv-powerdns sont devenus périmés et ont été retirés.

Mais un flux qu'on n'applique pas doit se voir. Le devis recense et affiche les ports sautés, avec la raison. Sans cette note, sauter proprement serait devenu un trou silencieux — la faute que ce dépôt traque sous tous ses déguisements.

Le même piège qu'à la frontière, une heure plus tôt

Là aussi le premier essai a signalé des échecs, et là aussi une partie du travail était déjà écrite : le devis suivant est passé de « 6 à créer, 10 à mettre à jour » à « 0 à créer, 1 à mettre à jour ». Un refus partiel n'est pas un refus.

2026-08-24 — La frontière appliquée, et une contrainte qui n'était écrite nulle part

a creer : 0 | a retirer : 0 | inchange : 65 + 15 routes — le devis est clos. La flotte de patient 0 vérifiée après coup : DNS interne, Internet et apt sans erreur sur les cinq.

OPNsense refuse un nom d'alias de 32 caractères ou plus

Le schéma SETOPS_<tenant>_<ROLE> ne laisse que 17 caractères au rôle :

SETOPS_PATI29_SERVEUR_ARTEFACTS_SITE   36   refusé
SETOPS_PATI29_SRV_ARTEFACTS_SITE       32   refusé encore, d'un caractère
SETOPS_PATI29_SRV_CACHE_SITE           28   passe

La contrainte n'était écrite nulle part, et elle s'est manifestée à l'application, pas au devis. Un devis qui promet un objet que la cible rejettera n'est pas un devis : nom_alias abrège désormais (SERVEUR_ → SRV_, comme le pare-feu est-ouest) et refuse bruyamment si le nom déborde encore. Le rôle est renommé serveur_cache_site.

« RIEN N'EST APPLIQUÉ » voulait dire autre chose que ce que j'ai lu

Le premier essai a signalé cinq échecs et affiché « RIEN N'EST APPLIQUÉ, la config reste en attente ». J'ai d'abord compris que le boîtier n'avait pas été touché. Le devis suivant disait autre chose : 62 objets inchangés au lieu de 49.

Les objets étaient bien écrits dans la configuration ; c'est le rechargement qui n'avait pas eu lieu — « en attente » au sens d'OPNsense. Une différence qui compte : entre les deux, la configuration contenait des objets que le pare-feu en marche ignorait encore.

Et la garde du boîtier injoignable a servi

Une tentative a échoué sur une poignée TLS expirée — le premier paquet après une pause, mesuré tout au long de la journée. Le code a refusé de lire un boîtier injoignable comme un boîtier vide, au lieu de proposer de tout recréer. C'est exactement ce pour quoi cette garde avait été écrite.

2026-08-24 — Le chaînage des caches, et la règle qui appartient au SITE

Le runner de SITE est le seul à toucher la configuration système du matériel. Sa responsabilité est de préparer le terrain — VNets, routes, flux — pour que le plan d'un OPS puisse tenir. Ensuite le runner du tenant fait la suite.

Cette mise au point tranche une question restée ouverte : une règle inter-tenant n'appartient ni à l'émetteur ni au récepteur, mais au site. Ce n'est pas « Chezlepro ouvre une porte chez patient 0 » — c'est le site qui autorise un flux entre deux de ses tenants.

Ce que je croyais, et qui était faux

J'avais annoncé que le générateur construisait « tenant par tenant, sur les interfaces de ce tenant », et que des règles croisées changeraient sa forme. Faux : il fait une seule passe sur tous les tenants du site, et il n'y a qu'une interface de transit. Les tenants s'y distinguent par leur alias source, pas par leur interface.

Deux silences fermés dans le générateur

flux_frontiere() ne retenait que les flux externe. Or deux tenants d'une même fabric vivent sur des VLAN distincts, routés par la frontière : leur trafic la traverse, donc elle doit le porter. Sans ça, le mot voisins_site était accepté par la validation et rendait zéro règle — un flux déclaré que personne n'applique.

Et un egress vers un voisin recevait !SETOPS_INTERNES en destination, comme tout flux sortant — c'est-à-dire le port ouvert vers l'Internet. La destination est désormais nommée.

Deux rôles, parce que les flux sont statiques

Un rôle unique aurait dû déclarer l'ingress inter-tenant pour tous les caches. Le devis l'a montré avant toute application :

+ TENANT_PATI29 -> CHEZ17_SERVEUR_ARTEFACTS     un maillage complet,
+ TENANT_TECH23 -> CHEZ17_SERVEUR_ARTEFACTS     chaque cache acceptant chaque autre

serveur_artefacts_site porte donc l'ingress, serveur_artefacts l'egress vers son amont. Résultat au devis : l'ingress ne vise plus que le cache du site.

Ce que ça donne

VM du tenant  ->  cache du tenant  ->  cache du SITE  ->  Debian

Debian téléchargé une fois pour toute la fabric. Un seul flux nouveau par écosystème, entre deux caches — jamais d'une VM vers le cache d'un voisin. Et le cache du site ne voit que des requêtes agrégées : jamais quelle machine installe quoi.

Vider serveur_artefacts_amont, c'est s'émanciper.

Ce qui reste large, et que je ne cache pas

L'egress autorise chaque cache de tenant à joindre le 3142 de tous ses voisins, alors qu'un seul lui sert d'amont. La déclaration étant statique, le rôle ne sait pas lequel de ses voisins est le cache du site. La portée effective reste juste — seul le cache du site accepte — mais la règle sortante est plus permissive que nécessaire.

Rien n'a été appliqué. Le devis se lit avant.

2026-08-24 — voisins_site : le vocabulaire d'un flux entre tenants, et le mur qu'il révèle

Le registre des flux connaissait flotte (mon écosystème), edge, admin et externe. Il ne savait pas dire « les autres tenants de ma fabric » — et ingress + externe signifie depuis l'Internet : déclarer ainsi un cache partagé l'aurait publié au monde.

voisins_site existe maintenant dans resoudre_flux. Il rend des CIDR — les supernets des tenants que ce site héberge — et réutilise devis_reseau.decouvrir_du_site() plutôt que d'en écrire un second recensement. Deux listes de tenants finiraient par diverger, et la divergence se lirait « tout va bien ».

Vérifié : depuis patient 0, il rend 10.17.0.0/16 et 10.23.0.0/16. Le lab, federe: false, est correctement exclu.

Une faute évitée de justesse. Ma première version lisait un federe absent comme « non fédéré » et excluait Chezlepro et Technolibre — leur nomenclature est antérieure à cette clé. devis_reseau dit n.get("federe", True) : l'absence vaut fédéré. Réutiliser sa découverte plutôt que d'en réécrire une a fermé le piège au passage.

Ce qui n'est PAS fait, et pourquoi

Le chaînage des caches — chaque écosystème garde le sien, qui prend celui de l'hébergeur comme amont, pour que Debian ne soit téléchargé qu'une fois par site — n'est pas livré.

Le vocabulaire est accepté, mais aucun générateur ne le rend : make frontiere-plan produit zéro règle pour le port 3142 entre tenants. Un flux déclaré que personne n'applique est précisément le piège que ce dépôt traque, alors les déclarations ont été retirées plutôt que laissées à moitié.

La difficulté est structurelle, pas cosmétique. Les règles d'OPNsense s'évaluent sur l'interface d'arrivée : un paquet venant de Chezlepro vers le cache de patient 0 arrive sur l'interface de Chezlepro, donc la règle doit être posée là. Or le générateur construit ses règles tenant par tenant, sur les interfaces de ce tenant. Émettre des règles croisées change la forme de ce qu'il produit — et ses propres commentaires répètent qu'une règle posée sur la mauvaise interface ne correspond jamais à un paquet.

Ce qui reste en place : le mot voisins_site, éprouvé ; et serveur_artefacts_amont, la capacité de chaînage côté rôle. Il manque le rendu, dans les deux générateurs de pare-feu.

2026-08-24 — Un résolveur par tenant, et non plus un par machine

client_unbound posait un Unbound sur chaque VM. C'était étanche, et c'était N démons identiques pour un service unique.

Mesure prise avant de décider : ~21 Mo de RSS par VM, et 28 à 189 requêtes servies depuis le démarrage. Le gain de cache mutualisé est donc négligeable ; ce qu'on récupère, c'est un démon au lieu de cinq — et un endroit à regarder au lieu de cinq.

Pourquoi pas sur la frontière

C'était la proposition, et elle aurait été la plus économe. Mais un résolveur partagé par tout le SITE devrait connaître la zone interne de chaque tenant : sans vues par réseau soigneusement réglées, le tenant A résoudrait les noms du tenant B. Et un tenant dont le résolveur vit chez l'hébergeur ne peut plus s'émanciper avec.

La récursion est générique ; la zone interne ne l'est pas. C'est ce qui fait du résolveur un service de tenant, et non de fabric.

La cohabitation avec l'autoritatif

Les deux vivent sur infra-dns-01 et voudraient le port 53. Le partage est dérivé, pas déclaré — l'exploitant n'a pas à se souvenir de déplacer un port parce qu'il a ajouté un rôle :

résolveur (Unbound)     10.29.19.11:53 + 127.0.0.1:53    la seule porte
autoritatif (PowerDNS)  127.0.0.1:5300                    derrière lui

Le port de PowerDNS est déclaré derive dans son meta/flux.yml — verifier_ports traite un port non numérique comme « pas une écoute fixe », ce qui est exactement le cas : les deux lient le port 53, mais sur des adresses différentes.

Deux pièges, dont un que le harnais seul a vu

Unbound refuse d'interroger une loopback. do-not-query-localhost vaut yes d'origine — une protection contre les boucles. Or l'autoritatif vit désormais sur 127.0.0.1:5300, derrière le résolveur. Sans la lever, toute la zone souveraine rendait SERVFAIL.

Et la panne était masquée par le plancher. getent hosts forge.genese.internal répondait 10.29.16.11 sur les cinq machines — c'était /etc/hosts, pas le DNS. J'ai d'abord conclu que la résolution fonctionnait. Seul un dig explicite a montré le SERVFAIL. Le plancher fait son travail — mais il rend une panne DNS invisible à qui mesure avec le mauvais instrument.

C'est la tâche de validation du rôle qui a refusé de se dire satisfaite, et elle avait raison contre moi.

Le client sait se retirer

client_resolveur désinstalle l'Unbound local après avoir basculé — jamais avant, sinon l'hôte perdrait toute résolution au milieu du play. L'hôte qui porte le résolveur est épargné : c'est le même paquet.

Sans cette tâche, le rôle aurait changé le resolv.conf sans rien économiser, et la raison même du changement aurait été perdue.

Le renommage

client_unbound n'installant plus Unbound, son nom mentait. Il devient client_resolveur — une vingtaine de fichiers, quatre plans d'instance et six modèles. Le harnais a rattrapé chaque oubli : playbook homonyme manquant, inventaires en écart, plan de recette périmé, README absent.

2026-08-24 — Collabora en natif : la dernière exception conteneurisée tombe

serveur_collabora lançait collabora/code dans Docker — seule exception de la flotte au principe fondateur : logiciel libre en natif, systemd + paquets + nginx. Elle traînait une dépendance entière, community.docker, qui n'était même pas déclarée dans requirements.yml et donc absente de tout runner.

Plus aucun rôle du dépôt n'a besoin de Docker. La collection est retirée.

Éprouver l'outil avant le rôle

Sur une Debian 13.6 réelle, sans rien installer :

apt-get install --simulate coolwsd  ->  résout jusqu'à « Conf coolwsd (26.04.3.1-1) »
libgcc1 (exigé par le paquet)       ->  fourni par libgcc-s1 sur trixie
le dépôt CODE-deb                   ->  PLAT : Packages et Release à la racine, pas de dists/

Le paquet livre aussi /etc/nginx/snippets/coolwsd.conf et un profil AppArmor — deux choses que cette flotte sait exploiter, contrairement à une image opaque.

La configuration par surcharge

Le coolwsd.xml livré fait 439 lignes richement commentées. Le remplacer par un gabarit maison obligerait à suivre son évolution amont et ferait perdre ses explications. coolwsd acceptant des surcharges --o:<clé>=<valeur>, le rôle pose un fragment systemd : la configuration de Set-OPS tient en un fichier, celle du paquet reste intacte.

La console d'administration est fermée

Elle expose un formulaire hors de tout SSO, avec un secret à créer, faire tourner et surveiller — pour une fonction dont personne n'a besoin. Même raisonnement que serveur_forgejo_connexion_locale.

Trois outils du harnais ont eu raison, et l'un avait tort

voute.py lisait les commentaires. Expliquer en commentaire « pour ouvrir cette option, fournir vault_x » suffisait à rendre vault_x obligatoire dans le gabarit ET dans la voûte réelle. Documenter le nom d'une clef ne doit pas l'exiger : le scanner ignore désormais ce qui suit un #. Contrôle négatif fait — une référence réelle, dans du code, reste exigée.

Le message d'erreur d'un assert comptait comme une référence. Il nommait la clef de voûte ; il nomme maintenant la variable que l'exploitant règle, ce qui est plus utile de toute façon.

Et verifier_intrants.py avait raison. Un assert de la forme var | length > 0 déclare un contrat — et l'outil ignore délibérément les when:, parce qu'un intrant exigé sous condition reste un intrant exigé. Écrite en deux morceaux, ma garde promettait un secret pour une console fermée. Réécrite en implication — si ouverte, alors un mot de passe — elle dit ce qu'elle veut vraiment dire.

Ce qui n'est pas prouvé

L'installabilité est établie. Le fonctionnement ne l'est pas : aucun écosystème de la flotte ne fait tourner Collabora aujourd'hui. La première mise en service devra vérifier l'édition d'un document de bout en bout, depuis Nextcloud.

2026-08-24 — La clé du runner, et trois silences fermés en chemin

Le poste d'exploitation fabriquait sa propre paire SSH depuis son premier déploiement, et n'avait jamais pu joindre un seul hôte. Deux verrous, pas un : la clé n'était autorisée nulle part, et nftables_admin_ssh ne listait pas son adresse. Les deux vont ensemble — c'est tout le sens de P24, un seul intrant pour la flotte et la frontière.

ssh_baseline gère désormais les clés d'administration déclarées au plan. Chaque entrée porte son etat : passer à absent révoque sur toute la flotte au prochain passage. Une liste purement additive ne sait pas retirer, et « révoquer » voudrait alors dire se connecter à la main sur chaque machine.

Pas d'exclusive: true : il effacerait la clé posée par cloud-init si le plan ne la reprenait pas, et fermerait la flotte à tout le monde d'un seul déploiement. Le prix assumé : une clé posée hors du plan n'est pas détectée ici.

Vérifié depuis ops-01 : les cinq hôtes répondent leur FQDN.

Cloud-init effaçait le plancher de résolution à chaque démarrage

manage_etc_hosts: true traîne dans le gabarit doré. À chaque boot, cloud-init régénère /etc/hosts depuis son propre modèle et efface le plancher dérivé de l'inventaire — la seule façon qu'a une machine de nommer ses voisines sans DNS.

Constaté sur infra-dns-01, redémarré lors d'un déplacement de disque : six entrées de flotte perdues. Le défaut s'est manifesté deux jours plus tard sous la forme d'un apt update qui ne résolvait plus le cache d'artefacts — un message qui ne parlait pas du tout du vrai problème. infra-edge-01 avait subi le même sort et s'était fait réparer sans qu'on le sache, par un redéploiement.

hosts_statiques dépose maintenant un fragment cloud.cfg.d qui neutralise cette réécriture. Les cinq hôtes : six entrées, manage_etc_hosts: false, apt sans erreur.

Trois collections utilisées, aucune déclarée

requirements.yml ne nommait que community.postgresql et community.general. Or les rôles utilisent aussi ansible.posix (serveur_backup) et community.docker (serveur_collabora). Ça marchait chez le mainteneur, où un gros lot est installé — et échouait partout ailleurs.

Le runner n'a que ce que ce fichier déclare : il n'aurait pas pu déployer les sauvegardes. C'est le genre d'écart qu'on ne voit qu'en portant le moteur sur une autre machine.

À trancher : community.docker contredit le principe « zéro conteneur ». La dépendance est déclarée parce qu'elle existe — mieux vaut une contradiction visible qu'un rôle qui échoue en silence — mais la question reste ouverte : Collabora en natif, ou l'exception assumée ?

Et le cache suivait la mauvaise chose

Le téléchargement des collections était gardé par creates: <cache>/requirements.yml : une collection ajoutée au fichier n'aurait jamais été récupérée, le témoin existant déjà. Le cache est désormais indexé sur l'empreinte du fichier — changer une ligne crée un cache neuf, donc un téléchargement.

Un when en écrase un autre

En ajoutant une condition, j'en ai laissé une seconde juste en dessous. YAML garde la dernière clé et jette l'autre, en silence : not ansible_check_mode avait disparu. ansible-lint l'a vu ; personne d'autre ne l'aurait vu. Les conditions vont dans une liste.

2026-08-24 — Deux runners, deux pouvoirs, aucun omnipotent

Le travail d'un runner se divise en trois, et la ligne de partage est celle des voûtes :

calculer      plan -> inventaire        aucune voûte      portée TENANT
configurer    rôles sur ses machines    voûte du TENANT   portée TENANT
matérialiser  créer/détruire des VM     voûte du SITE     portée FABRIC

Un runner par tenant qui matérialiserait mettrait la voûte du SITE en N exemplaires — le secret le plus dangereux du système, recopié autant de fois qu'il y a de locataires.

Un runner unique qui ferait tout devrait entrer en SSH chez tous les tenants, donc traverser le default-deny inter-tenant — et rendrait l'émancipation impossible : un écosystème dont le runner appartient à l'hébergeur ne peut plus se rebâtir sans lui.

D'où serveur_ops_site, additif : il crée des VM vides et n'entre jamais chez un tenant ; serveur_ops habille des machines et ne touche jamais la fabric. Réservé à l'écosystème de l'hébergeur — un tenant ordinaire qui le déclarerait s'arrogerait un pouvoir sur ses voisins.

La voûte, de droit plutôt que par emprunt

La cérémonie du prêt que j'avais bricolée en ligne de commande masquait une pièce manquante. Le rôle dépose maintenant la voûte du SITE, chiffrée, en 0600 — et le mot de passe n'est toujours pas stocké : le Makefile ajoute --ask-vault-pass quand aucun fichier n'est défini.

Une garde née d'une faute

decrypt: false est obligatoire sur la copie : sans lui, Ansible déchiffre la source quand il détient le mot de passe. Mesuré le jour même, en la déposant à la main — 776 octets en clair au lieu de 3465 chiffrés, sur une machine où ils n'avaient rien à faire.

Le rôle relit l'en-tête après avoir écrit et refuse si le fichier n'est pas chiffré. Contrôle négatif fait : pointé sur un fichier en clair, il échoue ; rétabli ensuite, la voûte déposée est bien $ANSIBLE_VAULT, 3465 octets, 0600.

Une fuite silencieuse aurait été le pire des cas : le fichier existe, le rôle se dit satisfait, et les clés du cluster dorment en clair.

2026-08-24 — Filiation, mutualisation, émancipation : nommer un motif que le moteur avait déjà

Un écosystème ne naît pas autoportant. Il lui faut une forge pour lire son génome, une source d'artefacts pour ses paquets, un dépôt pour ses sauvegardes — et rien de tout cela n'existe la première seconde. Il emprunte donc à son hôte.

Le moteur avait rencontré ce besoin trois fois sans le reconnaître :

client_artefacts_actif    dérivé : « une source existe-t-elle chez moi ? »
serveur_ops_forge_externe « je lis mon génome ailleurs »
client_backup_cible       patient 0 sauvegarde chez eregion — mutualisé, en production

Trois astuces, une seule notion — désormais déclarée dans docs/filiation-emancipation.md.

Ce que ça change pour les modèles

Quatre modèles sur six n'ont pas de forge : collaboration, identite, observabilite, presence-web. On pouvait lire ça comme une lacune — leur ops-01 n'aurait rien à cloner. C'en est une autre : ce sont des écosystèmes au premier âge, qui lisent leur génome chez leur hôte. L'ajout d'une forge n'est pas une correction, c'est une émancipation.

Les quatre le déclarent maintenant explicitement. forge et integral restent émancipés par défaut.

Et personne ne l'aurait vu : le harnais ne regarde pas les modèles privés — P17 n'en découvre qu'un seul, celui du dépôt public. Encore un vert sur un périmètre vide.

Une promesse sans substance

serveur_ops_forge_externe était citée par docs/dependances-groupes.yml et par le README du rôle, et définie nulle part. L'exemption qu'elle portait ne pouvait donc jamais s'appliquer : le registre refusait un écosystème sans forge tout en documentant comment l'autoriser. La variable existe maintenant, avec son amont obligatoire — lever le drapeau sans nommer chez qui l'on emprunte, c'est ne rien déclarer du tout.

Ce que l'émancipation devra prouver

Trois temps, dont le dernier est celui qu'on oublie :

  1. déclarer — lever le drapeau, déployer le service chez soi ;
  2. migrer l'état — génome, sauvegardes, certificats ;
  3. prouver que le lien est coupé.

Sans le troisième, on croirait s'être émancipé en restant dépendant sans le savoir. Une émancipation non prouvée est une émancipation non faite. L'instrument de cette preuve n'existe pas encore — c'est la prochaine pièce.

Ce que ça ouvre, côté commerce

L'hébergeur ne vend plus seulement des machines : il vend l'abri pendant la jeunesse. Chaque service mutualisé est une ligne de facture ; chaque émancipation est une décision du client, jamais une rupture technique. Pour une clientèle d'OBNL et de coopératives : on entre à bas coût, on grandit vers l'autonomie, et on n'est jamais captif — le génome est chez soi dès le premier jour.

2026-08-24 — Le poste d'exploitation entre dans les modèles, et un silence de plus est fermé

serveur_ops n'existait que chez patient 0. Aucun modèle ne le portait — donc aucun écosystème livré n'aurait su se relire lui-même : il aurait détenu son génome en dépendant, pour l'exécuter, de la machine de quelqu'un d'autre. Les six modèles déclarent désormais la fonction ops, la machine ops-01 et son application.

Un défaut du rôle, corrigé avant qu'il ne serve

Les valeurs par défaut de serveur_ops nommaient patient 0 :

serveur_ops_depots:
  - { depot: "ops-patient0", dest: "OPS-Patient0", role: "instance" }

Tout écosystème déployant un poste sans déclarer ses propres dépôts aurait donc cloné le génome de patient 0 — silencieusement, en croyant piloter le sien. Le défaut ne nomme plus que le moteur, qui est le même pour tous ; le dépôt d'instance doit être déclaré, et la tâche d'exigence refuse de poursuivre sans lui.

Le silence que presence-web a révélé

Ce modèle range tout son socle en zone 1 (« Fondations ») et n'a pas de catégorie 4. La machine ops-01 y a donc été posée sans que sa fonction existe — et le moteur a produit un inventaire en se déclarant réussi :

ops-01 -> adresse=None  vlan=None

deriver_nomenclature(...) or {} avalait l'échec : une fonction absente rendait un dictionnaire vide, et la machine entrait dans l'inventaire sans adresse. La panne ne serait apparue qu'au déploiement, sous une forme incompréhensible — Ansible tentant de joindre une adresse qui n'existe pas.

instancier.py refuse maintenant, en bloc et en nommant ce qu'il faut corriger :

Machines sans adresse derivable — leur `fonction` n'est pas declaree :
  - ops-01 : fonction « ops »
Fonctions connues de ce plan : data, infra-dns, infra-edge, infra-mail, infra-pki, …

Contrôle négatif fait, puis contrôle positif sur les six modèles.

Ce que ça dit de la méthode

Le défaut ne s'est pas montré pendant qu'on écrivait le rôle, ni pendant qu'on le déployait chez patient 0 — où la catégorie 4 existe. Il a fallu porter la pièce dans un contexte différent pour qu'il apparaisse. C'est la même leçon que Technolibre avait donnée en révélant six défauts moteur invisibles avec un seul écosystème : un moteur ne se prouve qu'au pluriel.

2026-08-23 — La source d'artefacts : la forge sert le code, il manquait qui sert les binaires

Pour poser une seule machine, un écosystème allait chercher chez six serveurs étrangers : deb.debian.org, security.debian.org, packages.smallstep.com, apt.grafana.com, packages.icinga.com, codeberg.org. La forge héberge le code ; rien n'hébergeait les binaires.

Deux rôles neufs — serveur_artefacts (apt-cacher-ng) et client_artefacts, intégration universelle qui s'éteint d'elle-même quand aucun hôte ne porte le service, et qui retire la direction posée auparavant : une intégration qui ne sait pas se retirer est un piège différé.

Chez patient 0, le service est colocalisé sur forge-01. C'était l'intuition de départ, prise au mot : la forge est la source — du code et des binaires.

Ce qui était déjà couvert, et ce qui ne l'était pas

téléchargements directs (Forgejo, Keycloak, Nextcloud, oauth2-proxy, collections) déjà couverts — le contrôleur télécharge une fois et pousse par SSH
dépôts apt c'est ce qui manquait

Deux mécanismes, parce que ce sont deux problèmes : on ne sert pas un dépôt apt par scp.

La preuve : couper l'amont

Tant qu'internet répond, un apt update qui réussit ne dit pas d'où vient l'octet. D'où le mode hors ligne, qui est autant une fonction qu'un instrument :

paquet DÉJÀ en cache    235 ko réceptionnés en 0s (0 o/s)     ← servi localement
paquet ABSENT du cache  503 Unable to download in offline mode ← refusé

Le 0 o/s est le témoin : rien n'a traversé le réseau. Contrôle positif et négatif dans la même minute.

Trois choses apprises en le construisant

apt fait hériter Acquire::https::Proxy de la valeur HTTP. Poser le seul proxy HTTP envoyait donc aussi les dépôts tiers en HTTPS dans le cache, qui refuse les tunnels — à juste titre : 403 CONNECT denied. packages.smallstep.com devenait injoignable pour toute la flotte. Il faut écrire DIRECT explicitement.

Un service ne doit pas dépendre de lui-même pour se réparer. La première version faisait apt update à chaque passage. Sur l'hôte qui porte le cache, cet apt update passe par le cache — et en mode hors ligne, il est refusé. Le rôle qui devait remettre le service en ligne ne pouvait plus s'exécuter, et il a fallu réparer la machine à la main. L'index n'est désormais rafraîchi qu'à la première installation.

Mes sondes ont menti deux fois de plus. apt-get update >/dev/null 2>&1 && echo ok a rendu « ok » sur cinq hôtes où le proxy était injoignable : rediriger la sortie d'erreur, c'est choisir de ne pas voir. Et grep -c rend un code de sortie 1 quand il compte zéro — un instrument qui crie à l'échec en constatant le succès attendu.

Ce que ça ne règle pas encore

Les dépôts tiers en HTTPS vont toujours en direct. Les faire passer par le cache demande de réécrire leurs sources en http://cache/<remap>/… — propre, faisable, pas dans cet incrément.

Et un cache ne sert jamais la machine qui le construit : client_artefacts est déployé en dernière couche, donc lors d'une construction from-zero les premières machines vont encore à l'amont. Il sert dès le deuxième passage, et à chaque reconstruction — c'est-à-dire exactement le scénario pour lequel il existe.

2026-08-23 — Le résolveur : un service qui répondait à des questions que personne ne posait

Les hôtes de patient 0 interrogeaient Quad9, alors que infra-dns-01 fait tourner un PowerDNS autoritatif pour genese.internal. Chaque requête DNS de l'écosystème sortait chez un tiers — une fuite au niveau le plus fondamental de la pile, pour une plateforme dont le principe est la souveraineté.

Le mécanisme n'était pas absent : client_unbound était désarmé, deux booléens à false, avec une garde qui refuse la bascule sans confirmation et valide qu'Unbound répond déjà — zone interne et Internet — avant de toucher /etc/resolv.conf.

La zone était fausse, et rien ne le disait

C'est le troisième effet, et le pire. En armant la bascule, la zone servie contenait :

genese.internal  SOA/NS/ns1 · dns CNAME
forge-01 · infra-dns-01 · infra-edge-01 · infra-pki-01

Manquaient ops-01 — la machine créée le matin même — et surtout forge.genese.internal, le nom que l'écosystème publie. Un service que personne n'interroge n'est pas surveillé : il est muet. La zone aurait pu être fausse pendant des mois sans qu'aucun vert ne vacille.

La cause est la même que celle du vhost nginx et du plancher /etc/hosts : le rôle dérive ses enregistrements de setops_plan_dir, qui pointait le mauvais plan. Un redéploiement avec la variable corrigée a suffi :

forge.genese.internal  A  10.29.16.11   ← l'edge qui le sert
ops-01.genese.internal A  10.29.19.41

La bascule

Quatre hôtes — infra-dns-01 reste hors du groupe, un autoritatif ne se résout pas auprès de lui-même par un récurseur local. Vérifié après coup :

resolv.conf   search genese.internal · nameserver 127.0.0.1
interne       forge.genese.internal -> 10.29.16.11   ops-01 -> 10.29.19.41
internet      deb.debian.org -> 151.101.138.132
apt update    ok
fetch du génome depuis sa forge, par le poste : ok

Et le point qui décide si le gain est réel ou cosmétique : forward-addr = 0. Unbound récurse depuis les serveurs racine, il ne renvoie à aucun tiers. Patient 0 est le premier écosystème de la flotte à ne plus poser ses questions à personne.

Une note d'outillage

grep -c rend un code de sortie 1 quand il compte zéro. Ma sonde a donc affiché FAILED sur les quatre hôtes alors que tout livrait — le zéro était précisément le résultat cherché. Un instrument qui crie à l'échec quand il constate le succès attendu vaut la peine d'être relu avant d'être cru.

2026-08-23 — serveur_ops : la différence entre une archive et une matrice

Un écosystème pouvait détenir son génome sans savoir l'exécuter. Les cinq dépôts vivaient sur sa forge — moteur, plans, modèles, étiquette signée — mais Set-OPS ne tournait que depuis le poste de son mainteneur. L'écosystème possédait le livre ; personne, chez lui, ne savait le lire à voix haute.

serveur_ops est le poste d'exploitation : Ansible épinglé sur la même famille que celle qui a servi à construire (core 2.18 — reconstruire avec une version différente, c'est changer la recette sans le dire), le génome cloné, et une clé SSH propre au poste.

Le génome vient de SA PROPRE forge

Le poste clone https://forge.<domaine>/genome/…, pas la forge parente. Ces dépôts y sont des miroirs resynchronisés toutes les huit heures : l'écosystème se reconstruit donc depuis lui-même, et non depuis son ascendant. Le clone est anonyme — un secret de moins sur une machine qui en concentre déjà beaucoup.

Les deux choses qu'il n'a pas, et qui ne sont pas des oublis

Le mot de passe de la voûte : saisi à l'exécution. Une machine détenant à la fois le plan, l'accès SSH à toute la flotte et la clé des secrets n'a plus aucune profondeur.

Le fichier de voûte : les dépôts d'instance excluent vault.yml de git. Le génome cloné porte donc le plan sans les secrets. D'où une conséquence qu'il vaut mieux énoncer que découvrir :

la STRUCTURE se reconstruit depuis la forge
les SECRETS se restaurent depuis la sauvegarde (restic)

Deux sources distinctes, qu'un même incident n'atteint pas ensemble.

Sa clé doit être autorisée à la main

Le poste fabrique sa propre paire, distincte de celle du mainteneur : deux exploitants, deux révocations possibles. Tant que sa clé publique n'est pas portée aux intrants SSH du plan, il ne joint aucun hôte. C'est volontairement un geste humain — donner à une machine le droit d'entrer partout mérite une décision, pas un effet de bord.

Le harnais a écrit la moitié de ce rôle

Le rôle écrit, make prouver a rendu 36 OK, 5 échecs, tous sur la pièce neuve :

P08  aucune couche de deploiement          -> couches-deploiement.yml
P09  pair de flux inconnu 'hyperviseur'    -> vocabulaire : edge|flotte|externe|...
P29  aucune declaration d'authentification -> meta/authentification.yml
P31  aucun README                          -> roles/serveur_ops/README.md
P38  le catalogue ne le nomme pas          -> docs/catalogue-services.md

Aucun de ces cinq oublis n'aurait empêché le rôle de fonctionner. Tous les cinq l'auraient rendu invisible à la carte, au graphe, à la politique de pare-feu et au lecteur. Le harnais ne vérifie pas que le code marche : il vérifie que le dépôt sait encore ce qu'il contient. Retour à 41 OK, 0 échec.

Au passage, pair: hyperviseur a été refusé à juste titre : l'hyperviseur n'est pas dans l'écosystème, il est de l'autre côté de la frontière — donc externe. C'est le seul flux par lequel un écosystème peut en engendrer un autre.

Ce que le poste a révélé en naissant

Une machine neuve est un révélateur : elle traverse tout le moteur sans rien hériter d'un état antérieur. ops-01 a buté sur cinq obstacles, dont deux étaient des défauts réels et silencieux du dépôt.

Le plan lu n'était pas celui déployé. setops_plan_dir valait {{ playbook_dir }}/../../instance/plan — le lien instance du moteur, en dur. Neuf rôles lisent cette variable. En déployant patient 0 par SETOPS_INSTANCE, ils lisaient donc le plan de Chezlepro. Conséquences constatées sur les machines :

/etc/hosts de ops-01     : auth.chezlepro.internal, forge.chezlepro.internal…
nginx de l'edge          : server_name forge.chezlepro.internal;

Un écosystème publiait les noms d'un autre. La variable est désormais ancrée sur {{ inventory_dir }}/../../plan : le plan qui a engendré cet inventaire. Les deux ne peuvent plus se contredire, et l'expression reste juste par le symlink comme par SETOPS_INSTANCE. Corrigé dans les quatre instances et dans le modèle public.

L'edge publiait derrière un certificat auto-signé. Trois instances sur quatre portaient group_vars/serveur_nginx.yml — les SAN d'exposition, le chemin du certificat step-ca, le rechargement de nginx. La quatrième, plus récente, ne l'avait pas ; le modèle public non plus. Résultat : ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem devant la forge de patient 0. Le service répondait, la page s'affichait après un avertissement, make prouver était vert. Le premier à refuser fut git clone — et il avait raison.

C'est un oubli de recopie, pas un bug : un câblage reproduit à la main finit toujours par manquer quelque part. D'où P42 — « L'edge porte les noms qu'il publie », qui le réclame désormais pour chaque écosystème déclarant un edge. Contrôle négatif fait : le fichier retiré, la preuve passe au rouge.

Et trois obstacles d'exécution, sans mystère mais instructifs :

  • la frontière ne connaissait pas ops-01 — normal, il est neuf : + alias SETOPS_PATI29_SERVEUR_OPS, + SETOPS_PATI29_CLIENT_UNBOUND, 0 retrait ;
  • ansible-galaxy ne joint pas galaxy.ansible.com depuis l'overlay, et c'est très bien ainsi. Ouvrir une règle vers un serveur étranger pour qu'un écosystème sache se reconstruire aurait été la mauvaise réponse. Le contrôleur télécharge dans son cache et pousse par SSH — même idiome que serveur_forgejo devant son propre tiers. Effet de bord recherché : le poste devient déployable hors ligne ;
  • le chemin du contrôleur contient une espace (« Espace Chezlepro/… ») et command: cmd: la lit comme un séparateur. Le message d'erreur ne parlait pas du tout du vrai problème. Forme argv désormais.

La preuve

Depuis ops-01, sous son propre compte :

8e125b7  plancher : nommer ce qui est hors de l'ecosysteme     le moteur
8c4a39b  amont : declarer par quelle adresse patient 0 …       son plan
etiquette v2026.08.21 — SSH SIGNATURE presente
make aide -> « Set-OPS — moteur d ecosystemes numeriques souverains »

L'écosystème lit son propre génome, depuis sa propre forge, avec son propre Ansible.

Le poste ne clonait que le moteur et son plan. Il savait donc configurer des machines existantes, pas en créer : placer une VM demande de savoir sur quel nœud, quel stockage, quel pont — c'est-à-dire l'underlay.

Patient 0 n'a pas de fabric à lui : il est tenant de SITE-Chezlepro, au même titre qu'OPS-Chezlepro. Son poste porte donc désormais les deux symlinks de D-80 :

Set-OPS-public/instance      -> /opt/setops/OPS-Patient0
Set-OPS-public/underlay.yml  -> /opt/setops/SITE-Chezlepro/underlay.yml

et quatre dépôts au lieu de deux — le moteur, son plan, la fabric qui le porte, et les modèles (un descendant ne se crée pas à partir de rien).

ops-chezlepro reste volontairement non cloné : c'est le plan d'un tenant voisin, que patient 0 n'a aucune raison de détenir. Il est présent sur sa forge par le poussage initial du génome — à revoir.

Où passe exactement la ligne

Mesuré depuis ops-01, sous son propre compte :

make instancier     DIFF VIDE : le plan reproduit exactement l'inventaire actuel
make underlay-plan  Aucune voute sous /opt/setops/SITE-Chezlepro

Patient 0 régénère sa propre structure, sur sa propre machine, sans aucun secret. Et dès qu'il s'agit de toucher la fabric, il est arrêté faute de voûte — underlay.vault.yml est hors dépôt, donc absent du génome. Le poste lit la carte du monde physique, jamais ses clés.

Ce que cette carte expose, en revanche, mérite d'être dit : underlay.yml décrit les quinze équipements du site, le VLAN de gestion 10.17.0.0/24 — celui-là même où la frontière interdit à patient 0 d'entrer — et les accès OOB/IPMI. Aucun justificatif, mais toute la topologie. C'est le prix de l'autonomie d'un tenant sur la fabric d'autrui, et il se paie en connaissance.

Patient 0

Fonction ops (zone Services-infra), machine ops-01 — adressage dérivé 10.29.19.41, VMID 129404101. Pas de client_backup : le poste ne détient aucun état propre, tout ce qu'il porte se recompose depuis la forge.

2026-08-23 — Les miroirs du génome, et un nom qui ne résout pas pareil selon d'où on le demande

La copie du génome sur patient 0 était figée au jour du poussage. Elle ne l'est plus : les deux dépôts publics sont désormais des miroirs Forgejo, resynchronisés toutes les huit heures depuis la forge amont. L'étiquette signée v2026.08.21 traverse le miroir intacte — vérifié SSH SIGNATURE présente sur le dépôt reconstruit.

Les deux dépôts privés utiles suivent désormais eux aussi, authentifiés par un jeton de lecture seule. ops-chezlepro n'en a pas : c'est le plan d'un tenant voisin, sans usage chez patient 0.

Le premier jeton fourni pouvait ÉCRIRE — un PATCH sur le dépôt parent accepté (HTTP 200), alors qu'administration, organisation et user rendaient bien 403 : l'instrument était fiable, et le verdict aussi. Un enfant qui peut réécrire son parent inverse le sens de la filiation ; il a été refusé et regénéré.

Le second porte read:repository seul — mesuré : organization, package, user et admin rendent tous 403 — et lit malgré tout les dépôts privés appartenant à une organisation. La question qu'on avait laissée ouverte (« faut-il aussi organization ? ») est donc tranchée par la mesure, et non par la précaution.

ÉPROUVER UN MIROIR AUTHENTIFIÉ DEMANDE LE BON INSTRUMENT. Le justificatif n'est pas dans le git config du dépôt — Forgejo le range dans sa base et l'injecte au moment de la synchronisation. Un git ls-remote à la main rend donc could not read Password, ce qui ne prouve rien. Le seul juge est l'horodatage mirror_updated que la forge tient elle-même : il a avancé pour les deux, donc l'amont privé a réellement été joint.

Le piège du jour : un nom, deux réponses

forge.alliance-boreale.ca ne résout pas pareil selon l'endroit d'où on le demande :

depuis le poste d'administration  ->  192.168.14.66   (LAN de l'hébergeur)
depuis l'overlay de patient 0     ->  69.70.26.51     (adresse publique)

Et depuis patient 0, c'est l'adresse publique qui marche : le tenant a le droit de sortir sur l'internet et pas d'entrer dans le LAN 192.168.x de son hôte — le default-deny entre les deux mondes, qui fait exactement son travail.

La mesure, prise depuis forge-01 :

ping 100 octets -> 192.168.14.66     BLOQUÉ      (même 100 octets : pas un problème de MTU)
ping / https    -> internet          passe, toutes tailles
curl https://69.70.26.51/api/v1/version -> {"version":"8.0.3"}   la vraie forge amont
la forge locale de patient 0            -> {"version":"16.0.2"}  bien distincte
8 essais sur 8  -> HTTP 200 en ~6,7 ms  (retour en épingle local, stable)

Et une fois de plus, la poignée TCP a menti : 192.168.14.66:443 répondait « ouvert » alors qu'aucune donnée ne passait. Seule la livraison compte.

hosts_statiques_externes — nommer ce qui est hors de l'écosystème

Le plancher /etc/hosts ne savait nommer que ce que l'écosystème contient. Or un écosystème doit atteindre des noms du dehors, à commencer par la forge dont il descend. Poser l'entrée à la main ne tiendrait pas : le fichier est regénéré intégralement à chaque passage du rôle. La déclaration vit donc au plan :

hosts_statiques_externes:
  - ip: "69.70.26.51"
    noms: ["forge.alliance-boreale.ca"]
    pourquoi: "forge amont du génome — l'adresse interne est bloquée par la frontière"

On épingle l'adresse dont on a prouvé qu'elle livre. Si un enregistrement à horizon partagé rendait un jour l'adresse interne, le miroir s'arrêterait sans bruit — et une copie du génome qui vieillit en silence est précisément le défaut que ce dépôt combat.

Ce que la conversion a coûté, et pourquoi elle est gardée

Forgejo ne convertit pas un dépôt ordinaire en miroir : il faut le détruire et le recréer. Trois gardes encadrent donc l'opération, et deux ont mordu pour de bon :

  • l'amont contient-il déjà le local ? La première version exigeait l'égalité et a refusé la conversion pour un simple commit de retard. Le bon critère est l'inclusion — merge-base --is-ancestor, qui distingue « en retard » (sans risque) de « en avance » (destruction de travail).
  • un répertoire résiduel est-il vraiment vide ? Une migration avortée avait laissé une coquille de set-ops-public.git — 0 référence — qui bloquait la suivante avec « Files already exist ». On ne la retire qu'après avoir compté les références.

Leçon d'outillage, aussi : no_log: true avait masqué l'erreur 409 et l'avait rendue indéchiffrable. La tâche qui porte le mot de passe reste muette, mais un debug séparé rend maintenant le statut et le message.

À suivre

Les hôtes de patient 0 résolvent par Quad9 et n'interrogent pas leur propre serveur infra-dns-01. L'écosystème fait tourner un résolveur que personne n'utilise.

2026-08-23 — Patient 0 porte le génome : la boucle est fermée

Les cinq dépôts qui fabriquent la lignée vivent désormais sur la forge de patient 0 — y compris l'étiquette signée, donc la filiation reste vérifiable depuis l'enfant.

Set-OPS-public   5547 Ko   main     v2026.08.21 ✓
OPS-Chezlepro     160 Ko   main
OPS-Patient0       37 Ko   main
Set-OPS-Modeles    23 Ko   master
SITE-Chezlepro     18 Ko   main

Le génome existe maintenant en trois exemplaires vivants et indépendants : eregion, le poste de l'exploitant, et patient 0. C'est le seuil à partir duquel réécrire l'histoire suppose de convaincre plusieurs témoins — la propriété qu'on cherchait, obtenue sans blockchain, comme effet secondaire de la lignée.

Le chemin a dû se plier à la politique, et c'est bon signe

Le port 3000 n'est pas ouvert depuis le poste, et le SSH de forge-01 refuse le transfert de ports (durcissement). Le versement s'est donc fait par paquets git déposés sur l'hôte, puis poussés depuis lui à travers l'API locale de la forge — donc par ses crochets, comme n'importe quel git push. Aucune règle n'a été assouplie pour la commodité.

Le compte de secours ne secourait rien

Forgejo exige par défaut un changement de mot de passe au premier accès, et refuse toute requête d'API tant qu'il n'a pas eu lieu. Or ce rôle désactive la connexion locale (SSO d'abord) : il n'existait aucun chemin pour effectuer ce changement.

Le compte administrateur était donc inutilisable dès sa création — sur toutes les forges déployées. --must-change-password=false est posé : le mot de passe vient de la voûte et tourne déjà par empreinte, exiger un changement manuel en plus ne ferait que faire diverger la voûte du réel.

Cinquième défaut révélé par le même écosystème. Aucun n'était visible sur une flotte debout : il fallait en construire une autre, et s'en servir.

2026-08-23 — Patient 0 est debout

Quatre machines, zéro échec, et la forge répond.

forge-01        121 tâches   forgejo actif, écoute 3000, HTTP 200
infra-pki-01    109
infra-edge-01    92
infra-dns-01     84

Le premier écosystème né du moteur corrigé — et le déploiement a servi de révélateur : quatre défauts, tous invisibles sur une flotte déjà debout.

Un serveur tiers intermittent arrêtait tout

packages.smallstep.com répond une fois sur deux : la même URL pend, puis rend 200 en 0,48 s au second essai. Le défaut de get_url est 10 secondes et aucune reprise — le socle échouait donc sur la première machine.

Avant d'accuser le réseau, on a mesuré : DNS résolvait, la poignée TLS aboutissait (Verify return code: 0), 138 Ko depuis deb.debian.org passaient en 0,09 s, et le chemin acceptait 1450 octets en refusant 1500 — exactement l'attendu en overlay. Le réseau n'y était pour rien. Reprises posées sur les trois téléchargements du chemin critique.

Audit au passage : une dizaine d'autres téléchargements de tiers restent sans reprise.

Un register a écrasé un chemin de fichier

En ajoutant la reprise, j'ai enregistré dans client_pki_cle — un nom que le rôle utilise déjà pour le chemin de la clé privée. L'écart ne s'est pas vu là : soixante-dix tâches plus loin, step ca certificate recevait un dict sérialisé à la place du fichier de clé et refusait « too many positional arguments ».

Un register écrit dans l'espace de noms de tout le rôle. Le nom est désormais long à dessein.

Le SSO se déclarait au lieu de se dériver

serveur_forgejo_oidc_actif: true en dur : la forge tentait de câbler une source OAuth2 vers un Keycloak qui n'existe pas dans un écosystème minimal. Il se dérive maintenant de l'inventaire — un groupe serveur_keycloak sans hôte, c'est un écosystème sans identité fédérée.

Quatrième manifestation en deux jours de la même hypothèse — le moteur supposait l'écosystème complet — après les intrants (P32), les bases (P35) et les dépendances causales.

Ce que ça vaut

Aucun de ces quatre défauts n'était visible sur Chezlepro, qui porte tout et tournait déjà. Ils ne pouvaient apparaître qu'au premier écosystème différent — c'est exactement ce qu'on attendait de patient 0, et il l'a rendu avant même de servir.

make verifier : 41 OK, 0 échec, 0 sauté.

2026-08-23 — Un boîtier injoignable n'est pas un boîtier vide

L'exploitant lance make frontiere-appliquer dans son propre terminal. Résultat :

Frontiere https://10.17.0.1 — 50 regles au devis
  a creer : 0   |  a retirer : 0   |  inchange : 53 + 15 routes
  La frontiere dit deja ce que le devis dit. Rien a faire.

La frontière était déjà conforme. Or j'avais annoncé, quelques heures plus tôt, « 89 objets à créer, 0 inchangé — donc les règles héritées sont invisibles à l'API ». C'était faux, et la cause était ailleurs.

Ce qui s'était réellement passé

L'intrant opnsense_api_url pointait encore sur 10.0.0.1, l'adresse d'avant la migration du boîtier. Chaque lecture échouait donc et rendait {"_erreur": …}. Le plan lisait .get("rows"), n'y trouvait rien — et concluait que la frontière était vide.

Avec CONFIRMER=true, on aurait poussé une politique entière en double sur un boîtier qui la portait déjà.

Et l'adresse avait pu rester périmée parce qu'elle était rangée au mauvais endroit : dans les group_vars d'un tenant, alors qu'elle décrit un boîtier que ce tenant ne possède pas. opnsense.yml vit désormais dans le dépôt de site, avec underlay.yml.

La troisième fois en une soirée

devis_placement itérait un dict d'erreur comme une liste → trace Python illisible
devis_underlay déclarait morts les réseaux qu'il ne joignait pas depuis le poste
appliquer_opnsense lisait un boîtier injoignable comme un boîtier vide

Trois formes d'une même confusion : « pas de réponse » pris pour « rien ». Toutes les lectures de la frontière passent maintenant par une garde qui refuse en nommant l'hôte, la cause, et le piège qu'elle évite. Éprouvée contre l'ancienne adresse : elle refuse.

Ce que ça dit de la méthode

Ce n'est pas le harnais qui a trouvé le défaut, ni moi. C'est l'exploitant, en lançant la commande dans son terminal, où il voit la sortie en direct. Mes commandes s'exécutent dans ma session : il n'en voit rien. Une commande lente ressemble alors à un blocage, et un blocage à une commande lente — j'ai conclu deux fois à tort avant qu'il ne regarde lui-même.

Pour toute écriture longue sur du matériel, c'est à l'exploitant de lancer la commande. Non par prudence formelle : parce qu'il est le seul à voir ce qui se passe.

2026-08-22 — Le moteur supposait l'écosystème complet

Troisième manifestation de la même hypothèse en cinq jours, et cette fois elle bloquait un déploiement. Le contrôle de dépendances a refusé patient 0 :

client_journal   requiert serveur_loki actif
client_metrique  requiert serveur_prometheus actif
serveur_forgejo  requiert serveur_postgresql actif
serveur_forgejo  requiert serveur_postfix actif

Quatre refus, une seule racine : le moteur suppose que tout écosystème porte tous les services. Après les intrants (P32, le 20) et les bases (P35, ce matin), c'est au tour des dépendances causales et des intégrations universelles.

Ce n'est pas un problème de patient 0. C'est le mur que rencontrerait toute offre plus petite que l'écosystème de référence — c'est-à-dire toute offre réelle.

Une intégration universelle a besoin d'un interlocuteur

« Tout hôte est mesuré » est vrai dans un écosystème qui porte un Prometheus. Dans un écosystème qui n'en a pas, la même phrase pose sur chaque machine un client qui n'a personne à qui parler.

La règle est désormais dérivée, pas déclarée : le service central d'une intégration est celui que le registre des dépendances lui donne déjà. Rien de neuf à tenir à jour, donc rien de neuf à oublier.

Chezlepro   → diff VIDE : tous ses services centraux existent, rien ne change
patient 0   → client_backup, client_pki, client_unbound  (journal et métrique tombent)

Une exigence n'est pas toujours absolue

Deux notions manquaient au registre, et les confondre coûtait cher :

sauf_si l'exigence tombe sous condition — Forgejo n'exige PostgreSQL que s'il n'a pas choisi SQLite
utilise_si_present un agrément, jamais bloquant — Forgejo notifie si un MTA existe, et s'en passe sinon

Confondre les deux obligeait une forge à déployer une pile courriel entière pour exister.

Et la leçon d'hier a servi

Trois lecteurs avaient besoin, le même jour, de lire une variable d'instance : P35 pour l'interrupteur _bd, le contrôle de dépendances pour sauf_si, et le générateur. Trois copies auraient recommencé exactement ce qu'on venait de refermer. Il y en a une, dans inventory_rules.

make verifier : 41 OK, 0 échec, 0 sauté. Chezlepro : inventaire identique.

Un écosystème minimal n'est pas un écosystème incomplet. Le moteur confondait les deux — et refusait de déployer ce qui n'a besoin de rien de plus.

2026-08-22 — Neuf copies d'une même question, et la pièce qui manquait

Cinq jours, cinq défauts, tous de la même famille : quelle instance, quel inventaire ? Neuf modules portaient chacun leur réponse.

Découvert Ce que la copie faisait
18 août P03 comparait chaque instance à l'inventaire d'une autre
19 août verifier_ports codait principal/ en dur ; verifier_intrants et _frontiere_absente lisaient le symlink au lieu de la variable
20 août devis_placement rendait un verdict juste sur le mauvais tenant
22 août P35, puis P36 — la dixième, trouvée par la preuve elle-même

Aucune n'était une faute d'inattention. Chacune avait été écrite de bonne foi, à un moment où le besoin semblait local. C'est le mode de panne de la duplication : pas l'erreur, mais la dérive — invisible depuis l'intérieur d'un fichier, parce que chaque copie a l'air correcte chez elle.

La résolution unique

inventory_rules porte désormais instance_courante(), inventaire_de(), dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le troisième manquait à la moitié des copies : un hosts.yml existant, puis un répertoire existant — le cas d'une instance neuve, celui qui faisait échouer make instancier sur le modèle public — puis le défaut.

Vingt-huit modules y sont branchés.

Ce qui rend ce refactor sûr

Avant de toucher quoi que ce soit, chaque module a été interrogé sur ce qu'il résolvait, pour les deux écosystèmes. Après refactor, la même mesure :

17 modules × 2 instances  →  diff vide

Aucune résolution n'a changé. Le refactor est prouvé neutre, pas supposé tel.

P41, et ses trois exemptions

La preuve échoue dès qu'un module réintroduit une copie. Éprouvée en négatif : une copie replacée dans genome.py est signalée avec son numéro de ligne.

Trois exemptions, nommées pour rester des choix : instances.py et inventory_gui.py manipulent le symlink lui-même — c'est la bascule d'instance —, et devis_opnsense lit délibérément quelle instance est active pour se situer dans la fédération. Ces trois-là parlent du lien, pas de la résolution.

Elle a d'ailleurs trouvé une dixième copie à sa première exécution : P36, dans le fichier même qui l'héberge.

make verifier : 41 OK, 0 échec, 0 sauté. Lint vert.

Une preuve qui trouve un défaut le jour où on l'écrit a payé son coût immédiatement. Celle-ci en a trouvé un dixième, dans prouver.py — et dans ma propre docstring, qui contenait le motif qu'elle interdit.

2026-08-22 — Une forge n'a pas besoin d'un serveur de bases pour trois personnes

Doute de l'exploitant en relisant patient 0 : « je doute de la pertinence de pgsql. » Mesuré plutôt que discuté, et le résultat est allé plus loin que la question.

Redis ne servait à rien. Le rôle serveur_forgejo ne le mentionne ni dans son app.ini, ni dans ses défauts, et ne déclare aucun lien vers lui. Il était au plan par héritage du modèle forge. Retiré.

PostgreSQL, lui, était exigé par le rôle : DB_TYPE = postgres écrit en dur, et resoudre_base appelé sans condition. Le doute était donc fondé mais le moteur ne savait pas faire autrement.

L'interrupteur

serveur_forgejo_bd: sqlite     # ou postgres (défaut)

En sqlite, la base devient un fichier sous serveur_forgejo_data. Ce que ça change ailleurs : rien. Le job de sauvegarde serveur_forgejo emporte déjà ce dossier ; la variable PGSSLROOTCERT de l'unité systemd était déjà conditionnée au mode TLS ; et P35 lit désormais l'interrupteur, donc n'attend aucune entrée de registre.

Une valeur inconnue est refusée au début du rôle. Retomber en silence sur PostgreSQL déploierait le contraire de ce qu'on croyait choisir, et l'écart se lirait au premier démarrage.

Ce que ça donne

patient 0 :  6 machines  →  4        (Dovecot, Redis, PostgreSQL et sa VM)

Sur la machine dont tout le reste descend, chaque service en moins est une chose de moins à défendre, à sauvegarder et à rebâtir un soir de reconstruction. Et l'effet dépasse patient 0 : une offre forge pour un petit organisme cesse d'exiger une VM PostgreSQL.

La neuvième

En vérifiant P35 sur patient 0, elle a rendu un verdict juste sur le mauvais écosystème : plan = RACINE / "instance" / "plan", le symlink en dur. C'est la neuvième résolution d'instance codée en dur trouvée en cinq jours — après le Makefile, verifier_ports, verifier_intrants, _frontiere_absente, devis_placement…

À ce stade la conclusion s'impose : ce n'est pas une série de bogues, c'est une pièce manquante. Une résolution unique et partagée, que chaque preuve et chaque devis appellerait au lieu d'en écrire une copie. À faire de tête reposée, en une fois.

make verifier : 40 OK, 0 échec, 0 sauté. Lint et syntaxe du rôle : verts.

Le doute d'un exploitant vaut une mesure. Celui-ci a retiré trois services, allégé une offre commerciale et révélé une neuvième occurrence d'un défaut de fond — parce qu'on est allé regarder au lieu d'argumenter.

2026-08-21 — L'intuition disait « blockchain » ; la réponse était déjà dans git

L'exploitant, en regardant la lignée s'ouvrir : « j'ai une intuition : blockchain. » L'intuition visait le bon problème — une mémoire partagée, vérifiable, sans centre — mais la réponse était à portée de main, et une vérification l'a montré :

1f47e9b  N    6148877  N    254268d  N        (N = aucune signature)
aucune étiquette

Git est déjà une chaîne de hachage. Chaque commit porte l'empreinte de son parent : modifier une ligne d'il y a trois mois casse toutes les empreintes suivantes. C'est un arbre de Merkle — la même structure qu'une blockchain, sans le reste. Ce qui manquait n'était pas la chaîne, mais l'auteur : user.name est déclaratif, et toute la soirée du 20 des commits ont porté « Daniel Allaire » sans qu'aucune preuve ne les lie à une clé.

Trois manques, trois réponses mûres

Manque Posé aujourd'hui
qui a écrit signature par clé SSH ; première étiquette signée v2026.08.21, vérifiée
quelles clés ont le droit .git-allowed-signers, versionné : qui clone vérifie sans rien demander à la forge
de quoi on descend parente.yml par écosystème, et la preuve P40

Ce que la fractale fait gratuitement

Une signature prouve l'auteur, pas que l'histoire n'a pas été remplacée — celui qui tient la forge peut réécrire et re-signer. Le seul remède est la multiplicité : si chaque enfant porte une copie du code dont il descend, réécrire suppose de convaincre tous les descendants.

C'est très exactement ce qu'une blockchain achète au prix d'une machinerie considérable, et que la lignée produit comme effet secondaire de sa forme. Une chaîne publique ajouterait une dépendance à un réseau extérieur ; une chaîne privée, une base de données distribuée exigeant plusieurs opérateurs — l'inverse de « un humain doit pouvoir la faire tourner ».

Le génome

make genome nomme les quatre dépôts sans lesquels un écosystème ne renaît pas : moteur, instance, hébergeur, modèles. Ils sont dérivés, pas déclarés — les modèles se reconnaissent à leur forme (des plans en sous-dossiers, aucun à la racine).

Deux critères appris d'un faux positif : sans le second, le détecteur désignait le lab, qui porte un lien OPS-Technolibre -> ../OPS-Technolibre que le motif traversait. Un lien vers un frère n'est pas un contenu.

Patient 0 sait désormais d'où il vient : moteur 742bcbf, étiquette v2026.08.21.

Enseigné, pas seulement posé

Nouvelle unité : Filiation, signatures & témoins — pourquoi git suffit, ce qu'une signature prouve et ce qu'elle ne prouve pas, pourquoi les témoins comptent plus que la longueur des clés, et quand un journal de transparence serait la vraie réponse (le jour où l'Alliance certifiera des écosystèmes).

Onze termes ajoutés au glossaire — génome, parenté, empreinte, Merkle, étiquette, signature, témoin, journal de transparence, horodatage… — et P39 les exige désormais : le vocabulaire de ce soir ne pourra pas rester non expliqué.

make verifier : 40 OK, 0 échec, 0 sauté.

La bonne question n'était pas « quelle technologie ». C'était : contre qui se protège-t-on, et qui, déjà, pourrait témoigner ? Les témoins existaient — ce sont les enfants. Il ne manquait qu'un nom sur les clés et un registre de filiation.

2026-08-21 — Le glossaire définissait Set-OPS et laissait dehors tout le métier

Demande de l'exploitant, après une soirée passée à croiser strophe FRR, VRF, VNet et nexthop-vrf : « il importe que cet écosystème soit pilotable par des humains, idéalement un seul. Alors révise notre glossaire, et que chacune des notions sous-jacentes soit enseignée. »

Mesuré avant d'écrire — 40 termes employés par le dépôt et absents du glossaire :

LDAP      184 fois        underlay  106 fois        EVPN   66 fois
playbook  165 fois        VRF        33 fois        LMTP   25 fois

Le glossaire expliquait le vocabulaire propre à Set-OPS — plan, index, voûte, zone — et laissait dehors tout ce qui vient du métier. Or c'est le métier qui perd le lecteur.

Ce n'est pas un défaut de rédaction

La règle fondatrice du dépôt est qu'un humain doit pouvoir piloter cet écosystème sans IA, idéalement seul. Chaque mot obscur retire une personne à la liste de celles qui peuvent reprendre le système. Un vocabulaire non expliqué est donc un défaut de conception.

Ce qui a été fait

Le glossaire est réécrit — 67 termes, groupés par famille (le plan, les machines, Ansible, le réseau, les noms, la confiance, l'identité, le courriel, l'état et sa preuve). Chaque entrée dit ce que c'est et pourquoi ce dépôt s'en sert, avec le renvoi vers l'unité qui développe.

Une unité d'apprentissage manquait : Le réseau des tenants. Dix-sept des quarante termes y vivaient sans domicile. Elle raconte le chemin dans l'ordre où les problèmes se sont posés : deux clients sur un même câble → le VLAN → ses deux limites → l'encapsulation → pourquoi 1450 → qui distribue les enveloppes → et le VRF, qui n'est pas une interdiction mais une ignorance structurelle.

P39, et ce qu'elle avoue ne pas savoir faire

Elle vérifie trois choses : chaque terme du jargon a une entrée ; chaque lien du glossaire mène à une page qui existe ; chaque page du wiki est atteignable depuis la navigation.

La liste des termes est déclarée, et c'est un choix mesuré. La dérivation automatique a été essayée : 153 acronymes dans le wiki et le README, dont la moitié sont des mots français en capitales — AUCUNE, AVANT, TOUS. Un contrôle qui exige une entrée de glossaire pour « AUCUNE » finit désactivé, et une preuve désactivée ne garde rien.

Éprouvée en négatif contre le glossaire d'avant : 49 termes manquants, nommés un par un.

make verifier : 39 OK, 0 échec, 0 sauté.

Ce que la preuve ne mesurera jamais. Qu'une explication soit bonne. Elle compte des entrées ; elle ne sait pas si on comprend. Ça, seul un lecteur peut le dire — et c'est précisément le lecteur qu'on cherche à ne pas perdre.

2026-08-20 — Le devis d'avant-vol validait le mauvais réseau

Remarque de l'exploitant, en préparant patient 0 : « le pont ne me semble pas approprié du tout, depuis qu'on crée des VNets pour des tenants. » Il avait raison, et le défaut était plus grave que cosmétique.

make placement-plan confrontait proxmox_clone_pont — vmbr1 — au cluster. Or ce n'est pas là que les VM de la flotte atterrissent : instancier pose dans chaque hôte le pont dérivé de sa zone (le VNet du tenant), et make creer-vm le passe au clone en écrasant ce défaut. vmbr1 n'est que le repli des clones manuels, hors plan.

Le devis mesurait donc un objet qui ne sert pas, et ne mesurait pas celui qui sert.

Ce que ça donnait sur patient 0

avant :  pont  vmbr1  existe        →  CONFORME
après :  reseaux VM  t29appl, t29donn, t29fron, t29serv  INTROUVABLE
         -> VNet(s) absent(s) : ... — passer `make sdn-appliquer` AVANT de creer les VM

Aucun des quatre VNets de patient 0 n'existe sur le cluster. Le devis d'avant-vol disait « conforme » à un tenant dont les VM n'auraient eu nulle part où naître.

D-80 avait pourtant été corrigée le 13 août : la liaison de placement est nœud, stockage et gabarit — le pont se dérive. Le devis, lui, continuait de compter quatre objets et de nommer le mauvais. Une doctrine corrigée dans un document ne se propage pas toute seule dans le code qui l'applique.

Ce qu'il mesure maintenant

Les réseaux où les VM atterriront : en sdn, les VNets dérivés confrontés à /cluster/sdn/vnets ; en switch, les ponts du nœud retenu. Avec, quand il en manque, le geste exact qui répare.

Non-régression vérifiée sur l'écosystème de référence : ses six VNets existent, verdict conforme, code de sortie 0. Quatre tests, Cluster simulé, aucun réseau touché.

Le vert le plus dangereux est celui qui porte sur un objet voisin du bon. Ici tout était vrai — vmbr1 existe bel et bien — et la conclusion était fausse. C'est la quatrième fois cette semaine : la frontière qui poliçait les tenants d'un autre site, P03 qui comparait à l'inventaire d'une autre instance, le catalogue qui décrivait un moteur d'il y a quatre mois, et maintenant un devis qui contrôle un pont que la flotte n'utilise pas.

2026-08-20 — Le devis d'avant-vol mourait au lieu de parler

Soir de reconstruction, VPN pas encore monté. make placement-plan — le devis qu'on lance avant quarante minutes de déploiement, pour savoir si le terrain est bon :

AttributeError: 'str' object has no attribute 'get'

Dix lignes de trace Python pour dire « le nom asgard ne se résout pas d'ici ».

En panne, Cluster.__call__ rend {"_erreur": "…"} — un dict. Le devis l'itérait comme une liste, et un dict itéré rend ses clés : d'où un str là où le code attendait un objet. Cluster.rate() existait précisément pour ça, et n'était appelé nulle part ici. Les quatre appels passent désormais par une garde qui nomme la cause, l'hôte interrogé et le geste à tenter.

Et le devis mesurait le mauvais tenant

placement_du_tenant() lisait instance/ en dur : viser patient 0 avec SETOPS_INSTANCE mesurait en silence le placement de l'instance montée. Le verdict était juste — pour l'autre tenant. Les deux portaient les mêmes quatre valeurs, ce qui est exactement la circonstance où l'erreur ne se voit pas.

C'est la huitième résolution d'inventaire ou d'instance codée en dur trouvée en trois jours. À ce compte, ce n'est plus une série de bogues : c'est une pièce manquante.

Au passage, l'en-tête annonçait « tenant instance » — le nom du lien, pas celui du tenant. Un devis doit nommer ce qu'il a mesuré.

Trois tests, aucun réseau touché

test_devis_placement.py simule le Cluster : une panne devient un refus lisible (cause, hôte, geste), une réponse qui n'est pas une liste est refusée elle aussi — c'est le cas silencieux, celui qui franchirait la première garde —, et le cas nominal traverse sans gêne. Branchés sur make test.

Ce qu'on répare ici n'est pas une exception, c'est un message. Un outil de diagnostic qui échoue en langage machine transforme une panne de trente secondes (monter le VPN) en une demi-heure de fouille. Le pire moment pour ça est celui où on l'utilise : quand quelque chose ne va déjà pas.

2026-08-20 — Le harnais ne se déclenchait que par mémoire

Trente-huit preuves, des tests, un lint — et rien ne les exécutait sans qu'un humain tape make. Le meilleur atout du dépôt dépendait de ne pas oublier. Il a maintenant une CI (.forgejo/workflows/verifier.yml) et une cible qui la rejoue à l'identique : make ci.

Ce que la CI a trouvé avant d'exister

Écrire le workflow supposait de répondre à une question jamais posée : est-ce qu'un dépôt public, seul, se tient ? Réponse mesurée sur un clone nu : non, à cinq endroits.

make instancier échouait sur le modèle public — le tout premier geste du QUICKSTART
P32 exigeait les intrants d'oauth2-proxy d'une instance qui ne le déploie pas
P24 le modèle ne déclarait aucun réseau d'administration
P33 verifier_ports.py codait principal/ en dur
P32, P24 (bis) lisaient le symlink instance/ au lieu de SETOPS_INSTANCE

Toutes de la même famille — celle de P03 avant-hier : une résolution d'inventaire recopiée, une variable d'environnement qui déborde de sa portée. Le dépôt en compte sept ; deux de plus ont été corrigées ici, et le commentaire de la septième le dit plutôt que de le taire.

Celle qui comptait le plus

P32 parcourait les 54 rôles sans regarder ce que l'instance déploie. Elle passait sur l'écosystème de référence parce qu'il porte tout. La conséquence dépassait le modèle : les modèles sont des offres, et toute offre plus petite que l'écosystème complet — c'est-à-dire toute offre réelle — échouait son propre harnais, pour des services qu'elle ne vend pas. Le périmètre juste se lit du plan : les groupes de l'inventaire, puis les rôles que leur playbook compose.

Ce que make ci ne fait pas

Il ne touche aucun symlink. Le modèle public est monté comme instance jetable, visé par SETOPS_INSTANCE / SETOPS_UNDERLAY, et détruit en sortant — ton instance reste montée pendant l'exécution. Deux détails, mesurés parce que devinés faux d'abord :

  • l'instance jetable est un dossier frère, pas un /tmp : la fédération se découvre par les dossiers frères, et ailleurs quatre preuves tombent en disant « aucun tenant fédéré découvert » ;
  • SETOPS_UNDERLAY n'est posé que pour la vérification, jamais pour l'application — sinon l'inventaire est écrit avec une fabric et régénéré avec une autre, et la commande fabrique elle-même l'écart qu'elle dénonce.

Le résultat

clone nu, aucune instance, aucun frère   →  make ci : 38 OK, 0 échec, 0 sauté
dépôt de l'exploitant, 3 instances       →  make ci : 38 OK, 0 échec, 0 sauté

Et une dernière chose, qui dit bien où on en est : le lint du dépôt a refusé mon propre fichier de CI avant qu'il ne tourne une seule fois — on: que YAML lit comme le booléen vrai. Le harnais mordait déjà.

Ce que le vert de cette CI dira, et ce qu'il ne dira pas. Que le moteur et son modèle public se tiennent — pas que la flotte va bien. Aucune VM n'est jointe, aucune voûte n'entre là. La santé de la flotte reste une question qu'on pose sur le poste de l'exploitant, avec make prouver. C'est écrit en tête du workflow, pour que personne ne lise ce vert pour plus qu'il ne vaut.

2026-08-19 — La carte des services avait quatre mois de retard sur le moteur

catalogue-services.md est le document qu'on lit pour savoir ce que Set-OPS fait : l'hébergeur d'un second site, un futur client, un mainteneur qui arrive. Il annonçait comme « capacités futures encore à implémenter » la collaboration et la couche web — dont les rôles existent et dont les hôtes sont actifs.

Vérifié rôle par rôle contre roles/, ce que le document disait de faux :

Ce qu'il annonçait La réalité
collaboration « à implémenter » serveur_nextcloud (533 lignes) + serveur_collabora, collab-01 actif
couche web « à implémenter » serveur_web_frontal / serveur_web_dorsal, codifiés depuis les spikes du 5 juillet
fédération LDAP « pas automatisée » serveur_keycloak/tasks/federation-ldap.yml
Keycloak « pas exposé » expose: auth.<domaine> au plan
infra-mail-01 : « Sendmail MTA » Dovecot — Sendmail est retiré depuis le 4 juillet
client_supervision n'a jamais existé : ni rôle, ni playbook
rôles nextcloud, metriques… une colonne décorative : le rôle porte le nom du groupe

Et neuf rôles vivants n'apparaissaient dans aucune table — le socle, toute la pile courriel, les sauvegardes, Icinga Web 2, oauth2-proxy, Unbound. Deux d'entre eux (serveur_backup, client_backup) n'étaient nommés nulle part dans le document.

Ce que P31 ne pouvait pas voir

La preuve de documentation vérifie que chaque script, cible make et rôle est nommé et atteignable. Elle ne dit rien de la justesse d'un document. Une carte peut être complète et périmée — celle-ci l'était depuis la consolidation du 3 juillet.

P38 — la table fait foi, dans les deux sens

tout rôle serveur_*/client_*     doit figurer dans une LIGNE DE TABLE
tout groupe cité dans une table  doit exister (rôle, ou playbook de groupe)

Le premier sens seul aurait été trop faible : la pile courriel était racontée en prose, et invisible pour qui lit le catalogue comme un index — c'est-à-dire tout le monde. Le second attrape les cases inventées, qui survivent des mois parce qu'une case de table ressemble à un fait.

Deux exemptions, nommées pour rester des choix : la prose peut citer des rôles retirés (serveur_sendmail, client_dns, client_ldap) — sinon on ne peut plus écrire d'où l'on vient ; et serveur_durci est accepté comme groupe sans rôle homonyme, son playbook composant onze rôles de durcissement.

Éprouvée en négatif : rejouée contre la version d'avant les corrections, elle échoue en nommant les neuf rôles absents et les trois cases fantômes. make prouver : 38 OK, 0 échec, 0 sauté.

Ce que la preuve ne mesure pas, et ne mesurera pas. Qu'un service soit dit « éprouvé » à bon droit se juge en revue, contre le CHANGELOG. Le tableau des capacités dit maintenant lui-même où il s'arrête : la reconstruction prouve qu'une machine nue atteint l'état voulu, elle ne dit rien de la tenue sous charge ni des mises à jour. Et pour Nextcloud, l'usage réel — déposer un fichier, éditer à deux — n'est pas consigné comme preuve ; le document le dit désormais au lieu de le laisser supposer.

2026-08-18 — make underlay confronte les tenants déclarés aux dossiers réels

Suite immédiate du filtre de portée : underlay.tenants nomme des dossiers frères. Une faute de frappe y était invisible — le tenant disparaissait simplement des trois devis du site, qui restaient « conformes » sur ce qu'il en restait.

Sur un site à un seul tenant — le cas de la prochaine implantation — la faute de frappe rend un devis vide : une frontière sans règle, un commutateur sans VLAN. Et rien dans le mot « conforme » ne dirait qu'on vient de dessiner le vide.

L'écart est entièrement lisible sans toucher au matériel : d'un côté une liste de noms, de l'autre les dossiers présents. Il se dit donc à make underlay (D-75), pas au moment où l'on pousse dans un boîtier. Quatre situations, quatre messages distincts :

dossier absent               → aucun dossier frere de ce nom (attendu : …/OPS-Fantome)
dossier sans nomenclature    → dossier present, mais sans plan/nomenclature.yml
nomenclature non fédérée     → `index` absent, `categories` vide ou `federe: false`
plus rien ne correspond      → les devis de ce site n'auraient rien a poser

Ce qu'un gabarit ne doit surtout pas subir

Un modèle décrit du matériel, pas un site déployé : il ne peut nommer aucun tenant réel. Sans garde, tout modèle portant un exemple de tenants échouerait chez quiconque n'a pas ce dossier — et P17 (« tous les modèles valident ») deviendrait rouge sur la machine du voisin. La distinction existait déjà dans le code : modeles.py passe des repères de tenants explicites, ce qui dit « gabarit » ; le site, lui, les laisse dériver. La vérification ne s'applique qu'au second cas.

Trois tests ajoutés à test_adressage_derive.py — le nom introuvable, la clé absente, et le gabarit épargné — avec un nom volontairement absurde pour qu'aucun test ne dépende des dossiers de la machine qui l'exécute. make test 15 + 9 ; prouver 37/37.

2026-08-18 — Les trois devis d'un site partagent enfin la même portée

Le 14 août, la frontière a appris qu'elle ne police que les tenants de son site. Le commit le disait lui-même : « même hypothèse ailleurs, non corrigée — devis_sdn et devis_reseau partent du même decouvrir(). À traiter quand ils serviront sur un second site. » C'est fait avant, pas pendant.

Les trois devis équipent le matériel d'un site :

devis ce qu'il pose ce qu'un tenant d'ailleurs y ajoutait
devis_opnsense règles et routes de la frontière des routes vers des sous-réseaux inexistants
devis_reseau VLAN, SVI, routes du commutateur des VLAN qu'aucune VM ne peuplera
devis_sdn zones et VNets EVPN de l'hyperviseur des zones sans machine

Aucun de ces objets ne fait de mal visible : le matériel les accepte, ils ne correspondent jamais à rien, et rien ne les signale. C'est la définition même du chèque vert sur un périmètre vide — sauf qu'ici, il faut le lire à l'envers : une politique qui a l'air complète et ne protège rien.

Une seule fonction, au lieu d'un filtre recopié trois fois

devis_reseau.decouvrir_du_site() — decouvrir() restreint par underlay.tenants, avec la doctrine écrite une fois pour les trois. Le filtre inline de devis_opnsense est retiré au profit d'elle. admin_tous_tenants() la suit : le routeur d'un site n'a aucune raison de savoir revenir vers le plan de gestion d'un tenant qu'il ne porte pas.

Éprouvé dans les trois situations qui comptent :

underlay sans la clé          → ['OPS-Chezlepro', 'OPS-Technolibre']   (identique à avant)
underlay du second site       → ['OPS-Technolibre']
un nom qu'aucun dossier ne fournit → ATTENTION, et le reste est retenu
le filtre ne retient rien     → refus, code 1 (jamais un devis vide)

Sans effet sur le site actuel : l'underlay de Chezlepro ne déclare pas tenants, et clé absente = toute la fédération. prouver 37/37, make test inchangé.

Et la clé est enfin documentée

C'était le vrai trou : underlay.tenants existait depuis le 14 et n'apparaissait ni dans underlay.yml.example ni dans l'annexe du runbook d'implantation. Un exploitant montant un second site ne pouvait pas la découvrir — il aurait posé la politique du premier tenant chez le second, et le seul symptôme aurait été un silence.

Traiter la deuxième occurrence quand on nomme la première. Le défaut de portée était écrit noir sur blanc dans le commit du 14, avec la liste des endroits où il restait. Quatre jours plus tard, le coût de le finir est d'une heure ; sur place, il aurait coûté une visite.

2026-08-18 — Le panneau d'intrants effaçait la mémoire écrite du dépôt

Un enregistrement du panneau « Intrants de base », à 13:48, a emporté 94 lignes de commentaire dans quatre fichiers (129 → 35 ; ce qui reste est l'en-tête que le panneau réécrit lui-même). Dont celle-ci, juste au-dessus de la valeur qu'on venait de changer :

# POURQUOI PAS ENCORE 10.17.0.0/24 (essayé puis retiré le 2026-08-12) : `devis_opnsense`
# dérive l'interface d'une règle de l'ATTACHEMENT RÉEL de sa source (D-61)… un second
# CIDR est classé « distant », et la règle atterrit sur `wan` où elle ne peut JAMAIS
# correspondre.
- 10.0.0.0/24

Ces phrases sont la seule trace de raisonnements qu'aucun code ne redit. safe_dump les efface toutes, à chaque sauvegarde, en retriant les clés au passage — un diff illisible par-dessus le marché.

Le dépôt connaissait déjà le geste juste

_ecrire_intrants_fabric (underlay.yml) et _ecrire_index_nomenclature remplacent la ligne, sans toucher au reste ; leur commentaire dit même « un safe_dump les effacerait toutes ». Les quatre fichiers d'intrants, eux, n'avaient jamais reçu ce traitement. Ce qui manquait pour l'étendre : savoir remplacer une valeur de liste, qui tient sur plusieurs lignes.

_fusion_chirurgicale le fait, sur trois règles :

une clé dont la valeur ne change pas n'est pas réécrite — zéro bruit au diff
les commentaires internes à un bloc remplacé conservés, jamais jugés
une clé absente du fichier ajoutée à la fin, jamais insérée au hasard

La deuxième règle mérite d'être assumée : une explication devenue fausse survit à la valeur qu'elle explique. C'est voulu. Corriger une phrase est un geste humain ; l'effacer parce qu'un champ a bougé, non. Le même principe que la fusion des clés posée le 10 août : un panneau qui ne connaît pas une valeur n'a pas le droit de la détruire.

Éprouvé sur le fichier réel, pas sur un exemple

L'enregistrement du 13:48 rejoué sur la version d'avant, tirée de git :

lignes        41 -> 41
commentaires  30 -> 30    perdus : 0
diff          1 ligne     - 10.0.0.0/24  →  + 10.17.0.0/24

Neuf tests dans scripts/tests/test_gui_intrants.py, branchés sur make test : le commentaire qui survit au changement qu'il explique, la liste multi-lignes remplacée, la clé inchangée non reformatée, la clé que le panneau ignore, la clé nouvelle, l'idempotence, le garde-fou des clés sensibles, et la création d'un fichier neuf.

Deux fois le même geste destructeur, sur le même chemin. Le 10 août ce panneau perdait des clés (dns_amorcage, amorcage_acces_courriel — une VM qui naît sans résolution) ; le 18, des commentaires. La première fois avait valu une fusion, pas un test. C'est le test qui manquait.

2026-08-18 — P03 mesurait toutes les instances contre l'inventaire d'une seule

Trouvé en validant une simple mise à jour du CHANGELOG. Deux invocations de la même preuve, deux verdicts :

make prouver                    NON CONFORME — « lab : 17 hôtes avec écart »
python3 scripts/prouver.py      CONFORME 37/37

Le lab n'avait aucun écart : SETOPS_INSTANCE=…lab instancier comparer --strict dit DIFF VIDE. C'est l'instrument qui mesurait ailleurs.

Deux variables désignent la cible, et c'est la seconde qui gagne

Makefile:13        export SETOPS_INVENTAIRE   → instance/inventories/principal/hosts.yml
instancier.py:68   SETOPS_INVENTAIRE FORCE la cible, par-dessus SETOPS_INSTANCE
prouver.py:505     env = {**os.environ, "SETOPS_INSTANCE": str(chemin)}   ← rien de retiré

P03 générait donc le plan de chaque instance fédérée et le comparait à l'inventaire appliqué de la seule instance active. D'où un rouge sur un lab sain.

Le rouge n'était pas le problème — le vert l'était

Sous make, l'inventaire appliqué de lab et de Technolibre n'était jamais lu. Or P03 a été écrite le 2026-08-12 pour exactement cet angle : un tenant qu'on ne regarde pas — parce qu'il n'a aucune VM, précisément — imposant ses vieilles adresses au pare-feu partagé. La preuve était aveugle au cas pour lequel elle existe, quand on l'invoque de la façon documentée. Les rapports du 13 et du 14 sortent de cette invocation-là.

La signature était visible sans lire une ligne de code : sous make prouver, les hosts.genere.yml de lab et de Technolibre ne bougeaient pas — tout était écrit dans le répertoire de Chezlepro.

Corrigé aux cinq sites, et rendu bruyant

env.pop("SETOPS_INVENTAIRE", None) partout où l'on redirige SETOPS_INSTANCE : P03 et P15 (prouver.py), et les trois applicateurs appliquer_opnsense / appliquer_proxmox_fw / appliquer_sdn, qui pointent SETOPS_INSTANCE vers l'hébergeur. Ces trois-là sont sans effet tant qu'hébergeur et tenant actif coïncident — c'est-à-dire jusqu'au second site. Le geste correct existait déjà dans le dépôt (modeles.py:96) ; il n'avait simplement jamais été repris.

Et pour que la classe cesse d'être silencieuse, inventory_rules.inventaire_force() refuse une cible hors de l'instance visée, en nommant les deux valeurs :

REFUS : SETOPS_INVENTAIRE designe un inventaire HORS de l'instance demandee.
  SETOPS_INSTANCE    …/OPS-Chezlepro-lab
  SETOPS_INVENTAIRE  …/OPS-Chezlepro/inventories/principal/hosts.yml

Éprouvée dans les deux sens : la contradiction sort en code 1, une cible légitime dans l'instance passe. Branchée sur les quatre résolutions de _inventaire (instancier, serveurs, applications, config_proxmox). Le GUI garde la sienne : il ne redirige jamais SETOPS_INSTANCE pour un fils, et sa résolution suit le symlink à chaque requête.

make prouver : 37 OK, 0 échec, 0 sauté — et cette fois les hosts.genere.yml des trois instances portent l'horodatage du passage, preuve que chacune a été lue chez elle.

Vérifier d'où l'instrument mesure. Le dépôt porte déjà la règle ; c'est ici la quatrième fois qu'elle paye. Une preuve qui change de verdict selon qu'on l'appelle par make ou à la main ne mesurait pas ce qu'elle annonçait dans au moins un des deux cas.

2026-08-14 — Une frontière ne police que les tenants de son site

Premier make frontiere-plan sur le second site. Le devis voulait poser sur la frontière de Technolibre les règles et les routes de Chezlepro : trente objets de plus, dont six routes vers des sous-réseaux 10.17.x qui n'existent pas là-bas.

Le défaut est de portée, et il est silencieux

Les devis partaient de devis_reseau.decouvrir(), qui rend toute la fédération — tout dossier frère portant une nomenclature avec un index. C'était juste tant qu'il n'y avait qu'un site : l'hébergeur unique portait bien tous les tenants. Dès le second, c'est faux.

Et rien ne l'aurait dit. Le boîtier aurait accepté ces trente objets ; aucun n'aurait jamais correspondu à un paquet ; aucune erreur, aucun avertissement. Une politique qui a l'air complète et ne protège rien — encore le chèque vert sur un périmètre vide.

Le correctif : l'hébergeur nomme ce qu'il porte

underlay:
  tenants: [OPS-Technolibre]   # les tenants HÉBERGÉS ici, pas la fédération

devis_opnsense s'y limite (underlay.tenants_du_site()). Clé absente = ancien comportement, toute la fédération : un site unique n'a rien à déclarer, c'est le second qui doit se nommer. Un nom déclaré qu'aucun dossier frère ne fournit est signalé, pas ignoré silencieusement ; et si le filtre ne retient aucun tenant connu, le devis refuse plutôt que de rendre une politique vide.

Même hypothèse ailleurs, non corrigée : devis_sdn et devis_reseau partent du même decouvrir(). À traiter le jour où ils serviront sur un second site — dit ici pour ne pas le redécouvrir.

Au passage, un défaut du document écrit la veille

Le squelette d'underlay.yml d'implanter-un-tenant-sur-un-site.md omettait index. Sans cette clé, make underlay refuse le réseau de gestion en le prenant pour le supernet d'un autre site : message déroutant, cause triviale. Trouvé en s'en servant, moins de vingt-quatre heures après l'avoir écrit.

prouver 37/37, make test 0.

Ce qui marche avec un seul écosystème n'est pas prouvé. Comme les six défauts moteur qu'avait révélés le second tenant, celui-ci n'existait que parce qu'un second site existe enfin. Une hypothèse implicite ne se voit qu'au moment où elle cesse d'être vraie.

2026-08-13 — La frontière poste en formulaire encodé : hasPost() et le failed nu

Mesuré sur un OPNsense 24.7 (version ancienne, mise à jour depuis) : toute écriture du moteur y échouait. Alias, règles, NAT, routes — make frontiere-appliquer n'aurait rien posé sur ce boîtier. Ce n'est donc pas un défaut universel du moteur : il écrit correctement sur la frontière de Chezlepro, plus récente. C'est un problème de compatibilité, et le correctif vaut surtout comme garantie de portabilité — le jour où l'on arrive sur un site dont on ne choisit pas le firmware, ce qui est exactement le cas.

Le symptôme mérite d'être retenu, lui, quelle que soit la version

Le contrôleur d'OPNsense lit ses champs avec hasPost(<racine>). Si le corps arrive en application/json et que le boîtier ne le décompose pas en variables de POST, ce test est faux :

HTTP 200   {"result":"failed"}   ← nu : aucune redirection, aucune validation

Le contrôleur ne dit pas quel champ manque, parce que de son point de vue il n'y avait aucun champ.

Trois fausses pistes avant la bonne : valeur invalide (un corps vide échouait pareil), racine de payload erronée (elle était juste), privilèges de la clé (la lecture passait). Ce qui a tranché : un corps vide aurait dû produire des validations. Leur absence disait que le contrôleur n'avait rien reçu.

Le correctif

Frontiere poste désormais racine[champ]=valeur — la forme que poste l'interface web elle-même. Aucune version d'OPNsense ne la refuse, alors que le JSON dépend du boîtier. Tous les corps du moteur sont des dicts plats de chaînes (alias, rule, route) : un seul niveau d'imbrication suffit.

Éprouvé sur le boîtier, dans les deux sens : addItem d'un alias sonde → saved, delItem → deleted, aucune trace laissée, lecture intacte (11 alias). Reste ouvert : ré-éprouver après la mise à jour, pour vérifier que le formulaire reste bon sur la version récente.

Un failed sans validation n'est pas un refus, c'est une absence. Quand un contrôleur ne nomme aucun champ fautif, cesser de chercher le mauvais champ : il n'a rien reçu.

2026-08-13 — Implanter un tenant sur un site neuf : le runbook de l'intervention

Le dépôt couvrait déjà « l'hébergeur prépare son matériel » et « un tenant vivant change d'hébergeur, sans coupure ». Il manquait le troisième cas, qui est celui qu'on s'apprête à faire : un tenant dont le plan existe déjà prend corps sur un site qui n'a jamais rien porté. Rien à migrer, rien à interrompre — donc ni la séquence de gel/bascule de migration-tenant.md, ni le bootstrap du QUICKSTART.

docs/implanter-un-tenant-sur-un-site.md, six phases, chacune fermée par une commande qui interroge le système :

0  bureau        index du site = index du tenant, dépôt hébergeur, gabarit, voûte
1  reconnaître   make underlay, make placement-plan
2  frontière     make frontiere-plan
3  gabarit       convertir en template — un clone de VM vivante en ferait 14 copies
4  composer      les DEUX symlinks (D-80) + les 4 valeurs de placement
5  matérialiser  make sdn-appliquer, puis make reconstruire
6  recette       devis, restauration éprouvée, supervision

Ce que le runbook porte et qu'aucun autre document ne dit

  • Un site neuf se bâtit d'emblée dans l'adressage cible (D-77/D-78). Le site historique est encore en 10.0.x et migrera par les runbooks §6 ; sur un site vierge, la cible ne coûte rien. Deux sites en 10.0.0.0/24 rendraient la reprise mutuelle impossible : deux plans de gestion identiques ne peuvent pas s'atteindre.
  • L'ordre de reconstruire, ligne à ligne, dont le piège make flux : sans lui, nftables tombe en policy drop sans aucune règle — la flotte monte, SSH répond, et tout le reste est mur (trouvé le 2026-08-10).
  • Un site à un seul nœud : ce nœud est aussi nœud de sortie, aucun pont ne peut être partiel, aucune haute disponibilité. À dire, pas à laisser supposer.
  • PVE 9 n'a jamais été éprouvé ici : tout écart est inconnu jusqu'à mesure.
  • « Ce qui n'est PAS fait en repartant » — resserrer le compte d'API, le lien inter-sites, le gabarit des deux côtés, la voûte hors de son propre site.

En annexe, les squelettes d'underlay.yml et de proxmox-hebergeur.yml pour un site à un nœud, à remplir depuis la reconnaissance et jamais de mémoire — c'est en recopiant des listes chez chaque tenant qu'elles avaient divergé.

Sixième porte dans la table de routage du README. Les 21 cibles make citées ont été vérifiées comme existantes. prouver 37/37 (dont P34 : 40 documents déclarent leur lecteur), make test 0.

La règle qui commande tout l'ordre du document : rien n'est fait tant que ce n'est pas mesuré sur place. Une valeur transmise par courriel, une liste relevée dans l'interface web, un vmbr cité de mémoire — chacun des trois a déjà produit une panne ici.

2026-08-13 — La machine d'épreuve jetable existait, et n'était écrite nulle part

L'exploitant a découvert par hasard, en creusant l'intrant « pont réseau », qu'on peut fabriquer une VM hors du plan. Vérification : cloner-vm n'était mentionné qu'une fois dans tout le dépôt, comme note de plomberie dans dimensionnement-ressources.md. L'usage, lui, n'était nulle part.

Une doctrine sans son instrument

Le dépôt porte déjà la règle « éprouver l'outil avant d'écrire le rôle qui l'enveloppe » — elle a évité les bugs de premier déploiement de rspamd et tranché le pivot Stalwart → Postfix/Dovecot. Mais l'instrument de cette règle n'était pas nommé.

make cloner-vm HOTE=essai-nginx VMID=99123 PONT_PROXMOX=t17appl \
     ADRESSE_IP=10.17.21.99 CIDR=24 PASSERELLE=10.17.21.1

Une Debian issue du gabarit doré, sur le réseau choisi, en deux minutes, sans toucher au plan. C'est ainsi que modeleSetOPS a lui-même été recapturé.

Documenter la discipline, pas seulement la capacité

Une telle VM est nue — et c'est à la fois l'intérêt et le danger :

l'inventaire ne la contient pas
make raser ne la détruira jamais — il dérive du plan
DNS, certificat, sauvegarde, pare-feu, nftables aucun
son VMID gardé par aucune preuve contre une collision

Elle ne disparaît que si on la détruit soi-même. Un VMID oublié squatte le cluster sans que rien ne le signale — c'est très exactement ainsi qu'un pont disparu a survécu dix jours dans une déclaration, le matin même.

Écrit à trois endroits, pour trois lecteurs : le geste dans vm-lifecycle.md §4bis, la capacité dans pouvoirs-set-ops.md (qui évalue le moteur), et le réflexe dans la discipline de carte-set-ops.md (qui modifie le moteur).

Le symptôme valait le diagnostic. Découvrir une capacité de son propre outil par accident, en creusant autre chose, est le signe qu'elle manquait à la documentation — pas au code.

2026-08-13 — Le « pont réseau » n'était pas un réglage, et l'intitulé le dit maintenant

Question de l'exploitant après la découverte de vmbr3 : à quoi sert l'intrant « pont réseau » ? Mesuré, et la réponse est : à presque rien.

proxmox_pont       14 occurrences dans l'inventaire  → DÉRIVÉ par hôte (le VNet de sa zone)
proxmox_noeud       0                                → proxmox_clone_noeud est la vraie valeur
proxmox_stockage    0                                → proxmox_clone_stockage est la vraie valeur

instancier pose le VNet de chaque zone dans proxmox_pont, et l'hôte l'emporte sur le défaut. proxmox_clone_pont n'est donc consulté que par un make cloner-vm manuel, hors flotte — utile pour dépanner, sans effet sur les quatorze VM du plan.

C'est exactement pourquoi vmbr3 a pu y être faux dix jours sans que rien ne bronche. Et c'est le pire genre d'intrant : on le voit dans le GUI, on le corrige, on redéploie, et rien ne change.

L'intitulé dit désormais sa portée — « Pont réseau — clones manuels seulement (les VM du plan reçoivent le VNet de leur zone) ». Un intrant dont on comprend la portée cesse d'être un piège.

D-80 corrigée

J'y avais écrit « trois clés : nœud, stockage, pont ». Faux pour le pont. La liaison de placement réelle est nœud, stockage et gabarit ; le pont se dérive comme le reste.

Vérifier avant d'énumérer. J'avais listé les clés de placement en lisant le fichier du tenant, sans regarder lesquelles sont réellement consultées. Deux le sont, une ne l'est pas — et c'est celle qui était fausse.

2026-08-13 — make placement-plan : et le gabarit, justement

J'avais écarté le gabarit de P37 — « objet du cluster, pas une liste déclarée, donc pas vérifiable statiquement ». L'exploitant a relevé que ce n'était pas une raison de ne pas le vérifier : ça déplace la question du statique vers le devis.

devis_placement.py interroge donc le cluster et confronte les quatre valeurs :

noeud      le nœud existe-t-il
stockage   existe-t-il ET accepte-t-il le contenu `images`
pont       existe-t-il SUR LE NŒUD retenu (un pont partiel est un piège)
gabarit    le VMID existe-t-il ET porte-t-il `template=1`

Ce dernier contrôle compte : cloner une VM ordinaire fonctionnerait, et produirait quatorze copies d'une machine vivante.

Au premier passage, il a trouvé une déclaration périmée

ponts réels sur les 3 nœuds   vmbr0, vmbr1, vmbr2
proxmox-hebergeur.yml         vmbr0, vmbr1, vmbr2, vmbr3

vmbr3 n'existe plus — disparu au passage du transport VXLAN sur les interfaces VLAN. La déclaration du 3 août a survécu à sa disparition, et P37 la validait : elle valide la déclaration, pas le cluster.

Invisible jusqu'ici parce que flotte-creer surcharge le pont avec le VNet dérivé de chaque zone — le défaut n'aurait mordu que sur un make cloner-vm manuel. Corrigé des deux côtés : la liste de l'hébergeur, et le défaut des trois tenants vers vmbr1, que le cluster nomme lui-même « VM (5Gig) ».

Ce que ça démontre. P37 et ce devis ne se remplacent pas : l'un garde la cohérence entre deux déclarations, l'autre confronte la déclaration au réel. Il fallait les deux pour voir un pont qui n'existait plus depuis dix jours.

Et P31 a refusé le script tant qu'aucune cible make ne l'atteignait.

2026-08-13 — D-80 : un tenant est agnostique de son underlay, à trois clés près

Formulation de l'exploitant, meilleure que celle du dépôt. Le commentaire disait « la fabric reste celle de l'hébergeur, quel que soit le tenant actif » — vrai, mais centré sur l'hébergeur. Le cadrage juste est centré sur le tenant : les deux symlinks composent deux axes indépendants.

instance      -> quel tenant
underlay.yml  -> sur quelle fabric

Tout l'adressage d'un tenant dérive du seed index : son plan se déplace d'une fabric à l'autre sans y toucher. Ce qui ne se déplace pas tient en trois clés :

proxmox_clone_noeud     proxmox_clone_stockage     proxmox_clone_pont

Elles vivent côté tenant — c'est lui qui choisit où se poser — mais elles nomment des objets de l'hébergeur. Trois, pas trente : c'est ce qui sépare « portable » de « théoriquement portable ».

P37 — le placement est confronté à ce que l'hébergeur offre

L'écart est entièrement lisible : le tenant déclare trois noms, l'hébergeur déclare ses listes dans proxmox-hebergeur.yml, trouvé par dérivation du symlink underlay.yml. Un nom absent est un écart statique (D-75). Sans cette preuve, une faute de frappe ne se découvre qu'au premier clone — après quarante minutes de déploiement.

Éprouvée en négatif sur les trois clés : un nœud, un stockage et un pont inexistants sont refusés, avec la liste de ce qui est réellement offert.

Non vérifié, et dit comme tel : le gabarit (_vmid_modele) est un objet du cluster, pas une liste déclarée. Seul le cluster peut dire s'il existe.

2026-08-13 — Reconstruction d'un trait : 43 groupes, 0 échec, 37 minutes

DEBUT 08:05:17   FIN 08:42:11   code=0
43 groupes   0 fatal   0 hote en echec

Chezlepro rasé puis reconstruit sans une seule intervention. Les sept correctifs de la nuit tiennent sur une flotte entièrement neuve. Sept devis CONFORME, prouver 36/36, make test 0, neuf sauvegardes réussies et cinq sans objet.

La bataille contre NodeName n'avait qu'une cause

Toute la lutte d'hier soir — aligner NodeName avant api setup, après, dériver de l'inventaire, puis adopter le nom court — visait un symptôme.

icinga2 api setup écrit NodeName d'après hostname -f. Tant que /etc/hosts plaçait le nom court en premier, hostname -f mentait et le certificat devenait invérifiable. Depuis que le FQDN est en tête, Icinga s'émet spontanément un certificat CN et SAN = FQDN. Il n'y avait rien à forcer : il suffisait que la machine sache comment elle s'appelle.

Le contournement par le nom court est donc retiré — il était devenu faux dès que la cause réelle a été corrigée. Le commentaire du rôle dit maintenant la causalité, pas ma fausse piste.

Ce que ça enseigne sur le diagnostic. J'ai passé trois heures à corriger un symptôme visible (le nom du certificat) alors que la cause vivait deux couches plus bas et affectait toute la flotte. Le signe qui aurait dû alerter : chaque correction était effacée au passage suivant. Un correctif qu'on doit reposer sans cesse ne corrige pas.

Une dette notée, pas traitée

Le clone de mon-01 a dépassé proxmox_clone_timeout (600 s) pendant la génération de son ISO cloud-init — la VM était debout quelques minutes plus tard. Quatre clones complets simultanés saturent le stockage. À régler par le délai ou le parallélisme, pas en urgence.

2026-08-13 — Reconstruction complète en 10.17.x.x : sept défauts, tous invisibles avant

Chezlepro reconstruit depuis zéro sur l'adressage dérivé sans décalage. Sept défauts sont tombés, et aucun n'était détectable sur une flotte déjà debout — c'est tout l'argument de la reconstruction comme preuve.

# Défaut Pourquoi il était invisible
1 aucune route vers le tenant sur le poste posée à la main un jour, jamais persistée ; disparue à la réactivation de l'interface
2 backup-01 exigeait l'AC d'Icinga l'AC existait déjà sur l'ancienne flotte
3 ma correction inversait les couches refusée par P08 avant d'entrer au dépôt
4 base Forgejo à moitié initialisée séquelle de l'arrêt du défaut n°2
5 /etc/hosts : nom court avant le FQDN hostname -f faux sur les quatorze machines, depuis toujours
6 API Icinga jamais activée la garde creates: d'api setup la saute dès que le fichier existe
7 restic refuse tout le lot si un chemin manque /srv/web n'existe qu'après la première webapp

Le cinquième dépassait Icinga

/etc/hosts déclarait 10.17.18.21 backup-01 backup-01.chezlepro.internal. Or hostname -f rend le premier nom : toute la flotte se croyait appelée mon-01, jamais mon-01.chezlepro.internal. Conséquence visible sur Icinga (certificat CN=mon-01 invérifiable en appelant par le FQDN) — mais le même piège attendait Postfix (myhostname), les journaux et tout ce qui se nomme ainsi. FQDN d'abord, nom court en alias.

Ce qu'on ne corrige pas : la convention d'Icinga

icinga2 api setup réécrit NodeName d'après le nom court et nomme ses certificats d'après lui. Aligné avant, il est écrasé ; aligné après, les certificats portent le mauvais nom. On a essayé les deux. On adopte donc sa convention : le dépôt appelle l'API par le nom court, celui que le certificat porte, résolu par le plancher /etc/hosts.

serveur_icinga_node_name dérive désormais de l'inventaire et non d'ansible_fqdn — un fait qui dépend du résolveur de la machine, et qui a rendu mon-01 alors que le FQDN était correct.

Une leçon presque comique. Le commentaire écrit pour avertir qu'une séquence accolade-dièse casse les gabarits Jinja… contenait cette séquence, et cassait le gabarit. Neuf hôtes en échec pour un avertissement mal rédigé.

Verdict

Sept devis CONFORME, prouver 36/36, make test 0. Neuf sauvegardes réussies, cinq sans objet, et la supervision reçoit les rapports du dépôt avec vérification du pair.

2026-08-12 — D-79 : l'hyperviseur filtre encore en iptables legacy

Question de l'exploitant : « pourquoi les règles de sécurité au pare-feu global, alors que chaque VNet peut en porter ? » La réponse honnête a demandé trois corrections successives — et la dernière était la bonne.

Ce que j'avais tort d'affirmer

« Une règle de VNet serait trop grossière. » Faux : elle porte source, dest, dport, proto. Éprouvé — règle créée, relue, retirée. L'exploitant avait raison.

« C'était un arbitrage. » Faux, et pire : zéro mention du pare-feu SDN dans le dépôt — ni doc, ni script, ni CHANGELOG. Ce n'était pas un choix, c'était une omission, présentée comme un raisonnement.

« Sur Debian 12, iptables c'est iptables-nft, donc c'est déjà du nftables. » Faux ici. Mesuré sur asgard : iptables v1.8.9 (legacy). Proxmox force l'alternative sur legacy, parce que pve-firewall dépend de l'ancien sous-système. Le défaut d'une distribution n'est pas une mesure.

Ce que la mesure établit

asgard   iptables v1.8.9 (legacy)        nftables v1.0.6 installé, inutilisé par Proxmox
         proxmox-firewall 0.7.1          INSTALLÉ, absent des services en cours
         pve-firewall 5.1.3              en service
         node firewall enable: 0         cluster firewall enable: 1
VNet fw  type/action/proto/source/dest/dport  +  policy_forward ∈ {ACCEPT, DROP}

Le pare-feu de VNet est entièrement expressif — et implémenté par le seul moteur nftables. Posée aujourd'hui, une règle serait acceptée, stockée, visible dans l'interface, et n'appliquerait rien. C'est le motif de la journée, une quatrième fois.

Trois gains dans le même geste

Activer proxmox-firewall fait passer le moteur de xtables à nftables natif (la doctrine « tout est nftables », que les invités respectent déjà et que l'hyperviseur trahissait), rend le pare-feu de VNet réel, et donne des règles qui survivent à la reconstruction — là où les 38 groupes par VM doivent être ré-attachés à chaque cycle.

Ce n'est donc pas une entorse à évaluer : c'est une dette à rembourser.

Différé après la reconstruction, sur un nœud d'abord, avec preuve de blocage et contrôle négatif. Deux réserves consignées : le jeu de règles chargé n'a pas été inspecté (nft list tables exige root, le compte ansible ne l'a pas), et aucun blocage réel n'a été éprouvé — aucune VM n'était en service.

2026-08-12 — Technolibre 11→23, lab 1→13 : sortir des plages qu'occupait le matériel

Le retrait du décalage de +10 a déplacé les tenants vers 10.<index>. Ces plages n'étaient pas vierges : les hyperviseurs portent des interfaces VLAN héritées que le plan ne connaît pas.

asgard   vlan5 = 10.11.5.41   vlan6 = 10.11.6.41   vlan7 = 10.11.7.41   ← dans 10.11 = Technolibre
         vlan1110 = 10.1.110.254                                        ← dans 10.1  = lab

Changer l'index plutôt que déloger le matériel : Technolibre passe à 23, le lab à 13. 10.23 et 10.13 sont libres partout — frontière, poste de l'exploitant, dépôt — et les VLAN dérivés (1231-1236, 1131-1136) ne croisent rien.

Les devis ne suppriment jamais ce qu'ils ne possèdent pas

Cette garde est juste — un devis ne doit pas détruire la zone d'un autre — mais elle laisse des orphelins quand un tenant change de nom dérivé :

Objet Sort du devis Retiré explicitement
Zone SDN t11 + 6 VNets + 6 sous-réseaux « zone hors devis, LAISSÉE INTACTE » oui
18 groupes de sécurité t11-* (31 règles) « à retirer : 0 » oui

Un VNet ne se supprime pas tant qu'il porte un sous-réseau, ni un groupe tant qu'il porte une règle : l'ordre est contenu d'abord, contenant ensuite.

Vérifié sur le réel, pas sur les devis

SDN        zones t17, t23 — 12 VNets, 12 sous-réseaux, aucun t11
Pare-feu   19 groupes t17, 17 groupes t23, aucun t11
Frontière  10.0 (underlay), 10.17, 10.23 — aucun 10.11, 10.21, 10.27

Les trois devis répondent « rien à faire », prouver et make test à 0.

Ce qui reste, et qui n'est pas une anomalie : vlan5/6/7 et vlan1110 vivent toujours sur les hyperviseurs. Ils n'appartiennent à aucun plan et ne collisionnent plus — c'était le but. Les déloger est un geste séparé, à faire avec le reste du déplacement physique.

2026-08-12 — D-78 : destination ou chemin, et le VLAN 50 pour éviter une collision

D-77 sortait déjà le stockage de l'espace dérivé, mais par une exception nommée plutôt que par une règle. Une question de l'exploitant — « le VLAN 40 n'a aucune VM, pourquoi ne pas lui donner une adresse non routée ? » — a fait apparaître le critère juste.

Ce n'est pas « y a-t-il des machines dedans » : le VLAN de gestion n'en a pas plus qu'un autre. C'est :

Ce réseau est-il jamais une destination, ou seulement un chemin ?

Réseau Rôle réel Adressage
Gestion (10) le poste, un VPN, demain le lien inter-sites doivent l'atteindre 10.<index>.0.0/24 — unique
Transit (40) rien que des prochains sauts, deux extrémités adjacentes 192.168.40.0/24
Transport VXLAN (50) VTEP ↔ VTEP, destination de rien 192.168.50.0/24
Stockage (20/30/31) baie ↔ hyperviseurs 192.168.20/30/31.0/24

Sur six réseaux, cinq cessent d'exiger la moindre coordination entre deux hébergeurs — et « unique » redevient signifiant : seul ce qui doit l'être l'est.

L'os : le VLAN 11 aurait collisionné

Sous la règle 192.168.<vlan>, le transport VXLAN (VLAN 11) aurait produit 192.168.11.0/24 — déjà occupé par la gestion des hyperviseurs sur vmbr0, passerelle .254. Trouvé par l'exploitant avant écriture.

Le VLAN 11 se libérera lorsque cette gestion rejoindra 10.<index>.0.x — mais faire dépendre un plan d'adressage de l'ordre d'une migration est le genre de dette qui se paie un an plus tard. Le transport passe donc au VLAN 50 : libre, 192.168.50.0/24 libre, et ne dépend de rien. Vérifié : rien ne code le 11 en dur, c'est de la donnée d'underlay.

Ce qui n'est PAS fait, et pourquoi

underlay.yml décrit le matériel tel qu'il est. Y écrire les nouvelles plages avant le déplacement physique le ferait mentir : P23 validerait une fiction et les devis émettraient une configuration pour un état inexistant. La décision est consignée ; les adresses changeront avec le matériel, dans l'ordre du runbook §6.

Un site neuf, lui, se monte directement au schéma final — le document de préparation d'un site hébergeur porte déjà le VLAN 50 et les 192.168.

2026-08-12 — P03 regarde TOUTES les instances, et trouve une collision au premier essai

P03 ne vérifiait la fraîcheur de l'inventaire que pour l'instance active — laissant un tenant qu'on ne regarde pas imposer ses adresses à la frontière partagée. Elle boucle désormais sur les instances découvertes, chacune vérifiée avec SETOPS_INSTANCE.

Au premier passage, elle a trouvé une troisième instance périmée — et une collision franche que personne n'avait vue :

OPS-Chezlepro-lab (index 1)   applique : 10.11.18.21    ← ancienne derivation (10+1)
OPS-Technolibre   (index 11)  derive   : 10.11.x.x      ← nouvelle derivation

Le lab occupait exactement la plage désormais attribuée à Technolibre. Sans cette preuve, la collision serait apparue le jour où les deux auraient tourné ensemble — c'est P21 qui garde les index, rien ne gardait les inventaires appliqués.

Les trois instances sont maintenant alignées : 10.1 (lab), 10.11 (Technolibre), 10.17 (Chezlepro).

Ce que la preuve ne fait pas : vérifier que le boîtier porte ce que le devis dit — c'est make frontiere-plan. Elle garde l'intrant de ce devis, pas sa sortie. Les deux sont nécessaires, et c'est l'intrant qui manquait.

2026-08-12 — Un tenant périmé injecte ses vieilles adresses dans le pare-feu partagé

Après le renumérotage de Chezlepro, la frontière portait encore 17 adresses 10.21.x — l'ancienne plage de Technolibre. Et frontiere-plan répondait « la frontière dit déjà ce que le devis dit ».

Les deux étaient vrais. La frontière construit deux natures d'objets :

Objet Source Comportement
SETOPS_TENANT_<T> (réseau) la formule (sous_reseau_de) s'est recalculé seul → 10.11
SETOPS_<T>_SERVEUR_* (hôtes) le hosts.yml du tenant est resté à 10.21

Le devis lisait donc fidèlement une entrée périmée, et l'annonçait conforme. Le contrôle n'était pas faux — son intrant l'était.

Ce que ça révèle

La frontière est partagée entre tous les tenants, mais P03 ne vérifie la fraîcheur de l'inventaire que pour l'instance active. Un tenant qu'on ne regarde pas — parce qu'il n'a aucune VM, précisément — continue d'imposer ses adresses au pare-feu de tout le monde, sans qu'aucune preuve ne s'en aperçoive.

Corrigé en régénérant l'inventaire de Technolibre puis en réappliquant. Vérifié non pas sur le devis mais sur la configuration réelle du boîtier, téléchargée et relue : il ne reste que 10.0 (underlay), 10.11 et 10.17. Zéro 10.21, zéro 10.27.

Ce qui manque encore : rien ne prouve que chaque tenant fédéré a un inventaire à jour. P03 devrait boucler sur les instances découvertes, pas seulement sur l'active.

2026-08-12 — P03 peut enfin échouer, et elle échoue

instancier.py comparer affichait l'écart puis renvoyait toujours 0. La preuve P03 « Diff-vide du plan » ne pouvait donc pas échouer : elle a passé au vert pendant que quatorze hôtes divergeaient du plan.

Deux appelants, deux besoins — d'où un mode plutôt qu'un changement de comportement :

Appelant Attente
make instancier inspection : voir le diff avant de décider. Un code d'erreur y transformerait la lecture en panne
P03 affirmation : « le plan reproduit l'inventaire ». Sans --strict, elle n'affirmait rien

prouver sort désormais à 1, et c'est exact : depuis le retrait du décalage de +10, le plan dérive 10.17.x.x tandis que l'inventaire appliqué — et les quatorze VM qui tournent — portent 10.27.x.x. Le dépôt est sciemment dans cet état jusqu'au renumérotage. Une preuve rouge qui dit vrai vaut mieux qu'une verte qui ne regarde rien.

2026-08-12 — L'index est borné : 10.300.0.0/16 n'est pas un réseau

Rien ne bornait index. supernet_de(300) rendait "10.300.0.0/16" — une chaîne qui ressemble à un réseau. Elle traverse tout le moteur sans bruit et n'échoue qu'au premier ip_network() qui la lit, très loin de l'index fautif.

La borne est posée à la source (valider_index), pas dans un validateur de plan : toutes les fonctions dérivées y passent — supernet_de, base3_de, vlan_de — donc aucune ne peut fabriquer une adresse invalide, d'où qu'on l'appelle : plan, GUI, devis ou test.

Elle protège un second plafond, moins visible : à l'index 255 le VLAN vaut 3550+zone, sous les 4094 du 802.1Q. Un index à trois chiffres débordait aussi là.

Et une garde statique dans le contrôle de fédération (P21), qui nomme le dépôt fautif au lieu de laisser l'erreur remonter d'une bibliothèque.

Le test a trouvé ce que la relecture n'avait pas vu

valider_index n'attrapait que TypeError et ValueError. Or int(float('inf')) lève OverflowError : un infini flottant passait la garde en la faisant planter au lieu de la faire refuser. Corrigé — et c'est le cas de test qui l'a levé, pas ma relecture.

test_underlay_bande_basse.py devient test_adressage_derive.py : il ne parlait plus seulement de la bande basse. 12 cas, dont les refus.

Un piège de structure, au passage. Les nouveaux cas, ajoutés après le bloc if __name__ == "__main__":, ne s'exécutaient pas — le bloc tourne avant que les fonctions suivantes ne soient définies, et le compte affichait tranquillement « 7 tests » au lieu de 12. Un harnais qui compte ses propres tests doit être lu : sept était la bonne réponse à la mauvaise question.

2026-08-12 — Le décalage de +10 est retiré : l'index se lit dans l'adresse

supernet_de(index) rendait 10.(10+index).0.0/16. Personne ne savait plus pourquoi — ni le commentaire de la constante, ni le wiki de l'adressage, ni le commit fondateur 36a882b ne le justifiaient. Trois endroits consultés, zéro raison écrite.

Ses deux effets constatés :

  • il réservait 10.0–10.9 sous la plage tenant. Utile tant que l'underlay vivait là — mais D-77 l'a fait entrer dans la bande basse de son propre /16, ce qui a vidé cette réserve de son rôle la veille ;
  • il éloignait le premier tenant de 10.0.0.0/16, la plage la plus répandue en réseau domestique. Ce risque revient donc aux index bas, et c'est assumé : choisir un index, c'est choisir sa plage — autant que ce soit lisible.

En échange, l'index se lit directement dans l'adresse — index 17 → 10.17.x.x — et le plafond passe de 245 à 255 écosystèmes fédérés.

Instance Avant Après
Chezlepro (17) 10.27.0.0/16 10.17.0.0/16
Technolibre (11) 10.21.0.0/16 10.11.0.0/16
lab (1) 10.11.0.0/16 10.1.0.0/16

Documentation alignée partout : les trois pages du wiki, multi-instances.md (dont le plafond et l'exemple, devenus faux arithmétiquement), sdn-evpn.md, le libellé de la GUI, la docstring d'underlay.py, D-77, et le document de préparation d'un site hébergeur. Les constats de terrain datés — incidents dans les commentaires, CHANGELOG, rapports d'audit — sont laissés tels quels : ce sont des mesures, pas des formules.

Ce que ce commit ne fait PAS

Il ne renumérote rien. Il change ce que le plan dérive ; l'inventaire appliqué, lui, porte toujours 10.27.x.x, et les quatorze VM tournent dessus.

14 hote(s) avec ecart — ansible_host, proxmox_passerelle, setops_supernet

Appliquer cet inventaire sans reconstruire la flotte la rendrait injoignable : Ansible chercherait des machines à des adresses que personne ne porte. Le renumérotage est une opération à part — instancier-appliquer, puis SDN, puis reconstruction, puis frontière — à mener à froid.

Au passage : une preuve qui ne peut pas échouer

P03 « Diff-vide du plan » ne prouve rien. instancier.py comparer affiche l'écart puis renvoie toujours 0 : la preuve passe quel que soit le nombre d'hôtes divergents. Elle aurait dû crier ici, sur quatorze. Non corrigé dans ce commit — le corriger ferait échouer prouver jusqu'au renumérotage, ce qui est exact mais bloquerait tout le reste. À traiter avec le renumérotage, pas avant.

2026-08-12 — P23 outille D-77 : la bande basse devient une règle, pas une convention

D-77 disait où l'underlay doit vivre. Rien ne le vérifiait — et une convention qu'on n'outille pas pourrit en silence (D-70). C'est exactement ce qui a laissé la sauvegarde vide pendant un mois.

Le contrôle disait l'inverse de la décision. underlay.py refusait tout chevauchement avec un supernet tenant. Il fallait le rendre plus fin, pas plus strict :

Situation Verdict
dans son propre supernet, bande basse conforme — c'est la règle
dans son propre supernet, bande haute refusé — collision avec ses propres zones
dans le supernet d'un autre site refusé — les deux ne pourront jamais être reliés
hors de tout supernet (10.0.x, 192.168.x) conforme — héritage, et stockage

La frontière est dérivée de OCTET_ZONE, jamais écrite en dur : déplacer la règle des zones déplace la borne avec elle. Le site déclare son index dans underlay.yml ; sans lui, on retombe sur la règle stricte d'avant D-77 — le comportement sûr pour un underlay qui n'a pas encore migré. Chezlepro reste donc conforme aujourd'hui, en 10.0.x.

Le piège que le test attrape

Un préfixe peut commencer dans la bande basse et déborder : 10.21.0.0/19 couvre les octets 0 à 31. Une vérification qui ne regarderait que le premier octet le laisserait passer. La borne est donc évaluée sur toute l'étendue du préfixe.

Deux de mes propres cas d'épreuve étaient mal choisis — 10.21.14.0/23 et 10.21.12.0/21 se normalisent entièrement dans la bande basse, et « conforme » y était la bonne réponse. Il a fallu construire un préfixe qui franchit réellement la frontière pour éprouver la garde.

scripts/tests/test_underlay_bande_basse.py — 7 cas, câblé dans make test.

2026-08-12 — modeleSetOPS, et une porte pour l'hébergeur

Le gabarit portait le nom du mauvais propriétaire

modeleChezlepro était déclaré par les trois instances — Chezlepro, Technolibre et le lab — qui pointaient déjà toutes sur le même VMID 99998. Le commentaire du rôle affirmait pourtant « chaque tenant a SON golden template » : c'était faux depuis longtemps, et personne ne pouvait le voir en lisant un seul fichier.

Renommé modeleSetOPS — sur le cluster et dans les trois instances. Le gabarit est un artefact du moteur, pas d'un tenant, et le nom d'un tenant sur le gabarit d'un autre était un piège qui n'attendait qu'un troisième hébergeur pour se refermer. Les scripts d'amorçage (model_creer.py, config_proxmox.py) proposaient encore modele-debian13 : alignés eux aussi.

Sans risque : le clonage se fait par VMID depuis la correction du 2026-08-10 — le nom ne sert plus qu'à l'affichage et aux vérifications. Rien n'empêche un tenant d'en désigner un autre ; il change le champ et le VMID.

Une porte de plus dans l'aiguillage : l'hébergeur

docs/preparer-un-site-hebergeur.md — pour quelqu'un qui prête son matériel sans rien connaître de Set-OPS. Il ne décrit que ce que la machine ne peut pas deviner : le plan d'adressage à respecter, la frontière, le stockage, l'hyperviseur, le gabarit, et la liste exacte de ce qu'il doit transmettre en retour.

Écrit à partir du dépôt, pas de conventions générales : les VLAN et MTU viennent d'underlay.yml, le bloc par tenant de inventory_rules.supernet_de(), les privilèges du jeton de config-proxmox.md, et le dimensionnement (~460 Go, ~37 Go de RAM pour quatorze VM) d'une mesure sur la flotte vivante.

Deux avertissements y sont écrits parce qu'ils ont déjà coûté cher ici : un gabarit personnalisé recopie son identité dans chaque clone, et un blocage contourné en silence se paie en heures — la panne est alors cherchée au mauvais endroit.

Le rôle Administrator sur le jeton Proxmox y est recommandé et signalé comme tel, avec le minimum documenté en regard : mieux vaut un privilège large assumé et resserré ensuite qu'un privilège serré qu'on élargit en panique au milieu d'un déploiement.

2026-08-12 — La donnée revient : restauration éprouvée, pas seulement sauvegarde

On savait que la donnée partait et arrivait. On ne savait pas qu'elle revenait — et c'est le seul test qui compte le jour venu.

Les trois charges critiques, éprouvées pour de vrai

Charge Preuve Résultat
Clés de l'AC (infra-pki-01) restauration + comparaison octet pour octet avec le vivant 12 fichiers, 11 identiques, root_ca_key et intermediate_ca_key compris
Annuaire (idm-01) slapadd -u (essai à blanc) sur le LDIF restauré rejouable, 7 entrées dont uid=sysadmin
Bases (data-sql-01) section forgejo rejouée dans une base d'épreuve 0 erreur, 130 tables, comptes réels (forgejo-admin, sysadmin)

Le seul écart sur l'AC est db/000000.vlog — le journal badger de step-ca, qui avance à chaque émission de certificat. Attendu, pas un défaut.

Contrôles négatifs, parce qu'un test qui dit toujours oui ne teste rien : un LDIF volontairement corrompu fait sortir slapadd en 1 ; la garde SQL a refusé une section mal découpée (voir ci-dessous). Production vérifiée intacte après le rejeu.

Le piège de pg_dumpall, trouvé par la garde

pg_dumpall écrit CREATE DATABASE <suivante> avant le \connect correspondant. Découper « du \connect X au \connect suivant » emporte donc un ordre visant une autre base. Ma première découpe l'a fait ; la garde a refusé de rejouer. Sans elle, un essai de restauration aurait touché icingadb. Consigné dans runbooks-exploitation.md §5.

La recette ne ment plus

playbooks/valider.yml exigeait une restauration de tous les nœuds client_backup — elle échouait donc sur ceux qui ne détiennent légitimement rien, et sur les dépôts vides. Elle distingue désormais quatre verdicts : OK, À CONFIRMER (restauré mais vide), SANS OBJET, ÉCHEC (la restauration elle-même). Alignés sur ceux de la supervision : deux verdicts opposés sur le même fait apprendraient à en ignorer un.

Elle prouve en outre que l'annuaire restauré est rejouable, pas seulement présent.

make valider : 0 échec sur toute la flotte.

2026-08-12 — Le curl -k est mort, mais pas comme prévu

Objectif : que le rapporteur vérifie le pair en appelant l'API Icinga, au lieu de sauter la vérification. C'est fait — et la manière a été imposée par la mesure, pas par le plan.

Servir un certificat step-ca sur l'API est IMPOSSIBLE

La tentative était directe : client_pki dépose déjà sur mon-01 un certificat portant serverAuth + clientAuth et le bon SAN. Il suffisait de le faire servir. Icinga le refuse, et le dit lui-même :

information/ApiListener: Our certificate will expire soon, but we own the CA. Renewing.

Icinga renouvelle tout certificat expirant sous 30 jours. Les certificats Set-OPS vivent 24 h. Possédant une AC, il ré-émet donc avec la sienne — écrasant le nôtre à chaque démarrage. Ce n'est pas réparable par configuration : c'est une collision entre deux politiques de PKI, et la nôtre (certificats courts) n'est pas négociable.

Deux découvertes en chemin, toutes deux par le garde-fou icinga2 daemon -C ajouté au rôle — qui a arrêté le déploiement avant de redémarrer la supervision :

  • cert_path / key_path / ca_path sont dépréciés depuis 2.8 ; les poser réveille un chemin de code hérité qui exige en plus un objet Endpoint.
  • L'identité de l'API est le CN du certificat. NodeName valait mon-01 : le certificat auto-émis portait donc SAN=mon-01 alors qu'on appelle par le FQDN, et aucune vérification n'aurait pu réussir. NodeName est désormais aligné sur le FQDN.

Ce qu'on fait à la place

Icinga garde son AC — un domaine de confiance fermé, ce qui est légitime — et backup-01 vérifie le pair contre cette AC-là, récupérée depuis mon-01 au déploiement. Le pair est authentifié ; seule la racine diffère. Le -k a disparu, ce qui était le vrai problème.

Contrôle négatif, parce qu'une vérification qu'on ne teste pas est un ornement : avec la mauvaise AC (celle de step-ca), curl refuse — unable to get local issuer certificate. Avec la bonne, les neuf rapports passent.

Et la sous-AC step-ca ?

Écartée, et pas par prudence de principe. Elle poserait sur l'hôte de supervision une clé capable d'émettre pour n'importe quel nom de l'écosystème — alors qu'on a justement choisi le sens du flux (le dépôt parle à la supervision, jamais l'inverse) pour que compromettre mon-01 ne donne rien. Une AC isolée pour un domaine isolé est le bon design, pas une entorse à la souveraineté.

2026-08-11 — Les sauvegardes sont surveillées, et c'est le dépôt qui parle

Icinga ne surveillait rien : aucun objet Host ni Service de Set-OPS, seulement la configuration Debian d'origine pointant sur localhost.

On ne supervise pas l'unité — on supervise ce qui est arrivé

Superviser setops-sauvegarde.service aurait reproduit le défaut du jour même : l'unité était verte sur onze nœuds pendant qu'elle n'emportait rien. Le nœud sait qu'il a lancé sa sauvegarde ; il ne sait pas qu'elle est arrivée. Seul le dépôt le voit.

backup-01 évalue donc ses dépôts restic et pousse un résultat passif par nœud vers l'API Icinga. Trois critères, parce qu'un seul suffit à mentir :

Critère Ce qu'il attrape
l'instantané existe la sauvegarde n'arrive pas
il est récent (26 h / 50 h) elle a cessé d'arriver
il contient au moins un fichier elle arrive mais ne porte rien

Le sens du flux est délibéré : le dépôt parle à la supervision, jamais l'inverse. Un seul flux nouveau, et compromettre mon-01 ne donne aucun accès aux sauvegardes.

Le silence alerte

Le ttl de 6 h porté par chaque envoi fait la fraîcheur : si le rapporteur se tait, Icinga périme les services tout seul. C'est le silence qui a laissé le défaut vivre un mois — il devait devenir la première chose qui alerte. Le rapporteur, lui, refuse d'avaler ses propres échecs et sort en erreur.

Deux erreurs de conception, corrigées par la mesure

Le corps --data-urlencode était refusé en Bad Request : l'API veut du JSON. Le flux, le TLS et l'authentification fonctionnaient — seule la charge était perdue. Sans lecture du journal d'Icinga, un curl silencieux aurait été pris pour un succès.

Le seuil « vide » en octets était faux. Il signalait idm-01 (2 363 octets) alors qu'un export LDIF d'un annuaire à un compte pèse légitimement cela. « Vide » se mesure en fichiers, pas en taille : zéro fichier, c'est exact quelle que soit la taille. Et le verdict est un avertissement, pas un critique — la machine ne peut pas distinguer « les données ont disparu » de « il n'y en a pas encore », mais l'humain doit le voir.

Mesuré de bout en bout

idm-01         OK     1 fichier, 2 363 o (l'annuaire)     infra-mail-01   AVERT.  aucun fichier
infra-pki-01   OK     20 621 o (les clés de l'AC)         web-frontal-01  AVERT.  aucun fichier
collab-01      OK     67 129 221 o                        web-dorsal-01   AVERT.  aucun fichier
data-sql-01    OK     1 085 158 o (toutes les bases)

Réserve assumée : le rapporteur appelle l'API en curl -k. L'API Icinga présente le certificat de sa propre AC (icinga2 api setup), pas celui de step-ca — la liaison est chiffrée mais le pair n'est pas vérifié. C'est la réserve connue sur 5665, et elle reste ouverte.

2026-08-11 — La sauvegarde emporte enfin quelque chose

Correction de l'entrée précédente : j'y attribuais le défaut à la reconstruction from-zero. C'est faux. Le commit fondateur 7476a54 (2026-07-03) le disait lui-même — « Reste : jobs Tier 1 (pg_dump/slapcat/vmail/forgejo) ». Ces jeux n'ont jamais été écrits. Le Tier 0 était prouvé sur infra-pki-01, mais l'hôte a ensuite perdu son intégration client_backup sans que rien ne le dise.

Le catalogue vit dans le rôle, dérivé de l'appartenance aux groupes

C'est le rôle qui possède la donnée qui dit comment la sortir. client_backup_jobs est l'intersection du catalogue et des group_names du nœud : un tenant qui déplace un service emporte sa sauvegarde avec lui, sans rien redéclarer. On sauvegarde l'état non régénérable — ni les zones PowerDNS ni les tableaux de bord Grafana n'y figurent, ils se redéploient.

Les chemins ne peuvent pas référencer les defaults du rôle propriétaire : make deployer déroule un play par groupe, et ceux de serveur_forgejo ne sont pas chargés pendant le play de client_backup. D'où la forme var | default(littéral).

L'unité qui ment est retirée, pas rendue bloquante

Refuser le déploiement d'un nœud sans jeu aurait cassé infra-edge-01, infra-dns-01 et mon-01, qui ne détiennent légitimement rien. Le défaut n'était pas là : il était dans le timer qui échouait chaque nuit en donnant l'apparence d'une sauvegarde. Le rôle installe donc la sauvegarde si et seulement si un jeu s'applique, et retire celle qui existerait. Une sauvegarde qui ne sauvegarde rien est pire que pas de sauvegarde : elle rassure.

P36 — tout détenteur d'état porte une sauvegarde

L'écart était lisible dans le plan depuis un mois (D-75). La preuve lit les groupes détenteurs dans client_backup_catalogue : ajouter un rôle au catalogue étend la preuve du même geste. Elle a immédiatement attrapé infra-pki-01, corrigé au plan.

Mesuré, hors-nœud

Hôte Emporté
collab-01 64,0 MiB · 272 fichiers
edge-mta-01 4,4 MiB · 139 (bayes rspamd appris)
data-sql-01 1,0 MiB · pg_dumpall de toutes les bases
forge-01 26,4 KiB · 68
infra-pki-01 20,1 KiB · 21 — les clés de l'AC
idm-01 2,3 KiB · 5 — l'annuaire par slapcat
infra-mail-01, web-frontal-01, web-dorsal-01 vides, et c'est exact : /var/vmail, /srv/web et /srv/webapp n'ont rien depuis la reconstruction du 2026-08-10

9 hôtes, 9 success, 5 hôtes sans sauvegarde parce qu'ils ne détiennent rien.

Ce qui reste : rien ne surveille encore l'unité. C'est ce silence qui a laissé le défaut vivre un mois — Icinga devrait voir une unité systemd en échec.

2026-08-11 — En consignant l'effet du rasage, la sauvegarde s'est révélée vide

Il s'agissait d'écrire une conséquence connue : raser l'hôte qui porte openldap détruit l'annuaire, donc le compte sysadmin est recréé depuis le jeton de la voûte et le changement forcé est réarmé. Mesuré après le rasage de idm-01 : pwdReset: TRUE, et le mot de passe choisi par l'exploitant n'existe plus.

La perte réelle est ailleurs, et le §2 la rendait prévisible : les appartenances ne sont jamais réconciliées. Set-OPS crée un compte. Tout ce que l'exploitant a construit depuis est détruit et ne sera pas recréé — contrepartie exacte du régime qui protège ces décisions du prochain make deployer.

Le contrôle qui devait rattraper ça ne fonctionne pas

En cherchant où pointer pour la restauration, mesure sur les 14 hôtes :

Hôtes État
11 (dont idm-01, data-sql-01, forge-01, collab-01) setops-sauvegarde.service en échec chaque nuit — Fatal: nothing to backup
infra-pki-01, obs-01, backup-01 aucune sauvegarde déployée — et infra-pki-01 porte les clés de l'AC

client_backup_jobs vaut [] par défaut et rien ne le surcharge dans l'instance : le timer tourne, restic initialise son dépôt, puis échoue faute de source. Aucune donnée de cet écosystème n'est sauvegardée. Vraisemblablement une victime de la reconstruction from-zero — les déclarations par nœud n'ont pas été redéclarées dans l'instance régénérée.

Consigné tel que mesuré dans autorisation.md §3.1, avec la sortie manuelle de l'annuaire en attendant la correction. Le défaut n'est pas corrigé par ce commit : il est rendu visible, et une unité en échec qui n'alerte personne est le second défaut à traiter.

2026-08-11 — L'ancre Keycloak existe (et la commande que j'avais donnée ne prouvait rien)

La vérification de signature était en place, mais elle ne prouvait que « la même clé qu'hier ». Restait à établir que cette clé est bien celle de Keycloak.

La première tentative était circulaire. gpg --recv-keys <empreinte> demande la clé par son empreinte — or une empreinte est le condensat du matériel de la clé : le serveur ne peut rien renvoyer d'autre. Confirmer que la clé reçue porte l'empreinte demandée n'établit donc rien. Et un serveur de clés n'est pas une autorité : n'importe qui y téléverse n'importe quelle clé avec n'importe quel UID (GPG l'affiche : [ unknown ]).

La manœuvre a tout de même révélé l'identité : Keycloak Bot <keycloak.bot@gmail.com>, ed25519 créée le 2024-02-13, expirant le 2027-02-12. Et, mesuré localement, la clé est auto-signée uniquement — aucune certification tierce, aucune toile de confiance.

L'ancre réelle : https://www.keycloak.org/keys publie 861ab50e8cc6611fb6bc01a6b8f12ea26fd6eeba, identique à l'épinglage. Ce canal (keycloak.org) est distinct de celui qui livre l'archive (github.com) — la propriété qu'avait déjà Forgejo et qui manquait ici.

Deux réserves consignées dans roles/serveur_keycloak/defaults/main.yml plutôt que passées sous silence : la page décrit la clé comme servant aux artefacts Maven (c'est notre vérification qui établit que le .asc de l'archive est validé par elle), et l'ancrage vaut ce que vaut le contrôle de keycloak.org — DNS et TLS.

2026-08-11 — Les épinglages éprouvés en vrai, par une reconstruction ciblée

Les rôles installent, ils ne mettent pas à jour (creates:). Relever une version ne change donc rien tant qu'une machine neuve ne la rencontre pas. Restait à l'éprouver sans raser les quatorze VM pour trois.

make raser accepte HOTE= et ne peut que restreindre. Trois VM concernées, rasées ; leurs trois bases supprimées puis recréées vides par serveur_postgresql depuis le registre — 7425 Ko chacune, la taille d'une base neuve.

VM Version obtenue Livraison réelle, via le frontal, TLS validé
idm-01 Keycloak 26.7.1 document OIDC complet du realm chezlepro
forge-01 Forgejo 16.0.2 {"version":"16.0.2+gitea-1.22.0"}
collab-01 Nextcloud 34.0.2.1 installed:true, needsDbUpgrade:false

33 couches, 0 échec, 0 injoignable, en ~15 minutes contre 54 pour une reconstruction complète.

Ce que la manœuvre a réellement prouvé

Les deux vérifications de signature PGP se sont exécutées en conditions réelles, sans ignore_errors ni failed_when: false : un refus aurait cassé le play avant le dépôt de l'archive. L'épinglage sur la clé primaire de Forgejo tient — la 16.0.2 est signée par une sous-clé différente de celle de la 12.0.0, et la vérification passe sans qu'on ait eu à baisser la garde.

Supprimer les bases n'était pas une commodité : occ maintenance:install refuse une base peuplée. Garder les bases aurait fait échouer Nextcloud, et fait traverser six majeures à Forgejo.

Verdict : sept devis CONFORME, prouver.py 35 OK / 0 échec.

Deux points restent ouverts, et il faut le dire : l'ancre de confiance Keycloak repose encore sur la continuité — rien dans la machine n'établit que l'empreinte épinglée est la bonne, c'est la décision humaine que verifier_signature.py dit explicitement ne pas pouvoir prendre. Et Collabora tourne toujours dans Docker sur collab-01.

2026-08-11 — Forgejo à 16.0.2, et une ancre de confiance qui existe vraiment

Six versions majeures d'un coup — mais la découverte importante est ailleurs.

La « rotation de clé » n'en était pas une

Quatre versions, trois signataires différents :

10.0.0 → B3B1F60AC577F2A2      14.0.0 → C4186DF66F4B6750
12.0.0 → D0A820050E1609E5      16.0.2 → C4186DF66F4B6750

Ce ne sont pas des clés distinctes : ce sont des sous-clés de signature sous une clé primaire stable depuis 2022 — EB114F5E…C5923710, Forgejo <contact@forgejo.org>. La sous-clé 0F527CF9…0E1609E5 est bien celle qui avait signé la 12.0.0.

D'où une correction du vérificateur : il comparait l'empreinte du signataire, donc une sous-clé. Il aurait échoué à chaque rotation légitime — et on aurait appris à lever la garde pour avancer, ce qui est la pire chose qui puisse arriver à un contrôle. Il accepte désormais la clé primaire (dernier champ de VALIDSIG), qui survit aux rotations tout en refusant une clé étrangère.

Forgejo a l'ancre que Keycloak n'a pas

forgejo.org/download publie l'empreinte — et le binaire vient de codeberg.org. La source de confiance est donc indépendante du canal de livraison, exactement ce qui manquait pour Keycloak. Forgejo publie en outre une somme sha256, vérifiée conforme.

Le projet annonce lui-même la rotation : « the GPG key is updated on a regular basis » — ce qui confirme qu'épingler la primaire est le bon choix.

Vérifié, dans les deux sens

Nominal 0. Binaire altéré d'un octet 1. Empreinte de Keycloak appliquée à Forgejo 1. Signature d'un autre artefact 1. Et Keycloak ne régresse pas après la modification du comparateur.

make versions-mesurer : 0 en retard. Rôle appliqué de bout en bout sur forge-01.

Rappel du modèle : forge-01 tourne toujours 10.0.0 — l'épinglage décrit ce qu'on installe, pas ce qui tourne.

2026-08-10 — Keycloak : vérifier QUI a produit l'archive, pas seulement qu'elle est intacte

L'exploitant : « j'ai besoin d'une confiance réelle. Keycloak est probablement l'élément le plus dangereux de cet écosystème. » C'est exact — Keycloak signe les jetons de tout l'écosystème. Une archive substituée là, et l'identité entière tombe.

Une correction, d'abord

J'avais écrit que « Keycloak ne publie aucune somme de contrôle ». Faux, et l'exploitant l'a relevé. Mesuré ensuite :

26.6.2 :  .sha1 200   .md5 200   .asc 200
26.7.0 :  .sha1 404   .md5 404   .asc 200
26.7.1 :  .sha1 404   .md5 404   .asc 200

Les sommes existaient jusqu'à 26.6.2, puis ont disparu. Et surtout : j'avais raté le .asc — une signature PGP, présente sur toutes les versions, et plus forte qu'une somme. Une somme prouve qu'un fichier n'a pas été corrompu ; une signature prouve qui l'a produit.

Ce qui est établi, et ce qui ne l'est pas

La même clé 861AB50E…6FD6EEBA a signé 26.0.7 (la version alors en production), 26.3.0, 26.6.2 et 26.7.1. C'est une continuité réelle.

Mais aucune source indépendante ne publie cette empreinte : ni keycloak.org/downloads, ni la page getting started, ni SECURITY.md, ni un fichier KEYS. Elle est absente de keys.openpgp.org ; on la trouve sur keyserver.ubuntu.com, qui n'est pas une autorité. Et la somme .sha1 n'ajoute rien : même canal que l'archive et la signature.

On peut donc prouver la continuité, pas l'origine. L'ancre est une décision humaine — et elle est maintenant écrite, versionnée, et vérifiée à chaque téléchargement.

scripts/verifier_signature.py

Trois exigences, chacune contre un contournement précis :

  • la clé publique vit dans le dépôt (roles/serveur_keycloak/files/keycloak-release.asc), versionnée et relue — aucune interrogation de serveur de clés au déploiement ;
  • l'empreinte est épinglée à côté de la version : une rotation de clé en amont devient un échec bruyant qui exige une relecture, pas un remplacement silencieux ;
  • trousseau jetable (GNUPGHOME temporaire) : le trousseau personnel n'est ni lu ni modifié, et deux machines donnent la même réponse.

Il lit VALIDSIG et compare l'empreinte du signataire réel à celle épinglée — se contenter de « bonne signature » laisserait passer une signature valide faite par une autre clé du trousseau.

Éprouvé sur cinq cas : nominal 0 ; artefact altéré d'un octet 1 ; empreinte épinglée différente 1 ; clé du dépôt corrompue 1 ; signature absente 1.

Ce que ça ne prouve pas

Que l'empreinte épinglée soit la bonne. Aucune machine ne peut l'établir. Le script garantit seulement qu'on ne s'en écarte plus sans le voir.

2026-08-10 — make versions-mesurer : l'écart avec l'amont devient lisible

Question de l'exploitant : « ne devrait-on pas prendre les versions les plus récentes ? »

Non, et la réponse tient en trois points. Résoudre « la dernière » au moment du déploiement détruirait la reproductibilité — celle-là même qu'on vient de prouver en rasant et remontant deux écosystèmes. Ça transformerait chaque déploiement en loterie : une heure de reconstruction ne doit pas dépendre de ce qu'un tiers a publié cette nuit. Et six versions majeures de Forgejo ne s'avalent pas en effet de bord d'un make — ça se fait délibérément, avec une sauvegarde avant et une vérification après.

Mais le vrai problème était ailleurs, et l'exploitant avait raison de tirer le fil : l'écart était invisible. Il a fallu quatre requêtes à la main pour découvrir l'état réel :

Composant Épinglé Publié
oauth2-proxy v7.15.3 à jour
Nextcloud 34.0.1 34.0.2
Keycloak 26.0.7 26.7.1
Forgejo 10.0.0 v16.0.2

Le devis ne juge pas, il renseigne. Un retard n'est pas une faute ; il sort donc en 0.

Ce qui le distingue d'une liste écrite à la main : les versions épinglées sont dérivées du dépôt (roles/*/defaults/*_version). La source amont, elle, ne peut pas se dériver — elle dépend de l'éditeur — et vit dans une table. Toute version épinglée sans entrée dans cette table fait sortir en erreur. Sans cette garde, un épinglage ajouté demain vieillirait sans que personne ne le voie, et on croirait tout surveillé alors qu'on ne verrait plus rien. Une exemption reste possible, mais écrite et motivée — deux le sont déjà (la série PHP de Debian, la version PostgreSQL vide par défaut).

Éprouvé dans les deux sens : un serveur_redis_version ajouté temporairement fait sortir en 1 en le nommant ; retiré, retour à 0.

Et il rappelle ce qu'on n'épingle pas. Grafana n'a aucune version dans le dépôt — le 13.1.1 vu dans l'historique apt était simplement ce que le dépôt Grafana servait ce jour-là. Debian, Grafana, smallstep et Icinga prennent tous ce qu'on leur donne au moment du déploiement. Deux régimes coexistent dans la même flotte, et les taire donnerait l'illusion que tout est maîtrisé.

2026-08-10 — raser n'annonce plus des destructions qui n'ont pas eu lieu

Le défaut noté la veille est corrigé. raser lisait l'accusé de réception de l'API et concluait au succès : le DELETE rend un UPID et la main immédiatement, la destruction se fait en tâche de fond, et elle peut échouer après. Le 2026-08-10, six VM ont été rapportées « détruites » alors qu'elles étaient toujours là — la tâche sortait sur VM is locked (clone), un verrou laissé par des clonages interrompus.

Confondre « demande acceptée » et « travail fait » est le pire mensonge possible pour la seule commande destructive du moteur : on croit la place libre, on relance la construction, et rien ne se crée sans qu'on comprenne pourquoi.

_attendre_tache() relit l'UPID, interroge l'état jusqu'à stopped, et rend l'exitstatus réel. Chaque VM est annoncée détruite ou en échec, avec la cause telle que le cluster l'a donnée — et le compte final ne ment plus.

Un test qui exerce le défaut, pas seulement le correctif

test_raser_resultat.py fabrique la situation exacte : un faux cluster qui accepte tout, puis rend une tâche terminée en erreur. raser doit sortir en 1, nommer la cause, et n'annoncer aucune destruction.

Éprouvé dans les deux sens — c'est ce qui distingue un test d'une décoration. Avec l'ancien comportement rétabli temporairement, il échoue en désignant précisément le défaut (« raser a rendu 0 alors que la destruction a ÉCHOUÉ ») ; avec le correctif, il passe. Raccordé à make test, donc rejoué par P02.

C'est le même motif que le clonage corrigé une heure plus tôt, dans l'autre sens : une opération asynchrone dont on ne vérifie pas l'issue. Les deux venaient du passage à des appels d'API directs, où plus rien n'attend à notre place.

2026-08-10 — Le clonage ne s'attendait plus lui-même, et ça a saturé le stockage

Mon optimisation de la veille au soir a mis le cluster à genoux, et la faute est entière.

En remplaçant proxmox_kvm par un appel d'API direct (pour corriger la résolution par nom), j'ai perdu quelque chose que le module faisait pour moi : attendre la fin de la tâche (timeout: 600). POST .../clone rend un UPID et la main immédiatement ; Proxmox copie le disque en tâche de fond.

En séquentiel ça ne se voyait pas — l'attente de SSH qui suit absorbait le délai. En parallèle, c'est tout autre chose : make creer-vm rendait la main pendant la copie, la limite de concurrence ne retenait plus que des processus vides, et les clones s'empilaient. Mesuré : limite à 4, quatorze copies intégrales du gabarit simultanées.

Le symptôme trompait : CPU de l'hyperviseur à 2 %, RAM à 13/62 Gio — et tout ramait. Ce n'était pas la machine, c'était TrueNAS (LVM sur iSCSI). Les 14 hôtes ont échoué, et flotte-creer a refusé de continuer — la garde ajoutée le matin même a fait son travail.

Le clonage attend désormais la fin réelle : il relit l'UPID rendu par l'API et interroge l'état de la tâche jusqu'à stopped, avec un message clair si la sortie n'est pas OK. La limite de concurrence retrouve alors un sens — quatre clones réels, pas quatre coquilles.

Au passage : raser annonçait des destructions qui échouaient

En nettoyant, make raser a rapporté « 6/6 VM détruites » alors que les six étaient toujours là. L'API accepte le DELETE, rend un UPID… et la tâche échoue ensuite sur VM is locked (clone). raser ne lit que la réponse immédiate, jamais le résultat. C'est exactement le même défaut, dans l'autre sens — noté ici, pas encore corrigé.

Ce que les optimisations ont réellement donné

Reconstruction complète de Chezlepro, gabarit déplacé sur CephNVMe (proposition de l'exploitant), concurrence à 3 : 54 min 02 s contre 1 h 12 min 48 s — 26 % de moins, zéro échec, 2 838 tâches.

Phase Avant Après
création des 14 VM 22m08s 17m57s
amorçage PKI + DNS 6m01s 6m07s
six couches 44m39s 29m58s

Le cache d'artefacts est le plus rentable, et de loin : Nextcloud 10m55s → 4m14s, Forgejo 3m42s → 1m33s. Vérifié — skipping sur chaque téléchargement, les fichiers du cache portent toujours leur horodatage d'origine. J'avais annoncé un gain « limité à la part téléchargement » : cette part était bien plus grosse que je ne le croyais, et la décompression bz2 n'était pas le mur que je décrivais.

forks = 20 : socle + durcissement + AC + enrôlement PKI des 14 hôtes, 3m09s → 1m37s.

Gabarit sur NVMe + 3 clones : 1m35s → 1m17s par VM. Le gain le plus modeste — les disques écrivent toujours sur TrueNAS, donc seule la moitié du chemin a été traitée.

L'amorçage n'a pas bougé, et c'est cohérent : deux hôtes l'un après l'autre, aucun levier ne s'y applique.

Sept devis CONFORME après coup, dont le MTU : les quatorze invités naissent à 1450 sans le moindre geste.

2026-08-10 — Le contrôleur télécharge et pousse ; la cible ne tire plus d'Internet

Inventaire mesuré de ce qu'une reconstruction de tenant télécharge — environ 1,5 Gio :

Quoi D'où Taille
nextcloud-34.0.1.tar.bz2 download.nextcloud.com 230 Mio
keycloak-26.0.7 github.com/keycloak 140 Mio
forgejo-10.0.0-linux-amd64 codeberg.org 101 Mio
oauth2-proxy v7.15.3 github.com/oauth2-proxy 18 Mio
image collabora/code Docker Hub 471 Mio
paquets apt Debian + Grafana + smallstep + Icinga le reste, ×14 hôtes

Les quatre premières sont épinglées en version et vont chacune sur UN SEUL hôte. Les retélécharger à chaque reconstruction est un gaspillage, et une dépendance de plus sur le chemin critique — un serveur tiers lent a déjà fait tomber un déploiement de flotte le 2026-08-09, sur le binaire Forgejo précisément.

Pourquoi pousser plutôt que servir un cache

L'exploitant proposait son poste comme cache HTTP. L'intention est juste, mais elle butait sur ce qu'on avait fermé le matin même : les règles sortantes visent !SETOPS_INTERNES, donc les trois blocs privés. Une VM de tenant ne peut plus atteindre le poste. Servir un cache aurait exigé de rouvrir un flux vers le plan d'administration.

L'inversion évite le problème entier : le contrôleur télécharge dans son cache (~/.cache/setops, gardé par un stat — une fois, jamais deux), puis pousse par le canal SSH qui existe déjà. Aucun port, aucun service, aucune règle, aucun couplage.

Effet recherché en prime : ces artefacts deviennent déployables hors ligne une fois le cache rempli. Sur une plateforme qui se veut souveraine, ce n'est pas un détail.

Ce que ça ne couvre pas, et qu'il faut nommer

  • collabora/code, 471 Mio depuis Docker Hub. serveur_collabora installe docker.io et tire une image — la seule entorse à la doctrine « plateforme native, zéro Docker » du dépôt. C'est aussi ce qui explique les règles docker0 du nftables généré. Elle mérite sa propre décision, pas un contournement discret.
  • Les paquets apt, quatorze apt update contre les mêmes dépôts. apt-cacher-ng est la bonne réponse, mais il lui faut un hôte toujours allumé et un flux déclaré : sa place est côté hébergeur, partagé par les tenants — pas sur le poste.

Une mesure qui a contredit mon hypothèse

J'avais avancé que le .zip de Nextcloud décompresserait plus vite que le .tar.bz2. Vérification : 271 Mio contre 230. Il télécharge donc plus pour décompresser moins lentement. Le gain net n'est pas établi — le changement de format reste en attente d'une mesure, pas d'une intuition.

2026-08-10 — Deux optimisations, choisies sur la mesure et non sur l'intuition

Le chronométrage d'une reconstruction complète (1 h 12 min 48 s, phase par phase) a désigné où part le temps. Deux leviers, pris dans l'ordre du gain mesuré.

forks = 20 — les plays multi-hôtes tournaient en trois vagues

ansible.cfg ne déclarait pas forks : défaut 5, pour 14 hôtes. Chaque couche qui balaie la flotte — socle, durcissement, enrôlement PKI, les cinq agents — s'exécutait donc en trois vagues successives.

Les gros rôles n'en profitent pas : Nextcloud (10 min 55 s), Forgejo, Grafana et Keycloak sont sur un seul hôte. C'est bien les couches larges qui payaient.

La création des VM se faisait une par une

14 clones × 1 min 35 s = 22 min 08 s, soit 30 % d'une reconstruction — et ce temps est surtout de l'attente : clone, démarrage, SSH, verrou dpkg, quatorze fois sans recouvrement. flotte-creer en lance désormais quatre à la fois (PARALLELE=n pour ajuster).

La sortie de chaque hôte va dans son propre fichier, recopiée en bloc à la fin. Quatre clones écrivant simultanément sur la même sortie donneraient un journal illisible — un comble après une journée passée à traquer des diagnostics masqués. Le marqueur === Creation VM: <hôte> === reste émis en direct pour suivre l'avancement ; le détail arrive ordonné.

Et un échec n'est pas avalé : le code de retour de chaque hôte est relu, et la cible sort en erreur si l'un d'eux a échoué — sinon _attendre-flotte partirait sur une flotte incomplète.

Gains estimés, à vérifier par la prochaine mesure : ~15 min sur les clones, ~5 min sur les couches larges. Le troisième levier identifié — l'archive Nextcloud en .tar.bz2, décompressée sur un seul cœur — est laissé de côté tant qu'il n'est pas mesuré.

2026-08-10 — Le jeton Keycloak vivait 60 s, et Grafana rendait son échec illisible

Technolibre remonté depuis zéro une seconde fois — 14 hôtes · 2 588 tâches ok · 0 failed. Deux défauts trouvés en chemin, dont un dont la cause était restée non établie le matin même.

Le jeton d'administration Keycloak était pris une fois pour toutes

Il était obtenu dans politique-mdp.yml — le 2ᵉ des neuf fichiers du rôle — et réutilisé jusqu'au 8ᵉ. Or le jeton admin-cli du realm master vit 60 secondes. Entre les deux : six fichiers de travail, dont groupes-ldap.yml et ses reprises espacées de 15 s.

Sur une construction neuve, le temps écoulé dépasse la minute et l'appel suivant se prend un 401. Sur un rejeu, tout est convergé, ça va vite, ça passe. D'où deux échecs le matin même, suivis chaque fois d'un succès au rejeu — ce qui donnait l'illusion d'une course au démarrage de Keycloak. J'avais écrit alors ne pas avoir de mesure qui le prouve ; c'était la bonne prudence, et la cause était l'âge du jeton.

jeton-admin.yml prend désormais un jeton frais là où on s'en sert. C'est gratuit : Keycloak répond en quelques millisecondes en local.

Grafana : un échec transitoire, rendu illisible par systemd

Au premier démarrage, après 67 secondes de migrations, grafana-server a échoué sur failed to create admin user: SQL logic error: no such column: uid — alors que la migration qui ajoute cette colonne était journalisée comme réussie. Base neuve : tout remigre correctement, service actif, colonne présente. L'incident ne s'est pas reproduit, et Chezlepro ne l'a jamais eu.

Je n'ai donc pas corrigé la cause — je ne l'ai pas reproduite. J'ai corrigé ce qui la rendait indéchiffrable :

  • Restart=on-failure venait du paquet sans RestartSec, donc 100 ms : systemd a relancé six fois en une seconde, chaque relance rejouant les migrations sur la même base SQLite. Un échec unique se présentait comme un désastre, et il a fallu remonter tout le journal pour retrouver la première erreur — la seule qui disait quelque chose. RestartSec=10 ;
  • la rotation du compte de secours échouait cinq fois sous no_log en annonçant « the output has been hidden », alors que la vraie cause était ailleurs et parfaitement lisible : le serveur ne démarrait pas. Une attente explicite sur le port précède maintenant la CLI, avec un message qui dit d'aller chercher la première erreur du journal.

Troisième fois dans la journée que no_log masque la cause au moment où elle sert. Le motif est constant, et il mérite d'être retenu : une garde qui protège un secret ne doit pas emporter le diagnostic avec lui.

Sept devis sur Technolibre

MTU, identité, certificats, PostgreSQL, courriel et frontière : CONFORME. Les expositions répondent depuis l'edge ; seul le plancher /etc/hosts du poste manquait.

Le devis du MTU mérite une mention : c'est la première flotte du dépôt à naître au bon MTU sans une seule intervention — le gabarit porte mtu=1, les quatorze invités sont à 1450 dès leur premier démarrage.

2026-08-10 — Le MTU de la zone n'atteignait pas les invités

Question de l'exploitant : « je ne vois nulle part un MTU à 1450 ». Elle était fondée.

Ce qui existait déjà : MTU_OVERLAY_DEFAUT = 1450 dans scripts/underlay.py, dont P23 dérive sa garde (transport ≥ overlay + 50), et que le devis SDN pose sur chaque zone. Vérifié sur le cluster : zones t11 et t17 bien à 1450.

Ce qui manquait : le MTU d'une zone ne se propage pas à la carte de l'invité. Les quatorze VM tournaient à 1500, et cloner_vm_debian.yml ne contenait aucune occurrence de mtu. La VM émettait donc des trames que son propre chemin ne pouvait pas encapsuler — la connexion s'établit, les petites requêtes passent, les grosses réponses restent suspendues. C'est la panne que le registre des flux décrit comme « la plus coûteuse à diagnostiquer », et pour laquelle il déclare l'ICMP « fragmentation nécessaire ».

Pourquoi personne ne l'avait vue : les quatorze VM vivent sur asgard. Deux VM du même hyperviseur communiquent par le pont local, sans encapsulation — rien ne rencontre le 1450. Le défaut serait apparu au premier éclatement de la flotte sur plusieurs nœuds, c'est- à-dire exactement quand il faudra héberger les deux tenants ensemble.

Corrigé en deux endroits, et mtu=1 plutôt que 1450 — la valeur Proxmox qui signifie « hérite du pont » : juste en SDN (1450) comme hors SDN (1500), et encore juste le jour où la fabric passera aux trames jumbo.

  • le gabarit le porte (net0 … mtu=1), ce qui couvre les clonages qui ne passent pas par le playbook — un clone fait à la main, par exemple. Proposition de l'exploitant, et elle est meilleure : elle attrape tous les chemins ;
  • cloner_vm_debian.yml le repose à chaque clone, parce qu'un gabarit se recapture (fait la veille) et que ce qui n'est pas versionné se perd en silence.

make mtu-mesurer — septième devis

Il rattache chaque hôte à sa zone par son pont dérivé (t17serv → zone t17) et lit le MTU attendu dans devis_sdn.py, la source qui configure les zones. Rien n'est saisi. Il a trouvé l'écart sur 14 hôtes du premier coup, et l'a confirmé corrigé.

Ce que j'ai cassé en corrigeant

Appliquer mtu=1 aux cartes de VM en marche a coupé le réseau des quatorze machines d'un coup : Proxmox détache et rebranche la carte, l'invité ne reconfigure pas son interface. Flotte à 0/14 pendant trois minutes.

Ce qui a permis d'en sortir : les VM tournaient et l'agent qemu répondait — un canal indépendant du réseau invité. Redémarrage par l'API, la configuration s'est appliquée proprement au démarrage, 14/14 ensuite.

La faute est d'avoir appliqué à la flotte entière un changement dont je n'avais pas mesuré l'effet à chaud. Dans le playbook, la même tâche s'exécute avant le démarrage du clone : aucun risque. Sur une VM déjà en service : poser la configuration, puis redémarrer — et sur une seule d'abord.

2026-08-10 — Technolibre est debout : six devis, et P35

L'épreuve de portabilité est passée. Un second écosystème souverain complet, monté depuis zéro par le même moteur : 14 hôtes · 2 583 tâches ok · 331 changed · 0 failed. Plan distinct, voûte séparée, realm technolibre, sa propre autorité de certification — et une topologie différente, LDAP et SSO sur des machines séparées là où Chezlepro les co-localise.

Les six devis, sur le déployé :

Devis Verdict
identité CONFORME — realm, fédération, mappeurs, politique
certificats CONFORME — aucun certificat servi en fin de vie (2 réserves latentes, identiques chez Chezlepro)
PostgreSQL CONFORME — chiffrement imposé, aucun réseau hors du supernet dérivé
courriel CONFORME — la chaîne tient, de la résolution LDAP à la boîte
frontière CONFORME — 55 lignes, 0 écart, 17 services livrés comme déclarés
expositions les 6 services répondent depuis l'edge ; le poste ne résout pas encore technolibre.internal (6 entrées /etc/hosts absentes — le « plancher »)

Le devis d'identité lisait l'annuaire par un socket local, depuis l'hôte SSO

Sixième défaut, et le plus instructif : le play tourne sur serveur_keycloak et interrogeait LDAP en ldapi:/// — un socket UNIX local. Cela ne fonctionnait que par co-location accidentelle. Un tenant qui sépare l'annuaire du SSO faisait échouer le devis sur « Failed to import the required Python library (python-ldap) » : l'hôte SSO n'a évidemment pas de client LDAP. Les deux lectures sont désormais déléguées à l'hôte dérivé par resoudre_annuaire. Co-localisés, la délégation est un aller-retour sans effet ; séparés, elle est la seule façon que ça marche.

P35 — une application qui exige une base en a une, et on le sait en deux secondes

resoudre_base porte déjà la garde (D-72), mais elle s'est déclenchée à la 92ᵉ tâche de collab-01, après quarante minutes, pour un écart entièrement lisible dans le plan. D-75 : ce qui est statiquement lisible se prouve statiquement.

Rien n'y est codé en dur — et c'est ce qui la rend juste. Les rôles qui exigent une base sont ceux qui incluent resoudre_base ; le groupe qu'ils réclament est lu dans le défaut de la variable qu'ils passent, jamais déduit de leur nom : serveur_icingaweb2 réclame la base de serveur_icinga, et une preuve qui aurait supposé « rôle = groupe » aurait crié sur un cas parfaitement sain. Les noms acceptables suivent la même règle que le résolveur : le groupe, ou toute application qui déclare ce groupe.

Éprouvée dans les deux sens et sur les deux tenants — dont les registres n'ont pas la même portée (noms courts chez l'un, noms de groupe chez l'autre) : base retirée → ÉCHEC la nommant ; restaurée → OK. P01–P35.

2026-08-10 — Épreuve de portabilité : monter un SECOND tenant révèle trois défauts invisibles

Les deux reconstructions from-zero de la semaine rebâtissaient Chezlepro sur son propre matériel : une preuve de reproductibilité, pas de portabilité. La vraie épreuve est un second tenant — Technolibre, index 11, plan distinct (id-ldap-01, id-sso-01, sup-01… là où Chezlepro a idm-01, mon-01), voûte séparée, sur le même cluster.

Elle a trouvé en une heure trois défauts qu'un seul tenant ne pouvait pas révéler.

1. Le clonage résolvait par NOM — et ne faisait rien

community.general.proxmox_kvm cherche d'abord une VM portant le name demandé. S'il en trouve une, il conclut « elle existe déjà », rend ok et ne clone rien — aucune tâche n'apparaît même côté cluster. Or les noms courts sont volontairement identiques d'un tenant à l'autre : même fonction, même nom, c'est le pool qui restitue l'appartenance. Le premier clone de Technolibre, backup-01, est donc tombé sur le backup-01 de Chezlepro, n'a rien fait, et l'attente a expiré sur une configuration qui n'existerait jamais.

Mesuré, pas déduit : id-ldap-01 et sup-01 — noms que Chezlepro n'a pas — se sont créés du premier coup ; backup-01 échouait systématiquement. Zéro VM créée, zéro tâche qmclone au cluster.

Le clonage passe désormais par un appel d'API ciblé par VMID : recensement des VM, puis POST /nodes/<n>/qemu/<gabarit>/clone seulement si le VMID cible est libre. Plus aucune résolution par nom.

Deux défauts de ce correctif, trouvés en le mesurant — et tous deux du même genre que ce qu'il corrige :

  • le corps de la requête était assemblé en Jinja avec >-, ce qui rend une chaîne : le pool s'est perdu en route et la VM est née hors de son pool, sans un mot. Réécrit en mapping YAML avec omit ;
  • l'application du gabarit de calcul expirait à 5 s de lecture — le nœud vient de terminer un clone complet. La VM restait aux valeurs du gabarit (2 cœurs / 2 Go au lieu du plan), en silence. Six tentatives espacées de 10 s.

Et no_log: true a masqué la cause au moment précis où elle servait : l'échec se lisait « the output has been hidden », et il a fallu interroger le cluster à la main. Les deux attentes disent maintenant ce qu'elles ont constaté, sans révéler l'en-tête d'autorisation.

2. Le GUI détruisait des intrants

Dans ecrire_intrants, la branche identite était la seule sur quatre à écrire par-dessus le disque au lieu de fusionner. Un enregistrement du panneau a supprimé dns_amorcage et amorcage_acces_courriel de Technolibre. Sans le premier, une VM naît sans résolution et apt ne peut rien installer ; sans le second, le déploiement s'arrête sur la garde de amorcage_acces (D-72). Le même geste sur Chezlepro aurait mangé les mêmes clés.

3. Le verrou de raser n'était prouvé que pour un tenant

Le faux cluster de scripts/tests/test_raser.py codait en dur les VMID de Chezlepro. Monté sur un autre tenant, le test rendait 0 au lieu de 2 — « aucune VM du plan n'est présente, rien à faire ». Le verrou de la seule commande destructive du moteur passait donc au vert sans rien éprouver. Le faux cluster fabrique désormais la collision sur le plan courant, quel qu'il soit.

4. Le repli nftables survivait à la bascule — et annulait tout

Le socle pose l'un de deux fichiers dans /etc/nftables.conf : le ruleset dérivé (make flux, table setops_flux) s'il existe, sinon un gabarit de repli plat (table setops_filter) qui n'ouvre que le 22. Ce sont des alternatives, jamais des couches.

Mais le rechargement est nft -f, qui ajoute sans purger, et le fichier dérivé retirait soigneusement setops_flux… jamais setops_filter. Un hôte passé du repli au dérivé se retrouvait donc avec deux chaînes input sur le même hook, toutes deux en policy drop. Le paquet traverse les deux : seule l'intersection de leurs accept passait — le 22 et l'ICMP, rien d'autre.

Le symptôme était parfaitement trompeur : l'AC debout, son port 8443 en écoute, sa règle ip saddr { … } tcp dport 8443 accept posée et acceptante, l'ICMP entre les deux VM à 0,15 ms — et step ca bootstrap qui expire. Il a fallu lire le ruleset entier pour voir la seconde table.

Le fichier dérivé retire désormais les deux tables (le repli, lui, portait déjà flush ruleset). Pas de flush ruleset côté dérivé : c'est un choix du dépôt pour ne pas détruire de tables étrangères, et il est respecté.

Ce qui l'avait rendu possible : make reconstruire ne générait jamais les flux. Sans eux, flux-genere/ est vide, le repli est posé, et l'hôte passe ensuite au dérivé — exactement la bascule qui casse. make flux est maintenant la première étape de reconstruire. Invisible sur un écosystème déjà construit, dont les .nft traînent d'une exécution précédente.

no_log a masqué la cause trois fois dans la même journée

Le clonage, l'attente de configuration, et la déclaration des URI de déconnexion Keycloak : trois échecs lus « the output has been hidden », dont un au terme d'un déploiement de 157 tâches. Le mot-clé protège de vrais secrets — un jeton d'API porté par un en-tête — et on ne peut pas simplement l'enlever.

Les trois tâches extraient donc désormais le verdict à part : ce qui a échoué, avec quel code et quel message, sans jamais toucher aux en-têtes. Une garde qui protège un secret ne doit pas emporter le diagnostic avec lui.

Ce qui relevait des données du tenant, pas du moteur

L'instance datait d'avant plusieurs évolutions, et les preuves statiques les ont toutes attrapées avant le déploiement : client_unbound déclaré au plan alors qu'il est devenu une intégration universelle ; amorcage_acces_courriel absent ; gabarit 99999 alors que le recapturé porte 99998 — le premier clone aurait échoué ; parefeu_interface: false, qui aurait laissé le pare-feu est-ouest inerte sans le dire ; collab-01 à 1 cœur / 1 Go au lieu du dimensionnement dérivé.

2026-08-10 — P34 : la convention « chaque document déclare son lecteur » devient une garde

La refonte de ce matin posait une convention. Une convention qu'on n'outille pas tient tant que quelqu'un y pense — c'est exactement le raisonnement de D-70, et voici son application au corpus documentaire. D-74, gardée par P34.

L'état de départ, mesuré : 2 documents sur 34 déclaraient leur lecteur. Les 32 autres disaient leur sujet. C'est ce qui avait enfoui le runbook de reprise le plus utile du dépôt au §6 de autorisation.md.

Les 38 documents le déclarent désormais, et le lecteur a été déterminé document par document — pas collé au gabarit. Trois familles : l'exploitant (les devis, la migration de tenant, le cycle de vie des VM, le gabarit d'or, autorisation.md §6…), le mainteneur (les conceptions, les registres, la carte), et deux cas à part — ecosysteme-chezlepro.md s'adresse au lecteur externe, MISE-A-JOUR-CODEX-CLAUDE.md à l'agent IA qui reprend le dépôt.

Deux exemptions, dérivées et non listées — un chemin en dur aurait vieilli à la première page ajoutée :

  • un document qui s'annonce généré ne se lit pas, il se régénère. On le reconnaît à sa propre en-tête (« Généré par », « ne pas éditer à la main ») : 13 documents, tous réellement générés — vérifié un par un, aucun document écrit à la main n'est exempté par accident ;
  • un fragment sans titre # n'est pas un document.

La preuve ne lit que l'en-tête, jamais le corps : une mention de « Pour qui » perdue au milieu d'une page ne serait pas une porte. C'est aussi ce qui empêche frontiere-opnsense.md et plan-et-generation.md — qui parlent de génération dans leur corps — d'être exemptés à tort.

Éprouvée dans les deux sens, parce qu'une garantie qu'on n'a jamais vue dire non est une habitude, pas une garantie. Elle a d'abord échoué toute seule à sa première exécution, en nommant deux documents que mon inventaire avait manqués (protocole-operateur-independant.md, reference-avant-reconstruction-2026-08-08.md — tous deux dans docs/audit/, hors de mon motif). Puis test négatif délibéré : déclaration retirée de meta-classe.md → ÉCHEC le nommant précisément ; restaurée → OK.

Ce qu'elle ne teste pas : que le lecteur déclaré soit le bon. Ça se juge en revue. Elle garantit qu'on a dû y penser — ce qui est précisément ce qui manquait.

P01–P34, et les comptes périmés corrigés au passage (AGENTS.md et devis-services.md annonçaient encore 30 preuves).

2026-08-10 — Refonte documentaire : on n'arrive pas avec un sujet, on arrive avec une situation

La documentation était organisée par sujet — identité, courriel, DNS, PKI, sauvegardes. C'est l'organisation juste pour de la référence. Mais personne n'arrive avec un sujet. Il y a exactement quatre situations, trois avaient déjà une porte, et deux de ces trois ne s'annonçaient pas :

Situation Lecteur Porte
« c'est quoi ? » qui découvre README.md
« je viens d'hériter » l'exploitant wiki/Reprendre-l-écosystème.md — n'existait pas
« je dois modifier » le mainteneur docs/carte-set-ops.md — le dit désormais
« j'apprends le métier » l'apprenant wiki/Home.md — le dit désormais

Une seule page créée, et elle ne contient presque rien en propre : un ordre et des renvois, en cinq temps. Dans quel état tu hérites (les six devis avant tout geste) ; entrer (la clé de voûte, l'amorçage, la racine qui mène à la mauvaise console, l'AC) ; de quoi c'est fait (à demander au plan, pas à lire) ; quand ça casse ; ce qui va te mentir.

Aucun fichier déplacé, aucune réécriture du wiki. Les liens, l'historique git et les renvois croisés valent plus qu'un rangement.

La convention qui empêche la rechute : chaque document déclare son lecteur en première ligne — pas un sujet, un lecteur et sa situation. C'est ce qui manquait vraiment : autorisation.md contient un runbook de reprise parce que le sujet est l'autorisation, et personne ne va l'y chercher. Un document qui déclare son lecteur se range tout seul, et un intrus s'y voit.

Le wiki devient la porte unique du lecteur, le dépôt reste la source. wiki-publier fait un delete puis recopie : une page modifiée dans l'interface de la forge est détruite à la publication suivante. La règle est maintenant écrite dans README.md, Home.md et la page de reprise — elle ne l'était nulle part.

Ce qui n'a finalement pas été écrit, et pourquoi. La page « Ce qui va te mentir » était prévue. Trois des cinq pièges qu'elle devait cataloguer ont trouvé un meilleur domicile pendant qu'on travaillait — le connect() vers le vide (frontiere-opnsense.md, et frontiere-mesurer porte désormais le contrôle qui tranche), le make prouver vert (Vérifier le déployé), le banner exchange. Les deux orphelins s'adressent à qui écrit du code, pas à qui reprend l'exploitation. Une page séparée aurait redit ce que trois autres disent déjà.

Sa substance survit : la section ⑤ de la page de reprise porte la règle qui les relie — vérifier l'instrument avant d'accuser le composant, une sonde porte toujours un contrôle — et renvoie chaque signal faux à son domicile.

Un trou trouvé en vérifiant mes propres renvois. Le banner exchange n'était documenté nulle part où on le cherche : un commentaire du Makefile et trois entrées de ce fichier. Écrit en runbook §3, avec ses trois causes par fréquence et ce qui tranche dans l'ordre.

Vérifié : chaque cible make et chaque lien contrôlés un à un (make ca-installer n'existe pas — c'est ca-racine + ca-empreinte) ; les cinq liens du README résolvent ; prouver.py 0 ; plan de recette inchangé.

2026-08-09 — La frontière est étanche : 56 lignes conformes, dans les deux sens

L'exploitant a retiré la dernière règle héritée, celle qu'il avait lui-même étiquetée PAS SUPPOSÉ -> ACTION REQUISE. make frontiere-mesurer : CONFORME, code 0. Tout ce qui est déclaré est livré, tout le reste est refusé — y compris collab-01:9980, le seul qui livrait vraiment un HTTP/1.1 200 OK depuis le poste.

Et mon instrument avait tort, pas la frontière. Il comptait 38 écarts. Il concluait depuis le client : connexion établie ⇒ la bordure a relayé. Faux, et vérifié à la destination — pendant que le poste tenait une connexion « établie » vers idm-01:389, idm-01 n'en voyait aucune ; collab-01 n'en voyait aucune sur 9980. La frontière répond elle-même à la poignée TCP, pour toute destination qu'elle route, sans jamais relayer.

Le devis raisonne désormais sur la livraison seule : un port est conforme s'il livre quand il doit livrer et ne livre rien quand il ne doit pas. Ce que fait la poignée TCP ne regarde personne. Le contrôle, en conséquence, ne rend le relevé NUL que s'il livre des données — qu'il ressorte AMBIGU est attendu ici, et le rapport le dit en toutes lettres à chaque exécution. Cette relaxation rend aussi le sens sortant mesurable : il était déclaré NUL en permanence.

Un flux publié n'est pas forcément fait pour un poste de travail. Nouveau mot-clé poste: false dans meta/flux.yml : le 25 entrant de Postfix est un flux serveur à serveur (les MX distants). La frontière l'étendait au VLAN d'administration, où le nftables de l'hôte le refusait — deux couches qui ne déclarent pas la même politique, et une politique qu'on ne peut plus lire. Deux règles retirées. Le mot-clé vit avec le rôle, qui sait ce que son port veut dire ; le générateur ne connaît toujours aucun numéro de port.

Vérifié : frontiere-plan sans écart (41 règles, 12 routes), flotte 14/14, frontiere-mesurer CONFORME, prouver.py 0.

2026-08-09 — Un sixième devis : « ce qui n'est pas déclaré est-il refusé ? »

devis_expositions.py pose la question positive — chaque exposition déclarée répond-elle. Il manquait la négative, et ce n'est pas la même : un pare-feu peut très bien servir tout ce qu'on lui demande et laisser passer tout le reste. make frontiere-mesurer la pose.

Les cibles ne sont pas saisies : ce sont les ports réellement en écoute dans la flotte, relevés par le playbook. Sonder un port fermé ne prouverait rien du pare-feu — le refus viendrait de la machine. La politique attendue non plus : elle est lue dans devis_opnsense.py, la source même qui configure la frontière.

scripts/sonde_tcp.py refuse de conclure. Deux principes, tirés des trois faux diagnostics de la semaine :

  1. Un contrôle avant tout verdict — une adresse où personne n'écoute. Si elle répond, le relevé est déclaré NUL et aucun verdict n'est rendu. Mieux vaut pas de mesure qu'une mesure fausse.
  2. Établir n'est pas livrer. La sonde fait parler le service : bannière, sinon requête HTTP minimale, sinon poignée TLS. Si rien ne revient, le verdict est AMBIGU — pas « ouvert ». LDAP et PostgreSQL attendent un message bien formé qu'on ne fabrique pas ici, et un synproxy se comporte exactement pareil.

Le verdict distingue deux natures d'écart, qui n'appellent pas le même geste : refusé attendu mais connexion établie = la bordure a relayé, c'est le trou ; livré attendu mais 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.

Deux pièges rencontrés en le construisant, tous deux corrigés et commentés sur place :

  • La sonde « tenant » s'exécutait en réalité sur le poste : dans un play connection: local, un delegate_to hérite de cette connexion. Elle rendait donc la frontière joignable depuis un tenant — ce qu'elle est, depuis le VLAN d'administration. Vérifié à la main avant de la croire : TimeoutError depuis backup-01 comme depuis infra-dns-01. Même famille que le connect() — vérifier d'où l'instrument mesure.
  • Le délai de lecture de 2 s faisait ressortir le 25 d'un Postfix parfaitement sain en AMBIGU : postscreen retarde sa bannière exprès. Porté à 8 s, compensé par du parallélisme — raccourcir aurait fabriqué de faux écarts.

Premier verdict, la frontière étant encore en l'état : 38 écarts. Trente-sept sont « la bordure a relayé » (la règle héritée étiquetée PAS SUPPOSÉ -> ACTION REQUISE laisse passer le VLAN d'administration vers tout port en écoute), dont un livre vraiment — collab-01:9980, Collabora, répond HTTP/1.1 200 OK depuis le poste. Le trente-huitième est de l'autre nature : la frontière autorise edge-mta-01:25 depuis le VLAN d'administration, mais l'hôte le refuse. Le relevé sortant est déclaré NUL, son contrôle ayant répondu.

2026-08-09 — « La frontière ne doit jamais laisser passer de trafic impertinent » — validé, et deux trous fermés

Exigence de l'exploitant, validée à l'instrument : de vraies requêtes applicatives contre des destinations que la politique interdit, jamais un connect().

Le transit tenant était déjà correct. Depuis une VM : 192.168.11.41:22 (hyperviseur), 10.0.0.1:22 (frontière), 10.0.0.17:22 (poste), 192.168.11.41:8006 (Proxmox) — tous muets. Seul ce qui est déclaré passe. Mieux : le 25 sortant passe depuis edge-mta-01 (bannière 220 mx.google.com ESMTP) et est refusé depuis infra-dns-01 — le filtrage est bien par hôte source, pas par tenant.

Trou n° 1 — nos flux sortants visaient any. « Vers Internet » n'excluait ni le plan de gestion, ni la frontière, ni le supernet du voisin. Mesuré : depuis une VM du tenant, https://10.0.0.1/ — la console d'administration du pare-feu — répondait. Autorisé par notre propre règle. Les dix-huit règles sortantes visent désormais !SETOPS_INTERNES, une destination niée valant les trois blocs privés RFC 1918. Pas la liste de nos réseaux : elle laissait dehors 192.168.11.0/24, le plan de gestion hérité — et une exclusion incomplète ne protège rien. Un réseau interne ajouté demain est couvert sans rien changer.

Vérifié après application : 10.0.0.1:443 bloqué depuis les deux VM testées, sortie web, DNS public et SMTP vers un MX public toujours passants, flotte 14/14.

Trou n° 2 — le défaut-deny du LAN n'avait aucun effet. Mesuré depuis le poste : le 443 d'un nginx répondait alors que seul le 22 est déclaré. La cause est la règle d'usine Default allow LAN to any rule, qui autorise tout depuis le VLAN d'administration. Elle n'est pilotable par aucune API — vérifié : zéro règle non-Set-OPS visible côté API. Sa désactivation appartient donc à l'exploitant, dans l'interface.

Ce que Set-OPS pouvait faire, et fait : déclarer les flux d'administration légitimes pour que cette désactivation ne coupe pas l'exploitant de ses propres services. Un service publié est joignable depuis Internet par le WAN, mais aussi depuis le VLAN d'administration où se trouve son poste ; ce second chemin ne reposait jusqu'ici que sur la règle d'usine. Dix règles ajoutées sur l'interface de gestion (80, 443, 993, 25 et ICMP frag-needed vers les hôtes concernés, par tenant). Vérifié : curl vers le nginx du tenant rend 302.

Ce qui reste, et qui n'est pas à nous : tant que Default allow LAN to any rule est active, le VLAN d'administration atteint tout. Les règles qui la rendent superflue sont maintenant en place — la désactiver est un geste d'interface, à faire les yeux ouverts.

2026-08-09 — La frontière ne déclare plus que ce qui existe, et l'applicateur possède enfin ses routes

Suite directe de l'enquête ci-dessous, menée jusqu'au bout à l'instrument plutôt qu'à l'hypothèse. Trois corrections, dans l'ordre où la mesure les a imposées.

1. Retrait des deux routes /16 de la frontière. Les douze /24 réellement attribués étant en place, les supernets ne servaient plus qu'à envoyer vers le nœud de sortie des destinations qui n'existent nulle part. Retirées par l'API après vérification que les quatorze hôtes planifiés tombent tous dans les six /24. Flotte : 14/14 avant, 14/14 après.

2. Les alias de tenant valaient le supernet. Le retrait des /16 n'a rien changé au symptôme, ce qui a désigné le vrai coupable : SETOPS_TENANT_CHEZ17 valait 10.27.0.0/16, donc nos propres règles autorisaient admin → tout le /16:22. L'état pf portait la description de notre règle. Les alias énumèrent désormais les sous-réseaux attribués — même geste que pour les routes, et pour la même raison. Ils servent à la fois de destination aux règles et de source au NAT sortant : les deux se resserrent ensemble.

3. Une garde pour que les deux ne divergent plus. verifier() exige maintenant que l'ensemble des réseaux routés et l'ensemble des réseaux autorisés coïncident exactement. Un alias plus large laisse le filtre approuver l'inexistant ; un alias plus étroit fait acheminer vers ce que le filtre refuse. Les deux pannes se voient au devis, plus à l'usage. Attachée à P24, qui ne vérifiait jusqu'ici que la traduction NAT.

Et le symptôme, alors ? Il subsiste, et il n'est ni dans nos règles ni dans nos routes. L'état pf porte désormais le nom de la règle d'usine Default allow LAN to any rule, qui répond au SYN à la place de la destination. Mesure qui tranche : depuis une VM du tenant, 10.99.99.99 et 172.31.99.99 — des adresses qui n'appartiennent à personne — « s'établissent » en 1 ms, et aucune ne rend de bannière SSH, quand la vraie VM rend SSH-2.0-OpenSSH_10.0p2. Le connect() ne mesure rien sur ce chemin, quel que soit le point de départ ; seule une requête applicative tranche. Les règles héritées appartiennent à l'exploitant : Set-OPS n'y touche pas.

Les routes sont enfin réconciliées. Je les avais posées avec un script hors dépôt : rien ne les comparait au devis, et leur disparition n'aurait été vue par personne — le défaut exact que cet applicateur existe pour empêcher. appliquer_opnsense.py les traite maintenant comme les règles et le NAT : identité portée par la description (setopsroute:<tenant>:<réseau>-><saut>), création avant retrait, périmètre strict. Le nom de la passerelle est résolu depuis l'adresse du prochain saut plutôt que redemandé en intrant. Les douze routes existantes ont été réétiquetées en place — aucune coupure.

Vérifié : make frontiere-plan → « la frontière dit déjà ce que le devis dit », 12 routes inchangées, flotte 14/14, prouver.py code de sortie 0.

2026-08-09 — Le connect() qui « ne prouvait rien » n'était pas de l'anti-usurpation

L'exploitant : « je ne trouve pas ça normal ». Il avait raison, et pendant deux jours nous avons tous les deux attribué ce comportement à une fonction d'anti-usurpation de la frontière — moi le premier, et je l'avais même consigné comme tel.

Mesuré, pas raconté. Depuis le poste, quatre connexions sur quatre s'établissaient, y compris vers une adresse où aucune machine n'existe. Mais vers des réseaux hors du tenant, tout était refusé — donc rien n'interceptait globalement. Et depuis l'intérieur du tenant, le comportement était correct partout où un VNet existe : hôte absent → refusé, port fermé → refusé. L'anomalie ne touchait que les portions de supernet non couvertes par un VNet.

La cause était une route manquante, pas un pare-feu.

vrf_t17 : les six /24 des VNets, puis default -> 10.0.4.1
          RIEN pour le reste de 10.27.0.0/16

Une adresse non attribuée sortait donc du VRF par le défaut, atteignait la frontière, qui la renvoyait à l'hyperviseur — où elle arrivait dans la table principale, pas dans le VRF, et repartait vers 192.168.11.254, la passerelle du réseau d'administration. ip route get 10.27.99.99 le disait en une ligne.

Corrigé où le dépôt a la main : strophe_frr pose désormais, dans chaque VRF, un puits sur le supernet du tenant — moins spécifique que les /24 de ses VNets, donc invisible au trafic légitime. Dérivé du seed, comme tout le reste.

depuis le tenant, apres :   10.27.99.99 refusee   10.27.18.99 refusee   10.27.18.21 ETABLIE

Une machine du tenant ne peut plus atteindre le réseau de gestion par une faute de frappe. C'était le vrai risque, et il est fermé.

Ce qui reste, et que je ne corrige pas sans arbitrage. Depuis le VLAN d'administration, le connect() réussit encore : ce trafic n'entre jamais dans le VRF. OPNsense route tout 10.27.0.0/16 vers l'hyperviseur, dont la table principale ne connaît que les six /24. Deux remèdes possibles — un puits symétrique dans la table principale, ou n'annoncer à la frontière que les /24 réellement attribués. Le premier touche la table qui porte l'administration des hyperviseurs ; ce n'est pas un geste à faire de sa propre initiative.

La leçon dépasse la route. Nous avons expliqué pendant deux jours un symptôme par une cause plausible et fausse, et cette explication est entrée dans la documentation. Ce qui l'a défaite n'est pas un raisonnement plus fin : c'est d'avoir mesuré depuis deux points de vue différents. Un seul point de vue donne une histoire cohérente — souvent la mauvaise.

2026-08-09 — Le wiki rattrape ce que la reconstruction a appris

Le wiki datait du 3 août — six jours avant tout ce qui précède. Vérifié avant d'y toucher : toutes les cibles make qu'il cite existent, aucune commande morte. Il était factuellement plus sain que craint.

Deux corrections, dont une qui compte.

La-preuve.md annonçait « P01–P21 » ; le harnais est à P33. Les trois nouvelles sont ajoutées au tableau des classes d'erreur — et surtout, la page dit désormais ce que ces preuves ne font pas : elles sont toutes statiques, elles lisent le dépôt, et c'est dans cet angle mort qu'une AC est restée expirée huit heures sous un harnais vert.

Infra-as-Code-et-idempotence.md enseignait l'idempotence comme acquise — « redéployer un rôle déjà en place → changed=0 ». Vrai rôle par rôle, faux à l'échelle de la flotte, ce que personne n'avait mesuré. La page enseigne maintenant depuis les chiffres (924 → 17 → 0) et raconte ce que les 17 cachaient : un service mort depuis des semaines, et un secret que le dépôt faisait tourner à chaque déploiement. Elle explique aussi pourquoi il faut vérifier le zéro — 2266 tâches exécutées contre 2152, donc convergence et non silence.

Nouvelle unité : Vérifier-le-déployé. C'est la notion que la journée a mise au jour et qu'aucune page ne portait : la différence entre valider du code et vérifier un système. Elle enseigne les trois règles qui séparent un devis utile d'un devis décoratif — ne jamais redéclarer ce qu'on vérifie, faire une vraie requête plutôt qu'un connect(), et se méfier d'un code de retour pris pour un verdict — puis renvoie au vocabulaire commun (drift detection).

Sa dernière consigne est celle que je retiens de ces deux jours : chercher, dans son propre outillage, une vérification qui n'a jamais échoué, et se demander si c'est parce que tout va bien ou parce qu'elle ne regarde rien.

2026-08-09 — Idempotence de la flotte : zéro, et c'est un zéro qui veut dire quelque chose

                      plays   taches ok   changed
rejeu depuis zero       61        2152       924
2e passage              30        2249        17
3e passage              30        2266         0

Zéro tâche changed, zéro échec, sur les quatorze hôtes. Le dépôt n'avait jamais fait ce test à l'échelle de la flotte.

Vérification du zéro, parce qu'un zéro peut aussi signifier que les rôles ne font plus rien : plus de tâches se sont exécutées au passage à vide qu'au rejeu — 2266 contre 2152. Elles ont toutes tourné et toutes trouvé le système conforme. Un zéro obtenu avec moins de tâches aurait dit l'inverse.

Ce que ce test aura coûté et rapporté. Trois défauts trouvés, dont deux n'étaient pas des défauts d'idempotence mais des pannes silencieuses : node_exporter mourait à chaque renouvellement de certificat sur les quatorze hôtes, et chaque déploiement invalidait les jetons OAuth2 de la forge. Aucune des deux ne se signalait autrement — c'est le compte de changed qui les a fait apparaître.

Ce chiffre devient la ligne de base. Un déploiement futur qui rapporte changed sur une flotte non modifiée signale désormais quelque chose. Tant que le fond était à 17, ce signal était noyé.

2026-08-09 — Les deux dernières tâches non idempotentes, et ce qu'elles cachaient

Prometheus — une liste non ordonnée. intersect rend un ensemble, dont l'ordre d'itération n'est pas stable d'un processus Python à l'autre. Le fichier de configuration se rendait donc différemment à chaque passage — mêmes quatorze cibles, ordre différent — et Prometheus redémarrait pour rien. Trié sur les hôtes : ordre déterministe et lisible. Second passage à changed=0.

Forgejo — le dépôt faisait tourner un secret du service. JWT_SECRET est généré par Forgejo au premier démarrage et ajouté par lui à la fin d'app.ini. Le gabarit ne le portait pas : chaque rendu l'effaçait, Forgejo en générait un nouveau au redémarrage, et le passage suivant recommençait.

Ce n'était donc pas du bruit : chaque déploiement invalidait les jetons OAuth2 émis par la forge. Le rôle relit maintenant le secret avant de rendre et le repose. Vérifié : même empreinte avant et après un déploiement, changed=0 aux deuxième et troisième passages.

Trois erreurs de méthode de ma part, dans cette seule enquête, et elles méritent d'être écrites :

  1. J'ai conclu « le diff est vide, donc le contenu est identique » — alors que no_log masquait le diff. Toute mon hypothèse sur le mode 0640 reposait là-dessus.
  2. J'ai appliqué un str.replace sur le gabarit sans vérifier qu'il avait pris. La section [oauth2] n'existait pas : le remplacement n'a rien fait, j'ai affiché un message de succès, et j'ai interprété trois passages d'essai sur cette base.
  3. J'ai gardé une expression Jinja qui fonctionnait en isolation mais échouait sur l'hôte, sans pouvoir la diagnostiquer parce que no_log — indispensable, c'est un secret — masquait l'erreur. Remplacée par une forme plus simple.

La leçon commune est celle de la journée, retournée contre moi : vérifier l'effet, pas l'intention. Un replace qui ne trouve rien réussit silencieusement, exactement comme kcadm -s sur une map.

2026-08-09 — Le passage d'idempotence trouve une panne, pas une imperfection

17 tâches changed au second passage, contre 924 au rejeu depuis zéro. La flotte converge à 98 %. Mais les 17 restantes ne sont pas du bruit — l'une d'elles cachait un service mort.

13x  client_metrique : Activer et demarrer node_exporter
 1x  serveur_prometheus : Deployer la configuration Prometheus  -> redemarrage
 1x  serveur_forgejo : Deployer app.ini                          -> redemarrage

Treize hôtes sur quatorze redémarraient node_exporter à chaque passage. Pas parce que la tâche est mal écrite : parce qu'Ansible le trouvait arrêté et le ressuscitait. Le dump du module le disait sans ambiguïté — ActiveState: inactive, SubState: dead, ExecStart ... code=killed ; status=1/HUP.

La cause. Le script de synchronisation du certificat faisait systemctl try-reload-or-restart prometheus-node-exporter. Cette commande recharge si l'unité déclare un ExecReload — et Debian en déclare un : kill -HUP $MAINPID. Or node_exporter ne sait pas se recharger : il meurt sur SIGHUP.

Le commentaire du script énonçait l'hypothèse inverse — « node_exporter relit le cert à chaud ; un reload suffit ». C'est l'hypothèse qui était fausse, pas le code.

Conséquence, jusqu'à aujourd'hui : à chaque renouvellement de certificat — toutes les 24 h — la collecte de métriques s'arrêtait sur toute la flotte, et rien ne le disait. Elle repartait au déploiement suivant, ce qui rendait la panne invisible à qui déploie souvent.

Mesuré plutôt que supposé, sur backup-01 :

systemctl reload alloy                    -> active
systemctl reload loki                     -> active
systemctl reload prometheus-node-exporter -> INACTIVE

Seul node_exporter est concerné ; alloy et loki honorent leur ExecReload. Le motif try-reload-or-restart reste donc valable ailleurs — mais il fait confiance à une promesse de l'unité que le binaire peut ne pas tenir, en silence.

Corrigé en restart. Preuve : synchronisation déclenchée sur les quatorze hôtes, quatorze active.

2026-08-09 — Plus aucun get_url sans garde : la dépendance externe est comptée

Arbitrage rendu par l'exploitant : garder aussi les clés de signature. Zéro get_url sans garde dans le dépôt, contre neuf ce matin.

avant : 6 serveurs tiers x 14 hotes recontactes a chaque deploiement
apres : 0

Conséquence assumée, écrite dans chaque rôle : une rotation de clé amont n'est plus récupérée toute seule. Elle ne passe pas inaperçue pour autant — apt refuse alors le dépôt, bruyamment, et le remède est d'une ligne : supprimer le fichier et rejouer le rôle. C'est un défaut sonore, pas un défaut silencieux ; toute la journée a consisté à transformer les seconds en premiers.

Ce que ça change vraiment : un déploiement de flotte ne dépend plus d'aucun serveur étranger pour ce que la machine possède déjà. La question posée le matin — combien de serveurs tiers doivent être joignables pour redéployer ce que l'on possède ? — a maintenant une réponse mesurée, et c'est zéro.

Reste à éprouver sur un rejeu depuis zéro : sur un hôte neuf le fichier n'existe pas, donc la garde laisse passer le téléchargement. Correct par construction, pas encore mesuré.

2026-08-09 — Dépendances externes sur le chemin critique du déploiement

Le passage d'idempotence a échoué sur Telecharger le binaire Forgejo : « Connection failure: The read operation timed out » — pour 106 Mo déjà présents sur la machine. Un déploiement de flotte tombait parce qu'un serveur tiers était lent.

Recensement de tous les get_url du dépôt : 9 sans aucune garde (ni checksum, ni condition d'existence), 1 avec.

Nature Rôles Traitement
artefact épinglé à une version forgejo, keycloak, nextcloud, oauth2-proxy gardé
clé de signature de dépôt apt client_journal, client_pki, grafana, loki, step_ca à arbitrer
trousseau .deb icinga à arbitrer

Les quatre premiers sont immuables par construction : leur chemin de destination porte la version. forgejo-10.0.0 ne peut pas désigner un autre contenu demain. Les retélécharger n'a aucun sens, et les recontacter encore moins.

Ce qui reste à trancher, et ce n'est pas à moi. Cinq rôles récupèrent une clé de signature apt à chaque passage — cinq serveurs externes × quatorze hôtes, soit soixante-dix allers-retours par déploiement. Les garder par existence supprimerait cette dépendance, au prix de ne plus détecter une rotation de clé. L'argument contraire : une clé tournée casse apt bruyamment, donc l'oubli se voit.

Sur une plateforme qui se veut souveraine, la question mérite d'être posée explicitement : combien de serveurs tiers doivent être joignables pour redéployer ce que l'on possède déjà ?

2026-08-09 — Reconstruction complète d'un seul trait, et une correction que je me dois

Deuxième reconstruction from-zero : zéro échec, zéro injoignable, sur les quatorze hôtes. La première avait demandé six corrections et autant de reprises ; celle-ci est allée au bout d'un seul trait, make myDay compris — création, amorçage du socle, trente couches.

D-71 se lit dans les chiffres : infra-pki-01 à changed=3 et infra-dns-01 à changed=1, parce qu'ils étaient déjà debout, montés par _amorcer-socle avant que la flotte ne démarre. Les couches sont passées à vide sur eux.

Les cinq devis : CONFORME.

La correction. J'ai écrit hier que MaxStartups et MaxSessions « ne viennent d'aucun rôle, elles sont dans le gabarit doré ». C'est faux. J'avais grepé ssh_baseline seul. C'est ssh_hardening qui les pose — depuis toujours, dans templates/20-setops-hardening.conf.j2, en dur :

MaxSessions 2
MaxStartups 5:30:20

Le dépôt déclarait donc bien son durcissement. Ce qu'il ne faisait pas, c'est l'exposer : des valeurs écrites dans un fichier de rendu sont invisibles à qui lit les defaults, et inajustables sans toucher au template. Elles sont désormais des variables (ssh_hardening_max_startups, ssh_hardening_max_sessions).

Et ma première correction avait empiré les choses : en ajoutant ces mêmes clés à ssh_baseline, j'avais créé deux fichiers gérés par deux rôles déclarant la même directive avec des valeurs différentes. Retiré.

Mesure finale, sur les quatorze : logingracetime 20 maxsessions 10 maxstartups 10:30:60 — identique partout, et conforme à ce qui est déclaré.

2026-08-09 — « Banner exchange » : ce n'était ni le réseau, ni l'hôte, ni sshd

La course qui avait interrompu deux déploiements est comprise, cette fois — parce que j'ai gardé le journal.

Ce que la machine dit d'elle-même : un seul démarrage, toujours en cours ; aucune coupure réseau ; aucun redémarrage de sshd. Et le « trou » de 72 secondes dans son journal n'en était pas un — l'entrée qui le referme est ma propre commande de diagnostic. L'hôte n'a rien fait pendant ce temps parce que plus personne ne lui parlait. Ce n'est pas lui qui a disparu, c'est Ansible qui n'entrait plus.

La cause, mesurée :

maxstartups 5:30:20     defaut Debian : 10:30:100
maxsessions 2           defaut Debian : 10

Au-delà de cinq connexions non authentifiées simultanées, sshd en refuse une partie sans envoyer de bannière. Le client attend une bannière qui ne viendra pas et rapporte « Connection timed out during banner exchange » — un message qui accuse le réseau pour un refus applicatif. Les sessions arrivaient à une par seconde juste avant la coupure.

Ces valeurs ne viennent d'aucun rôle : ssh_baseline ne les pose pas. Elles sont dans le gabarit doré, où l'exploitant les a durcies. C'est un réglage de sécurité qui ne vit que dans une image disque — invisible du dépôt, invisible des preuves, et qui gouverne pourtant la voie d'administration.

Corrigé côté client, pas en affaiblissant l'hôte. ansible.cfg n'avait aucune section [ssh_connection] : ni pipelining, ni ControlPersist explicite. Ajoutés, avec un control_path_dir court — un chemin trop long dépasse la limite des sockets UNIX et fait retomber Ansible sur une connexion par tâche, ce qui ramènerait le problème sans qu'on le voie.

Mesure avant / après, sur le même play de 30 tâches :

avant : une session SSH par seconde, des centaines par hôte
après : 0 nouvelle session pour tout le play

Ce qui reste ouvert : MaxStartups et MaxSessions devraient être déclarés par ssh_baseline plutôt que dormir dans le gabarit. Un durcissement qu'aucun fichier du dépôt ne mentionne ne peut être ni revu, ni prouvé, ni expliqué à qui reprend la machine.

2026-08-09 — L'ancien gabarit supprimé, le nouveau porte son nom

Le cluster ne porte plus qu'un gabarit : modeleChezlepro, VMID 99998. Le plan le désigne par nom et par VMID, et les deux concordent.

Trois vérifications avant de supprimer, parce qu'un gabarit doré ne se remplace pas sur une impression :

  • le nouveau avait déjà produit une VM déployée et prouvée — web-dorsal-01, rasée, recréée, déployée sans échec, cinq devis CONFORME ;
  • proxmox_clone_complet: true, et aucune VM ne dépendait du disque de 99999 (vérifié en cherchant base-99999 dans les disques de toutes les VM du cluster) : un clone lié aurait rendu la suppression destructrice pour la flotte entière ;
  • le nom de 99999 confirmé avant le DELETE — le même verrou que raser, pour la même raison.

L'ordre n'était pas indifférent : supprimer d'abord, renommer ensuite. L'inverse aurait laissé deux modeleChezlepro sur le cluster, et le clonage par nom serait devenu ambigu — exactement la collision qui avait fait rapporter ok à proxmox_kvm sans rien faire, le 2026-08-07.

Ce que le nouveau n'a plus, et que l'ancien portait depuis sa fabrication : un /etc/resolv.conf pointant 192.168.12.254, un searchdomain public, et une clé privée d'hôte SSH.

2026-08-09 — client_unbound : survivre à la perte de connexion, sans la masquer

Le déploiement de web-dorsal-01 s'était interrompu autour du redémarrage d'Unbound — « Connection timed out during banner exchange ». Ansible marque alors l'hôte injoignable et abandonne toutes ses couches suivantes, alors que la machine répondait de nouveau une minute plus tard.

Ma première explication était fausse, et je l'ai vérifiée avant de coder : je pensais à la résolution inverse de sshd au moment où le résolveur bascule. sshd -T répond usedns no — elle n'a pas lieu. Et la cause reste inconnue : j'avais écrasé le journal du déploiement raté en relançant, donc il n'est plus lisible. Faute de méthode, pas de raisonnement.

Ce que le dépôt sait déjà de ce symptôme est consigné dans le Makefile (_attendre-hote) : à travers la frontière, le TCP s'établit par proxy SYN, et l'échec se lit « banner exchange » même quand l'hôte n'est simplement pas là. Le message accuse SSH pour un problème d'accessibilité.

Corrigé sans prétendre connaître la cause : le handler de redémarrage tolère la perte (ignore_unreachable), et une tâche exige ensuite le retour de l'hôte (wait_for_connection, 180 s). On attend une condition, pas une durée.

Ce n'est pas masquer une panne : si l'hôte ne revient pas, la tâche suivante échoue franchement. Ce qui change, c'est qu'une absence d'une minute n'annule plus une heure de déploiement.

Et une règle pour moi : ne plus écraser le journal d'un échec avant de l'avoir lu. Le diagnostic de cette course a été rendu impossible par un rm -f de confort.

2026-08-09 — Le plan bascule sur le gabarit recapturé, prouvé par une VM réelle

proxmox_clone_source_nom / proxmox_clone_vmid_modele pointent désormais sur modeleChezlepro-travail (99998). L'ancien (99999) reste sur le cluster : il porte encore un /etc/resolv.conf et une clé privée d'hôte SSH du réseau de fabrication. À supprimer quand plusieurs VM seront nées du nouveau — pas avant.

Preuve par une machine réelle, et non par lecture de configuration : web-dorsal-01 — l'hôte le moins engagé de la flotte, 12 Ko dans /var/www — rasé puis recréé par le chemin normal (make raser HOTE=…, make creer-vm, make deployer).

Ce qu'elle a hérité du nouveau gabarit :

resolv.conf → les commentaires seuls, aucune identité de fabrication
machine-id  → cc9c58c1… neuf et unique
clé d'hôte  → SHA256:pES0/… régénérée
cloud-init  → done

Déploiement complet, zéro échec, et les cinq devis de service CONFORME.

raser accepte maintenant --hote. Raser une seule machine sert à éprouver un gabarit ou à reprendre un hôte ; les quatre verrous restent en vigueur — on ne fait que restreindre la liste dérivée du plan, jamais l'élargir.

Un défaut relevé au passage, non corrigé. Le premier déploiement s'est interrompu sur client_unbound, à l'instant où le résolveur bascule vers 127.0.0.1 : toute résolution en vol se fige le temps qu'Unbound réponde, y compris celle que fait sshd à l'ouverture de session — d'où « Connection timed out during banner exchange ». L'hôte répondait de nouveau une minute plus tard et la reprise est passée intégralement, presque tout en changed=0. C'est une course, de la même famille que celles de la reconstruction, et elle mérite le même remède : attendre une condition plutôt que de subir la bascule.

2026-08-09 — Gabarit recapturé sur copie de travail, sans toucher à l'original

modeleChezlepro (99999) cloné en modeleChezlepro-travail (99998), préparé, vérifié, nettoyé, converti. L'original n'a pas été touché — il reste le gabarit en service tant que le nouveau n'a pas produit une VM qui fonctionne.

Deux défauts de mon propre outillage, trouvés en l'utilisant pour de vrai — et c'est tout l'intérêt de s'en servir plutôt que de le déclarer prêt :

  • l'inventaire d'un seul hôte (-i "<ip>,") ne porte aucun group_vars, donc aucun utilisateur de connexion : Ansible tentait le compte local de l'opérateur. MODELE_HOTE passe maintenant ansible_user (défaut ansible, le ciuser du gabarit) ;
  • les playbooks ciblent hosts: modeles_vm, et un inventaire d'un seul hôte place la machine dans all — pas dans ce groupe. La commande était juste et la cible introuvable : « skipping: no hosts matched », qui n'est pas une erreur. J'avais vérifié l'affichage de la commande, pas son effet. Les trois playbooks acceptent désormais cible_modele.

Le nettoyage a gagné deux choses :

/etc/resolv.conf est vidé — il portait search chezlepro.ca et nameserver 192.168.12.254, l'identité du réseau de fabrication.

Et les clés d'hôte SSH sont supprimées. Le gabarit transportait une clé privée : quiconque détient l'image détient de quoi se faire passer pour une VM qui n'aurait pas régénéré la sienne. La suppression n'est sûre que parce que cloud-init les recrée au premier démarrage — vérifié avant de l'écrire : les hôtes de la flotte portent des empreintes toutes différentes.

L'exploitant a par ailleurs retiré mtu=9000 des deux gabarits — le réglage mort relevé plus tôt, qui ne se propageait pas aux clones.

Ce qui reste à faire, et qui n'est pas à moi : faire pointer proxmox_clone_source_nom / proxmox_clone_vmid_modele sur le nouveau, une fois qu'une VM en sera née et aura fonctionné. Tant que ce n'est pas fait, rien n'a changé pour la flotte.

2026-08-09 — modeles_vm : trois commandes qui ne pouvaient rien faire

Le groupe était toujours vide, et rien ne le signalait. instancier l'émet comme squelette ("modeles_vm": {"hosts": {}}) et les états d'un serveur ne connaissent que actif et planifie — aucun chemin ne permettait d'y faire entrer une machine. Les trois cibles qui le ciblent recevaient « skipping: no hosts matched », qui n'est pas une erreur.

Le gabarit ne peut pas venir du plan, et c'est structurel. Sa configuration Proxmox le place sur le réseau de fabrication (ip=192.168.12.99/24), pas dans le supernet. Ce n'est pas un hôte de l'écosystème : c'est la matrice dont l'écosystème est tiré. Peupler modeles_vm depuis plan/serveurs.yml aurait été forcer un objet dans un registre qui n'est pas le sien.

MODELE_HOTE=<ip> le désigne explicitement, et l'inventaire d'un seul hôte (-i "<ip>,") sert les trois playbooks. Sans lui — et sans groupe peuplé — elles refusent en expliquant, au lieu de ne rien faire :

Refus: aucune VM de gabarit designee.
  Le gabarit vit sur le reseau de fabrication, pas dans le tenant :
  il ne peut pas venir du plan. Le designer explicitement —
    make preparer-modele MODELE_HOTE=192.168.12.99

Les prérequis d'accès et de privilèges suivent la même cible : ils interrogeaient eux aussi le groupe vide.

C'est la même famille que tout ce que la journée a produit — une capacité déclarée dont personne ne vérifiait qu'elle est branchée. À la différence près que celle-ci ne se manifestait par aucun symptôme : elle ne faisait rien, poliment.

2026-08-09 — Le gabarit doré : ce qu'il transporte de son réseau de naissance

Question de l'exploitant : « on peut l'optimiser ? ». Mesuré avant de répondre — et la réponse n'est pas celle que la question suggère.

Rien à gagner côté performance ni paquets. La configuration Proxmox est soignée : UEFI/q35, virtio-scsi-single avec iothread, discard=on + ssd=1 pour le TRIM, cpu x86-64-v2-AES — ce dernier compte quand tout le trafic est chiffré. agent 1, balloon 0. Et tous les paquets du socle sont déjà cuits dans l'image : common_packages les trouve présents, l'optimisation évidente est déjà faite.

Ce qu'il transporte, en revanche, c'est son lieu de naissance :

ipconfig0     ip=192.168.12.99/24,gw=192.168.12.254
nameserver    192.168.10.10 192.168.10.20
searchdomain  chezlepro.ca
net0          bridge=vmbr1,mtu=9000,tag=12

Les deux premiers sont surchargés au clonage. Le troisième ne l'était pas : proxmox_clone_domaines_recherche n'était défini nulle part, donc les 14 VM héritaient de chezlepro.ca — le domaine public — alors qu'elles vivent dans chezlepro.internal. Désormais dérivé de domaine_interne, par le mécanisme qui existait déjà pour le DNS (SETOPS_DOMAINE, comme SETOPS_DNS).

Honnêtement : ça ne réparait pas de panne. serveur_debian réécrit /etc/resolv.conf au déploiement avec les seuls nameserver, sans ligne search — vérifié sur la flotte. Le domaine hérité ne vit donc qu'entre le clonage et la première couche, et la zone publique n'a pas de joker. C'était faux, et ça ne tenait que par chance.

mtu 9000 est un réglage MORT : les clones tournent en 1500, la valeur ne se propage pas. Un réglage mort dans l'actif le plus central du dépôt est un mensonge pour qui le lira ensuite — à retirer ou à rendre délibéré de bout en bout.

Et le vrai enjeu n'est pas l'optimisation, c'est l'appartenance. Ce gabarit est celui de Chezlepro : son IP, son DNS, son domaine, son pont, son VLAN. La preuve de portabilité suppose qu'il serve aussi Technolibre. Le rendre agnostique vaut plus que n'importe quel réglage de performance.

Défaut trouvé en chemin : le groupe modeles_vm est vide. make preparer-modele, verifier-modele et nettoyer-modele n'ont aucune cible — trois commandes documentées qui ne peuvent rien faire, et rien ne le signale.

2026-08-09 — P33 : deux rôles co-localisés ne revendiquent pas le même port

Deuxième des trois chantiers ouverts par la reconstruction. Il retrouve son défaut nº 6 à froid, sans machine.

Un port n'appartient à personne : le premier service démarré le prend, l'autre échoue. Sur infra-mail-01, le SASL de Dovecot (12345, choix délibéré) et l'interface HTTP d'Alloy (12345, défaut amont) se le disputaient depuis le premier jour — et c'est Dovecot qui perdait, sans que rien ne le dise. Il a fallu inverser l'ordre de démarrage, ce que fait un rejeu depuis zéro, pour que ça devienne audible.

Le contrôle n'était possible qu'après avoir déclaré le port d'Alloy. C'est la vraie leçon du défaut nº 6 : un port subi — le défaut amont d'un logiciel qu'on n'a pas choisi — n'existe pour aucun registre, donc aucune preuve ne peut le voir. Il faut l'imposer pour pouvoir le vérifier.

partage: true, nouveau mot du registre des flux, distingue deux situations qu'il confondait : un rôle qui ouvre une écoute, et un rôle qui décrit celle d'un autre — serveur_backup empruntant le sshd de serveur_debian. Sans lui, la seule co-location légitime de la flotte (tcp/22 sur backup-01) serait signalée à tort. Une preuve qui crie sur un cas sain finit par être ignorée : c'est pire que de ne pas l'avoir.

Vérifié dans les deux sens. Sur la flotte réelle : 32 revendications, aucune collision. En remettant le port d'Alloy à 12345 comme hier : infra-mail-01 : tcp/12345 revendiqué par client_journal et serveur_dovecot, code 1 — le défaut nº 6 reproduit sans toucher à une machine.

Harnais : 33 preuves, 0 échec, 0 sautée.

2026-08-09 — P32 : un assert de rôle est un contrat, et l'instance doit l'honorer

Le premier des trois chantiers que la reconstruction avait rendus évidents. Il aurait trouvé son défaut nº 1 — amorcage_acces_courriel — sans rien détruire.

Un rôle qui assert une variable non vide déclare un contrat : sans cette valeur, le déploiement s'arrête. Rien ne vérifiait que l'instance les honore, et le manque ne se voit qu'au moment où la garde s'exécute pour de vrai — c'est-à-dire, pour un intrant d'amorçage, seulement quand on repart de rien.

Un intrant est satisfait par un défaut non vide dans le rôle (y compris un {{ vault_* }}, dont la présence réelle relève de P18), par un set_fact de résolveur, ou par une déclaration de l'inventaire. Aucune voûte n'est déchiffrée : la preuve reste statique, comme les 31 autres.

Deux fois mon instrument a accusé le composant à sa place, et les deux fois avant la première exécution utile. Il criait au manque sur serveur_postfix_mailstore_hote, qui est pourtant bel et bien fourni — d'abord parce que je ne lisais que group_vars/ et host_vars/ en oubliant le fichier d'inventaire lui-même, ensuite parce que je n'y cherchais que les blocs vars: alors que instancier écrit les valeurs dérivées directement sous le nom d'hôte. Un vérificateur incomplet est pire qu'absent : il fait douter de ce qui marche.

Vérifié dans les deux sens. Sur l'instance réelle : 30 exigences, toutes satisfaites. Sur un double où l'on retire la déclaration ajoutée la veille : amorcage_acces_courriel nommé, code de sortie 1 — le défaut nº 1 reproduit à froid.

Ce qu'il ne fait pas, et c'est écrit dans son en-tête : il ignore les when: qui rendent une assertion conditionnelle, donc il peut signaler un intrant exigé seulement quand une option est active. Signaler à tort coûte une ligne de déclaration ; ne pas signaler coûte un déploiement.

Harnais : 32 preuves, 0 échec, 0 sautée.

2026-08-09 — La reconstruction from-zero est prouvée : cinq devis sur cinq

Écosystème chezlepro détruit — 14 VM, disques compris — puis rejoué depuis le plan seul. Aucune sauvegarde restaurée. Les cinq devis rendent le même verdict qu'avant la destruction, et docs/audit/reference-avant-reconstruction-2026-08-08.md porte les deux états côte à côte pour que « identique » soit vérifiable et non ressenti.

C'est la première fois que le dépôt peut affirmer que le système reconstruit est celui que le plan décrit, au lieu d'affirmer que le dépôt est cohérent avec lui-même.

Six défauts trouvés, tous invisibles autrement — trois d'ordre, deux de course, un conflit de port. Chacun a fait l'objet de son propre commit ; ce qu'ils ont en commun mérite d'être dit : ils dormaient tous derrière un état préexistant. Un compte qui existait déjà, des rôles créés par un passage antérieur, des clients déjà là, un service qui tournait depuis toujours, un port déjà tenu. Le rejeu n'a rien cassé — il a retiré l'état qui masquait.

Le sixième est le plus instructif. Alloy et le SASL de Dovecot revendiquent tous deux le port 12345 sur infra-mail-01. Le conflit existait depuis le premier jour, mais dans l'autre sens : Alloy tenait le port et c'est l'écoute SASL de Dovecot qui échouait, en silence. L'ordre des couches d'une reconstruction a inversé les rôles et rendu le défaut audible. Le port d'Alloy est désormais imposé et déclaré — le vrai défaut n'était pas le numéro, c'était qu'un port subi ne se déclare nulle part, donc qu'aucun contrôle ne pouvait voir la collision.

Ce qu'il reste à construire, et que ce rejeu a rendu évident :

  • une preuve « tout intrant qu'un rôle exige est fourni par l'instance » — le défaut nº 1 se serait vu sans détruire quoi que ce soit ;
  • une preuve « deux rôles co-localisés ne revendiquent pas le même port » — maintenant que les ports subis se déclarent, elle devient possible ;
  • vider /etc/resolv.conf à la capture du gabarit doré : il transporte encore le résolveur de son réseau de fabrication.

2026-08-08 — D-71 éprouvée : l'AC et le DNS montent seuls, sur des machines neuves

Première exécution de _amorcer-socle sur une flotte qui vient d'être clonée, aucun pair debout. Les deux exceptions structurelles nommées la veille se sont exercées pour de vrai.

L'AC s'auto-signe, comme annoncé :

subject=O=Set-OPS Internal CA, CN=Set-OPS Internal CA Root CA
issuer =O=Set-OPS Internal CA, CN=Set-OPS Internal CA Root CA

Les deux zones sont posées et répondent — requêtes réelles, pas lecture de fichier :

zones : chezlepro.internal.zone   27.10.in-addr.arpa.zone
10.27.19.21 -> infra-pki-01.chezlepro.internal.
10.27.21.11 -> forge-01.chezlepro.internal.
10.27.18.21 -> backup-01.chezlepro.internal.

forge-01 et backup-01 ne sont pas déployés — ils viennent d'être clonés, et leur PTR répond quand même. C'est la démonstration de l'arbitrage rendu la veille : la zone est générée depuis le plan, pas enrôlée par la VM. Un enrôlement aurait fait dépendre le DNS de l'état de chaque machine ; ici le nom existe parce que le plan le dit.

Deux fois de plus, ma sonde était fausse avant le système : dig @127.0.0.1 refusait la connexion — PowerDNS écoute sur ansible_host, pas sur la boucle locale. Vérifier l'instrument avant d'accuser le composant, encore.

2026-08-08 — D-71 : PKI et DNS debout avant tout le reste, et la zone inverse

Contrainte posée par l'exploitant en voyant backup-01 et collab-01 créés avant l'AC et le DNS : une PKI et un DNS fonctionnels avant toute chose ; puis par VM, socle → enrôlement PKI → enregistrement DNS (A et PTR).

Ma première réponse était incomplète. J'avais expliqué qu'un clone est inerte et que site.yml respecte bien les couches — c'est exact, mais ça esquivait deux points justes : un échec de création à la douzième VM coûte quarante minutes sans rien déployer, et un journal qui montre backup-01 en tête donne l'impression que le moteur ignore ses propres couches.

Ce qui manquait vraiment, mesuré avant de coder :

enregistrements A     → DEJA derives du plan (zone generee depuis `hotes_actifs`)
zone inverse / PTR    → n'existe NULLE PART — aucun role ne touche `in-addr.arpa`
ordre d'amorcage      → aucun : `deployer-tout` est par couches, pas par hote

Le premier point a réduit le travail de moitié : je m'apprêtais à écrire un enrôlement DNS par hôte alors que la zone directe se dérivait déjà correctement.

La zone inverse est dérivée du supernet, comme le reste : un /16 10.(10+index).0.0 donne (10+index).10.in-addr.arpa — 27.10.in-addr.arpa ici. Les PTR viennent de la même source que les A (hotes_actifs et son ansible_host) : deux zones alimentées par une seule vérité, donc pas d'endroit où elles puissent diverger. Vide si le supernet n'est pas un /16 — mieux vaut pas de zone inverse qu'une zone fausse.

_amorcer-socle monte l'AC puis le DNS complètement, hôte par hôte, avant deployer-tout. Les deux se dérivent de applications.<app>.hote ; l'ordre entre eux n'est pas alphabétique mais causal — le DNS a besoin d'un certificat, l'autorité n'a besoin de personne.

Deux exceptions structurelles, nommées plutôt que découvertes à l'exécution : l'AC s'auto-signe, et le DNS pose son propre enregistrement. La règle « socle → PKI → DNS » ne peut pas s'appliquer à ceux qui la rendent possible.

Au passage, le harnais a attrapé une faute que je venais d'introduire : j'avais inventé un handler Recharger PowerDNS qui n'existe pas — le rôle écoute Validate and reload PowerDNS. P10 l'a nommée avant tout déploiement.

2026-08-08 — Une question de l'exploitant trouve un trou dans P31, une heure après

« Pourquoi pas make myDay ? » — la cible existe, c'est un alias strict de reconstruire. Mais elle n'avait aucun texte d'aide, et P31 la déclarait conforme.

Le motif était ^[a-z][a-z0-9_-]*: : toute cible contenant une majuscule échappait au contrôle. myDay est citée dans l'aide du Makefile et dans la GUI ; elle n'apparaissait dans aucun recensement. Corrigé en ^[A-Za-z][A-Za-z0-9_-]*:, et l'aide posée.

Ce n'est pas un détail sur une cible. Une preuve ne vaut que ce que vaut son motif — et celle-ci a été écrite avec la conviction d'être rigoureuse, testée dans les deux sens le jour même, et elle laissait quand même passer un cas. Le trou n'a pas été trouvé par un test mais par quelqu'un qui a demandé « et celle-là ? ».

À ranger à côté des deux critères creux de P31 (le rapport généré qui se citait lui-même, et l'inventaire généré qui aurait satisfait le critère par construction). Trois fois sur la même preuve, en une journée : la difficulté n'est pas d'écrire un test, c'est de délimiter honnêtement ce qu'il regarde.

Compte après correction : 87 cibles documentées, 36 scripts, 54 rôles.

2026-08-08 — make raser : la seule commande destructive du moteur

Ajoutée pour rendre la reconstruction from-zero répétable — un test qu'on ne peut jouer qu'une fois, à la main, n'est pas une recette. Tout le reste du dépôt crée ou réconcilie ; celle-ci détruit, et elle est écrite en conséquence.

Quatre verrous, tous éprouvés avant usage :

Verrou Ce qu'il empêche Vérifié
VMID dérivés du plan uniquement détruire une VM hors écosystème ; le gabarit doré est structurellement exclu, son VMID ne se dérive pas à blanc : 14 VM listées, template absent
le nom doit correspondre détruire la machine de quelqu'un d'autre sous un VMID du plan test isolé, faux cluster
nommer l'écosystème (INSTANCE=) raser la mauvaise instance : le symlink instance/ peut pointer n'importe où refus sans nom, et refus sur mauvais nom
CONFIRMER=true tout le reste sans lui : inventaire, rien d'autre

Le deuxième mérite d'être détaillé, parce qu'il vient d'un fait et non d'une précaution abstraite : le 2026-08-07, une VM héritée portait un VMID du plan sous le nom web-frontal-01, et proxmox_kvm avait rapporté ok sans rien faire. Rasé sans ce contrôle, on détruisait une machine étrangère. Le refus porte sur l'opération entière, pas sur la seule VM en conflit — un cluster qui ment sur un VMID peut mentir sur d'autres.

C'est aussi le seul verrou qu'on ne peut pas éprouver sur le vrai cluster sans y fabriquer une collision : scripts/tests/test_raser.py isole la logique derrière un faux cluster, et le test est rattaché à P02. Le harnais passe désormais 31 preuves, 0 sautée.

2026-08-08 — État de référence figé avant la reconstruction from-zero

L'exploitant recadre : les 14 VM sont un POC, pas de la production. Ma prudence venait d'une hypothèse que je portais, pas de lui. Les constats sur les sauvegardes restent du travail à faire avant que ça devienne de la production — pas avant le test.

Et reconstruire Chezlepro est un meilleur test que construire Technolibre. On dispose d'un état de référence : les cinq devis y sont CONFORME à l'instant. Toute divergence après rejeu sera un défaut réel, mesurable contre une base connue. Sur Technolibre, qui n'a jamais tourné, un échec serait ambigu — plan faux ou moteur faux ?

docs/audit/reference-avant-reconstruction-2026-08-08.md fige : les cinq verdicts, les 14 hôtes avec leur adresse et leur compte de services, et surtout ce qui sera perdu et devra être refait à la main (clé racine de l'AC — donc la racine installée dans le navigateur — et le mot de passe du compte sysadmin). Ce document existe pour que « identique » soit prouvable plutôt que ressenti.

Ce qui survit et rend le rejeu possible : les deux voûtes et ~/.config/setops-vault-pass vivent hors dépôt et hors cluster ; le code est sur eregion.chezlepro.ca (192.168.12.201), machine distincte du tenant — vérifié, parce qu'un dépôt dont le origin vivrait dans l'écosystème à détruire serait une dépendance circulaire fatale.

Constat au passage : le moteur n'a aucun chemin de destruction. make reconstruire crée les VM manquantes et déploie ; il ne rase rien. C'est cohérent avec la doctrine (rien de destructif sans garde explicite), mais ça veut dire qu'un test « depuis zéro » suppose une suppression faite hors du moteur.

2026-08-08 — « Set-OPS trichait ? » — non, il se sous-estimait

Question de l'exploitant après avoir vu P31 manquer deux fois sa cible. Elle méritait un audit, pas une assurance.

La réponse est non, et c'est le dépôt lui-même qui la donne. Le registre des affirmations contient onze de ses propres promesses publiques marquées ❌ fausse. Il déclare son périmètre — « aucune VM / Proxmox / réseau touché ». Et D-25 en fait une règle : le dépôt n'affirme pas que ses devis s'appliquent, il affirme qu'ils dérivent. Un système qui triche n'écrit aucune de ces trois choses.

Il y avait bien un angle mort, et c'est celui fermé aujourd'hui : les 30 preuves sont statiques. CONFORME : 30 preuves se lit comme « le système fonctionne » alors que ça signifie « le dépôt est cohérent avec lui-même ». La restriction était écrite dans le registre et invisible dans la sortie quotidienne — c'est ainsi que le certificat de l'AC a pu expirer huit heures sous un harnais vert.

Les onze ❌ ont été rejugées, chacune reconfrontée au dépôt : make verifier passe (30 preuves) ; QUICKSTART ne promet plus que le modèle socle et pointe inventories/production/ ; PasswordAuthentication no par défaut et le texte contradictoire a disparu ; make help et syntax-template n'existent plus nulle part ; la voûte Proxmox est unifiée ; les domaines du socle valident ; le socle génère bien dans production/ sans repli. Onze sur onze : résolues.

Et le rejugement a trouvé mieux qu'un registre oublié. Les résolutions étaient déjà documentées dans les sections « Phase 3 » du registre. Mais son tableau de synthèse annonçait encore « ❌ fausse : 8 ». Deux représentations du même fait, une corrigée et l'autre non, rien qui vérifie qu'elles se rejoignent — le défaut exact que ce registre existe pour traquer, appliqué à lui-même. Il penchait du bon côté, ce qui l'a rendu invisible : personne ne se plaint d'une mauvaise nouvelle périmée.

Le tableau de juillet est conservé comme photo de départ ; un bloc « état courant » le suit. Un ❌ qui subsiste doit désormais se lire comme un signal vivant, pas un vestige.

2026-08-08 — D-70 : l'exigence de documentation devient une preuve (P31)

Directive de l'exploitant : « la doc dit et explique tout ce que Set-OPS fait, et pourquoi c'est ainsi. » Une exigence qu'on se contente d'énoncer pourrit en silence — on venait d'en avoir trois exemples le jour même dans la carte.

L'écart mesuré avant de le combler :

cibles make sans texte d'aide : 66 sur 85   → `make help` en montrait 19
scripts jamais cités en doc   : 11 sur 35   → dont 3 applicateurs et 4 devis du jour

Les 66 cibles ont reçu leur aide : make aide couvre maintenant 85 commandes au lieu de 19. C'est ce qui rend le moteur utilisable par quelqu'un qui ne lit pas le Makefile — la règle « un sysadmin l'exploite sans IA » n'a pas d'autre traduction concrète.

P31 garde l'exigence, et le chemin pour l'écrire a été instructif : deux fois mon critère s'est révélé creux.

D'abord « le nom du script apparaît dans un document » : le rapport d'audit généré recopiait les noms manquants dans son message d'échec, ce qui les rendait cités au tour suivant. Une preuve qui se nourrit de sa propre sortie passe au vert sans qu'une ligne soit écrite.

Puis, en corrigeant, j'ai failli créer le même trou en plus grand : générer un inventaire de l'outillage aurait satisfait le critère par construction. Un critère qu'on peut satisfaire en générant du texte ne prouve rien. P31 teste donc que chaque script porte une docstring qui l'explique et qu'il reste atteignable — par une cible make, ou par un autre outil.

Vérifiée dans les deux sens, comme les devis : on retire l'aide d'une cible et la docstring d'un script, les deux défauts sont nommés ; on restaure, CONFORME.

Ce que P31 ne garde pas, et c'est dit dans son propre code : que l'explication soit bonne. Le « pourquoi » se juge en revue. Il vit dans ce journal — qui porte le fait mesuré, pas seulement le changement — et dans le registre des décisions. Prétendre le mesurer mécaniquement serait se mentir.

2026-08-08 — Tisser le travail du jour dans les points d'entrée

Question de l'exploitant : faut-il refondre la documentation ? Non. L'état mesuré ne le justifie pas — 54 rôles, 54 README (couverture complète), 34 documents, une carte avec un ordre de lecture, un registre de décisions. Une refonte ferait courir le vrai risque : perdre le pourquoi accumulé, qui a pris des mois et ne se régénère pas.

Ce qui était réellement en retard était petit et nommable : le travail du jour était documenté dans son coin. Les cinq devis n'existaient que dans deux fichiers — leur propre doc et le registre des décisions. Absents de la carte, d'AGENTS.md, du runbook du sysadmin et de la GUI. Autrement dit : découvrables uniquement par qui connaît déjà le Makefile — ce qui contredit « un sysadmin l'exploite sans IA ».

Tissés dans les quatre points d'entrée : ligne « Conformité du déployé » dans la carte, section « Écrire, puis relire (D-68) » dans AGENTS.md, §6.0 du runbook (le premier réflexe avant de suivre quoi que ce soit), et la vue Reconstruction de la GUI — sa place logique, puisque ce sont ces devis qui diront si un remontage a produit le système décrit.

Et le tissage a fait tomber trois affirmations périmées, ce qui est sa vraie utilité :

  • la carte annonçait 28 décisions ; il y en a 66 en vigueur (D-01 → D-69, 3 renversées) ;
  • elle disait des accès et habilitations « décidé, non construit : ou=people et ou=groups existent et restent vides ». Mesuré : un compte, un groupe, et la chaîne LDAP → Keycloak → groupe → service exercée de bout en bout sur Icinga Web 2 le jour même ;
  • la GUI parlait des « deux devis » d'infrastructure ; il y en a quatre depuis l'arrivée du SDN EVPN et du pare-feu est-ouest.

Ce qui n'est pas fait, et pourquoi. La formation et le wiki attendent — leur audience et leur condition de vérité sont différentes. Un runbook que personne n'a suivi sauf son auteur est une hypothèse ; la reconstruction from-zero est le test de cette documentation. Écrire la formation avant, ce serait enseigner une procédure que personne n'a exécutée.

2026-08-08 — D-68 / D-69 : la règle n'est pas « toujours l'API »

Question de l'exploitant après deux pannes causées par kcadm : ne devrait-on pas toujours utiliser une API quand il en existe une ?

Non — et la journée le montre mieux qu'un principe. Sur six familles de défauts, deux seulement viennent d'un CLI ; un module Ansible (ldap_entry, qui crée sans jamais modifier) a commis exactement la même faute, et trois autres viennent d'un grep de fichier, de la précédence Ansible, et de mon propre comparateur. Le facteur commun n'est pas l'interface : c'est d'avoir écrit sans relire.

D-68 — on écrit, puis on relit et on compare, quelle que soit l'interface ; on choisit celle dont le chemin de lecture parle le même langage que le chemin d'écriture. Une API est souvent préférable pour une raison précise — elle rend la ressource entière, ce qui permet le patron de chaque devis : fusionner l'attendu dans le réel ; si rien ne change, c'est conforme. Mais la plupart de la flotte n'a pas d'API (Postfix, Dovecot, nginx, slapd, nftables), et postconf -h / postconf -e sont parfaitement symétriques.

D-69 — sur Keycloak en particulier : l'API pour toute map ou collection (smtpServer, attributes, config), où kcadm -s sort en succès sans rien écrire ; kcadm ailleurs, parce que c'est le vocabulaire de la documentation du produit — donc lisible par un sysadmin sans IA.

Deux raisons de ne pas systématiser l'API méritent d'être dites : le CLI est souvent le contrat du fournisseur et encode des invariants (occ user:resetpassword hache correctement), et chaque appel d'API demande un jeton, donc du secret manipulé dans chaque tâche.

2026-08-08 — Déconnexion OIDC : Keycloak valide une SECONDE liste d'URI

Nextcloud se connectait parfaitement et échouait à la déconnexion, sur un « We are sorry… invalid redirect uri » qui ne dit pas de quelle liste il parle.

Keycloak valide les URI de retour après déconnexion séparément des URI de rappel. Aucun des quatre clients ne déclarait l'attribut ; Nextcloud était seulement le seul à envoyer une URI de retour, donc le seul à révéler le trou. Les trois autres l'auraient rencontré dès qu'on leur aurait câblé une déconnexion propre.

post.logout.redirect.uris est désormais dérivée de web_origins, qui porte déjà l'URL de base de chaque service : la connaissance existait, il n'y avait pas à la réécrire. Posée par l'API et non par kcadm -s — attributes est une map, et sur une map kcadm accepte la commande, sort en succès et n'écrit rien (mesuré le même jour sur smtpServer). Relu après écriture, comme il se doit maintenant.

Et le second passage a révélé un défaut dans le travail de l'heure précédente. La tâche de journalisation se déclarait changed à chaque déploiement : écrits champ par champ, Jinja rendait True et 1209600 en chaînes, et la comparaison au réel (booléen, entier) ne pouvait jamais être satisfaite. Corrigé en composant le dictionnaire en une seule expression, qui rend des types natifs. Un changed permanent n'est pas cosmétique : c'est un bruit qui finit par masquer un vrai changement. Deux passages consécutifs à changed=0 désormais.

2026-08-08 — 502 sur Icinga : le tampon de nginx, après une authentification réussie

Symptôme trompeur s'il en est : oauth2-proxy journalisait AuthSuccess — jeton, jeton d'identité, rafraîchissement, tout obtenu — pendant que le navigateur recevait une erreur de passerelle. L'authentification n'était pas en cause ; c'est la réponse qui ne passait plus.

upstream sent too big header while reading response header from upstream
server: icinga.chezlepro.internal, request: "GET /oauth2/callback?..."

Le cookie de session d'oauth2-proxy porte le jeton d'identité, découpé en plusieurs en-têtes Set-Cookie. Le tampon par défaut de nginx (4 Ko) ne peut pas les contenir. serveur_nginx pose désormais proxy_buffer_size / proxy_buffers / proxy_busy_buffers_size sur toutes les expositions : c'est une propriété du proxy, pas de ce service-là, et le prochain service placé derrière un IdP rencontrerait le même mur.

Trouvé uniquement parce que le journal des évènements de Keycloak venait d'être activé : il a montré LOGIN puis CODE_TO_TOKEN réussis pour icingaweb2, ce qui a écarté d'un coup l'identité et renvoyé l'enquête vers le chemin de retour.

Deux constats laissés ouverts, faute de pouvoir conclure.

Le journal de l'edge montre trois upstream timed out vers Keycloak en quatorze heures (console de compte deux fois, autorisation Nextcloud une fois). Mesuré depuis l'edge, Keycloak répond en millisecondes — ce n'est pas lui qui est lent. Cause non établie.

Et ma sonde MTU ne valait rien : ping -M do annonce 100 % de perte alors que HTTP répond en 5 ms, parce que l'ICMP est bloqué par construction entre hôtes (seul frag-needed est ouvert au registre des flux). Troisième fois aujourd'hui que l'instrument est le problème et non le composant — après /dev/tcp sous sh et Maildir/new/.

2026-08-08 — Keycloak ne gardait aucune trace des connexions

Deux services n'aboutissaient pas pour l'exploitant (Nextcloud, Icinga Web 2). Tout a été vérifié côté serveur et tout était correct : clients OIDC actifs, URI de rappel exactes, secret d'oauth2-proxy identique à celui de Keycloak (même empreinte SHA-256), CA de confiance depuis mon-01 (200 sur la découverte), email_domains = ["*"] donc aucune restriction. Le premier saut de chaque parcours a été rejoué avec curl : Nextcloud redirige vers sa page locale (qui propose bien le bouton SSO « Chezlepro »), Icinga part correctement vers Keycloak, qui répond 200.

Et là, plus rien à examiner : eventsEnabled = False. Keycloak ne gardait aucune trace — ni qui est entré, ni pourquoi une authentification a échoué. Impossible de savoir ce que l'utilisateur avait rencontré.

C'est un manque d'exploitation autant que de diagnostic : « un sysadmin l'exploite sans IA » suppose qu'il puisse lire lui-même ce qui s'est passé. Le journal des évènements (connexions et actions d'administration, rétention 14 jours) est désormais réconcilié par serveur_keycloak, comme le reste — pas activé à la main dans une console.

2026-08-08 — La livraison interne était en panne, et le devis disait CONFORME

Suite de l'arbitrage sur l'adressage : le courrier local est désormais routé par identifiant, plus par l'attribut mail.

L'argument décisif n'est pas théorique — Dovecot le fait déjà : mail_home = /var/vmail/%{user | username}, la partie locale, jamais mail. Les deux moitiés n'utilisaient donc pas le même mécanisme et ne s'accordaient que par coïncidence, tant que mail valait uid@<domaine_interne>. Aligner Postfix ne crée pas un modèle nouveau : ça met fin à une incohérence. Coût assumé : l'adresse interne est dérivée de l'identifiant et ne se choisit plus ; un alias voulu passera par virtual_alias_maps, non câblé aujourd'hui. En échange, mail redevient libre de porter la vraie adresse de la personne — celle que Keycloak affiche et que « mot de passe oublié » utilise.

Puis la preuve de bout en bout a révélé bien pire. Un vrai courriel envoyé à sysadmin@chezlepro.internal n'arrivait pas :

SSL_connect error to infra-mail-01[10.27.19.31]:24: Connection timed out
status=deferred (Cannot start TLS: handshake failure)

Postfix était durci (lmtp_tls_security_level = verify), le port LMTP de Dovecot écoutait en clair — son ssl = required global ne concerne que les services de connexion. Les deux côtés d'un même flux avaient été traités séparément, et toute livraison interne était différée depuis, sans qu'aucun écran ne le montre. Corrigé des deux bouts, et accordé : ssl = yes sur l'inet_listener (TLS implicite, ce que sait faire un listener) et lmtp_tls_wrappermode = yes côté client. L'un sans l'autre ne marche pas.

Livraison prouvée : status=sent (250 2.0.0 … Saved), message présent dans /var/vmail/sysadmin/Maildir/.INBOX/new/.

Le devis, lui, annonçait CONFORME. Il vérifiait la résolution LDAP et les dialectes SMTP/IMAP — tout était correct — et jamais si le courrier bouge. C'est la leçon la plus chère de la série : un devis qui ne regarde que les réglages ne dit pas si le service rend son service. Il relève désormais la file d'attente et ses raisons de blocage ; le test négatif confirme qu'il aurait nommé cette panne.

Au passage, deux fois où ma sonde était fausse et non le système : /dev/tcp sous sh (qui ne le connaît pas), et Maildir/new/ alors que l'INBOX est Maildir/.INBOX/new/. Vérifier l'instrument avant d'accuser le composant, encore.

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 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. Le devis fait donc une requête HTTPS complète, avec la racine de l'AC, et lit le code de retour.

Deux points de vue — depuis l'edge (edge + dorsal) et depuis le poste (DNS + frontière + edge + dorsal). C'est leur différence qui diagnostique : 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. Mon premier comparateur ne testait que la présence d'un code : un 502 des deux côtés passait pour conforme alors que le dorsal est mort derrière. Trouvé par le test négatif, pas par la relecture — troisième fois aujourd'hui que c'est l'épreuve, et non le raisonnement, qui tranche. Un 302 ou un 401 reste en revanche un service vivant : il redirige vers l'IdP ou exige une authentification.

État : les six expositions déclarées au plan répondent, des deux points de vue. Test négatif — une frontière qui bloque et un dorsal tombé — les deux écarts sont nommés distinctement, code de sortie 1.

2026-08-08 — Le devis des certificats trouve l'autorité expirée depuis huit heures

Deuxième application du patron devis/applicateur aux services, sur le défaut le plus coûteux qu'on connaisse : un certificat renouvelé sur disque mais toujours servi périmé depuis la mémoire du service.

Trouvé à la 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 :

'step ca renew' requires the '--ca-url' flag
notAfter=Aug  8 02:51:21 2026 GMT     (il était 11:14 UTC)

Cause : sur l'hôte de l'AC, /etc/step est le STEPPATH du serveur, pas un amorçage client — il n'y a donc pas de defaults.json, et l'unité de renouvellement, identique partout, en dépendait. La leçon avait déjà été apprise, et écrite noir sur blanc dans le commentaire de la tâche d'émission (« l'autorité ne bootstrape pas »), qui passe --ca-url et --root explicitement. Elle n'avait jamais été reportée sur l'unité de renouvellement.

Et le rôle ne pouvait pas se soigner. La condition de ré-émission ne regardait que la forme — cert absent, ou SAN manquant. Un certificat expiré portant les bons SAN ne déclenchait rien. client_pki vérifie désormais aussi la validité (client_pki_marge_renouvellement, une heure).

Ce qu'il a fallu désapprendre pour écrire le devis. Les certificats vivent 24 h et le minuteur les renouvelle toutes les ~14 min : une empreinte servie différente de celle sur disque est l'état normal. Comparer les empreintes aurait donné 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.

Le devis a d'abord menti, du défaut même qu'il traque. include_vars au niveau du play prime sur les group_vars : le premier jet rapportait client_pki_reload_services: [] sur les quatorze hôtes alors que quatre groupes le déclarent. Le correctif suivant a paru fonctionner — set_fact accepte un dictionnaire entier en argument libre sans erreur et n'en fait rien. Il faut réimposer clé par clé, en boucle. Les deux devis sont corrigés et le piège est consigné dans docs/devis-services.md, avant d'écrire le prochain.

Deux latents relevés et corrigés au passage : step-ca (8443) et icinga2 (5665) servaient une copie qu'aucun rechargement ne rafraîchissait ; les deux déclarent maintenant leur service, en reload (SIGHUP pour step-ca, safe-reload pour icinga2), sans interruption.

Vérifié dans les deux sens : CONFORME sur les quatorze hôtes après correction ; sur un relevé où l'on rejoue une copie périmée en mémoire, deux écarts listés et code de sortie 1.

2026-08-08 — Un devis pour l'identité : rien ne comparait le déployé au déclaré

Constat de l'exploitant après trois séries de corrections : « ça fait beaucoup de trucs incohérents qu'on débusque ensemble ». Exact, et il y a une raison mesurable.

Les 30 preuves sont statiques. scripts/prouver.py ne fait aucun appel réseau, aucun SSH, aucun ansible. Elles établissent que le dépôt est cohérent avec lui-même. Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce — et c'est exactement là que vivaient les quatre défauts de la journée.

La classe statique, elle, est presque épuisée. Recensement des motifs « crée mais ne réconcilie jamais » : amorcage_acces (délibéré, D-67), serveur_openldap (corrigé le matin), et un seul reste réel — rbac-oidc.yml, qui crée trois objets sans jamais les mettre à jour. Une preuve statique de plus aurait rapporté une ligne. Le trou est ailleurs.

Le dépôt avait déjà la réponse sans l'avoir appliquée aux services. Le patron devis/applicateur (D-23/D-24) existe pour les quatre pare-feu : make frontiere-plan lit la frontière réelle et montre l'écart. Rien d'équivalent pour l'identité.

make identite-plan comble ça. Le playbook relève le déclaré et le réel et les dépose en JSON ; scripts/devis_identite.py compare. La séparation n'est pas cosmétique : j'ai écrit deux fois de suite une expression Jinja de comparaison illisible avant d'admettre que le raisonnement n'a rien à faire là — et le dépôt a déjà cette forme pour les devis réseau.

Le déclaré n'est jamais recopié dans le devis : il charge les défauts du rôle et appelle resoudre_politique_mdp et resoudre_annuaire. Un devis qui redéclare ce qu'il vérifie ne vérifie rien.

Vérifié dans les deux sens, ce qui est le minimum pour un instrument : sur le système réel, CONFORME. Sur une copie du relevé où les quatre défauts du jour sont rejoués, plus deux régressions plausibles (SMTP disparu, compte sans adresse) — six divergences listées, code de sortie 1. Un vérificateur qui ne sait dire que « conforme » ne vaut rien.

Couvre l'identité seule. Les autres services attendent le même traitement ; le patron est là pour être repris.

2026-08-08 — La politique de mot de passe existait des deux côtés et ne s'appliquait d'aucun

Question de l'exploitant : « l'intégration Keycloak/LDAP est incomplète, non ? » Elle l'était, et pas cosmétiquement. Quatre défauts mesurés, tous de la même famille — une valeur déclarée d'un côté, consommée de l'autre, sans que rien ne vérifie qu'elles se rejoignent.

1. Aucune règle de mot de passe ne s'appliquait sur le chemin d'un vrai utilisateur. Compte sonde, même mot de passe abcd : l'opération étendue LDAP le refuse (Constraint violation (19) — Password fails quality checking policy), Keycloak l'accepte (204), et ldapwhoami avec abcd réussit ensuite. Deux causes empilées : Keycloak écrivait userPassword directement, donc l'overlay ppolicy n'interceptait rien ; et le realm n'avait aucune passwordPolicy. Chacun déléguait la vérification à l'autre.

2. ldap_entry ne fait que créer. L'entrée cn=default,ou=policies était figée à ce qu'elle valait le jour de sa création : toute modification ultérieure de la déclaration était ignorée en silence. Le dépôt annonçait pwdMustChange: TRUE, le serveur portait FALSE (corrigé à la main après la boucle de changement de mot de passe du 2026-08-07). Une reconstruction from-zero aurait donc ressuscité le défaut. ldap_attrs state: exact réconcilie désormais la politique et les réglages de l'overlay — dont le DN, qui porte un index attribué par slapd, est lu et non deviné.

3. Un compte créé dans Keycloak n'atteignait jamais l'annuaire. POST users → 201, rien dans ou=people : syncRegistrations était absent. Ce compte aurait eu un accès web, aucune boîte aux lettres, et serait resté invisible du modèle de groupes — Postfix et Dovecot lisent LDAP, pas Keycloak. C'est la divergence nettoyée le matin même sur l'adresse du sysadmin, réintroduite par une autre porte.

4. Le prénom était mappé sur cn. Dans inetOrgPerson, cn porte le nom complet : Keycloak affichait « Administrateur systeme systeme ». Le mappeur pointe désormais sur givenName, que amorcage_acces écrit, dérivé par le même découpage que sn.

Ce qui est ajouté. roles/resoudre_politique_mdp/ porte la déclaration, en termes neutres, et la traduit dans les trois dialectes qui doivent l'appliquer : pwdPolicy, passwordPolicy du realm, protection anti-force-brute. serveur_openldap et serveur_keycloak la consomment ; aucun des deux ne la redéclare.

La fédération est durcie de six clés, dont deux portent la correction et se complètent : usePasswordModifyExtendedOp (slapd voit passer le changement) et validatePasswordPolicy (Keycloak valide avant d'écrire). La seconde est la porteuse — Keycloak se lie en rootDN, et slapd n'applique pas ses contrôles de qualité au rootDN. S'en remettre à la première seule aurait donné une correction qui paraît juste et ne tient pas ; c'est le test qui a tranché, pas le raisonnement.

Vérification, mêmes sondes qu'au diagnostic — abcd → 400 Invalid password: minimum length 12, absent de LDAP ; mot de passe conforme → 204 puis ldapwhoami accepté (l'écriture traversante reste intacte) ; POST users → 201 et dn: uid=setops-sonde2,ou=people,…. Sondes supprimées des deux côtés. Second passage des deux playbooks : changed=0.

2026-08-08 — « Mot de passe oublié » : Keycloak sait enfin envoyer

Le realm affichait une politique d'accès complète et aucun moyen d'écrire à qui que ce soit : smtpServer vide, resetPasswordAllowed à false. Conséquence concrète — tout oubli de mot de passe remontait à l'exploitant, qui n'avait alors d'autre choix que de manipuler le mot de passe de quelqu'un d'autre. C'est précisément ce que « une identité, une personne » (§3 d'autorisation.md) cherche à écarter.

Ce qui est ajouté. serveur_keycloak/tasks/courriel-realm.yml réconcilie la strophe courriel du realm et le drapeau « mot de passe oublié ». L'hôte du relais est dérivé du plan (applications.postfix.hote) : aucun nom de machine n'est écrit. Si le plan ne déclare pas de MTA, le rôle refuse — un écran qui promet un courriel que personne n'enverrait serait pire que pas d'écran du tout.

kcadm.sh ne sait pas écrire une map, et ne le dit pas. Sur smtpServer, les deux formes documentées — -s smtpServer.host=… et -s 'smtpServer={"host":…}' — sortent en succès, sans rien écrire. Le champ est resté { } après deux déploiements verts. La tâche passe donc par l'API d'administration (uri), qui répond 204 et écrit vraiment. Même famille que le reste de ce journal : une valeur déclarée d'un côté, jamais vérifiée de l'autre. Ce qui l'a rattrapée, c'est d'avoir relu l'état après l'avoir posé — pas le code de retour.

L'adresse de l'amorçage ne se dérive pas. J'avais d'abord posé {{ amorcage_acces_uid }}@{{ domaine_interne }} comme défaut : c'est un piège. Cette adresse désigne une personne, donc quelque chose d'extérieur au système qu'on amorce, et une boîte interne n'est pas lisible tant qu'on n'a pas justement l'accès qu'on essaie de récupérer. amorcage_acces_courriel redevient donc à déclarer, et le rôle refuse de créer le compte sans elle — mais seulement à la création, pour qu'un écosystème déjà amorcé ne se mette pas à échouer parce qu'on a durci la règle après coup.

Un reliquat de READ_ONLY mis au jour. Le compte sysadmin portait sysadmin@chezlepro.ca dans Keycloak et rien dans LDAP. L'adresse avait été saisie dans la console de compte quand la fédération était encore en lecture seule : Keycloak l'avait gardée pour lui, l'annuaire ne l'a jamais reçue, et les deux côtés ont affiché des valeurs différentes sans que rien ne le signale. Corrigé dans LDAP (source de vérité) puis resynchronisé ; consigné au runbook (§6.6) parce que d'autres comptes créés avant la bascule en WRITABLE peuvent porter le même écart.

Preuve de bout en bout, et pas un connect() : bannière SMTP lue depuis idm-01, RCPT TO accepté, puis un vrai execute-actions-email déclenché — journal du MTA : starttls=1, to=<sysadmin@chezlepro.ca>, relay=mx.chezlepro.ca[69.70.26.53]:25, status=sent (250 2.0.0 Ok). Le second déploiement rapporte changed=0 : la tâche réconcilie, elle ne réécrit pas.

2026-08-06 — le chemin nord-sud devient dérivable

vault_openldap_admin — quatre consommateurs et deux pièges

Le secret le plus délicat de la liste : Keycloak, Dovecot, Postfix et Icinga Web 2 s'y lient tous.

Premier piège : ldappasswd ne peut pas le changer. cn=admin n'est pas une entrée de la base mais le rootDN déclaré dans cn=config — la commande répond « No such object ». Le mot de passe vit dans olcRootPW et se modifie par un bind EXTERNAL. L'échec était sans dégât : l'ancien fonctionnait toujours, vérifié avant de continuer.

Second piège : Keycloak stocke le mot de passe de liaison dans sa base, et le masque. La réconciliation ajoutée hier couvrait l'URL, les DN et le mode — pas bindCredential. Tourner le secret aurait coupé Keycloak de l'annuaire, et plus personne n'aurait pu se connecter. Comme on ne peut pas comparer une valeur masquée, la réconciliation passe par une empreinte — même mécanisme que les comptes de secours.

Vérifié, consommateur par consommateur : synchronisation LDAP de Keycloak (qui prouve la liaison), carte LDAP de Postfix, Dovecot actif. Les trois rejouent à changed=0.

Six secrets tournés depuis hier ; chacun a d'abord demandé de construire la capacité de le faire. C'est le motif de fond de ces deux jours : le dépôt savait créer, pas changer.

vault_forgejo_oidc — le secret exposé ne vaut plus rien

Il avait fui dans une sortie de diagnostic. Le faire tourner a d'abord demandé de rendre la rotation possible : ni Keycloak ni Forgejo ne réconciliaient un secret OIDC existant.

Le commentaire de clients-oidc.yml l'avouait — « la réconciliation fine n'est pas faite : create-si-absent » — et update-oauth, côté Forgejo, ne passait pas --secret. Régénérer la voûte aurait donc laissé les deux côtés sur l'ancienne valeur, ou pire, un seul des deux : le SSO aurait cassé sans que rien ne l'annonce.

Les deux réconcilient désormais. Keycloak compare le secret en place (get client-secret) à celui voulu avant d'écrire — pas de changed inutile.

Vérifié par empreinte, aux trois endroits :

voûte      2484770b53a4fd76f9b57bf1
Keycloak   2484770b53a4fd76f9b57bf1
Forgejo    2484770b53a4fd76f9b57bf1

Keycloak rejoue à changed=0.

Une non-idempotence préexistante, signalée sans être corrigée : Deployer app.ini change à chaque passage sur Forgejo. Le rôle réécrit un fichier que Forgejo modifie lui-même — il y persiste ses secrets générés. Ce n'est pas lié à la rotation, et le corriger demande de décider quelles clés appartiennent au gabarit et lesquelles au service.

vault_keycloak_admin : le secret qui est la clé de son propre changement

Rotation faite, chaîne d'identité intacte (groupe, membre, rôle), rejeu à changed=0.

L'ordre n'est pas indifférent, et c'est le point à retenir. Ce compte est le moyen de se changer lui-même : régénérer la voûte d'abord l'aurait rendu inapplicable — plus rien n'aurait pu s'authentifier pour poser la nouvelle valeur.

1. s'authentifier avec la valeur ACTUELLE
2. poser la nouvelle dans Keycloak
3. vérifier qu'elle fonctionne
4. seulement alors, écrire la voûte

C'est une procédure, pas un redéploiement — et la même contrainte vaut pour vault_openldap_admin, qui reste à faire. Consigné au runbook (§6.9), avec le rappel qu'une vérification n'est pas un message de succès.

La rotation des comptes de secours devient possible — elle ne l'était pas

Le runbook §6.8 promettait de régénérer les comptes de secours ; le code ne savait pas le faire. Les trois rôles ne posaient le mot de passe qu'à la création :

grafana     GF_SECURITY_ADMIN_PASSWORD   n'agit qu'à la création du compte
forgejo     admin user create            `creates: .admin-created` — une seule fois
nextcloud   maintenance:install          seulement à l'installation

Régénérer la voûte sans cela aurait produit exactement le mensonge silencieux corrigé toute la journée : la voûte dit une chose, le service en a une autre.

Chaque rôle sait désormais changer un mot de passe existant, avec une idempotence par empreinte du secret appliqué — on ne peut pas relire un hachage, donc on mémorise ce qu'on a posé. Pour Nextcloud, le secret passe par l'environnement (--password-from-env) et non par la ligne de commande, où il serait visible dans la table des processus.

grafana-cli écrivait dans une base fantôme

Et disait « Admin password changed successfully ✔ » à chaque fois.

La CLI prend paths.data par défaut à <homepath>/data ; le paquet Debian range la base dans /var/lib/grafana. Elle créait donc /usr/share/grafana/data/grafana.db, y écrivait, et annonçait le succès — pendant que le serveur lisait l'autre fichier.

Ce qui l'a démasqué : le champ updated du compte, resté à l'heure du déploiement initial malgré quatre réinitialisations « réussies ». Un message de succès n'est pas une preuve ; l'état l'est. --configOverrides=cfg:default.paths.data=… est désormais imposé.

Vérifié par authentification réelle : Grafana 200, Nextcloud 200. Pour Forgejo, ENABLE_BASIC_AUTHENTICATION = false ferme l'API par doctrine (D-41) — la seule preuve disponible est le retour de la commande, qui confirme le changement.

Quatre secrets régénérés : vault_grafana_admin, vault_forgejo_admin, vault_nextcloud_admin, vault_sysadmin_amorcage.

Icinga Web 2 cesse de nommer des personnes

Le dernier porte_par: liste-uid du catalogue. serveur_icingaweb2_admins valait un uid en dur ; roles.ini porte désormais groups = "sysadmin", et serveur_icingaweb2_admins devient un repli de dépannage, vide par défaut.

Le mode SSO complique le montage, et il faut le dire. Les membres d'un groupOfNames sont des DN ; en auth: external, le nom d'utilisateur vient de REMOTE_USER — une chaîne, pas un DN. Un backend LDAP supplémentaire est donc déclaré dans authentication.ini : jamais utilisé pour authentifier (l'externe répond en premier), uniquement pour que groups.ini résolve le nom vers son DN.

Sans ce pont, l'habilitation par groupe est impossible en SSO — et il faudrait continuer à nommer des personnes.

Ce que je n'ai pas pu vérifier. La configuration est déployée et cohérente, mais la résolution REMOTE_USER → DN → appartenance est interne à Icinga Web 2 : seule une connexion réelle par le SSO la prouve. Je ne la déclare donc pas prouvée.

Administrer le realm par appartenance — le dernier porte_par: aucun

realm-admin (rôle du client realm-management) est attaché au groupe sysadmin. Administrer le realm ne passe plus par le compte local : il suffit d'appartenir au groupe dans l'annuaire.

Portée : ce realm seulement, jamais master. Le compte admin reste hors d'atteinte du groupe, et c'est délibéré — un accès de secours qui dépendrait des habilitations qu'il doit pouvoir réparer n'en serait pas un (D-40).

La console à utiliser est celle du realm — /admin/<realm>/console/ — pas la racine /admin/, qui est celle de master. Le runbook nomme désormais les trois portes et dit ce que chacune gouverne.

Les rôles de client sont un espace de noms distinct des rôles de realm ; la déclaration gagne un champ roles_client. Et l'API attend l'UUID du client, pas son clientId : interroger par le nom rendait une erreur, la vérification échouait toujours, et la tâche se déclarait changed à chaque passage alors que le rôle était déjà posé. Corrigé — deux passages consécutifs à changed=0.

La boucle de changement de mot de passe

Après editMode=WRITABLE, le changement partait mais rebouclait sans fin. Cause : pwdMustChange: TRUE signifie « quand un administrateur pose un mot de passe, l'utilisateur doit le changer ». Or Keycloak écrit en tant qu'administrateur (cn=admin) — chaque changement relayé était donc vu comme une réinitialisation, et OpenLDAP reposait pwdReset aussitôt.

C'est incompatible par construction avec un IdP qui relaie le changement. La contrainte a été déplacée là où l'utilisateur la voit : pwdMustChange: FALSE côté annuaire, et Keycloak pose l'action requise UPDATE_PASSWORD tant que pwdReset est vrai — un écran qui explique, au lieu d'un refus muet au niveau du protocole.

Je ne l'avais pas vu parce que j'avais éprouvé pwdReset au niveau LDAP, où il fonctionne parfaitement, sans jamais parcourir le chemin complet à travers Keycloak. L'opérateur l'a dit avant moi : « ce n'est pas du tout explicite » — c'était le symptôme de deux mécanismes qui ne se parlent pas.

« Federated storage is not writable » — la fédération était en lecture seule

Le changement de mot de passe imposé échouait : editMode=READ_ONLY était codé en dur dans federation-ldap.yml. Keycloak lisait l'annuaire sans jamais pouvoir y écrire — donc pwdReset était un cul-de-sac : LDAP exige le changement, et Keycloak ne peut pas le faire.

Trois modes, un seul tient avec la doctrine :

Mode Effet Verdict
READ_ONLY Keycloak n'écrit jamais le changement de mot de passe est impossible
UNSYNCED Keycloak écrit dans sa base Dovecot et Postfix, qui se lient directement à LDAP (D-39), valideraient encore l'ancien — une identité, deux mots de passe
WRITABLE Keycloak écrit à travers vers LDAP l'annuaire reste la source unique ; Keycloak n'en est qu'un client

UNSYNCED aurait « marché » à l'écran tout en cassant le courriel en silence. C'est le piège qu'il fallait éviter.

Le mode devient une variable, et il est réconcilié — comme l'URL depuis hier. Un provider créé en READ_ONLY le serait resté à vie.

Deux fautes de ma part dans le même correctif. Mon extraction du mode actuel s'ancrait sur $ alors que la ligne finit par un guillemet : elle ne correspondait jamais. Et sans || true, un grep sans correspondance tue le script entier sous pipefail. La tâche échouait — masquée par no_log, pour la troisième fois aujourd'hui.

« invalid username or password » — c'était la porte, pas le mot de passe

Première tentative de connexion du sysadmin : refusée. Ni le jeton ni le compte n'étaient en cause — vérifié dans l'ordre : ldapwhoami avec le jeton retourne le DN, le compte n'est ni verrouillé ni en échec (pwdFailureTime absent), et Keycloak voit sysadmin activé et fédéré.

https://auth.<domaine>/ redirige vers /admin/ — la console d'administration du realm master, où sysadmin n'existe pas. Il vit dans le realm applicatif. Keycloak répond donc « identifiants invalides » : exact, et parfaitement trompeur.

Le runbook disait « se connecter à Keycloak » sans donner d'URL, et l'URL évidente est la mauvaise. C'est un défaut du document, pas de la manipulation. Il nomme désormais les deux consoles et dit laquelle sert à quoi :

/realms/<realm>/account/   ton compte, tes accès      sysadmin + jeton
/admin/                    administrer Keycloak       admin + vault_keycloak_admin

L'absence de trace d'échec côté LDAP était le vrai indice. pwdFailureTime vide signifiait qu'aucune tentative n'atteignait l'annuaire — donc que le problème était en amont de la validation, pas dedans. Chercher d'abord où la requête s'arrête vaut mieux que présumer ce qui est faux.

Le sysadmin ne pouvait atteindre aucune interface web

Depuis le poste d'administration, auth.chezlepro.internal ne répondait pas — ni aucun autre service. Le pare-feu de l'hyperviseur n'autorisait le 443 de l'edge que depuis +t17-flotte, les hôtes du tenant. Le réseau d'administration est dans t17-admin, pas dans t17-flotte.

L'exploitant arrivait donc par un troisième chemin que rien ne déclarait : ni externe (Internet, affaire de la frontière), ni flotte (le tenant). Il n'est ni l'un ni l'autre — et le runbook de reprise, écrit la veille, supposait pourtant qu'on ouvre Keycloak dans un navigateur.

admin devient un pair déclarable, au même titre que flotte et edge : les réseaux de l'intrant nftables_admin_ssh, déjà source unique de la garde anti-lockout. serveur_nginx le déclare pour son 443, et les deux générateurs le traduisent — l'IPSet t17-admin existait déjà. L'edge seul reçoit ce droit : c'est le point d'entrée unique, et ouvrir les services en direct élargirait la surface sans rien gagner.

Les six interfaces répondent maintenant depuis le poste.

Une erreur de méthode que j'ai commise en donnant les instructions. Mon premier test utilisait /dev/tcp et concluait « atteignable ». C'était faux : la frontière répond au SYN à la place de la cible. J'avais consigné ce piège le matin même et j'y suis retombé. Un connect() ne prouve rien ; seule une lecture prouve.

make ca-racine — la racine de l'AC, et son empreinte

Le runbook demandait de faire confiance à l'AC interne sans dire comment. Deux cibles :

make ca-racine      # écrit ./root_ca.crt, affiche sujet, validité, empreinte
make ca-empreinte   # la même empreinte, lue SUR l'AC — le témoin de comparaison

L'hôte de l'AC est dérivé du groupe serveur_step_ca, jamais nommé ; une instance sans autorité interne reçoit un refus qui l'explique.

La racine est un certificat public : elle n'a rien à faire dans la voûte, et tout à faire dans le magasin de confiance de qui administre. Mais la sortie insiste sur la comparaison d'empreinte — installer une AC, c'est lui donner le droit de signer n'importe quel nom.

step-ca publie aussi sa racine sur https://<ca>:8443/roots.pem, joignable depuis le tenant. Ce chemin n'est pas ouvert au réseau d'administration, délibérément : la commande ci-dessus donne déjà le résultat, et la racine de confiance n'a pas besoin d'une porte de plus.

Quatre empreintes muettes — et une VM qui en est morte

collab-01 a cessé de répondre en SSH. Le symptôme ressemblait à un problème réseau ; la cause était un fichier YAML mal formé depuis des semaines.

Quatre meta/empreinte.yml déclaraient leurs valeurs à la racine, sans la clé setops_empreinte: : collabora, nextcloud, web_dorsal, web_frontal. charger_empreinte_role les lisait comme vides et rendait {0,0,0} — en silence. Les VM concernées recevaient le minimum du socle.

collab-01 s'est donc retrouvée avec 1 cœur / 1 Go pour porter Nextcloud et Collabora. L'installation de PHP 8.4, nginx et Redis a épuisé la mémoire, et sshd n'a plus pu forker. Après correction : 4 cœurs / 5 632 Mo. web-dorsal-01 passe de 1024 à 2048 Mo.

Un fichier qui existe mais ne dit rien est pire qu'un fichier absent : le repli aurait donné des valeurs sensées. Une garde refuse désormais cette forme, en nommant le fichier — et elle distingue « pas d'empreinte » de « empreinte illisible », qui n'appellent pas la même réponse.

Les deux VM ont été redimensionnées à chaud (arrêt propre, config PUT, redémarrage). Elles sont dans la couche apps — la dernière — donc rien ne dépendait d'elles.

Deux courses de premier démarrage, corrigées à la racine

Le clone rend la main avant que sa configuration existe. Sur un stockage lent, qm clone retourne et le .conf n'est pas encore écrit ; la tâche suivante échouait sur « Configuration file … does not exist ». Une attente active interroge l'API jusqu'à 150 s.

Au passage, j'ai failli livrer pire que le défaut : pour raccourcir une ligne trop longue, je l'avais coupée avec >- — qui replie les retours en espaces, insérant une espace au milieu de l'URL. Le lint passait, la requête aurait échoué. L'URL est assemblée dans une variable.

Le verrou dpkg frappait hors de common_packages. J'y avais mis lock_timeout hier, mais chaque autre rôle installant des paquets restait exposé — serveur_nextcloud en a fait les frais. Il est désormais posé en module_defaults sur les 30 playbooks de groupe : toute tâche apt du play en hérite, y compris celles des rôles inclus. Une déclaration au lieu de trente.

Une collision de noms qui rapportait ok

web-frontal-01 ne se créait pas : une VM héritée portait déjà ce nom (911401, arrêtée, adressage 192.168.15.x de l'ancien monde). proxmox_kvm identifie par le nom, l'a trouvée, et a rapporté ok sans rien cloner.

C'est plus grave que l'échec : un déploiement peut paraître réussi alors qu'aucune VM n'a été créée. Ça touche directement D-37 — les noms courts sont volontairement identiques d'un tenant à l'autre, et un parc hérité qui partage un nom crée exactement cette collision silencieuse. La VM héritée a été renommée.

resoudre_idp — le nom inventé était dans quatre rôles

forge-01 a échoué sur serveur_forgejo_oidc_discovery, qui pointait https://keycloak.<domaine> — le nom que rien ne publie. J'avais corrigé exactement ça dans serveur_oauth2_proxy quelques heures plus tôt, en croyant régler un cas isolé.

Il était en fait dans quatre rôles : forgejo, grafana, nextcloud, oauth2-proxy. Chacun fabriquait le même nom par la même convention, et chacun aurait échoué au même endroit — oauth2-proxy l'a fait le premier parce qu'il a été déployé le premier.

Une cinquième copie corrigée à la main aurait divergé comme les quatre autres. D'où roles/resoudre_idp : il lit l'exposition déclarée au plan et rend resoudre_idp_hote, resoudre_idp_base, resoudre_idp_discovery. Les trois rôles restants l'appellent ; leur défaut ne garde plus qu'un repli nommé, jamais la valeur de travail.

Vérifié en base sur forge-01 :

OpenIDConnectAutoDiscoveryURL : https://auth.chezlepro.internal/realms/chezlepro/…
GroupClaimName : 'groups'   AdminGroup : 'sysadmin'

Le câblage par groupe et l'URL correcte, sur un service déployé pour la première fois.

La leçon est la même que pour resoudre_annuaire hier, et elle mérite d'être dite deux fois : corriger la valeur là où elle échoue ne corrige que là. Ce sont les autres copies, silencieuses, qui coûtent la journée suivante.

Forgejo et Nextcloud câblés — avant d'être déployés

Les deux services n'existaient pas encore : les câbler maintenant vaut mieux que les corriger après. porte_par passe de aucun à claim-groupe dans leurs meta/acces.yml.

Forgejo reçoit --group-claim-name + --admin-group sur sa source OAuth2. Et sa tâche est passée de « créer si absent » à add-oauth ou update-oauth : le même défaut que la fédération Keycloak — créé une fois, jamais corrigé — l'attendait sinon.

Nextcloud était déjà en upsert. Il reçoit --mapping-groups et --group-provisioning, plus une tâche qui verse les membres du groupe d'habilitation dans le groupe interne admin : être dans un groupe projeté ne donne aucun pouvoir en soi. L'absence du groupe au premier déploiement n'est pas une erreur — il n'existe qu'à la première connexion d'un membre.

Le maillon qui manquait aux deux. Les groupes existaient dans le realm mais n'apparaissaient dans aucun jeton : pas de mapper de protocole. Forgejo et Nextcloud auraient lu un claim vide et n'auraient rien accordé — un câblage correct des deux côtés, et rien au milieu. oidc-group-membership-mapper est désormais posé sur les trois clients, avec full.path=false pour que le claim porte sysadmin et non /sysadmin.

Vérifié : grafana, forgejo, nextcloud portent chacun le mapper.

La chaîne d'habilitation est complète

group-ldap-mapper construit, et le rôle est attaché au groupe — pas à une personne.

LDAP      cn=sysadmin,ou=groups   member: uid=sysadmin
Keycloak  groupe `sysadmin` projeté, membre `sysadmin`
          rôle de realm `grafana-admin` attaché AU GROUPE
Grafana   claim roles → oidc_role_path → Admin

Ajouter quelqu'un à cn=sysadmin dans l'annuaire lui ouvre Grafana en Admin : sans toucher au dépôt, sans déploiement, sans nommer personne. C'est D-66 réalisé, et c'est ce qui rend la reprise par le sysadmin effective. Rejoué : changed=0.

serveur_keycloak_role_assignments reste vide, et son commentaire dit maintenant que c'est définitif.

Un défaut de fond découvert en chemin : la fédération n'était jamais réconciliée. federation-ldap.yml créait le provider s'il manquait, puis ne le corrigeait plus jamais. Le provider pointait donc encore ldaps://id-ldap-01.chezlepro.internal — le nom d'hôte erroné corrigé le matin même dans resoudre_annuaire. La fédération était muette, et la synchronisation des groupes échouait sur un laconique UnknownHost.

C'est le revers de D-67 appliqué au mauvais endroit : l'infrastructure se réconcilie, seules les appartenances ne le sont pas. L'URL, le DN des utilisateurs et le DN de liaison sont désormais corrigés à chaque passage.

Et j'avais masqué l'échec. La synchronisation portait || true : le mapper existait, le realm restait vide, et rien ne disait pourquoi. Ça m'a coûté la moitié du diagnostic. Le || true est retiré, l'erreur remonte avec son message.

Les cinq meta/acces.yml — et ce qu'ils rendent visible

Chaque rôle web déclare désormais le groupe qu'il reconnaît et ce qu'il lui accorde. Un troisième champ s'est imposé en écrivant : porte_par — le mécanisme qui transporte réellement l'habilitation. Sans lui, les déclarations auraient décrit une chaîne inexistante, ce qui est exactement le défaut levé sept fois hier.

L'état réel, mesuré rôle par rôle :

Rôle porte_par Ce que ça veut dire
serveur_grafana role-realm mécanisme réel : claim roles → oidc_role_path
serveur_forgejo aucun rien n'est câblé ; tout authentifié a le niveau par défaut
serveur_nextcloud aucun idem — l'administration passe par le compte local
serveur_icingaweb2 liste-uid nomme des personnes (D-66)
serveur_keycloak aucun la projection LDAP → rôle de realm n'existe pas

Trois services sur cinq n'ont aucun mécanisme, et deux nomment des personnes — ce que D-66 interdit, écrit une heure plus tôt.

Le maillon manquant est chez Keycloak. serveur_keycloak_role_assignments assigne un rôle à un utilisateur, nommément ; son propre commentaire l'admettait déjà (« en prod, préférer l'assignation via groupe d'annuaire ; ici, explicite pour la preuve »). Sans mapper group-ldap-mapper, les groupes LDAP n'atteignent jamais les services : la chaîne s'arrête avant le premier. Sa méta a donc une autre forme — acces_projection, car Keycloak projette au lieu de consommer (D-65).

Un défaut concret corrigé. serveur_icingaweb2_admins valait "testmail" — un compte de test codé en dur dans le moteur, qu'aucune instance ne surchargeait. Le seul administrateur déclaré de la supervision était donc un utilisateur inexistant. Il suit désormais l'uid d'amorçage ; vérifié sur mon-01 : users = "sysadmin".

Ce que la doctrine visait est maintenant lisible. Le §1 d'autorisation.md disait qu'aucun service ne lisait ou=groups. Les métas le disent maintenant service par service, avec le mécanisme qui manque à chacun — c'est la condition pour qu'une preuve puisse un jour le vérifier.

ppolicy chargé — le jeton d'amorçage devient vraiment à usage unique

serveur_openldap charge désormais l'overlay ppolicy et pose une politique par défaut. Le module était sur disque (/usr/lib/ldap/ppolicy.so) mais jamais chargé : seul back_mdb l'était. Depuis OpenLDAP 2.5 son schéma est intégré au module — aucun .ldif à charger, contrairement à 2.4.

La contrainte mord, mesuré sur un compte fraîchement amorcé :

$ ldapsearch -D uid=sysadmin,… -w <jeton>
Insufficient access (50)
Operations are restricted to bind/unbind/abandon/StartTLS/modify password

Le sysadmin peut se connecter et rien d'autre que changer son mot de passe. Ce que la doctrine promettait est maintenant garanti techniquement, pas seulement demandé.

pwdMustChange est ce qui donne son effet à pwdReset : sans lui, marquer une entrée n'oblige à rien. Les deux vont ensemble, et c'est le genre de couple qu'on découvre en le testant. La politique apporte aussi la longueur minimale (12 — pwdMinLength n'est appliqué que si pwdCheckQuality > 0), le verrouillage après 5 échecs et l'historique.

olcPPolicyUseLockout reste à FALSE, délibérément : répondre « ce compte est verrouillé » renseignerait un attaquant sur l'existence du compte.

Le DN de la base est lu, pas supposé. olcDatabase={1}mdb est l'usage courant mais l'index n'est pas garanti — le rôle le cherche.

Et la détection du rôle d'amorçage s'est vérifiée d'elle-même : rejoué après le chargement, il annonce « Changement FORCÉ à la première ouverture (ppolicy actif) » là où il disait l'inverse une heure plus tôt. C'est exactement pourquoi il détecte au lieu de supposer.

Le rôle d'amorçage : amorcage_acces

Crée un compte (uid=sysadmin) et un groupe (cn=sysadmin) dans LDAP, puis se retire. Prouvé sur idm-01 : le compte s'authentifie (ldapwhoami retourne son DN), et le second passage ne touche à rien — changed=0, sept tâches sautées.

Il suit l'annuaire, il ne se déclare pas au plan. Le rôle écrit par ldapi:/// — socket locale — donc il doit tourner sur l'hôte de l'annuaire. Le déclarer comme groupe obligerait chaque instance à le poser sur le bon hôte, et les instances ne nomment pas cet hôte pareil : idm-01 chez Chezlepro, id-ldap-01 chez Technolibre. Je m'en suis convaincu en le posant d'abord sur infra-pki-01 par erreur. Il est donc appliqué par le playbook de serveur_openldap.

Trois défauts trouvés en le construisant, tous par la machine :

pwdReset n'existe pas dans ce schéma — il vient de l'overlay ppolicy, non chargé (seul back_mdb l'était). L'entrée entière était rejetée, et no_log: true masquait la cause. J'avais supposé un mécanisme sans vérifier qu'il existait. Le rôle le détecte désormais : overlay présent → changement forcé ; absent → il le dit en clair, et le changement devient une obligation d'exploitation. La doctrine a été corrigée pour ne plus promettre ce qui n'a pas lieu.

Le mot de passe aurait été stocké en clair. ldap_entry écrit userPassword littéralement ; le jeton d'amorçage aurait été lisible par quiconque lit l'annuaire. Il est maintenant haché par slappasswd -h {SSHA} sur la cible.

Le recensement des secrets ne voyait pas ce rôle. voute.py ne scannait que roles/<groupe> — un rôle appliqué par un playbook sans être lui-même un groupe échappait au recensement, ce que D-20 interdit. Il suit désormais les listes roles: des playbooks, ce qui vaut pour tout rôle futur dans ce cas. Le gabarit signale correctement vault_sysadmin_amorcage, généré ensuite dans les deux tenants.

Set-OPS amorce les accès, le sysadmin gouverne (D-65 → D-67)

L'authentification était résolue et gardée (P29) ; l'autorisation n'existait nulle part. Constat mesuré : ou=groups est créé par serveur_openldap depuis le début, et aucun des 29 rôles ne le lit — pas un memberOf, pas un filtre. ou=people est vide aussi : personne ne peut entrer autrement que par les comptes de secours en voûte.

La décision principale contredit délibérément la doctrine du dépôt (D-67). Partout ailleurs, un écart entre le déclaré et le réel est un défaut à corriger : les devis réconcilient, les applicateurs retirent ce qui n'est plus demandé. Pour les habilitations, l'écart est légitime — c'est le sysadmin qui fait son travail.

Deux régimes, et la frontière entre eux :

Qui décide Régime
quel groupe accorde quoi dans un service Set-OPS réconcilié, comme le reste
qui appartient à quel groupe une personne amorcé une fois, jamais réconcilié

Set-OPS crée un accès — celui du sysadmin — puis se retire. Idempotence par existence, pas par conformité : compte absent, on le crée ; compte présent, aucune action quel que soit son état. Il a pu être renommé, promu, déplacé. Un dépôt qui réconcilierait les appartenances effacerait le compte créé la veille pour un nouvel employé — exactement la « correction » qu'un agent zélé ferait sans y penser, d'où la nécessité de l'écrire.

D-65 — les groupes LDAP portent l'autorisation, Keycloak les projette. Argument mécanique : Dovecot et Postfix ne savent pas lire un rôle Keycloak. L'y loger rendrait la moitié courriel aveugle et imposerait au sysadmin deux modèles de permissions. Conséquence pratique : un seul endroit à administrer.

D-66 — un service nomme un groupe, jamais une personne. C'est ce qui rend la reprise possible : révoquer quelqu'un ne demande pas un déploiement.

Le §6 est un runbook de reprise, et c'est la partie utile : récupérer le mot de passe d'amorçage, où administrer quoi, comment se rouvrir si on se ferme dehors, et ce qu'il faut changer en priorité. Ce dernier point mérite d'être dit : les comptes de secours ont été générés pendant le déploiement, et l'auteur du déploiement y a eu accès. Les régénérer n'est pas une formalité — c'est ce qui transforme une livraison en transfert.

Effet secondaire du cadrage : sans registre de personnes, aucune donnée personnelle n'entre dans l'historique git.

Rien n'est construit. Le rôle d'amorçage, les meta/acces.yml et la preuve restent à écrire.

Le courriel interne, et l'annuaire qui ne désignait personne

infra-mail-01 (Dovecot) et edge-mta-01 (Postfix + rspamd) déployées — dix-neuf playbooks d'affilée sans un échec. Neuf VM debout.

Postfix → Dovecot:24                    ouvert
lmtp_tls_security_level = verify        zéro-confiance sur le LMTP
mynetworks = … 10.27.0.0/16             le supernet dérivé, à l'œuvre
virtual_mailbox_maps → ldaps://idm-01   liaison établie

C'est la seconde branche de la directive d'authentification : LDAP direct pour les protocoles qui ne parlent pas OIDC, sans passer par Keycloak.

resoudre_annuaire fixait id-ldap-01 en dur — une machine qui n'existe dans aucun plan. Postfix ne pouvait pas se lier : Unable to bind to ldaps://id-ldap-01.chezlepro.internal:636 (Can't contact LDAP server).

L'intention du rôle était pourtant juste, et son commentaire le disait : « LE seul point où le nom d'hôte de l'annuaire est fixé, au lieu d'être répété dans chaque rôle ». Un seul point de vérité — mais écrit au lieu d'être dérivé. Une valeur unique et fausse vaut mieux qu'une valeur répétée et fausse ; elle reste fausse.

Le plan le déclare (applications.openldap.hote: idm-01) : c'est de là qu'elle vient désormais. Septième occurrence du même motif aujourd'hui.

Ce qui n'est pas prouvé : aucun compte n'existe dans LDAP — l'approvisionnement des utilisateurs n'est pas une étape d'infrastructure. Tout ce qui précède la boîte aux lettres est vérifié ; la remise elle-même attend un compte.

Le SSO fonctionne — quatre défauts, un seul motif

obs-01 et mon-01 déployées : Loki, Prometheus, Grafana d'un côté ; Icinga, Icinga Web 2 et oauth2-proxy de l'autre. Sept VM debout, et la supervision est réelle — 7 cibles Prometheus up, une par hôte vivant, scrutées en TLS à travers cinq zones de sécurité du VRF.

La preuve du SSO :

GET http://127.0.0.1:4180/  →  302
https://auth.chezlepro.internal/realms/chezlepro/protocol/openid-connect/auth
  ?client_id=icingaweb2&redirect_uri=https://icinga.chezlepro.internal/oauth2/callback

C'est le patron générique : Keycloak devant une application sans OIDC natif, fédérant LDAP.

Il a fallu quatre corrections, et l'erreur changeait à chaque fois — signe qu'on avançait.

Le secret de cookie était en base64 standard. oauth2-proxy décode en base64 url-safe ; un secret contenant + ou / fait échouer le décodage, il retombe sur la chaîne brute et se plaint de sa longueur — « is 44 bytes » — sans jamais mentionner l'encodage. Régénéré en url-safe dans les deux tenants, avec une garde qui refuse + et / en nommant la cause.

L'issuer était inventé. Le rôle fabriquait https://keycloak.<domaine> par convention — un nom que rien ne publie. Le plan expose Keycloak sous auth.<domaine>, et c'est ce nom que PowerDNS résout et que nginx sert.

Keycloak s'annonçait sous ce même nom inventé, à la source : issuer did not match the issuer returned by provider. Même correctif — le nom d'hôte se dérive de l'exposition.

Le pare-feu est-ouest bloquait l'edge. Le flux existait d'un seul côté : oauth2-proxy déclarait egress 443 → edge, la matrice était satisfaite (nginx déclare bien 443) — mais avec pair: externe, que le devis est-ouest saute volontairement, puisqu'il relève de la frontière. Aucune règle d'hyperviseur n'était émise, et la connexion expirait. serveur_nginx déclare désormais aussi son 443 depuis la flotte : les FQDN publiés vivent à l'edge, et un service interne qui appelle un autre service passe par son nom publié.

Trois de ces quatre sont le motif du jour : un nom construit par convention d'un côté, déclaré de l'autre. Le champ expose du plan a maintenant cinq consommateurs — PowerDNS, nginx, /etc/hosts, oauth2-proxy et Keycloak — pour une seule source.

Le supernet du tenant devient un intrant dérivé

Keycloak ne démarrait pas :

FATAL: aucune entrée dans pg_hba.conf pour l'hôte « 10.27.17.11 »,
       utilisateur « keycloak », base « keycloak », chiffrement SSL

PostgreSQL n'autorisait que 10.11.0.0/16 — l'ancien monde — pour un tenant en 10.27.0.0/16. La valeur était figée dans group_vars, sous un commentaire « AJUSTER au sous-réseau réel de déploiement » que personne n'avait suivi. Un commentaire qui demande une action est une action qui n'aura pas lieu.

Postfix portait la même valeur périmée, et aurait échoué de la même façon plus tard, sur le courriel. Technolibre aussi : 10.12.0.0/16 pour un tenant en 10.21.0.0/16. Quatre fichiers, une seule faute, répétée parce que recopiée.

instancier émet désormais setops_supernet, dérivé du seed comme le VMID et l'adresse. Les quatre fichiers le consomment, et la valeur suit le tenant sans être saisie :

Chezlepro     10.27.0.0/16
Technolibre   10.21.0.0/16

La chaîne d'identité est debout

keycloak   active   issuer = http://keycloak.chezlepro.internal:8080/realms/…
slapd      active   namingContexts: dc=chezlepro,dc=internal

idm-01 : dix playbooks, aucun échec, fédération LDAP configurée. Et les quatre premiers se sont rejoués à zéro changement — tout ce qui a été corrigé aujourd'hui converge.

Quatre VM sur quatorze sont entièrement déployées : l'autorité de certification, le DNS autoritatif, l'identité (annuaire + SSO) et les bases (PostgreSQL + Redis).

Trois défauts que seul un vrai déploiement pouvait montrer

idm-01 — première VM d'une autre zone de sécurité (t17iden) — a prouvé le routage inter-zone dans le VRF : 10.27.19.21 joint 10.27.17.11, à travers deux passerelles anycast. Puis elle a levé trois défauts, tous invisibles jusqu'à ce qu'on déploie pour de vrai.

Le handler de client_pki rechargeait un service pas encore installé. Conséquence directe de l'ordre rétabli : client_pki s'exécute avant les services — ils ont besoin du certificat — et son handler tentait systemctl reload slapd. Un consommateur absent n'est pas une erreur : il prendra le certificat déjà posé à son installation. Le silence est resserré sur ce cas précis ; un vrai échec de rechargement reste fatal, sinon un service servirait un certificat périmé sans que personne ne l'apprenne.

resoudre_base ne trouvait aucune base de portée application. Le registre accepte deux portées : groupe désigne un groupe opérationnel, application une application du plan. Keycloak déclare consommateur: keycloak — valide, le validateur l'accepte — mais le rôle cherchait serveur_keycloak. Le lien entre les deux était déclaré (applications.yml nomme le groupe de chaque application) : on le suit désormais, plutôt que de retirer un préfixe à la main. Une convention de nommage se contredit un jour ; une déclaration se corrige.

Keycloak attend une base qui n'existe pas encore. data-sql-01 n'est pas déployée : le service démarre, se connecte, échoue. Ce n'est pas un défaut — make deployer HOTE=x ne connaît que l'ordre intra-hôte. L'ordre inter-hôtes existe (couches-deploiement.yml place serveur_postgresql avant les applications) et c'est make site qui l'exploite.

Deux attentes, sans lesquelles make myDay ne peut pas reconstruire

Enchaîner creer-vm puis deployer échouait presque toujours sur une machine neuve, pour deux raisons distinctes :

  • SSH n'est pas levé quand Proxmox rend la main. Le symptôme trompe : à travers la frontière le TCP s'établit (SYN proxy) et l'échec se lit « timed out during banner exchange ».
  • Le verrou dpkg est tenu par les mises à jour automatiques de Debian, par vagues, pendant plusieurs minutes après le premier démarrage.

_attendre-hote attend les deux, et creer-vm rend désormais une VM prête plutôt que seulement démarrée. ATTENTE_HOTE (600 s) borne l'attente : dépasser reste un échec, pour qu'une panne ne devienne pas une attente infinie.

Deux couches, pas une. Le premier essai vérifiait le verrou avec fuser — un instantané. Il était libre au test, repris juste après. Chaque tâche apt de common_packages porte donc lock_timeout: 300 : le Makefile attend la fin des vagues, le rôle survit à une vague qui repart entre deux tâches.

Et un défaut dans l'attente elle-même : unattended-upgrades.service est un démon (Type=simple), toujours active. L'inclure dans la condition la rendait impossible à satisfaire — elle échouait au délai, systématiquement. Seules les unités apt-daily* sont des one-shot ; c'est le verrou qui dit si dpkg est occupé.

make deployer ignorait l'ordre des couches

client_metrique échouait sur les deux premières VM : il exige le certificat TLS du node_exporter, que seule l'AC peut émettre. Cause : afficher_playbooks_hote() triait les groupes alphabétiquement après le socle. client_journal, client_metrique, client_unbound passaient donc avant serveur_step_ca — sur l'hôte de l'AC lui-même.

docs/couches-deploiement.yml existe précisément pour définir cet ordre, et sa dernière couche dit en toutes lettres : « intégrations déployées en dernier, quand leurs cibles sont debout ». make site le lit ; make deployer ne l'avait jamais lu. Deux chemins pour la même question, un seul registre consulté.

Le tri se fait désormais par couche — socle d'abord, alphabétique seulement à l'intérieur d'une couche — depuis le même registre que l'orchestrateur.

infra-pki-01   socle → serveur_step_ca → client_pki → intégrations
infra-dns-01   socle → client_pki → serveur_powerdns → intégrations

L'autorité n'est plus une exception, elle est un cas à part

Deux politiques universelles se contredisaient. client_pki exemptait l'AC — « elle EST la source de la confiance » — et client_metrique refuse toute exemption — « un collecteur muet sur son propre état est un angle mort ». Les deux avaient raison séparément, et l'AC restait la seule machine impossible à mesurer.

L'exemption confondait ne pas s'enrôler et ne pas avoir de certificat. L'AC n'a pas à aller chercher sa racine par le réseau, chez elle, en vérifiant une empreinte qu'elle vient de produire — mais ses services ont besoin de certificats comme tous les autres. Elle est précisément la machine qui peut se les signer, localement, sans réseau.

client_pki distingue donc les deux chemins : bootstrap pour les autres, émission locale sur l'AC. L'exemption disparaît, et la doctrine reste intacte.

Un effet de bord instructif. step ca bootstrap écrit aussi le defaults.json qui porte l'URL de l'AC : en sautant le bootstrap, on perdait l'information sans le voir — flag '--ca-url' is required. --ca-url et --root sont désormais explicites pour tous les hôtes. Dépendre d'un fichier écrit par une étape qu'on saute volontairement, c'était reconstruire le même piège.

Deux services souverains debout

step-ca    active, :8443   `step ca health` → ok
powerdns   active, 10.27.19.11:53   (plus 0.0.0.0 — la restriction d'écoute a pris)
           dig @10.27.19.11 infra-pki-01.chezlepro.internal → 10.27.19.21

infra-dns-01 est la première VM tenant entièrement déployée : huit playbooks, aucun échec — socle, durcissement, PKI cliente, PowerDNS, sauvegarde, journaux, métriques, courriel. La zone souveraine résout, et l'AC a émis son premier certificat à un tiers.

L'ICMP n'a pas de port — et la seconde barrière n'avait jamais démarré

nftables.service refusait de démarrer sur la première VM déployée :

/etc/nftables.conf:23  icmp dport frag-needed accept
                            ^^^^^  syntax error, unexpected string

Le générateur émettait {protocole} dport {port} pour tout flux. L'ICMP n'a pas de port : il a un type et un code. Les deux flux PMTUD de D-30 produisaient donc un jeu que nft rejette — et un jeu rejeté ne se charge pas du tout, si bien que l'hôte perd sa barrière au lieu d'en gagner une.

Le défaut touchait les quatorze hôtes. La seconde barrière de D-31 — les nftables d'hôte, qui doublent le filtrage de l'hyperviseur — n'avait jamais pu démarrer nulle part. Personne ne s'en était aperçu parce qu'aucune VM tenant n'avait encore été déployée.

_selecteur_nft() traduit désormais : tcp dport 22 reste tel quel, icmp frag-needed devient icmp type destination-unreachable icmp code frag-needed. Un code ICMP inconnu est refusé à la génération, avec le nom du rôle fautif.

La garde tourne aussi à la vérification, pas seulement à la génération. Un rôle peut déclarer un flux que personne ne porte encore : le devis passerait, et la panne arriverait le jour où un hôte prend ce rôle. nft -c en local demanderait des privilèges netlink ; construire le sélecteur ne coûte rien et attrape exactement la même faute.

Mesuré après correction : nftables=active sur les deux VM, 16 et 17 règles chargées, dont la garde anti-lockout ip saddr 10.0.0.0/24 tcp dport 22 accept.

client_unbound devient universel — et l'amorçage DNS trouve sa place

Une VM ne peut pas s'installer sans résoudre des noms : apt en dépend. Or le DNS autoritatif du tenant (PowerDNS) répond uniquement pour la zone souveraine et refuse le reste — il ne récurse pour personne. Il manquait donc un résolveur récursif, et client_unbound est exactement l'outil écrit pour ça : zone interne déléguée à l'autoritatif, récursion depuis la racine, aucune dépendance au résolveur d'un fournisseur.

Il rejoint client_journal et client_metrique parmi les intégrations universelles : 13 hôtes sur 14, déclarés une fois dans meta/integration.yml, et 8 déclarations redondantes retirées du plan.

L'exemption, et son revers. infra-dns-01 est exempté : PowerDNS occupe déjà son port 53, y ajouter Unbound produirait un conflit de liaison. Au passage, serveur_powerdns_listen_addresses passe de 0.0.0.0 à l'adresse de l'hôte — lier toutes les interfaces occupait aussi 127.0.0.1:53, là où un résolveur local voudrait s'installer.

Mais l'appartenance au groupe est aussi ce qui ouvre le port 53 à la frontière. En exemptant la machine, je lui retirais le droit de résoudre : le serveur qui devait servir de DNS au tenant était le seul à ne pas pouvoir s'installer. serveur_powerdns déclare donc son propre flux sortant — il ne récurse pour personne, mais il doit résoudre pour lui-même.

Le résolveur d'amorçage, et pourquoi cloud-init ne suffisait pas

Nouvel intrant dns_amorcage, dérivé jusqu'à make creer-vm. Cloud-init l'écrit bien — mesuré : dns-nameservers 9.9.9.9 149.112.112.112 dans 50-cloud-init. Et il reste sans effet : dns-nameservers d'ifupdown exige resolvconf, absent du gabarit doré, qui transporte en outre un /etc/resolv.conf figé de l'ancien monde. Installer resolvconf demanderait apt, qui demande la résolution : la boucle se referme.

serveur_debian pose donc le résolveur en pre_tasks, avant common_packages — donc avant le premier apt. Une garde tient le passage de relais : si 127.0.0.1 est déjà là, client_unbound a basculé et le fichier n'est pas touché. Sans elle, chaque déploiement aurait défait la bascule, et deux rôles se seraient disputé le même fichier sans fin.

Un défaut créé puis corrigé en chemin. La première version de _intrants_communs() lisait instance/ en dur : un test sur inventaire synthétique serait allé chercher les intrants de la production. Le chemin dérive maintenant de l'inventaire reçu, et un test le prouve dans les deux sens — avec inventaire, et sans.

La première VM tenant, et les quatre défauts qu'elle a révélés

infra-pki-01 recréée pour éprouver la chaîne complète. Le CA est le bon premier service : il est le seul rôle exempté de client_pki — il ne s'enrôle pas auprès de lui-même — donc sans dépendance amont.

Tout ce qui dérive du seed est exact :

VMID     117402101      pool Chezlepro-17
carte    bridge=t17serv, firewall=1, aucune étiquette VLAN
adresse  10.27.19.21/24, gw 10.27.19.1
calcul   1 cœur / 1024 Mo
depuis le VRF : ping 0.08 ms, port 22 ouvert

Mais y arriver a demandé quatre corrections, et chacune serait passée inaperçue.

Le pare-feu est-ouest aurait enfermé Ansible. t<idx>-srv-debian n'autorisait SSH que depuis +t<idx>-flotte — les hôtes du tenant. admin_de(nom) était collecté dans le devis puis jamais utilisé. La première VM passée en policy_in=DROP se serait fermée derrière l'outil qui venait de la configurer. Un IPSet t<idx>-admin dédié porte désormais l'intrant nftables_admin_ssh, et une règle s'y source. Pas d'ajout à flotte : ce mot-clé sert aussi LDAP, SQL et les métriques, qu'il aurait ouverts au réseau d'administration.

L'applicateur ne convergeait pas. Proxmox range 192.168.255.2/32 sous la forme 192.168.255.2. Comparés littéralement, l'écart ne se referme jamais — chaque passage croit devoir corriger. _norm() ramène les deux à la même forme.

cloner-vm refusait toute VM de tenant. Son garde exigeait VLAN=, alors qu'en SDN l'étiquette est portée par le VNet et le VLAN est volontairement vide. Il accepte désormais un VLAN vide si un pont est fourni — sans quoi la VM ne serait branchée nulle part.

Le clonage ignore cores et memory. L'API Proxmox ne les accepte pas au moment du clone : la VM héritait du gabarit — 2 cœurs / 2048 Mo contre 1 / 1024 au plan. Le plan était contredit sans un mot. Une tâche les repose après le clone, et la mesure le confirme.

Troisième verrou posé : proxmox_clone_parefeu_interface: true. Sans firewall=1 sur la carte, les groupes de sécurité affectés à la VM ne s'appliquent jamais — le filtrage est-ouest serait posé et sans effet (D-64).

Le DROP se pose par VM, pas au datacenter (D-64)

Le devis enseignait un geste dangereux : « pare-feu activé, politique d'entrée DROP » au niveau du datacenter. Or policy_in y est la politique par défaut de toute VM dont le pare-feu s'active. Sur ce cluster, cela vise 37 machines héritées qui n'ont aucune règle.

policy_in existe aussi par VM. Le devis et l'applicateur le posent désormais là :

datacenter   enable=1, policy_in laissé au défaut ACCEPT
VM tenant    enable=1 + policy_in=DROP + carte firewall=1
VM héritée   rien — politique inchangée, pare-feu éteint

Même isolation est-ouest, sans le moment où tout bascule. Et l'applicateur n'a plus besoin de refuser une partie de son devis : la partie dangereuse a disparu.

Trois verrous, pas un. Une VM n'est filtrée que si le datacenter est actif, que son propre enable vaut 1 — défaut 0, c'est le verrou du milieu — et que sa carte porte firewall=1. C'est ce verrou du milieu que j'avais manqué en annonçant que huit VM en production tomberaient.

enable=1 au datacenter : mesuré, pas supposé

Basculé avec vérification immédiate. Après :

pve-firewall            enabled/running
chaînes iptables        12, toutes des chaînes-cadres PVEFW-*
chaînes par VM          aucune
règles visant roxanne   0
15 VM en marche         15
hyperviseurs, frontière joignables
sortie tenant           2/2, 13 ms

enable=1 pose le cadre et rien d'autre. Ce que le schéma laissait prévoir est maintenant constaté sur la machine — la distinction compte, et c'est la seule raison d'avoir tenté le geste plutôt que de l'écrire.

Un applicateur pour le pare-feu est-ouest — et un refus assumé

scripts/appliquer_proxmox_fw.py réconcilie les trois couches du devis : 26 IPSets, 36 groupes de sécurité, et les affectations aux VM. Créé, mis à jour, retiré — même contrat que les deux autres.

Ce qu'il ne fera jamais : activer le pare-feu du datacenter. Ce réglage (enable=1 + policy_in=DROP) vaut pour toutes les VM du cluster, y compris les 37 machines héritées qui n'ont aucune règle. Le basculer couperait le parc d'un coup. L'écart est signalé à chaque exécution, en toutes lettres ; la décision reste humaine.

C'est la première fois qu'un applicateur de ce dépôt refuse par conception de faire une partie de son devis. Le refus vaut mieux qu'une option qu'on finirait par cocher sans y penser.

Les 28 VM du devis n'existent pas encore. Elles sont listées comme différées, pas comme erreurs : leur affectation se posera au prochain passage, une fois clonées.

Les objets posés sont inertes, précisément parce que le prérequis n'est pas rempli. C'est ce qui rend l'application sûre aujourd'hui : la politique est en place et vérifiable, sans rien filtrer tant qu'on ne l'a pas décidé.

scripts/proxmox_api.py extrait ce que les deux applicateurs Proxmox partagent — surtout la recomposition du jeton utilisateur@royaume!nom, dont la voûte ne porte que le nom. La dupliquer, c'était préparer un 401 muet le jour où l'un des deux morceaux changerait.

Une fausse alerte en passant : les noms tronqués à 18 caractères semblaient collisionner entre IPSets et groupes. Ce sont deux espaces de noms distincts chez Proxmox, et les règles référencent un IPSet par un +. Aucune collision — et le devis portait déjà sa garde.

Un applicateur pour le SDN, et pour la sortie des VRF

scripts/appliquer_sdn.py — deuxième applicateur du dépôt, même contrat que celui de la frontière : ce qui manque est créé, ce que le devis ne demande plus est retiré.

Deux cibles, parce qu'elles n'ont pas la même prise. Les objets de cluster (zone, VNets, sous-réseaux) passent par l'API Proxmox ; la sortie du VRF est un fichier sur chaque nœud de sortie, qu'aucune API n'expose — donc SSH. make sdn-plan lit, make sdn-appliquer CONFIRMER=true agit.

Périmètre strict. Seules les zones nommées par le devis et celles de la liste explicite des anciens nommages sont touchées. Une zone inconnue est signalée et laissée intacte : ce dépôt n'est pas seul au monde sur ce cluster.

Deux garde-fous sur le retrait. Un VNet encore branché à une VM est refusé, avec le nom des machines — on ne débranche personne par inadvertance. Et l'ordre suit les dépendances : sous-réseaux, puis VNets, puis zones à la suppression ; l'inverse à la création. Proxmox refuse tout autre ordre, et l'avait déjà appris à ses dépens.

Une source unique pour la strophe. devis_sdn.strophe_frr() sert à la fois au devis qui l'affiche et à l'applicateur qui la compare au fichier distant. Deux rendus séparés auraient fini par diverger — c'est le mode de panne que ce dépôt passe ses journées à fermer.

Un défaut trouvé par la première exécution. L'applicateur lisait frr.conf.local sans sudo ; la lecture échouait, un || true masquait l'échec, et un fichier présent était déclaré absent — donc réécrit sans raison. Il distingue désormais « absent », « illisible » et « différent », trois états qui appellent trois réponses.

Le rejeu ne trouve plus rien à faire, les six VRF portent leur défaut, et la sortie tenant répond toujours.

Trois nœuds de sortie, et le primaire enfin émis

t17 passe à asgard,gandalf,vishnu, primaire asgard — t11 l'était déjà. La strophe FRR est posée sur les trois nœuds, et les six VRF (deux zones × trois nœuds) portent leur défaut.

Un défaut du devis découvert au passage. proxmox_sdn.sortie_primaire était déclaré et utilisé par devis_opnsense pour dériver le prochain saut des routes de la frontière, mais devis_sdn ne l'émettait jamais. Une zone créée depuis ce devis n'aurait pas eu de primaire, et les deux devis se seraient contredits : la frontière aurait pointé un nœud que le SDN n'avait pas désigné. Le devis l'émet désormais, et refuse un primaire absent de la liste des nœuds de sortie.

Une mesure qui accusait à tort. Depuis gandalf et vishnu, la sortie semblait morte : 100 % de perte là où asgard passait. La capture a tranché — pendant que gandalf pingait, asgard recevait les quatre réponses :

IP 10.0.4.1 > 10.27.19.1: ICMP echo reply, seq 1..4     (vu sur asgard)

La source du test, 10.27.19.1, est la passerelle anycast : elle existe à l'identique sur les trois nœuds. La frontière route 10.27.0.0/16 par sa route statique unique vers 10.0.4.41, et asgard consomme les réponses. Une VM tenant a une adresse unique, et le relais EVPN la lui rend — mécanisme déjà observé (10.27.19.21 via 10.0.5.41).

Ce retour par le primaire n'est pas un défaut : c'est le sens de « primaire », et la conséquence d'une route statique unique côté frontière. À retenir pour les mesures futures : une adresse anycast ne peut pas servir de source de test — elle ne désigne pas le nœud d'où l'on part.

Les tenants sortent — et le chemin est entièrement dérivé

Bout en bout, mesuré depuis vrf_t17 puis vrf_t11 sur asgard :

tenant -> 10.0.4.1    (frontière)        3/3   0.14 ms
tenant -> 69.70.26.49 (passerelle FAI)   3/3   0.31 ms
tenant -> 9.9.9.9     (Internet)         2/2   11 ms  (Chezlepro)
                                         2/2   17 ms  (Technolibre)

Il a fallu lever deux obstacles, et aucun des deux n'était celui qu'on croyait.

Proxmox n'installe aucun défaut dans le VRF du tenant (D-62). Déclarer des nœuds de sortie ne suffit pas : default-originate annonce une route aux autres nœuds, il n'en pose pas chez lui. show ip route vrf vrf_tNN 0.0.0.0/0 était vide sur les deux zones — et t11 était configuré depuis plus longtemps, donc ce n'était pas un oubli récent.

La sortie vient d'une strophe dans /etc/frr/frr.conf.local, que Proxmox fusionne à chaque régénération (EvpnPlugin.pm, read_local_frr_config). Vérifié : la ligne se retrouve dans le frr.conf généré, survit à pvesh set /cluster/sdn et à un systemctl restart frr.

Le choix de construction est le cœur de l'affaire. nexthop-vrf default emprunte une adresse — celle de la frontière, connectée sur vlan40 — au lieu d'importer la table principale. Conséquence voulue et vérifiée : la route par défaut des hyperviseurs ne gouverne pas la sortie des tenants, et peut rester où elle est. import vrf default, le geste « simple », aurait fait sortir les tenants par 192.168.11.254 en contournant la frontière, tout en leur donnant 10.0.5.0/24 (transport VXLAN), la gestion et les VLAN hérités.

Le NAT sortant ne couvrait pas les supernets tenants (D-63). Le mode « automatique » d'OPNsense ne traduit que les réseaux directement attachés ; un supernet joint par route statique en sort silencieusement. Le diagnostic est venu d'un ping vers la passerelle du FAI — un seul saut, donc aucune ambiguïté sur l'origine de la panne — puis de la table d'états :

état  10.27.19.1 -> 69.70.26.49  icmp  0:0     nat_addr : absent

Le filtre laissait passer (un état n'existe que si une règle a autorisé) ; c'est la traduction qui manquait. devis_opnsense émet désormais une règle de NAT par tenant (section 2bis), le réconciliateur les applique et les retire par /api/firewall/source_nat/*, et P24 refuse tout supernet routé mais non traduit — la garde qui aurait nommé la panne du premier coup.

devis_sdn §4 émet la strophe FRR : le nom du VRF vient de l'index, l'adresse de la frontière vient de passerelle_sortie dans l'underlay. Rien n'est saisi.

Le réconciliateur sait enfin retirer

scripts/appliquer_opnsense.py remplace les scripts jetables qui appliquaient la frontière depuis un bac à sable. Il travaille dans les deux sens : ce que le devis demande et qui manque est créé ; ce que le devis ne demande plus est retiré.

Sans ce second sens, un devis qui change laisse derrière lui des règles mortes. Elles n'ouvrent rien — mais elles décrivent une politique qui n'est plus la nôtre, et une bordure dont la lecture ment est pire qu'une bordure vide. C'est exactement ce que D-61 venait de produire : deux règles SSH sur wan que plus aucun devis ne réclame.

Périmètre strict. Seuls les objets marqués setops: (règles) ou préfixés SETOPS_ (alias) sont touchés. Ce qu'un humain a posé à la main dans l'interface n'existe pas pour ce script — il ne peut donc pas le supprimer.

Ordre imposé par les dépendances, et il porte une propriété : les alias d'abord (une règle référençant un alias absent est refusée), puis les créations, puis seulement les retraits. À aucun instant la politique n'est plus permissive qu'avant, et si un retrait échoue on reste en surcouverture — jamais avec un trou.

Garde de la règle 4. Sans CONFIRMER=true, aucune écriture : le script affiche le plan et s'arrête. make frontiere-plan pour lire, make frontiere-appliquer CONFIRMER=true pour agir. Le devis doit en outre passer sa propre garde P24 avant qu'une seule requête ne parte.

Un alias encore référencé par une règle survivante n'est jamais retiré — la suppression échouerait et laisserait le boîtier à moitié réconcilié.

La frontière filtre pour de vrai

La règle any → any d'opt1 est retirée. Les 26 règles posées la veille n'étaient jusque-là qu'une intention derrière un laissez-passer ; elles sont maintenant la politique.

Prouvé dans les deux sens, pas seulement constaté :

frontière → asgard   sonde de passerelle    Online, 0 %, 0.2 ms
asgard    → 10.0.4.1  ping depuis vlan40    2 transmis, 0 reçu, 100 % perte

Le lien L2 est sain — c'est le pare-feu qui jette. Et la sonde tient : le trafic émis par le pare-feu lui-même passe par let out anything from firewall host itself, sa réponse revient par l'état, et les règles pass in on opt1 ne le concernent pas. J'avais annoncé un risque là-dessus ; il n'existait pas.

Conséquence ferme, désormais mesurée. Les 14 règles d'opt1 n'autorisent que les supernets tenants en source. Rien n'autorise 10.0.4.41/.43/.47. Poser la route par défaut des hyperviseurs sur vlan40 les couperait donc immédiatement : cette tâche dépend maintenant de l'inventaire d'exploitation de l'hébergeur (D-46/48), qui n'avait pas d'échéance.

L'interface d'une règle se dérive de l'attachement, pas du sens du flux (D-61)

Le passage de l'administration au VLAN 10 a révélé une hypothèse devenue fausse. Le devis rangeait tout flux entrant sur le WAN, en supposant que l'administration revenait par l'adresse publique. Vrai pour 192.168.255.0/24 ; faux pour 10.0.0.0/24, qui est directement attaché sur lan (igb0, « GESTION »).

Les deux règles SSH étaient donc mortes deux fois : mauvaise interface, et Block private networks les aurait filtrées de toute façon. Elles ne fonctionnaient que grâce au Default allow LAN to any hérité. Pire, le devis en tirait un conseil nuisible — « décocher Block private networks sur WAN » — qui aurait affaibli l'interface publique pour admettre un réseau qui n'y arrive jamais.

reseaux_locaux_frontiere() dérive de l'underlay les sous-réseaux où la frontière porte elle-même une adresse, hors lien de transit. Chaque CIDR d'administration est rangé de ce côté-là ou du WAN, avec un alias par interface : Technolibre a les deux, Chezlepro n'a que la gestion. L'avertissement RFC1918 ne compte plus que les sources réellement côté WAN.

Sans underlay, tout retombe sur le WAN — le comportement d'avant, inchangé.

La garde tient la décision. P24 confronte chaque règle d'administration à l'attachement de sa source : une règle sur la gestion dont la source n'est attachée nulle part, ou une règle sur le WAN dont la source est locale, font échouer le devis. Vérifié en rejouant l'ancien comportement : la garde le refuse.

Nouvel intrant opnsense_if_gestion (lan ici), au catalogue du GUI. 27 règles au lieu de 26 — Technolibre en gagne une, ayant des sources des deux côtés.

Le réseau d'administration est le VLAN 10, partout

nftables_admin_ssh déclarait encore 192.168.255.0/24 chez Chezlepro — l'ancien monde. Cet intrant a trois consommateurs : les alias SETOPS_ADMIN_* de la frontière, le devis du commutateur, et le jeu nftables de chaque VM. Il est donc devenu la source unique du « d'où administre-t-on ».

  • Chezlepro : 10.0.0.0/24. Le VLAN 10 est son réseau d'administration (D-54).
  • Technolibre : ses deux réseaux plus 10.0.0.0/24. Un tenant doit accepter son propre admin et celui de qui l'héberge — c'est de la VLAN 10 de l'hébergeur qu'Ansible se connecte. Sans le second, aucun déploiement ne joindrait ses VM.

Propagé : alias mis à jour sur la frontière, 14 aperçus nftables régénérés.

Ce que ça évitait. flux-genere/infra-pki-01.nft était figé avec l'ancienne valeur, et il prime sur le gabarit plat quand il existe. Un make deployer aurait posé un jeu n'autorisant SSH que depuis 192.168.255.0/24, sur un hôte joint depuis 10.0.0.x — la machine se serait fermée derrière l'outil qui venait de la configurer. Les treize autres n'avaient pas d'aperçu et seraient tombées sur le gabarit plat : SSH ouvert à tous, pas de blocage mais aucune restriction non plus. Les quatorze portent maintenant la bonne source.

make flux était cassé par un port non numérique

Le tri du registre comparait les ports directement, donc int contre str dès que deux types se croisaient dans un même sens pour un même rôle. Le défaut était latent : serveur_nginx a derive depuis longtemps, mais son voisin est dans l'autre sens et le tuple tranche sur le rang avant d'atteindre le port. Les deux flux ICMP frag-needed de serveur_debian, eux, sont dans les deux sens — ils ont rendu la comparaison inévitable.

_cle_port() rend la clé homogène : les numériques d'abord, les symboliques ensuite par ordre alphabétique. Le registre se régénère.

L'EVPN tourne

Les commutateurs configurés, le trunk vérifié : 10.0.5.41 joint .43 et .47. Le SDN appliqué a fait basculer les tunnels — ils sortaient de 192.168.11.41, la carte de gestion, et sont maintenant sur vlan11. C'est ce que l'objection du 4 août visait, et ce n'est vrai que depuis cette application.

bgpd et bfdd étaient à no sur les trois nœuds : FRR tournait avec zebra seul, donc aucune session EVPN. Activés un nœud à la fois — vishnu, gandalf, asgard — avec vérification du quorum et de la route par défaut après chacun. Maillage complet : six sessions établies, 16 préfixes échangés.

Sauvegarde /etc/frr/daemons.avant-bgpd posée sur chaque nœud avant modification.

Une alarme retirée

J'avais annoncé les VRF « inversés » — vrf_t11 portant les sous-réseaux de Chezlepro. Le noyau tranche : 10.21.16.0/24 is directly connected, t11fron dans vrf_t11. Chaque VRF porte bien ses propres sous-réseaux, par les passerelles anycast de ses VNets. Les ip route … null0 sont ceux des autres zones — et avec deux tenants, « les autres » et « inversés » se ressemblaient exactement.

Une brèche réelle, à filtrer avant la première VM

Une fois les nœuds de sortie actifs, une VM tenant atteint tous les réseaux directement connectés sur son propre hyperviseur — gestion Proxmox, stockage iSCSI, Ceph, et le transport VXLAN lui-même. Mesuré avant la suppression de la VM d'essai.

Ce n'est pas un défaut de configuration : c'est le prix du routage inter-VRF. Une route connectée l'emporte sur la route par défaut, donc déplacer celle-ci (D-57) n'y suffira pas. Il faut une règle « supernet tenant → réseaux de l'hyperviseur : DROP », que make devis-proxmox-fw sait déjà dériver.

Déclencheur : avant la première VM tenant. À ce stade — plateforme en construction, zéro locataire — c'est un chantier, pas un incident.

La frontière : identifiant vérifié, nœud de sortie dérivé

La clé d'API fonctionne (OPNsense 26.1.2_5). L'API des règles donne elle-même la liste des identifiants qu'elle accepte :

lan → « GESTION »    opt1 → « TENANTS »    wan → « WAN »

Ni le périphérique (vlan040), ni le libellé. L'intrant opnsense_if_transit: opt1 était juste — la question ouverte depuis deux jours est tranchée par la mesure.

Le prochain saut des routes tenants ne s'écrit plus <NOEUD-DE-SORTIE-EVPN> : il dérive. Deux déclarations doivent concorder, et c'est voulu — l'hébergeur nomme le nœud (proxmox_sdn.sortie_primaire, propriété du cluster), l'underlay dit son adresse sur le lien de frontière. Nommer un nœud absent du lien rend le devis muet plutôt que faux.

route add 10.21.0.0/16 via 10.0.4.41
route add 10.27.0.0/16 via 10.0.4.41

Le devis explique pourquoi cette adresse-là, et pourquoi un seul saut : une route statique n'en porte qu'un, et deux nœuds actifs en sortie avec une seule route en entrée donneraient un chemin asymétrique — la réponse reviendrait par une interface où l'état n'a pas été créé.

30 preuves OK.

2026-08-04 (suite 4) — le numéro est l'adresse

Les deux commutateurs deviennent bifrost-3 et bifrost-4, avec leurs adresses 10.0.0.3 et 10.0.0.4. Tout l'ensemble de bordure porte désormais un seul nom, et N est son dernier octet :

bifrost-1   frontiere   10.0.0.1 · 10.0.4.1     ← passerelle
bifrost-2   frontiere   10.0.0.2 · 10.0.4.2
bifrost-3   switch      10.0.0.3                ← racine du spanning-tree
bifrost-4   switch      10.0.0.4

Plus de table de correspondance : bifrost-4, c'est .4.

D-12 est renversée. Elle disait « bifrost aux frontières, sleipnir à la fabric » — le nom portait le type de la machine. Or c'est le champ role qui le fait, et lui seul pilote le devis : aucune logique ne dépendait du nom, seulement des données et un commentaire. Ce qui survit de D-12, c'est l'idée qu'un nom doit se retenir — elle passe maintenant par le numéro.

Le devis a suivi seul, jusqu'aux marqueurs de ports (<PORT-VERS-BIFROST-4>).

Inconvénient assumé : bifrost-3 ne dit plus « commutateur ». Il faut lire role. En pratique le devis s'en charge — sa partie B s'intitule « SWITCHES D'ACCÈS (L2 pur) : bifrost-4 ».

30 preuves OK.

2026-08-04 (suite 3) — le VNet d'une VM se dérive, et un hyperviseur a plusieurs pattes

Le pont n'était pas seulement non portable : il était faux

proxmox_clone_pont faisait naître les VM sur vmbr1 avec une étiquette VLAN — l'ancien monde. En SDN, une VM appartient à son VNet. C'est ce qu'il a fallu corriger à la main sur infra-pki-01, et les treize suivantes auraient suivi.

Le VNet est dérivable : index + zone de sécurité → t17serv, comme le VMID et l'adresse le sont déjà. deriver_nomenclature() expose désormais la zone, instancier émet proxmox_pont et une étiquette vide — le VNet porte déjà le tag, en poser un second donnerait un double étiquetage.

SETOPS_PONT='t11appl'
SETOPS_VLAN=''

Trois pièges en chemin. Un doublon dans le Makefile passait PONT_PROXMOX deux fois dans la même cible : la seconde, vide, aurait écrasé la valeur dérivée. Un repli naïf sur proxmox_vlan aurait fait revenir l'étiquette en SDN — le repli ne s'applique que si la clé est absente, jamais si elle est présente et vide : c'est la différence entre « on ne sait pas » et « on a décidé qu'il n'y en a pas ». Et le test unitaire est tombé, à raison ; il couvre maintenant cette distinction.

La vocation du dépôt réseau (D-55)

Il porte le contrat entre l'Alliance et ses hébergeurs : si chacun présente la même interface, un tenant se déplace sans rien changer chez lui. Il abstrait le matériel en encapsulant chaque tenant dans sa zone EVPN — le VRF borne ce qu'il a le droit de connaître.

Mesuré : un tenant est à deux valeurs de la portabilité complète (noeud, stockage). Aucun ne nomme un commutateur, un VLAN, une adresse d'underlay ni une zone.

Un hyperviseur a plusieurs pattes (D-57)

Les hyperviseurs reçoivent 10.0.0.41/.43/.47 sur vmbr0 — interface sysadmin, sans route par défaut. On ne l'atteint que depuis le même domaine de diffusion : un accès distant doit être ouvert explicitement à la frontière, il ne peut pas exister par accident.

Leur route par défaut passe sur vlan40, vers l'OPNsense. Ça tranche la question laissée ouverte depuis ce matin — option A, mais sur une interface dédiée, ce qui lève l'objection qui la bloquait : le trafic tenant ne touche plus la carte d'administration.

Le modèle ne savait pas exprimer deux interfaces sur un même hôte : ajouter le VLAN 10 l'a mis sur le trunk de bond3, remettant la gestion dans le domaine qu'on venait d'en sortir. D'où le champ via, dont le devis dérive un port par interface et son type :

<PORT-VERS-PROXMOX-BOND3>   trunk   11,40
<PORT-VERS-PROXMOX-VMBR0>   access  10

Un seul VLAN sur une interface = port d'accès ; plusieurs = trunk. Dérivé, pas déclaré.

Régression créée puis corrigée au passage : le modèle public, qui ne déclare aucun hyperviseur, n'émettait plus rien pour ce port. Un devis muet ferait croire qu'il n'y a rien à configurer. Il émet désormais tout l'underlay, en disant que c'est un repli.

Côté cluster

vmbr3 retiré des trois nœuds, vlan11 et vlan40 créées sur bond3 aux bonnes adresses. Les pairs du contrôleur EVPN pointaient encore sur 10.0.0.x — des adresses qui n'existaient plus. Corrigés vers 10.0.5.x, dérivés d'underlay.yml plutôt que retapés : une liste saisie à la main diverge au premier changement, ce qui venait précisément d'arriver. Confronté à l'API : les trois pairs correspondent à une vlan11 réelle.

Pas encore câblé, donc pas encore appliqué. infra-pki-01 n'a rien senti.

30 preuves OK.

2026-08-04 (suite 2) — l'invariant du dernier octet retrouve sa portée

Il valait partout ; il ne vaut que là où il a un sens : l'adressage dérivé des tenants, où passerelle_de(index, zone) produit le même .1 dans les treize sous-réseaux. C'est une propriété de la dérivation, pas une loi universelle.

Dans l'underlay, il produisait deux effets pervers :

  • un seuil arbitraire — les sous-réseaux plus étroits qu'un /24 étaient exemptés, donc élargir un /29 changeait la validité du fichier sans que rien d'autre bouge ;
  • une couture entre propriétaires — l'octet attendu venait de la nomenclature d'un tenant, appliquée à la fabric de l'hébergeur. La validité de l'underlay aurait dépendu du tenant actif si l'un d'eux réservait autre chose.

Ce qui reste est plus fort, et suffit : une passerelle doit être l'adresse d'un hôte déclaré sur ce réseau (D-52). Elle attrape les passerelles fantômes, ce que le comptage d'octets ne faisait pas.

À noter, parce que l'ordre était mauvais. Le ré-adressage de l'OPNsense en .1 a été demandé au nom de cette règle, deux messages avant qu'elle soit recadrée. Il n'est pas perdu — .1 est la position conventionnelle d'une passerelle, et la frontière la porte désormais partout — mais la portée de la règle aurait dû être questionnée avant de faire changer une adresse sur un boîtier en service.

Deux décisions consignées, non construites

D-53 — le réseau et l'underlay de l'hébergeur méritent leur propre dépôt. underlay.yml décrit une infrastructure ; le dépôt de tenant décrit une organisation. Un tenant peut déménager, une fabric non. Les mêler oblige, à chaque commit, à trancher ce qu'on touche. Le symlink désignant l'hébergeur pointerait vers ce dépôt-là — la distinction deviendrait visible dans les chemins.

D-54 — 10.0.0.0/24 est réservé à l'IPAM, la gestion des équipements et l'OOB ; accès sysadmin. Aucun hyperviseur, aucune VM, aucun trafic tenant — ni encapsulé, ni décapsulé. C'est la raison d'être des VLAN 11 et 40. Écrit dans underlay.yml à côté du réseau lui-même, pas seulement dans la documentation.

30 preuves OK.

2026-08-04 (suite) — plus aucun commutateur ne route

Question posée à froid : « je ne vois plus de valeur ajoutée au point de routage sleipnir-01 ». Vérification faite, aucun de ses trois SVI n'avait de consommateur.

SVI Membres du VLAN Qui a besoin de routage
Vlan11 les trois VTEP, tous en 10.0.5.0/24 personne — même sous-réseau
Vlan40 frontières et nœuds de sortie, tous en 10.0.4.0/24 personne — adjacents
Vlan10 les commutateurs eux-mêmes, pour leur sortie

Et l'OPNsense a déjà une patte sur le VLAN 10 : les commutateurs peuvent l'utiliser directement.

Deux décisions séparées avaient vidé ce rôle sans qu'on regarde leur effet cumulé. Le passage à l'EVPN a retiré les VLAN tenants du fil ; la fusion du lien de sortie dans le VLAN 40 a rendu les nœuds de sortie adjacents à la frontière. Chacune était justifiée seule.

sleipnir-01 disparaît

Pas seulement son rôle L3 : la machine. En étoile, le centre est sur tous les chemins — un point de panne unique pour le plan de données entier. Ça vidait aussi de son sens l'ajout d'une seconde carte à bond3 : deux liens qui aboutissent au même commutateur protègent d'un câble, pas d'un équipement.

Deux commutateurs L2 reliés entre eux, avec les bond3 répartis, donnent la redondance qu'une étoile ne peut pas donner. D-51, et D-05 est renversée.

Ce que le devis perd

Trois SVI, quatre routes statiques, et surtout la section 5 — celle qui portait « à appliquer en dernier ; ces routes coupent l'accès d'administration au switch lui-même ». La manœuvre la plus risquée du devis n'existe plus. Le devis frontière annonce désormais « prérequis réciproques : AUCUN ».

passerelle change de sens (D-50)

Elle signifiait « l'adresse du SVI du commutateur » — une hypothèse déguisée en donnée. Elle signifie maintenant « la passerelle de ce sous-réseau, où qu'elle vive », et le devis dérive s'il doit émettre une interface routée : uniquement si le porteur déclaré a le rôle switch.

Le même moteur sert donc les deux postures. Le modèle public démontre celle où le commutateur route ; Chezlepro celle où la frontière route.

Deux gardes remplacées, pas affaiblies

passerelle_sortie exige aussi passerelle et routeur.ip == passerelle encodaient l'ancienne hypothèse et refusaient la seule configuration correcte. À leur place, une règle plus forte : une passerelle doit être l'adresse d'un hôte déclaré sur ce réseau. Elle attrape en plus les passerelles fantômes — une adresse inventée, un octet de trop. Éprouvée par trois sabotages, les trois attrapés.

Elle a immédiatement trouvé une sous-déclaration dans le modèle public : il annonçait un SVI de transit à 10.0.4.6 sans dire qui le porte.

Trois trous trouvés en chemin

Tous de la même famille — une liste figée finit toujours par mentir :

  • le port vers la frontière était figé sur le transit, muet dès que les bifrost ont eu une seconde patte ;
  • le trunk vers Proxmox excluait le transit « parce qu'aucun hyperviseur n'y est » ;
  • les switches d'accès sautaient le VLAN de transit à la déclaration.

Et un quatrième, créé par la suppression du SVI : le commutateur de tête n'avait plus d'adresse de gestion. Tant qu'il portait le SVI, son adresse était la passerelle ; sans SVI, elle n'était plus émise nulle part — visible seulement en commentaire.

Adressage résultant

VLAN 10  bifrost-1 .1 (passerelle)  bifrost-2 .2   sleipnir-02 .3  sleipnir-03 .4
VLAN 11  asgard .41  gandalf .43  vishnu .47                        aucune passerelle
VLAN 40  bifrost-1 .1 (sortie)     bifrost-2 .2   asgard .41  gandalf .43

La frontière porte .1 partout : l'invariant du dernier octet (D-04) est enfin vrai pour le seul routeur. opnsense_api_url passe de .254 à .1 — le boîtier doit suivre, mais rien dans Set-OPS n'appelle son API aujourd'hui, donc aucune automatisation ne casse en attendant.

D-03 est renversée : le /29 élargi en /24 fait tomber l'exemption d'invariant, et le .1 revient à la passerelle. Le plan survit, décalé d'un cran.

Question ouverte

L'octet attendu vient de plan/nomenclature.yml d'un tenant, appliqué à l'underlay de l'hébergeur. Deux propriétaires, une seule valeur : la validité de la fabric dépendrait du tenant actif si l'un d'eux réservait autre chose. Non corrigé.

30 preuves OK.

2026-08-04 — séparation des plans, et où vont les services de l'hébergeur

Deux VLAN pour séparer ce qui était mêlé

Le VTEP vivait sur le VLAN 10, donc le transport tenant partageait son domaine de diffusion avec l'administration des équipements (SVI du commutateur, OPNsense). Plus grave : un nœud de sortie décapsule le trafic tenant et le remet dans sa table principale — dont la route par défaut sort par vmbr0, l'interface de gestion des nœuds. Le trafic des VM aurait emprunté le lien physique de l'interface web, de SSH et du cluster.

Ça défaisait ce que l'EVPN devait obtenir. underlay.yml déclare donc :

vlan 11  underlay-vxlan  10.0.5.0/24  SVI 10.0.5.1   transport VXLAN
vlan 41  sortie-tenant   10.0.6.0/24  aucun SVI      trafic décapsulé

sortie-tenant n'a volontairement pas de passerelle : le commutateur le transporte sans le router, donc le trafic tenant en clair ne traverse aucun SVI de gestion. Le devis n'émet d'ailleurs pas d'interface Vlan41 — le modèle a exprimé l'intention sans qu'on ait à la commenter.

Support prévu : bond3 une fois doublé — enp7s0 est libre sur les trois nœuds, et le bond est déjà en active-backup, le mode qui convient sans MLAG. Aujourd'hui bond3 n'a qu'une carte : tout le trafic tenant, intra-zone compris, repose sur enp8s0.

Un défaut que ce changement a créé, et corrigé

Le port du commutateur vers la frontière était figé sur le seul VLAN de transit. Les bifrost ayant désormais une patte sur le 41, ce port serait resté muet : le devis aurait eu l'air juste et le trafic ne serait jamais arrivé. Il dérive maintenant des rattachements déclarés des hôtes role: frontiere → allowed vlan 40,41.

Vérification de la construction parallèle

Le cluster actuel ne correspond pas à ce qui est planifié, et c'est voulu : on construit à côté. Encore faut-il qu'il n'y ait pas de collision. Mesuré sur les 38 VM héritées : étiquettes 7, 8, 9, 10, 12, 13, 14, 15, 1001, 1003 — aucune ne croise les VNI projetés (1111‑1116, 1171‑1176) ni les VLAN d'underlay.

Deux points relevés : le VLAN 10 porte 4 VM héritées (il n'est donc pas purement de la gestion), et infra-pki-01 est encore branchée à l'ancienne — vmbr3 + étiquette 1174 — pas sur le VNet t17serv. À rebrancher à la bascule.

Où vont les services de l'hébergeur (D-46 à D-48)

Constat : aucun équipement de l'hébergeur n'est dans un inventaire Ansible, et rien ne sauvegarde leurs configurations. Ni les hyperviseurs, ni les commutateurs, ni la frontière.

Un hébergeur porte trois catégories : son tenant (son courriel, sa forge — un client comme les autres), ses opérations (supervision de la fabric, journaux, sauvegarde des configs, DNS d'underlay), et le plan de contrôle (déjà dehors).

Les opérations vivent dans le dépôt de l'hébergeur, et leurs VM se rattachent à un pont VLAN, jamais un VNet : un service qui observe la fabric ne peut pas dépendre d'elle. Et la doctrine « hors flotte » ne vaut que pour les commutateurs et la frontière — les hyperviseurs sont des Debian joignables en SSH.

Décision consignée, rien n'est construit. docs/hebergeur-exploitation.md.

Question laissée ouverte

L'index 0 réservé au tenant propre de chaque hébergeur — local par construction, donc jamais à coordonner. Deux obstacles mesurés : il produit les VNI 1001–1006, que le parc hérité utilise déjà (1001 TechnoLibre historique, 1003 KBR) ; et P21 déclencherait une fausse collision entre deux dépôts d'hébergeurs. En attente.

2026-08-03 (suite 17) — le plan de données existe (make devis-sdn)

Ajouter un tenant n'ajoute pas qu'un plan : cela implique 1 zone EVPN + 6 VNets + 6 sous-réseaux sur le cluster. Aucun générateur ne les produisait — c'était la dernière lacune dans un dépôt où tout dérive du seed.

make devis-sdn les émet, tenant par tenant. 26 objets pour les deux tenants, tous dérivés : le VNI est 1000 + index×10 + zone, le sous-réseau et la passerelle viennent des mêmes fonctions que l'inventaire.

Le nommage, en deux temps

Première version : VRF0017 / v1174, alignés sur ce que le cluster portait déjà. C'était le réflexe inverse du bon — cette convention venait d'une création à la main, ne disait pas de quel tenant il s'agissait, et chez174 demandait d'ouvrir la table des catégories pour être lu.

Forme retenue : t<index> pour la zone, t<index><zone abrégée> pour le VNet — t17, t17serv, t17obse. C'est le préfixe que le pare-feu Proxmox utilisait déjà (t17-cli-metrique, t17-flotte) : un seul schéma se lit dans tout le dépôt. L'abréviation vient des 4 premières lettres du libellé, accents retirés, donc dérivée.

Pas de tiret entre l'index et la zone, contrairement aux IPSets : t245-serv ferait 9 caractères. Sans séparateur, t245serv en fait 8 — la forme reste uniforme jusqu'au dernier index de la fédération.

La contrainte qui a tout cadré

Zones et VNets sont limités à 8 caractères par Proxmox — l'identifiant sert de base aux noms de bridge, veth et tap. Le tableau de sdn-evpn.md annonçait chez17-services-infra, soit 21 : il aurait été refusé à l'application. P30 refuse désormais tout dépassement, sur les deux objets, et toute collision de nom, de VNI ou de sous-réseau entre tenants.

Éprouvé aux bornes (t1serv 6, t17serv 7, t245serv 8) et par sabotage : deux libellés partageant leurs 4 premières lettres produisent le même VNet, et la garde l'attrape.

Vérification la plus forte disponible

Avant renommage, la dérivation reproduisait à l'identique les deux zones déjà créées à la main — nom, VNI de VRF, MTU, contrôleur. La dérivation retombait sur ce qu'un humain avait posé.

voute.py saisir : le pendant de la génération

On génère un secret dont le dépôt est la source ; on saisit celui dont un tiers est la source. Inventer une clé d'API OPNsense donnerait une valeur syntaxiquement correcte, refusée à la première requête — et P18 au vert sur une voûte inutilisable.

Saisie sans écho, double confirmation, rien sur la ligne de commande. Éprouvée sur une voûte jetable : écrit, préserve l'existant, refuse d'écraser sans --remplacer.

Ce qui reste ouvert

Aucune zone ne déclare de nœud de sortie, et le devis émet un marqueur plutôt qu'une valeur plausible. Deux points à trancher avant :

  • le nœud de sortie route selon sa propre table ; la passerelle par défaut des trois hyperviseurs est 192.168.11.254, pas la frontière ;
  • l'entrée n'est pas redondante : elle dépend d'une route statique d'OPNsense vers un nœud. Deux nœuds de sortie ne donnent aucune redondance entrante.

D-43, D-44, D-45, AFF-112. 30 preuves OK.

2026-08-03 (suite 16) — la directive d'authentification est gardée (P29)

Une règle qu'aucune garde ne vérifie finit par ne plus être vraie : c'est exactement ce qui était arrivé aux 28 lignes d'intégration recopiées. Chaque rôle serveur_* porte désormais un meta/authentification.yml, et P29 le confronte à son code.

web-sso            5   grafana, forgejo, nextcloud, icingaweb2, oauth2-proxy
socle-identite     2   keycloak, openldap — ils SONT la chaîne d'identité
ldap-direct        2   dovecot, postfix
interne-sans-auth  2   prometheus, loki — lacunes nommées
sans-auth-humaine 12

Elle refuse l'oubli et le mensonge

Éprouvée par sabotage, sept cas : déclaration supprimée, portée inventée, secours retiré, posture de formulaire retirée, raison retirée, ldap-direct mensonger, réglage retiré des defaults. Les sept sont attrapés.

Les deux derniers passaient dans ma première version.

Le mensonge passait à cause d'un de mes propres commentaires. Je cherchais le mot « ldap » dans le rôle — or serveur_grafana/defaults/main.yml contient la phrase « désactiver quelqu'un dans LDAP ». De la prose suffisait à valider une déclaration fausse. La preuve exige maintenant un indice nommé : une variable du namespace du rôle (<rôle>_oidc, <rôle>_ldap) ou une URI ldap://.

Le réglage retiré passait parce que le gabarit citait encore la variable alors que plus rien ne lui donnait de valeur. La preuve lit désormais defaults/main.yml en YAML et exige que la clé y soit définie, pas mentionnée.

Une valeur que la preuve a forcé à inventer

Elle a d'abord échoué sur oauth2-proxy : je l'avais déclaré « formulaire local fermé » alors qu'il n'a aucun compte local — c'est une passerelle. D'où formulaire_local: aucun, qui distingue « il n'y en a jamais eu » de « il y en a un, il est fermé ». Écrire « fermé » aurait laissé croire qu'une porte avait été close.

Correction : Prometheus et Loki ne sont pas exposés

Contrairement à ce que la note de la veille affirmait, ils n'ont aucun expose au plan. Seuls six groupes sont exposés : collabora, forgejo, grafana, keycloak, nextcloud, oauth2-proxy. Le risque est intra-tenant, pas frontalier — plus petit qu'annoncé, réel quand même.

Ces deux lacunes sont comptées, pas masquées : interne-sans-auth ne fait pas échouer la preuve, mais figure dans chaque rapport. La refuser bloquerait le harnais sur une décision déjà prise ; la taire la ferait oublier.

AFF-111, décision D-42. 29 preuves OK, 0 échec, 0 sautée.

2026-08-03 (suite 15) — la porte de secours cesse d'être annoncée

Client OIDC Nextcloud ajouté aux deux tenants — quatre clients chacun désormais, sur le chemin que l'app user_oidc impose (…/apps/user_oidc/code). Le secret existait des deux côtés depuis hier ; le client qui devait le porter manquait.

La directive

Trois règles, indissociables parce que chacune crée le problème que la suivante résout : toute authentification web passe par Keycloak ; LDAP est la source unique des comptes ; chaque service garde un accès de secours par sudo sur l'hôte.

La chaîne service → Keycloak → LDAP est en série. Le secours n'est donc pas une entorse au SSO : c'est ce qui rend les deux premières règles tenables. Portée : le web seulement — IMAP et SMTP se lient à LDAP directement, SSH est en clé seule.

La posture : <rôle>_connexion_locale: false

Le compte local existe — il ne peut pas dépendre de Keycloak, sinon il tomberait avec lui — mais son formulaire n'est plus proposé au repos. Un formulaire ouvert en permanence contourne la politique de mot de passe, le MFA, et surtout la révocation centrale : désactiver quelqu'un dans LDAP laisserait son compte local valide, sans que rien ne le signale.

Fermer ne coûte rien depuis qu'on a tranché que sudo suffit : sudo est le mécanisme de réouverture.

Ce que chaque service sait vraiment faire

Vérifié auprès de l'amont, puis par rendu réel des gabarits dans les deux postures.

Service Réglage Effet réel
Grafana GF_AUTH_DISABLE_LOGIN_FORM ferme le formulaire
Forgejo ≥ 10 ENABLE_INTERNAL_SIGNIN + ENABLE_BASIC_AUTHENTICATION ferme la connexion interne et l'API en Basic
Nextcloud hide_login_form masque seulement

Nextcloud est une exception, écrite comme telle. …/login?direct=1 atteint encore le formulaire, et l'amont le documente comme voulu — c'est ainsi qu'un administrateur entre. La protection est de ne plus l'annoncer, pas d'interdire. Le présenter comme équivalent donnerait un faux confort.

Forgejo tombe juste. ENABLE_INTERNAL_SIGNIN n'existe que depuis la v10 (ticket amont 7476, clos : « This option was added to Forgejo v10 ») et le rôle épingle 10.0.0. Sur une version antérieure il serait ignoré sans erreur : un assert refuse la fermeture sous 10.0.0 plutôt que de laisser le rôle croire qu'il a fermé la porte.

Le défaut que le rendu a attrapé

Ma première version testait {% if not serveur_forgejo_connexion_locale %} sans | bool. En rendu réel, aucune des deux postures n'émettait quoi que ce soit : une valeur transmise en chaîne (-e, ou un group_vars non typé) est vraie au sens Jinja, donc le bloc ne sortait jamais — la connexion locale serait restée ouverte en silence. C'est exactement le genre de panne que lire le gabarit ne révèle pas.

Décisions D-38 à D-41, docs/authentification.md.

Ce qui reste

Prometheus et Loki exposent une interface sans SSO ; oauth2-proxy est déjà éprouvé devant Icinga Web 2. Et aucune preuve ne garde cette directive — même risque que les intégrations universelles avant leur inversion.

2026-08-03 (suite 14) — les deux secrets Nextcloud, et un qui ne se génère pas

vault_nextcloud_admin et vault_nextcloud_oidc étaient exigés par le plan et absents de la voûte réelle de Technolibre. Générés (32 octets urlsafe), en mémoire, avec relecture et aller-retour de chiffrement vérifiés avant écriture ; jamais affichés. L'opération est idempotente : une clé déjà renseignée n'est pas touchée, et une clé présente mais vide compte comme absente — c'est le cas du gabarit recopié.

Générer était légitime ici parce qu'Ansible configure les deux côtés depuis la même variable : le client Keycloak déclare secret: "{{ vault_nextcloud_oidc }}" et le rôle Nextcloud lit la même clé. La valeur n'a pas à préexister ailleurs.

Harnais complet, voûte lisible : 28 preuves OK, 0 échec, 0 sautée.

Le contre-exemple, trouvé chez Chezlepro

La même vérification y signale vault_opnsense_api_key et vault_opnsense_api_secret absents. Il ne faut surtout pas les générer. OPNsense est hors flotte : ses identifiants d'API sont émis par le boîtier (System > Access > Users > API keys), et le secret n'est affiché qu'à la création. Une valeur inventée serait syntaxiquement correcte et refusée à la première requête.

La distinction vaut d'être retenue : on génère un secret dont le dépôt est la source, jamais un secret dont un tiers est la source. Le boîtier n'étant pas encore installé, la question ne se pose pas avant sa mise en service.

Une lacune connexe, non corrigée

Aucun des deux tenants ne déclare de client OIDC Nextcloud dans group_vars/serveur_keycloak.yml — seulement grafana, forgejo et icingaweb2. Le secret existe donc désormais des deux côtés, mais le client qui doit le porter n'est pas déclaré. À ajouter avant tout déploiement de collab-01, sur le patron des trois autres.

2026-08-03 (suite 13) — le cluster répond, et il contredit trois hypothèses

Reconnaissance en lecture seule de l'API Proxmox, avec le jeton de la voûte. Trois valeurs que j'avais devinées étaient fausses, et deux défauts bloquants sont apparus.

Ce que le cluster a corrigé

Stockages : truenas-dbsql manquait à ma liste. Et le catalogue ne doit offrir que ceux qui portent images — PBS, cephFS, local et truenas (iSCSI brut, content=none) n'accueillent pas de disque de VM.

Ponts : vmbr0 à vmbr3, vérifiés présents sur les trois nœuds. J'avais omis vmbr0 et je n'avais pas contrôlé l'uniformité — un pont partiel est un piège, la VM ne démarre que sur certains nœuds.

Un troisième homonyme : web-frontal-01 (vmid 911401) existe déjà hors Set-OPS, sans pool. Les deux tenants en planifient un chacun.

Les pools sont créés

Chezlepro-17 et Technolibre-11, dérivés comme le reste. Les pools Env.Tenant antérieurs (Prod.Chezlepro, Lab.KBR…) sont l'ancien monde : on n'y touche pas, et on n'y verse pas la flotte générée — les mélanger effacerait la frontière que Set-OPS établit.

Diff constaté sur le cluster : 2 pools ajoutés, 0 retiré, 1 VM sur 38 déplacée — infra-pki-01, qui n'appartenait à aucun pool.

Défaut bloquant : make creer-vm aurait échoué en 401

proxmoxer recompose utilisateur!nom à partir d'api_user et d'api_token_id. La voûte stocke la forme complète, que les playbooks passaient telle quelle — d'où ansible@pve!ansible@pve!nom et un 401 muet, alors que le même jeton fonctionne en curl. Le diagnostic aurait coûté cher.

Mesuré des deux côtés avec un module en lecture seule : forme complète = 401, forme courte = OK, 3 nœuds. Les playbooks normalisent désormais (split('!') | last), ce qui accepte les deux écritures.

Le reliquat proxmox.vault.yml est supprimé

Toléré « en compatibilité », il restait le seul porteur du jeton chez Technolibre. Et comme *.vault.yml est gitignoré, ce jeton ne voyageait avec aucun dépôt : une voûte unique (D-19) qui ne l'était pas.

Migration faite en mémoire — aucune valeur en clair sur disque ni affichée — avec relecture et aller-retour de chiffrement vérifiés avant écriture. Puis suppression du fichier et retrait des listes de chargement des deux playbooks. Validé par un appel API réel ne chargeant que all/vault.yml.

Et la garde qui manquait

voute.py verifier ne comparait que le gabarit. C'est pourquoi il annonçait « complet » pendant qu'un secret vivait ailleurs : le gabarit dit ce qu'il faudrait, pas ce qui est.

Il contrôle maintenant aussi la voûte réelle, quand ANSIBLE_VAULT_PASSWORD_FILE la rend déchiffrable — noms de clés seulement, jamais de valeur. Sans mot de passe, la vérification se saute : le harnais reste utilisable sans accès aux secrets.

Dès son premier passage, elle a trouvé un second trou : la voûte réelle de Technolibre n'a ni vault_nextcloud_admin ni vault_nextcloud_oidc, que le plan exige depuis l'arrivée de Nextcloud. Le déploiement aurait cassé sur une variable indéfinie. Non corrigé : générer ces deux secrets est une décision, et le secret OIDC doit correspondre à ce que Keycloak connaîtra.

2026-08-03 (suite 12) — un pool Proxmox par tenant

Onze des quatorze serveurs portent le même nom court chez Chezlepro et chez Technolibre : infra-pki-01, backup-01, obs-01…

Vérifié un par un, ce n'est pas un problème technique. Tout le reste dérive du seed et diverge : 10.27.19.21 contre 10.21.19.21, VMID 117402101 contre 111402101, VLAN 1174 contre 1114, et deux domaines internes distincts. Et rien n'est indexé sur le nom court — toutes les opérations Proxmox portent un vmid (le name: n'est qu'une étiquette), les certificats un FQDN, et client_backup_repo vise backup-01.{{ domaine_interne }}, donc le serveur du tenant.

Le coût est humain, et il est réel : la console Proxmox affiche le nom. Deux infra-pki-01 y sont indiscernables à l'œil, et c'est ainsi qu'on éteint la mauvaise machine. Le VMID porte pourtant le tenant — encore faut-il connaître le codage.

Ce qui a été fait

Un pool par tenant, dérivé : dossier d'instance + index → Chezlepro-17, Technolibre-11. L'index étant déjà unique par P21, le nom l'est aussi — aucun registre de plus.

make devis-proxmox-pools produit le rattrapage de la flotte existante (création du pool, puis affectation des VM actives). Non destructif, à relire avant d'appliquer.

Les VM créées ensuite entrent d'elles-mêmes : make creer-vm dérive le pool par la même fonction et le passe à la création. Le playbook crée le pool au préalable — deux raisons : proxmox_kvm échoue sur un pool inconnu, et l'API ne sait pas changer le pool d'une VM existante. C'est aussi pourquoi le rattrapage passe par les membres.

P28 garde deux collisions : deux tenants ne peuvent pas revendiquer le même nom de pool, ni le même VMID — une machine appartenant à deux tenants serait pire qu'une homonymie. Décision D-37, affirmation AFF-110.

Ce que je n'ai pas fait

Renommer les VM par tenant. Ça casserait ce que ces homonymes prouvent : même fonction, même nom, partout — c'est ce qui rend un modèle réutilisable.

Le devis ne lit pas le cluster : il dit l'état cible, pas l'écart. Les commandes sont idempotentes, donc rejouables sans risque, mais il ne saura pas dire ce qui est déjà en place.

2026-08-03 (suite 11) — le cluster appartient à l'hébergeur

En ouvrant le panneau « Intrants de base », on trouvait côte à côte et sans distinction des valeurs du tenant (son domaine, son realm, son modèle) et des valeurs de l'hébergeur (son cluster, sa frontière, sa fabric). Deux propriétaires, deux dépôts, deux cycles de vie — et rien à l'écran ne le disait.

Trois sections sur sept étaient déjà chez l'hébergeur (Frontière, Fabric). La section Proxmox, elle, ne l'était pas — alors qu'un cluster est du matériel possédé par l'hébergeur au même titre que ses commutateurs.

La recopie avait déjà divergé

Même cluster, deux inventaires contradictoires :

Chezlepro    stockages [TrueNAS, CephHDD, CephNVMe]   ponts [vmbr3]
Technolibre  stockages [local-lvm, TrueNAS]           ponts [vmbr1, vmbr2]

Rien ne « cassait » : ces listes ne peuplent que des menus déroulants. Mais un opérateur plaçant une VM Technolibre ne se voyait jamais proposer CephNVMe, et ça n'était la décision de personne. Le fichier de l'hébergeur prend l'union des deux — aucune n'était complète, et choisir l'une aurait été arbitraire. À confirmer contre le cluster réel.

Le partage retenu

Hébergeur (<dépôt hébergeur>/proxmox-hebergeur.yml, à côté d'underlay.yml) : proxmox_api_host, _user, _port, _validate_certs, proxmox_noeuds, _stockages, _ponts.

Tenant (group_vars/proxmox.yml) : son golden template — chaque tenant a le sien — et ses défauts de placement (nœud, stockage, pont). Ce sont des choix faits à l'intérieur de ce que l'hébergeur offre.

Le chemin se dérive du symlink underlay.yml, qui désigne déjà l'hébergeur : rien de nouveau n'est déclaré (D-17 tenue). Sans underlay monté, tout retombe dans le fichier du tenant — un dépôt autonome fonctionne exactement comme avant.

Ce que ça a demandé de moins que prévu

Les playbooks chargent ces fichiers par chemin explicite (include_vars), pas par appariement de groupe Ansible — il n'existe d'ailleurs aucun groupe proxmox dans l'inventaire. Une tâche stat + include_vars de plus a suffi ; aucun symlink dans group_vars, aucune génération.

Vérifié en exécution réelle : depuis Technolibre (tenant actif), la dérivation résout vers OPS-Chezlepro/proxmox-hebergeur.yml et charge asgard + les quatre stockages.

Le panneau nomme désormais le propriétaire

Chaque section porte une pastille tenant (bleu) ou hébergeur (ambre), avec en infobulle ce que ça implique : éditer une section « hébergeur » vaut pour tous ses tenants. Le schéma d'intrants porte un champ proprietaire — c'est la donnée qui manquait, pas l'affichage.

P27 garde la séparation : aucune clé de l'hébergeur ne peut réapparaître dans un group_vars de tenant. Sans elle, le premier make config lancé d'un autre poste recommençait la recopie. Décisions D-35 et D-36, affirmation AFF-109.

Une chose à trancher

modeleChezlepro reste déclaré comme golden template de Technolibre. Ta décision — un modèle par tenant — le rend incorrect, mais le corriger suppose qu'un modeleTechnolibre existe réellement sur le cluster. Laissé tel quel, signalé ici.

2026-08-03 (suite 10) — les intégrations universelles cessent d'être recopiées

Le plan portait 57 lignes d'intégration écrites à la main. Le décompte est sans appel : client_metrique 14/14, client_journal 14/14, client_pki 13/14 — mais client_backup 7/14, client_smtp 8/14, client_unbound 1/14.

28 de ces 57 lignes disaient oui à quelque chose de vrai pour tout le monde. Elles n'existaient donc que pour être oubliées une vingt-neuvième fois — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01 n'étaient ni supervisés, ni journalisés, ni certifiés. Rien ne l'aurait signalé, puisqu'une machine non supervisée ne proteste pas.

Ce qui change

Le rôle déclare sa politique, une fois, dans roles/<role>/meta/integration.yml :

integration:
  universelle: true
  raison: "Tout hôte est mesuré. Une machine hors supervision tombe sans que personne ne l'apprenne."
  sauf_role: serveur_step_ca      # l'AC ne s'enrôle pas auprès d'elle-même

Le plan ne porte plus que les intégrations qui sont un vrai choix — sauvegarde, courriel, résolveur. Et il refuse désormais une recopie : deux sources finiraient par diverger, et surtout, l'absence de client_metrique en face d'un serveur se lirait « non supervisé » alors qu'il l'est.

L'exemption suit le service, pas le nom d'hôte

sauf_role: serveur_step_ca retire client_pki à l'hôte qui rend le service. Déplacez step-ca sur une autre machine et l'exemption suit toute seule. Un nom d'hôte en dur, lui, aurait laissé la nouvelle AC s'enrôler auprès d'elle-même et l'ancienne sans certificat.

Une seule fonction de résolution

inventory_rules.integrations_de() est lue par les trois consommateurs — inventaire, voûte et panneau. La voûte en particulier : sans elle, les secrets des intégrations universelles auraient cessé d'être exigés et P18 serait passé au vert sur une voûte incomplète.

Vérification

Sur Technolibre, make instancier donne diff vide : la politique reproduit exactement ce que les 41 lignes retirées produisaient. Sur Chezlepro, elle produit précisément les trois groupes manquants sur backup-01 et les deux sur infra-pki-01 — et pas client_pki sur ce dernier, l'exemption ayant joué. Le trou se referme, rien d'autre ne bouge.

P26 garde les deux côtés : aucun hôte sans intégration universelle, aucune recopie dans le plan. Décisions D-33 et D-34.

Ce que le panneau montre

Dans la fiche d'un serveur : les universelles en ✓ non décochables, les exemptions barrées, chacune avec sa raison en infobulle. Sans cet affichage, un plan devenu silencieux se serait lu comme une flotte non supervisée — l'inverse exact de la vérité.

Et une vue Intégrations : la matrice

L'autre axe manquait, et c'est celui qui aurait servi. La fiche montre les intégrations d'un serveur ; savoir qui n'a pas de sauvegarde demandait d'ouvrir les quatorze. Le trou de Chezlepro n'a d'ailleurs pas été trouvé par le panneau — il est sorti du devis de pare-feu Proxmox, qui énumère les rôles par hôte. L'information était à l'écran, répartie sur quatorze clics, donc invisible.

La matrice serveurs × intégrations : colonnes ✓ vertes pour la politique, — barré pour les exemptions, cases à cocher pour les facultatives — éditables sur place, en-têtes et colonne de noms figées, clic sur un nom pour ouvrir sa fiche.

La ligne couverture affiche n/N sans juger : 7/14 sur client_backup peut être exactement juste. Elle rend le motif visible ; décider s'il s'agit de choix ou d'oublis reste au lecteur.

Sur Technolibre, elle affiche 41 ✓ et une exemption — soit très exactement les 41 lignes retirées du plan et le client_pki de l'AC.

2026-08-03 (suite 9) — le SSH inter-nœud était perdu

En éclatant les règles par rôle source, un défaut de ma première version est apparu : je sautais le flux entier dès qu'un de ses pairs valait externe.

Or le SSH du socle est déclaré pair: [flotte, externe]. La moitié externe relève bien de la frontière — mais la moitié flotte, le SSH entre hôtes, celui d'Ansible, était perdue. Sous une politique DROP, plus aucun hôte n'aurait été joignable en SSH depuis l'intérieur. Même piège pour le SMTP interne de Postfix, déclaré [externe, client_smtp].

externe est désormais sauté pair par pair, jamais le flux entier. 36 groupes, 56 règles.

Ajouté — ce qui n'a aucune règle entrante, et pourquoi

Onze rôles portés n'ont aucune règle entrante : sous DROP, ils sont injoignables. C'est voulu dans les onze cas — la boucle locale pour Prometheus, Redis, rspamd, Icinga et Unbound, la frontière seule pour nginx, aucun service pour serveur_durci et les clients.

Le devis les nomme avec leur motif au lieu de laisser un lecteur le vérifier lui-même. Et un douzième motif existe, marqué /!\ : « flux entrants déclarés mais aucune source résolue ici » — celui-là serait un vrai trou.

2026-08-03 (suite 8) — le pare-feu Proxmox, troisième lecture du même registre

make devis-proxmox-fw (preuve P25). Le filtrage est-ouest intra-tenant est désormais dérivé pour l'hyperviseur : 34 groupes de sécurité, 40 règles, 2 tenants — depuis les 57 flux intra-tenant que le registre connaissait déjà.

Décidé : la défense est en profondeur, pas en remplacement. L'hyperviseur filtre, puis l'hôte destinataire filtre à nouveau. Une VM compromise doit franchir les deux. Le coût de maintenance est nul : les deux barrières lisent le registre par les mêmes fonctions (_resoudre_sources, _pairs, _hotes_du_groupe) — la duplication est dans l'application, jamais dans la décision.

Un IPSet par rôle porte les membres, les groupes de sécurité y renvoient : ajouter un hôte à un rôle met à jour toutes les règles qui l'autorisent, en un seul endroit.

La garde qui manquait à ma première version

Proxmox limite un nom de groupe à 18 caractères. Ma première version tronquait sans vérifier : deux rôles tronqués au même nom auraient fusionné leurs règles, donnant à une VM les autorisations d'un rôle qu'elle ne porte pas — silencieusement.

Le préfixe porte maintenant l'index (t17-) plutôt que l'étiquette (chez17-), ce qui rend la troncature bien plus rare, et une garde échoue sur toute collision plutôt que d'émettre un devis pareil. Exercée.

Unifié — un seul schéma de nommage dans le devis

Les IPSets portaient l'étiquette longue (chez17_serveur_postgresql), les groupes l'index court (t17-srv-postgresql) : deux conventions à lire dans un même document. Tout porte désormais le préfixe t<index>- et la même forme abrégée de rôle.

La troncature reste propre à chaque objet : Proxmox est large sur les IPSets, étroit (18 caractères) sur les groupes. Un nom peut donc être entier d'un côté et abrégé de l'autre — chacun respecte sa contrainte, et le préfixe reste commun.

Vérifié : aucune collision d'IPSet, et tout renvoi +X d'une règle pointe vers un IPSet qui existe — 58 IPSets, 34 groupes, aucun orphelin.

Corrigé — des listes d'adresses en dur, et 48 IPSets inutilisés

Six règles par tenant portaient quatorze adresses en dur : les mots-clés flotte et edge n'avaient pas droit à un IPSet, seuls les rôles en avaient. flotte en reçoit un désormais, et edge renvoie à celui de nginx. 36 des 40 règles se lisent maintenant -source +t17-….

Et le devis listait 28 à 30 IPSets par tenant dont la moitié n'était référencée nulle part : un opérateur en aurait créé 58 pour n'en utiliser qu'une douzaine. Seuls les IPSets réellement référencés sont émis — 6 par tenant. Un devis crée ce qu'il liste.

Puis : une règle par rôle source

Les quatre règles restées en liste explicite sont éclatées — un flux dont le pair nomme quatre rôles donne quatre règles, chacune renvoyant à l'IPSet de son rôle. Plus une seule adresse en dur : 52 règles, toutes par IPSet.

Le gain n'est pas cosmétique : une règle porte désormais qui elle autorise. -source +t17-srv-keycloak se lit ; -source 10.27.16.21,10.27.17.11,10.27.19.31,10.27.20.21 demande de retrouver à qui appartient chaque adresse.

La raison, elle, appartient au flux et non à chacune de ses règles : elle est écrite une fois au-dessus du paquet qu'elle explique, au lieu d'être répétée quatre fois.

Corrigé — l'affectation variait selon l'état du tenant

Elle partait de hotes_actifs, avec un repli sur « tous » quand il n'y en avait aucun. Deux tenants donnaient donc deux comportements : Technolibre listait ses 14 VM (zéro actif → repli), Chezlepro une seule (un actif). Un opérateur aurait lu qu'une seule VM avait besoin de règles.

Les IPSets et les groupes incluaient déjà les hôtes planifiés, délibérément — un pare-feu se prépare avant que la VM existe. L'affectation suit désormais la même règle : 14 de chaque côté, toutes avec leur VMID.

Conséquence à retenir

Puisque tout ce qui entre dans un tenant passe par la frontière, le contrôleur Ansible aussi. L'OPNsense devient un prérequis de déploiement, pas une étape parmi d'autres : sans lui, plus rien ne se déploie.

2026-08-03 (suite 7) — le modèle déclarait un MTU que le matériel n'a pas

En préparant le déplacement des adresses de VTEP, la lecture des interfaces a montré deux choses que le modèle ignorait.

underlay.yml annonçait 9000, vmbr3 est à 1500

La garde P23 validait donc une déclaration fausse : elle exigeait ≥ 1550 et passait parce que le fichier disait 9000. Une garde qui valide une déclaration plutôt qu'une réalité donne un faux confort — c'est pire qu'une garde absente, qui au moins n'endort personne.

Le seuil ne peut pas non plus être fixe : 1550 aurait rejeté à tort un transport à 1500 portant un overlay à 1450, qui tient exactement. Il dérive désormais d'un mtu_overlay déclaré (1450 par défaut) : transport ≥ overlay + 50.

Le fichier dit maintenant la vérité — 1500 — et la garde passe pour la bonne raison. Exercé : un overlay porté à 1500 sur ce transport est refusé.

vmbr3 n'est pas VLAN-aware

Pas de bridge_vlan_aware, contrairement à vmbr2. L'adresse du VTEP y est non étiquetée : elle vit dans le VLAN natif du port de commutateur. Déplacer le VTEP vers l'underlay n'est donc pas un changement d'adresse — il faut soit un VLAN natif 10, soit une interface étiquetée dédiée (bond3.10).

C'est la raison pour laquelle le déplacement n'a pas été effectué : le geste demandé suppose une décision de câblage qui n'est pas prise.

2026-08-03 (suite 6) — les hyperviseurs entrent au modèle, à leur adresse cible

Redresser les pairs EVPN vers l'underlay suppose d'abord que les hyperviseurs existent dans le modèle. Ils n'y étaient pas.

La reconnaissance a montré la cause

vmbr3 — la nouvelle interface 2,5G — porte 10.27.19.{41,43,47} sur les trois nœuds : l'adresse des VTEP est prise dans le supernet de Chezlepro. Le transport du cluster dérive donc de l'index d'un tenant, et une VM de sa zone Services-infra partage son sous-réseau avec les trois VTEP.

Le modèle refuse d'exprimer cet état : déclarer 10.27.19.0/24 en underlay ferait échouer P23. La garde écrite deux jours plus tôt détecte la faute avant qu'on ne la documente.

underlay.yml déclare donc asgard, gandalf et vishnu à leur adresse cible 10.0.0.{41,43,47} — dernier octet conservé, comme sur vmbr0.

Ajouté — un role sur les hôtes de l'underlay

switch (défaut), hyperviseur, frontiere. Le réseau ne suffit pas à le déduire : un hyperviseur partage le réseau de management avec les commutateurs, et recevait une configuration de commutateur en partie B du devis dès qu'on le déclarait.

Corrigé — une « source unique » qui n'en était pas une

switches_acces() avait été introduite comme la décision du « qui est un switch d'accès », utilisée pour les rayons de l'étoile. Mais partie_acces() avait gardé sa copie locale du filtre et ne l'appelait jamais. Les deux ont divergé au premier hôte non-commutateur déclaré.

Écrire « source unique » dans un commentaire ne la crée pas.

Deuxième blocage signalé, non corrigé

vishnu : son vmbr3 n'a aucun port physique. Le pont existe, porte une adresse, ne mène nulle part. Un pair VXLAN pointé sur lui ne fonctionnera jamais — c'est du câblage, pas de la configuration.

2026-08-03 (suite 5) — l'ICMP entre au registre, parce que l'overlay descend à 1450

Décision : l'overlay EVPN plafonne à 1450. Elle a une conséquence qui ne se voit pas — sous 1500, tout ce qui traverse la frontière dépend de la découverte de MTU de chemin, donc de l'ICMP « fragmentation nécessaire ».

Or le registre des flux ne connaissait que TCP et UDP. Ce message ne pouvait pas être déclaré, et la bordure en block in log all l'aurait jeté. Symptôme : la connexion s'établit, les petites requêtes passent, les grosses réponses restent suspendues — la panne la plus coûteuse à diagnostiquer, et celle qu'on impute d'abord à l'application.

protocole: icmp est admis ; pour lui, le champ port porte le type (frag-needed). Le socle déclare les deux sens : entrant pour qu'un distant puisse nous demander de réduire nos paquets, sortant pour que nos hôtes signalent l'overlay aux correspondants.

Vérifié : les nftables d'hôte sont inchangés — le pair externe reste sauté, ces flux relèvent de la bordure. Le devis frontière passe à 26 règles.

Reconnaissance de l'existant (lecture seule)

L'EVPN est déjà à moitié construit sur le cluster : Proxmox 8.4.19, contrôleur EVPN0017 (ASN 65000), zones VRF0011 et VRF0017 — un VRF par tenant, VNI égal à l'index, conforme à D-08. Mais aucun VNet et aucun nœud de sortie : le plan de contrôle existe, le plan de données non.

Signalé, non corrigé : les pairs BGP sont 10.27.19.41/.43/.47, dans le sous-réseau Services-infra de Chezlepro. Le transport du cluster dérive donc de l'index d'un tenant — et une VM de cette zone partage son sous-réseau avec les trois VTEP, ce qui perce l'isolation à l'endroit même que l'EVPN devait fermer.

2026-08-03 (suite 4) — un index des décisions d'architecture

docs/decisions-architecture.md. Les décisions étaient écrites là où elles s'appliquent, et leur histoire dans ce journal — mais « pourquoi le /29 et pas le /30 ? » demandait de relire vingt entrées. Le registre ne répète rien : il dit quelles décisions existent, pourquoi, où lire le détail, et ce qui les garde.

28 décisions en quatre familles — le réseau, qui possède quoi, les secrets, la méthode. Chaque ligne porte sa raison en une phrase et sa preuve quand il y en a une. Une décision peut n'être gardée par aucune preuve : elle reste une décision, et le registre le montre plutôt que de laisser croire à une couverture complète.

La section qu'on omet d'habitude : les décisions renversées

Trois y figurent — l'isolation par ACL de commutateur, le routage inter-zone sur les commutateurs, l'underlay gitignoré à la racine du moteur. Les garder évite de refaire le chemin, et explique pourquoi le code porte encore des branches qui semblent inutiles : acl_inter_tenant: true et routage_tenants: switch restent les défauts, parce qu'une autre fabric peut en être capable.

Aucun de ces renversements ne vient d'un changement d'avis : les trois viennent d'un fait découvert après la décision — une commande absente de l'aide du matériel, une capacité manquante, une dizaine de modifications irrécupérables. C'est l'argument le plus fort pour éprouver avant de figer.

Les 30 renvois internes du registre ont été vérifiés : aucun document ni aucune section citée n'est introuvable.

Corrigé le jour même — des dates déduites plutôt que vérifiées

Les trois dates de la section « décisions renversées » avaient été estimées. L'historique git les corrige : les ACL et le routage sur commutateur remontent au 2026-07-07 (make devis-reseau), pas au 29 juillet ; l'underlay gitignoré au 2026-07-24, pas au 31.

Un registre qui invente une date perd la confiance qu'on lui accorde sur le reste. Chaque renversement cite désormais le commit qui l'a opéré, donc vérifiable en une commande.

Ajouté aussi : qui décide. Toutes ces décisions sont celles de l'opérateur du dépôt, plusieurs prises sur recommandation — l'assistance propose et argumente, elle ne tranche pas. La distinction compte : une décision se renverse par celui qui l'a prise.

2026-08-03 (suite 3) — les preuves réseau sont rattachées à de vraies affirmations

Trois preuves — P21, P23, P24 — renvoyaient à AFF-001, qui affirme que « Set-OPS est un moteur Ansible générique … à partir d'un plan ». Aucun rapport avec la fédération, l'underlay ni la frontière. Trois autres — P17, P19, P20 — n'avaient aucune référence.

Une preuve accrochée à la mauvaise affirmation ne prouve rien. Elle passe au vert et n'atteste de rien de ce qu'on croit.

Ajouté — §10 du registre : architecture réseau et fédération

Six affirmations (AFF-101 à AFF-106) : dérivation intégrale depuis le seed, absence de collision d'index, underlay disjoint de la plage tenant, garde anti-lockout de la frontière, validité des modèles underlay compris, couverture du plan par le panneau — cette dernière en 🟡, avec ses exceptions nommées plutôt que tues.

Et une affirmation volontairement absente : la justesse des devis. Leur syntaxe dépend d'un matériel que le dépôt ne possède pas ; six familles ont été confrontées à un commutateur réel, deux étaient fausses, mais c'est une vérification datée et non une preuve rejouable. Le dépôt n'affirme pas que ses devis s'appliquent ; il affirme qu'ils dérivent.

Corrigé — quatre preuves sans référence, et une erreur de la table

P03, P06, P12, P13 portent désormais les références que la table de couverture leur attribuait déjà : la correspondance existait en double, dans le document et dans le code, et seul le document la tenait.

La table attribuait par ailleurs AFF-030 (« inventaire complet ») à P15, qui valide le modèle socle. C'est P16 qui exécute ansible-inventory --list.

Vérifié : 35 affirmations référencées, aucune référence orpheline, une seule preuve sans référence — P16, dont la référence existe mais sous une autre forme syntaxique.

2026-08-03 (suite 2) — le panneau présente les deux devis

La vue Réseau n'affichait que le devis des commutateurs : le devis frontière était totalement absent de l'interface, alors qu'il porte les règles de la bordure et ses avertissements — dont celui sur « Block private networks », invisible dans les règles elles-mêmes.

Ajouté : /api/devis-opnsense et son bloc d'affichage, avec bouton de copie. Vérifié par HTTP que les deux points d'API servent exactement ce que le CLI produit — 181 et 125 lignes, identiques au caractère près.

L'import est paresseux et gardé : ce module lit l'underlay et les inventaires de tous les tenants, et une erreur y aurait sinon empêché l'affichage du reste de la vue.

Corrigé — le texte d'aide était périmé sur trois points

Il annonçait les VLAN tenants « uniques sur le trunk » — faux en SDN, où aucun ne circule ; renvoyait le dialecte à SETOPS_DIALECTE alors que c'est un intrant de la section Fabric ; et disait que « la route par défaut vers OPNsense reste à adapter » alors que la section 5 l'émet depuis hier.

Il dit maintenant ce qui reste réellement à nommer à la main : les ports physiques et, en SDN, le nœud de sortie EVPN. Rien d'autre — adresses, VLAN et routes se dérivent.

2026-08-03 (suite) — le devis frontière rattrape la bascule SDN

Trois affirmations du devis frontière étaient devenues fausses, dont une qui cassait le routage.

Corrigé — le prochain saut des routes tenants

Elles pointaient le SVI du commutateur (10.0.4.6). En EVPN, il ne route plus les tenants : une route pointée là arriverait sur un équipement sans chemin vers le tenant. Configuration qui s'applique sans erreur et ne fonctionne pas — la signature qu'on traque depuis deux jours.

Le devis émet désormais <NOEUD-DE-SORTIE-EVPN> et dit pourquoi, à figer après le spike.

underlay.passerelle_sortie garde son sens : c'est l'adresse du pare-feu sur le lien de transit, donc la sortie de l'underlay. Deux choses distinctes qu'il ne faut pas confondre.

Corrigé — la description du lien affichait le marqueur du prochain saut

Régression de la correction ci-dessus : le champ décrivant le câblage du lien de transit réutilisait la variable du prochain saut. Les deux étaient identiques jusqu'à la bascule SDN ; elles ont divergé, et la section 1 annonçait switch <NOEUD-DE-SORTIE-EVPN>. Sur ce lien, le commutateur est bien à 10.0.4.6 — le nœud de sortie n'y figure pas. Le champ décrit désormais le câblage, indépendamment du routage.

Corrigé — une contradiction entre les deux devis, antérieure au SDN

La section 0 du devis frontière demandait de router les réseaux d'administration vers 10.0.4.6 — le SVI du commutateur lui-même. make devis-reseau émet 10.0.4.1, l'adresse du pare-feu. Les deux devis se contredisaient, alors que l'un affirmait que l'autre « émet déjà ces routes ». Elles coïncident maintenant, vérifié ligne à ligne.

Corrigé — deux commentaires qui affirmaient l'inverse de la décision

L'en-tête (« les passerelles de zone restent sur les switches L3 ») et la description des routes (« routage inter-zone sur les switches L3 ») se dérivent maintenant du mode de routage.

2026-08-03 — le SDN prend le routage tenant, le devis switch se vide

Trois décisions, et le devis les applique déjà : une zone EVPN par tenant ; le routage et le filtrage entre les zones d'un même tenant à ce niveau ; l'inter-tenant obligatoirement par l'OPNsense.

underlay.routage_tenants — switch (défaut) ou sdn

En sdn, le devis switch cesse d'émettre VLAN tenants, SVI de zone et ACL, et les retire des trunks. Chez Chezlepro, les trunks ne portent plus que deux VLAN d'underlay au lieu de quinze : aucun VLAN de tenant ne circule sur le fil, seulement du VXLAN que le commutateur transporte sans le lire.

Les sections 1 à 3 sont remplacées par la raison, dont celle-ci : les passerelles .1 n'ont pas changé d'adresse, elles ont changé de porteur — passerelle anycast du VNet, présente sur chaque hyperviseur.

Le MTU devient une garde, pas un conseil

VXLAN ajoute 50 octets. make underlay refuse un réseau de transport sous 1550 dès que routage_tenants: sdn, en disant pourquoi : sous ce seuil, le ping passe et les transferts échouent — la panne la plus coûteuse à diagnostiquer. Les deux réseaux de la fabric principale passent à 9000.

Corrigé — la partie B déclarait encore les VLAN tenants

Le filtre routage_tenants n'avait été posé que sur la partie A et les trunks : la partie B a sa propre boucle de déclaration, et sortait toujours les douze VLAN tenants. Aucun trunk ne les portait, aucun SVI ne les utilisait — mais leur présence suggérait que les switches d'accès devaient les connaître, ce qui contredit la décision.

Vérifié dans les deux sens : zéro VLAN tenant en mode sdn, les vingt-quatre déclarations (douze par partie) de retour en mode switch.

Le partage des responsabilités, écrit

Une table dans docs/sdn-evpn.md dit qui route et qui filtre pour chaque nature de trafic. Deux conséquences y sont nommées : l'inter-tenant ne peut plus être oublié — il doit sortir du VRF, donc traverser une bordure en block par défaut ; et le commutateur ne voit plus rien du trafic tenant, donc y chercher la trace d'un problème applicatif est une perte de temps.

Point ouvert ajouté : le registre des flux n'a aucun mot-clé pour un flux inter-tenant. Défaut sûr aujourd'hui, mais il rend impossible de déclarer une exception légitime.

2026-08-02 (suite 20) — décision : le routage passe aux hyperviseurs (SDN EVPN)

docs/sdn-evpn.md. Les commutateurs ne savent pas lier une ACL à une interface de routage ; plutôt que d'assumer indéfiniment la perte d'isolation réseau que cela entraîne, le routage inter-zone passe à Proxmox SDN, zones EVPN.

Une zone EVPN est un VRF — c'est celui qu'on regrettait de ne pas avoir dans le matériel, obtenu en logiciel. Et il referme le trou signalé quelques heures plus tôt : un tenant n'a plus de route vers l'underlay, celui-ci n'étant pas dans sa table de routage. Le plan de gestion redevient protégé par construction, pas par une règle qu'on pourrait oublier.

La projection du modèle ne demande aucun changement de dérivation — vérifiée sur les deux tenants fédérés :

Objet SDN Vient de
zone (VRF) le tenant
VNet la zone de sécurité
tag (VNI) vlan_de(index, zone)
subnet + gateway sous_reseau_de(...) + passerelle_de(...)

Le .1 ne change pas d'adresse, il change de porteur : du SVI d'un commutateur vers la passerelle anycast du VNet, présente sur chaque hyperviseur. L'invariant du dernier octet survit tel quel.

Le devis switch maigrira d'autant : plus de VLAN tenants, plus de SVI de zone, plus d'ACL — en EVPN aucun VLAN de tenant ne circule sur le fil. La fabric redevient un transport IP.

Rien n'est éprouvé et rien n'est généré. Le document fixe la cible, la projection et une séquence de spike en cinq points — dont la vérification du MTU, premier mur de VXLAN, et surtout la tentative d'accès à l'underlay qui doit échouer, puisque c'est le gain principal. Le principe du dépôt s'applique : éprouver l'outil avant d'écrire le rôle.

2026-08-02 (suite 19) — pas d'ACL sur cette fabric : on route, et c'est tout

Les interfaces VLAN du Binardat n'offrent aucun access-group : impossible de lier une ACL à un SVI. Plutôt que d'émettre des règles qui ne seraient jamais liées — elles auraient l'air d'isoler sans jamais filtrer —, la capacité devient déclarée : underlay.acl_inter_tenant: false.

Ce n'est pas lié au dialecte de CLI mais au matériel : un autre commutateur parlant la même CLI pourrait savoir lier des ACL. Par défaut la valeur reste true, donc rien ne change pour une fabric qui en est capable.

À false, la section 3 du devis ne contient plus de règles mais la raison — et surtout ce qu'on perd :

Une VM émettant vers l'underlay voit son paquet routé localement par le commutateur — mgmt des switches, mgmt Proxmox, OOB/IPMI. Les nftables des VM n'y peuvent rien (politique output permissive), et l'IPMI n'est pas un hôte géré.

L'isolation inter-tenant repose désormais entièrement sur les nftables d'hôte, en policy drop. C'est défendable — c'est déjà là que vit le zéro-confiance est-ouest — mais le plan de gestion de la fabric perd sa seule protection réseau.

Des VRF auraient donné cette isolation sans ACL, par séparation des tables de routage. Ce matériel n'en a pas : c'est le critère à retenir au prochain renouvellement. Parade structurelle disponible d'ici là : sortir le management de la fabric routée des tenants, comme l'est déjà le stockage.

2026-08-02 (suite 18) — la liaison des ACL n'existe pas sur une interface VLAN

ip ? sur une interface VLAN du Binardat n'offre aucun access-group, et la liste complète des commandes de ce mode n'en contient pas davantage. La ligne ip access-group <NOM> in que le devis pose sur les douze SVI n'existe donc pas sur cette plateforme.

C'est la ligne qui rend l'isolation effective. Sans elle, les ACL de la section 3 sont parfaitement définies et jamais liées : show access-lists afficherait « used 0 time(s) », et rien d'autre ne signalerait que l'isolation inter-tenant ne filtre rien. Même signature que le défaut du trunk — une configuration qui a l'air juste et n'agit pas.

Le firewall disable aperçu dans un show running-config prend rétrospectivement du sens : le filtrage semble conditionné globalement.

Aucune forme de remplacement n'est devinée. Le devis porte un avertissement à cet endroit, en dialecte binardat uniquement — après trois syntaxes supposées dont deux fausses, marquer l'incertitude vaut mieux qu'un quatrième pari. Reste à trancher sur une interface physique (ip ?, access-group ?) et sur le rôle de firewall enable.

2026-08-02 (suite 17) — spanning-tree vérifié : les six familles sont closes

spanning-tree ? en mode configuration tranche la dernière inconnue, en faveur de la forme émise : mode et priority s'acceptent au niveau global. La priorité n'a pas besoin d'être portée par une instance, même en MSTP — la réserve inverse, notée la veille, était infondée et le devis ne la porte plus. Une mise en garde fausse est aussi nuisible qu'une syntaxe fausse.

Ajouté : spanning-tree seul, qui garantit l'état actif. Sans lui, mode et priority sur un boîtier où le protocole aurait été désactivé configureraient un arbre qui ne tourne pas. Sans effet s'il est déjà actif — même logique déclarative que pour les trunks.

Vérifié contre le matériel : VLAN, SVI, trunks, routes, définition des ACL, spanning-tree global. Deux ont révélé un défaut réel plutôt que de confirmer l'existant — les routes (notation CIDR) et surtout les trunks, dont la forme add ne retranchait rien.

Rectification (même jour). L'entrée ci-dessus a d'abord annoncé « les six familles sont closes ». C'était faux : l'aide consultée était celle du mode configuration globale, et trois lignes du devis vivent ailleurs — spanning-tree portfast trunk et ip access-group … in au niveau interface, ip default-gateway en global mais absent de cette aide. Elles restent non vérifiées, et la plus douteuse est portfast trunk : trunk est un mot-clé Cisco.

2026-08-02 (suite 16) — la syntaxe des ACL vérifiée sur le matériel

show access-lists confirme la dernière famille de syntaxe restée ouverte : une ACL générée s'applique telle quelle sur le Binardat, ses règles dans l'ordre émis — ip access-list extended <NOM>, masques normaux, any.

Détail de lecture consigné : le boîtier affiche any-destination là où l'on saisit any. Comparer un show access-lists au devis ferait apparaître une différence qui n'en est pas une — c'est le genre de faux positif qui fait perdre une heure.

Bilan des dialectes. Cinq familles sur six sont désormais vérifiées contre le matériel : VLAN, SVI, trunks, routes, ACL. Ne reste que la forme d'entrée des commandes de spanning-tree, que show ne révèle pas.

2026-08-02 (suite 15) — underlay.stp.mode aligné sur le matériel

mode: mstp, parce que c'est le mode d'usine du commutateur (show spanning-tree : IEEE 802.1s, Force Version 3). Sur une étoile sans lien redondant, l'instance 0 de MSTP se comporte comme un RSTP : changer de mode aurait donné le même résultat, au risque près de toucher à un protocole qui fonctionne déjà.

Le devis énonce désormais sa propre incertitude là où elle est : en MSTP la priorité se règle souvent par instance, alors que la forme émise est globale. Le générateur le dit plutôt que de laisser croire à une syntaxe vérifiée — show spanning-tree donne l'état du protocole, jamais la forme d'entrée des commandes.

Corrigé au passage : le commentaire de la section 6 disait « RSTP » en dur alors que le mode est déclaré. Il le dérive.

2026-08-02 (suite 14) — le spanning-tree du matériel : MSTP, actif, priorité par défaut

show spanning-tree sur le commutateur Binardat corrige une déduction fausse et en apporte deux faits.

Correction. J'avais déduit de son absence du show running-config que le spanning-tree était probablement désactivé. Il est actif : il n'y figurait pas parce qu'il est aux valeurs d'usine. Une absence dans une configuration ne veut pas dire une absence de fonction.

La plateforme est en MSTP (IEEE 802.1s, Force Version 3), alors que underlay.stp.mode déclare rstp. Sur une étoile sans lien redondant les deux se comportent identiquement — la question est de savoir si l'on aligne la déclaration sur le matériel ou le matériel sur la déclaration.

La priorité de pont est déjà 32768, donc la ligne émise pour les switches d'accès est un non-opérant : elle écrit ce qui est déjà vrai.

Reste non vérifiée la forme d'entrée des commandes de spanning-tree : show en donne l'état, pas la syntaxe. En MSTP la priorité se règle en général par instance, ce que la forme actuellement émise ne fait pas.

2026-08-02 (suite 13) — le trunk ne restreignait rien

switchport trunk allowed vlan ? sur le matériel réel confirme la syntaxe et révèle un défaut : add ajoute à la liste courante, la forme sans mot-clé la définit.

Le devis émettait add. Or un port trunk neuf autorise tous les VLAN — dans une configuration réelle, les ports n'avaient aucune ligne allowed vlan, ce qui signifie exactement cela. Y ajouter la liste des VLAN voulus n'en retranchait aucun : le trunk continuait de tout transporter, et le devis donnait l'illusion de restreindre.

C'est le pire genre de défaut — une configuration qui a l'air juste, s'applique sans erreur, et ne fait pas ce qu'elle annonce.

La forme sans mot-clé est retenue, et elle est aussi atomique : none puis add aurait coupé le trunk entre les deux commandes, ce qui suffit à perdre la session si on l'applique sur le port de gestion.

2026-08-02 (suite 12) — la syntaxe des routes, vérifiée sur le matériel

Un show running-config du commutateur Binardat tranche la question restée ouverte : la plateforme écrit ses routes en notation CIDR — ip route 0.0.0.0/0 192.168.10.254 — et non en masque séparé comme Cisco. Le générateur produisait du Cisco quel que soit le dialecte.

route_statique() suit désormais le dialecte, au même titre que les masques d'ACL. Vérifié dans les deux formes.

Restent non vérifiés faute d'apparaître dans la configuration réelle : la syntaxe des ACL, celle de switchport trunk allowed vlan add, et le spanning-tree — totalement absent du show running-config, ce qui suggère qu'il est désactivé par défaut sur cette plateforme.

2026-08-02 (suite 11) — le responsable prend sa section, et la symétrie est dite

Corrigé — un renvoi ambigu

« Il ne dégèle qu'en cas de retour arrière (§6) » figurait dans l'étape 6. Deux « 6 » ne désignant pas la même chose dans une seule phrase, alors que tout le document distingue soigneusement sections et étapes. La section est désormais nommée plutôt que numérotée.

Déplacé — le responsable désigné devient le §3

Il vivait dans « Le modèle : le transfert de nom de domaine », alors que ce n'est pas un emprunt aux registraires : c'est une décision de modèle, valable migration ou pas. Le §2 ne traite plus que de ce qui est emprunté et de là où l'analogie casse.

Ajouté — ce qui suit le tenant, en regard de ce qui reste

Le §8 énumérait ce que la migration ne déplace pas — fabric, frontière, index — sans dire ce qu'elle déplace. Or le responsable désigné, lui, suit le tenant : c'est exactement l'inverse, et le dire renforce la ligne de partage.

Si quelque chose appartenant à l'organisation ne peut pas partir, elle n'est pas vraiment souveraine ; si quelque chose appartenant à l'hébergeur devait partir, c'est que la frontière entre les deux est mal tracée.

Cette symétrie est le test le plus simple d'une migration bien conçue.

Sections renumérotées en conséquence (9 au lieu de 8) ; les six renvois internes vérifiés un par un.

2026-08-02 (suite 10) — le responsable désigné, et la réversibilité nuancée

Décidé — chaque tenant a un responsable désigné

Un domaine a un titulaire ; un tenant a un responsable désigné — la personne qui engage l'organisation, et dont la signature seule vaut mandat de migration.

Ce n'est pas une formalité. Sans responsable nommé d'avance, la question « qui peut décider de déménager cette organisation ? » se pose au pire moment : quand les deux hébergeurs ont un intérêt dans la réponse. Un employé de bonne foi ne peut pas mandater le déménagement de son employeur.

Corrigé — la table des états laissait croire que revenir est facile jusqu'au bout

Elle annonçait une réversibilité « gratuite » entre préparé et libéré, alors que le §6 établit qu'elle change de nature à la bascule. Les deux ne se contredisent pas — on peut effectivement revenir jusqu'à libéré — mais un lecteur pressé s'arrêtant au tableau en retirait une fausse impression.

Ce qui reste gratuit est l'adressage, pas le retour : tout dérive d'un seul chiffre, dans les deux sens.

Ajouté aux points à trancher — trois questions de gouvernance

Où le responsable désigné est déclaré, et surtout comment on en change : c'est un acte au moins aussi sensible que la migration, puisqu'il décide qui pourra la mandater ensuite.

Le recouvrement de la clé du responsable — elle se perd, se compromet, ou la personne quitte l'organisation. Sans procédure, un tenant devient inmigrable : captif non par contrat mais par accident, exactement ce que la recette existe pour empêcher.

Deux écueils symétriques y sont consignés. Trop lourde, la procédure n'aboutit jamais et le tenant reste bloqué. Trop légère, elle devient le chemin de moindre résistance pour contourner la signature — inutile de forger un mandat si l'on peut se faire attribuer la clé. Le recouvrement doit être au moins aussi difficile que ce qu'il protège.

Piste retenue, la plus transposable des registraires : un contact de secours nommé en même temps que le responsable, tant que personne n'est en situation d'urgence.

La durée de rétention : convenue avec qui, consignée où, attestée par qui. Sur une séparation d'hébergeur, un flou ici finit en litige.

2026-08-02 (suite 9) — le retour arrière de la migration

La recette affirmait la réversibilité sans jamais décrire le retour. Or elle change de nature à la bascule, et le geste évident — repointer le DNS — devient faux à cet instant.

Trois régimes, désormais écrits :

  • avant le gel : sans conséquence, le sortant n'a jamais cessé de servir. C'est ce que la règle d'ordre achète — tout ce qui peut échouer sans coût échoue là ;
  • pendant le gel : dégeler, l'interruption se limite à la durée du gel ;
  • après la bascule : ce n'est plus un retour mais une migration inverse. Les utilisateurs ont écrit chez l'entrant — courriels, fichiers, commits — et ces données n'existent nulle part ailleurs. Repointer le DNS les perdrait, et silencieusement.

Point rendu explicite : le sortant reste gelé après la bascule, jusqu'à confirmation. Le dégeler « au cas où » créerait deux copies vivantes du même tenant et plus aucune vérité. En contrepartie il n'a pas divergé, donc le delta d'un retour reste à sens unique.

Deux points de non-retour à ne pas confondre : la bascule fait perdre le retour gratuit (il reste la migration inverse) ; la purge fait tout perdre.

D'où une exigence ajoutée aux points à trancher : les critères de confirmation de l'étape 7 se fixent par écrit avant la première bascule. Décider après coup ce qui compte comme « ça marche » revient à se donner raison.

Corrigés au passage : deux renvois d'étape faux (le rattrapage est à l'étape 5, non 4 ; le chemin de vérification hors DNS public est un prérequis de l'étape 3 elle-même).

2026-08-02 (suite 8) — recette de migration d'un tenant entre hébergeurs

docs/migration-tenant.md : la séquence, les états et les gardes. Écrite avant tout code, délibérément — figer un enchaînement qu'on n'a jamais joué serait prématuré.

Le modèle est le transfert de nom de domaine, qui résout depuis trente ans les mêmes problèmes : le mandat appartient au client (ni l'hébergeur sortant ni l'entrant ne peut déplacer un tenant seul), verrou par défaut, deux actes délibérés et traçables.

Là où l'analogie casse, elle est remplacée plutôt qu'étirée : il n'y a pas de registre central pour arbitrer, donc le mandat est signé par le tenant et vérifié contre une clé publique de son plan — un secret partagé ne prouverait rien à l'entrant, il pourrait venir du sortant.

L'ordre est commandé par une règle unique : le receveur doit être prouvé prêt avant que quoi que ce soit ne gèle. L'entrant se construit et se prouve pendant que le sortant sert normalement ; l'interruption se réduit au rattrapage du delta plus la propagation DNS. Ce n'est pas un conseil mais une garde de transition — l'état gelé est inaccessible tant que préparé n'est pas prouvé.

Deux pièges consignés parce qu'ils ne vont pas de soi : le TTL s'abaisse à l'étape 1, pas à la bascule, sinon tout le bénéfice de l'ordre est perdu ; et l'entrant doit être vérifiable sans être public, sinon le tenant sert des deux côtés et l'identité se dédouble.

Enfin, la libération est une révocation, pas une transmission : re-clétage de la voûte et rotation des secrets chez l'entrant. Transmettre le mot de passe laisserait à l'ancien hébergeur un accès à vie aux secrets d'un client parti — rien ne rattrape ça après coup.

2026-08-02 (suite 7) — la frontière appartient à l'hébergeur, pas au tenant actif

Un hébergeur sert plusieurs tenants et n'a qu'une frontière. Or ses intrants (group_vars/opnsense.yml) étaient lus chez le tenant actif : basculer sur un invité — Technolibre, qui n'a pas de frontière à lui — faisait perdre au devis l'URL de gestion, l'adresse publique et les deux interfaces. Il repartait en marqueurs, comme si le boîtier n'existait pas. Vérifié en simulant la bascule : AUCUN intrant lu.

Même famille que le défaut de l'underlay corrigé plus tôt, et c'est la distinction hébergeur/tenant qui le fait apparaître.

Le devis et le panneau lisent désormais la frontière chez l'hébergeur. Celui-ci n'est pas déclaré pour autant : le symlink underlay.yml le désigne déjà, et une seconde déclaration ouvrirait la porte à deux valeurs contradictoires. Repli sur l'instance active quand aucun underlay n'est monté — un site sans fabric déclarée continue de fonctionner.

Vérifié : devis identique avec l'hébergeur actif, et intrants conservés avec un invité actif. docs/frontiere-opnsense.md gagne un §2 qui pose qui possède quoi.

2026-08-02 (suite 6) — l'underlay devient modélisable

Un modèle décrivait jusqu'ici un tenant : ses services, ses zones, ses bases. Or tous les hébergeurs n'ont pas le même matériel, et l'infrastructure physique mérite le même traitement.

Ajouté — exemples/modeles/socle/underlay.yml

Le modèle public gagne un underlay volontairement minimal : un seul commutateur, pas de fabric de stockage séparée. C'est le point de départ honnête d'un petit hébergeur ; les montages plus riches (étoile à trois commutateurs, paire en MLAG, stockage jumbo dédié) sont d'autres modèles, conformément à la doctrine — un générique public, les étoffés en privé.

Le modèle contient désormais deux moitiés qui ne vont pas au même endroit : plan/ et inventories/ chez le tenant, underlay.yml chez l'hébergeur. Chez un hébergeur qui est son propre tenant, les deux atterrissent au même dépôt — c'est le cas particulier, pas la règle.

Étendu — modeles.py verifier valide l'underlay (preuve P17)

La validation est facultative (un modèle sans underlay reste valide) et porte sur la cohérence interne seulement : VLAN sous la plage tenant, sous-réseaux disjoints, passerelle dans son réseau, routeur déclaré, ports non dupliqués, dernier octet partagé.

Elle n'est pas confrontée aux tenants fédérés réels : un modèle est un gabarit, pas un site déployé. Il a fallu pour cela rendre paramétrables deux hypothèses du validateur, qui lisait la nomenclature de l'instance active et globait les dépôts frères — sur un modèle, les deux auraient été faux. charger_depuis() et plan_nomenclature= ; comportement par défaut inchangé.

Cinq cas de rejet exercés sur un modèle fautif : VLAN empiétant sur la plage tenant, passerelle au mauvais dernier octet (lue dans la nomenclature du modèle), routeur inconnu, sortie hors du lien, port déclaré deux fois.

2026-08-02 (suite 5) — l'underlay rejoint le dépôt de l'hébergeur

underlay.yml vivait gitignoré à la racine du moteur : consommé par deux générateurs, validé par la preuve P23, et versionné nulle part. La dizaine de modifications de la journée — transit, renumérotage du /29, séparation des fabrics, spanning-tree, dialecte, ports — n'était récupérable d'aucune façon, et un clone frais repartait du gabarit.

Il appartient à l'hébergeur : ce sont ses switches, ses câbles, ses VLAN. Pas au moteur, qui est générique, ni à un tenant, qui n'en possède pas. Chezlepro est ici hébergeur et tenant, d'où la confusion : un tenant qui s'hébergerait sur son propre matériel aurait son propre underlay, dans son dépôt.

Le moteur le monte par symlink, comme il monte le plan par instance/ :

Set-OPS-public/underlay.yml -> ../OPS-Chezlepro/underlay.yml

Ce lien ne suit pas make instance-utiliser. Basculer l'instance active sur un autre tenant ne change pas la fabric : elle reste celle de l'hébergeur. Deux symlinks, deux durées de vie — c'est la conséquence directe de la distinction hébergeur/tenant.

Vérifié : les deux devis sortent identiques octet pour octet avant et après, P23 reste verte, 24 preuves. Et un symlink brisé — le cas d'un clone du moteur sans le dépôt de l'hébergeur — dégrade proprement : exists() suit le lien, l'underlay est vu comme absent, et les devis omettent leurs sections au lieu d'échouer. Cas exercé.

2026-08-02 (suite 4) — deux devis qui se contredisaient, et une case à cocher

Corrigé — le devis frontière certifiait des routes inexistantes

Sa section 0 annonçait trois routes de retour « DÉJÀ ÉMISES par make devis-reseau ». Le devis switch n'en émettait qu'une : devis_reseau lisait nftables_admin_ssh de la seule instance active, alors que la frontière était passée multi-tenant la veille. Les deux réseaux d'administration de Technolibre n'étaient routés nulle part.

Pire qu'un silence : une affirmation fausse désamorce la vérification.

admin_tous_tenants() vit désormais dans devis_reseau et devis_opnsense l'importe au lieu d'en refaire une copie. Les routes de retour et les règles lisent les mêmes tenants, par construction. Vérifié : les deux listes sont identiques.

Ajouté — l'avertissement « Block private networks »

Le SSH d'administration a une source RFC1918 arrivant sur une interface WAN. OPNsense active par défaut ce filtre d'interface, qui s'applique avant les règles : coché, il jette le paquet sans qu'aucune règle ne soit consultée. La config paraît juste, le SSH ne passe pas.

Le devis le signale dès qu'une source RFC1918 entre par le WAN — un réglage d'interface est invisible dans les règles, il fallait donc l'écrire à part.

Le prédicat est exactement RFC1918, périmètre de cette case ; ipaddress.is_private aurait été trop large (plages de documentation, CGNAT), et l'avertissement se serait déclenché à tort. Les trois cas exercés : RFC1918 → averti ; 8.8.8.8/32 → muet ; 203.0.113.7/32 (documentation) → muet.

2026-08-02 (suite 3) — chaque règle porte son interface, et l'octet est gardé

Ajouté — l'interface d'arrivée, dérivée du sens du flux

Dans OPNsense une règle est toujours in sur l'interface d'arrivée : posée ailleurs, elle ne s'applique jamais et le trafic est bloqué sans que la configuration paraisse anormale. Les règles n'en portaient aucune, alors que le champ est obligatoire dans l'API.

L'attribution se dérive : un flux ingress/externe arrive par le WAN, un flux egress/externe par le lien de transit. Le SSH d'administration suit la première ligne — le VPN est hébergé sur le pfSense voisin et revient par l'adresse publique de la frontière. C'était la dernière inconnue, et le montage parallèle décrit le 2026-08-02 la lève.

Le rendu abandonne pass out pour pass in on <interface>, qui est l'idiome réel d'OPNsense et ce que le futur client d'API devra envoyer.

Ajouté — opnsense_wan_ip, la face publique

L'adresse publique de la frontière (69.70.26.62, reprise du pfSense) est un intrant de la section Frontière et s'affiche en section 1 du devis.

Ajouté — invariant du dernier octet (preuve P23)

Convention d'exploitation : un point de routage porte le même dernier octet sur tous les sous-réseaux où il participe — on retient une adresse, pas treize. sleipnir-01 est .1 partout. Le chiffre n'est pas codé en dur : il vient de reservations.passerelle dans la nomenclature, et make underlay refuse une passerelle qui s'en écarte.

Exemption assumée : les liens plus étroits qu'un /24. Sur le /29 de transit, l'adressage est dicté par les participants — les deux frontières prennent .1 et .2, le switch .6. Vérifié que l'invariant était déjà respecté sur les 13 sous-réseaux routés avant d'écrire la garde.

2026-08-02 (suite 2) — la frontière porte les règles de TOUS les tenants

Le devis était multi-tenant pour ses routes et mono-tenant pour ses règles : il routait 10.21.0.0/16 et 10.27.0.0/16, mais ne filtrait que l'instance active. Technolibre aurait été routé jusqu'à la bordure puis bloqué dans les deux sens, SSH d'administration compris, sans qu'aucune ligne ne dise pourquoi. Chemin présent, politique absente — le mode de panne du 2026-07-29, transposé.

La résolution est désormais paramétrée par tenant : inventaire_de() lit le hosts.yml de chaque instance fédérée, cibles_par_role() prend l'inventaire en argument, et les alias d'hôtes sont préfixés (SETOPS_CHEZ17_SERVEUR_NGINX). 11 règles par tenant, 22 au total.

Cloisonnement du plan de gestion

Première version de ce correctif : SETOPS_ADMIN devenait l'union des réseaux d'administration. Le plan de gestion de Technolibre aurait alors pu entrer en SSH chez Chezlepro — la bordure rouvrait ce que les ACL de switch ferment. Corrigé avant livraison : un alias par tenant, SETOPS_ADMIN_<TENANT>, n'ouvrant que son propre supernet.

L'union est conservée pour les routes de retour côté switch et la garde P24 : router n'est pas autoriser, et le switch doit savoir revenir vers tous les plans de gestion.

Deux omissions annoncées au lieu d'être tues

Un tenant sans inventaire généré : aucune règle, et le devis le dit. Un tenant dont nftables_admin_ssh est vide : la règle SSH est omise plutôt qu'ouverte à any, ce qui exposerait le SSH à Internet. Cas dégradé exercé.

2026-08-02 (suite) — la sortie générale est déclarée, pas subie

Le devis frontière se terminait par block out log all avec une seule règle sortante (le relais SMTP). Appliqué tel quel, il coupait la flotte d'Internet : plus de apt, plus de NTP, plus de récursion DNS. Rien ne le signalait — c'était la ligne la plus lourde de conséquences du devis, posée à la suite des autres.

La sortie est désormais déclarée dans le registre, donc dérivée comme tout le reste :

  • serveur_debian (le socle, porté par les 14 hôtes) — 443/tcp et 80/tcp pour les dépôts apt, 123/udp pour l'horloge. Une dérive d'horloge fait échouer la validation des certificats step-ca et le SSO, des semaines après la cause.
  • client_unbound — 53/udp et 53/tcp : client_unbound_transitaires est vide, donc Unbound interroge lui-même la racine. C'est le choix souverain ; il a un coût réseau qu'il faut déclarer. Le TCP n'est pas optionnel — c'est le repli obligatoire dès qu'une réponse DNSSEC dépasse la taille UDP.

Le devis passe de 6 à 11 règles, et sa section 5 énonce le default-deny sortant, le nombre de règles qui l'accompagnent, et où déclarer un besoin oublié — jamais à la main dans le pare-feu, la règle serait perdue à la génération suivante.

Vérifié : les nftables d'hôte sont inchangés, octet pour octet. Le pair externe reste sauté par resoudre_flux.py — ces flux relèvent de la bordure, et d'elle seule.

Non déclaré volontairement : le rôle chrony existe mais n'est référencé par aucun groupe, aucun playbook ni le graphe de dépendances. Lui écrire un flux aurait créé une règle morte.

2026-08-02 — les interfaces se nomment par leur identifiant

opt1, igb1 et TENANTS désignent le même port dans OPNsense : l'identifiant interne, le périphérique FreeBSD et le libellé affiché. L'API REST ne parle que du premier, et c'est lui que veulent opnsense_if_wan et opnsense_if_transit. Le libellé de ces deux intrants ne le disait pas — la question s'est posée en pratique.

Les libellés le disent maintenant explicitement, et docs/frontiere-opnsense.md §6 explique les trois couches ainsi que le motif du choix : opt1 est le plus stable des trois, il survit à un changement de carte réseau comme à un renommage.

La note « opnsense_prochain_saut dérive de l'underlay » est repliée dans l'en-tête que le panneau régénère : une sauvegarde l'effaçait, puisque le fichier est réécrit depuis le YAML analysé. Vérifié qu'une sauvegarde préserve valeurs et références de voûte.

Corrigé — la doc portait encore l'ancien plan du /29

Après le renumérotage (bifrost-1/-2 en .1/.2, SVI en .6), deux passages de docs/frontiere-opnsense.md annonçaient toujours 10.0.4.2 comme prochain saut. La doc contredisait le devis généré ; les ip route des deux coïncident désormais.

2026-08-01 (suite 14) — l'empreinte du root CA n'est pas un secret de voûte

client_pki dérive l'empreinte à chaud depuis l'autorité (step certificate fingerprint en delegate_to sur serveur_step_ca) — précisément parce qu'un from-zero régénère l'AC avec une empreinte neuve. Une empreinte figée en voûte serait périmée dès la première reconstruction, et une empreinte périmée fait échouer le bootstrap de chaque hôte.

Or defaults/main.yml portait encore client_pki_ca_fingerprint: "{{ vault_step_ca_fingerprint | default('') }}". Ce défaut était mort : la tâche suivante écrase le fait sans condition. La valeur de la voûte n'avait aucun effet, quel qu'en soit le contenu.

Le recensement de voute.py s'y laissait prendre — il cherche la chaîne vault_* dans les fichiers, sans pouvoir savoir qu'un défaut n'est jamais lu. La « source unique » avait donc hérité de l'erreur, et le panneau réclamait un secret impossible à fournir avant que l'AC n'existe.

Le défaut mort est retiré, et la clé disparaît d'elle-même du recensement (25 → 24 exigés), du gabarit et du panneau. client_pki_ca_fingerprint_override reste le moyen documenté d'épingler une empreinte (AC externe, migration).

Corrigé — une troisième copie manuelle de la liste des secrets

docs/intrants-communs.md §H énumérait les secrets à la main, avec les deux mêmes erreurs. Elle renvoie désormais à scripts/voute.py lister et à la preuve P18 plutôt que d'entretenir une copie de plus.

2026-08-01 (suite 13) — le rappel des secrets dérive de voute.py

SECRETS_ATTENDUS était une liste écrite à la main dans inventory_gui.py, en parallèle du recensement que scripts/voute.py fait déjà depuis le plan, les rôles des groupes actifs et les group_vars. Deux sources pour la même vérité, et la manuelle avait divergé :

Clé Panneau Gabarit Référencée
vault_ldap_sssd annoncée absente nulle part — aucun rôle sssd n'existe
vault_step_ca_fingerprint omise présente roles/client_pki/defaults/main.yml:24

Un opérateur qui suivait le panneau créait donc un secret que rien ne consomme, et oubliait celui dont client_pki a besoin pour vérifier l'empreinte de l'AC racine.

Le rappel dérive désormais de voute.secrets_exiges() — la même source que la preuve P18 — augmentée de SECRETS_HORS_MOTIF pour les jetons Proxmox, qui ne portent pas le préfixe vault_. Vérifié : 27 noms, écart nul avec le gabarit. Sur un dépôt public nu, la liste est vide plutôt qu'en erreur.

2026-08-01 (suite 12) — la fabric se règle depuis la console

Tout le modèle d'underlay bâti aujourd'hui — routeur, spanning-tree, fabrics, transit — s'éditait à la main dans un YAML, pendant que la doctrine dit qu'un sysadmin doit exploiter l'outil sans IA. La frontière avait eu sa section dans le panneau ; l'underlay, non.

Une section Fabric couvre désormais les valeurs plates : le switch routeur, le dialecte de CLI, le mode et la topologie de spanning-tree. Les listes de tables (reseaux, hotes, et donc les ports) restent hors de portée du panneau — elles demandent une vue dédiée, comme celle des serveurs.

Le dialecte devient un intrant déclaré

Il ne vivait que dans SETOPS_DIALECTE, une variable d'environnement. C'est une propriété du matériel, donc de la fabric : elle se déclare dans underlay.yml. Précédence désormais explicite : drapeau --dialecte > variable d'environnement > intrant déclaré > cisco. make underlay refuse un dialecte inconnu.

Écriture chirurgicale, pas de safe_dump

underlay.yml porte 23 lignes de commentaires qui expliquent des décisions d'architecture — pourquoi un seul routeur, pourquoi le transit vit dans l'underlay, pourquoi les rayons ne sont pas des ports de bord. Un safe_dump les aurait toutes effacées, comme c'est arrivé aux commentaires de plan/applications.yml. Le panneau remplace donc la ligne existante en respectant son indentation. Vérifié : trois valeurs modifiées, 73 lignes et 23 commentaires avant comme après.

Une clef absente du fichier n'est pas créée : le panneau refuse explicitement plutôt que de l'inventer à un endroit arbitraire.

2026-08-01 (suite 11) — les ports physiques entrent dans le modèle

Les noms de ports n'étaient modélisés nulle part : <PORT-VERS-PROXMOX> et consorts étaient des marqueurs littéraux dans le générateur. L'opérateur les remplaçait à la main dans la sortie, et recommençait à chaque régénération. C'était le seul endroit du devis où le travail était perdu à répétition.

Ils se déclarent désormais par équipement dans underlay.yml, sous quatre clefs qui correspondent aux quatre natures de lien :

ports:
  hyperviseurs: [Te1/0/1, Te1/0/2, Te1/0/3]   # ports terminaux (portfast)
  frontiere: [Gi1/0/23]                        # vers le pare-feu (portfast)
  rayons: { sleipnir-02: Te1/0/47, … }         # côté ROUTEUR
  montante: Te1/0/48                           # côté SWITCH D'ACCÈS

Le devis émet alors les vrais ports, y compris plusieurs vers les hyperviseurs — il n'en supposait qu'un seul jusqu'ici, alors que le cluster en compte trois. Non déclarés, les marqueurs reviennent : la dégradation est par équipement, un switch renseigné et un autre non cohabitent sans problème.

La partie B devient par switch : adresse de gestion, montante et ports terminaux différant d'une machine à l'autre, un bloc commun n'avait plus de sens.

make underlay refuse un port déclaré deux fois sur un même équipement, un rayon vers un switch inconnu, des rayons sur autre chose que le routeur, une montante sur le routeur lui-même. Les quatre cas exercés.

2026-08-01 (suite 10) — une interface, un bloc

La partie A déclarait ses ports de bord deux fois : l'interface en section 4, son portfast en section 6. La partie B, elle, posait tout dans le même bloc. Deux conventions pour la même chose dans un seul document — un opérateur qui applique la partie A section par section configurait la même interface à deux endroits.

Le portfast est désormais posé avec son interface (sections 4 et 4b). La section 6 se réduit à ce qui est global — mode, priorité du pont racine — plus les deux avertissements : que les rayons de la section 4c n'en sont volontairement pas, et pourquoi BPDU guard n'est pas émis.

Vérifié : aucune interface n'est déclarée deux fois dans une même partie, trois portfast avec stp, zéro sans lui, zéro sur un rayon.

2026-08-01 (suite 9) — rayons de l'étoile séparés des ports terminaux

L'ajout du spanning-tree venait de rendre dangereuse une imprécision qui, jusque-là, n'était qu'un titre approximatif. La section 4 s'appelait « Trunk vers Proxmox + inter-switch » et n'émettait qu'un port, que la section 6 déclarait en bord de réseau. Réutiliser ce placeholder pour les rayons vers les switches d'accès revenait à mettre portfast sur les liens qui portent les BPDU — c'est-à-dire à désactiver la protection anti-boucle exactement là où elle sert.

Les rayons sont désormais dérivés et émis à part (section 4c côté routeur, B3a côté accès), un par switch d'accès, avec l'avertissement qu'ils ne sont pas des ports de bord. La section 4 ne désigne plus que les hyperviseurs, et la partie B distingue sa montante (B3a) de son trunk terminal (B3b).

underlay.switches_acces() devient la source unique du « qui est un switch d'accès » — utilisée pour leur devis et pour les rayons côté routeur : les deux ne peuvent pas diverger.

Vérifié : trois commandes portfast émises avec stp déclaré, aucune sans lui, et aucune sur un rayon.

2026-08-01 (suite 8) — spanning-tree dérivé de la topologie déclarée

Ajouté — underlay.stp et les sections 6 / B4

Le devis ne disait rien du spanning-tree. Sur une fabric convergée à trois switches, une boucle par brassage accidentel n'est pas discrète : c'est une tempête de diffusion.

La topologie se déclare (mode: rstp, topologie: etoile) et le devis en tire la configuration. Le routeur est désigné pont racine — il est le centre de l'étoile, tous les chemins passent déjà par lui, donc l'arbre logique suit le câblage physique au lieu de sortir d'une élection arbitraire. Les switches d'accès reçoivent une priorité volontairement haute : ils ne doivent jamais devenir racine.

Les ports terminaux (hyperviseurs, frontière) sont déclarés en bord de réseau. BPDU guard n'est délibérément pas émis : un pont Linux dont le STP serait activé enverrait des BPDU et ferait tomber le port côté hyperviseur. Le devis dit pourquoi, et à quelle condition l'ajouter.

En étoile, aucun lien n'est redondant : RSTP est un filet, pas une nécessité — le devis le dit plutôt que de laisser croire à une protection indispensable.

make underlay valide mode et topologie, et refuse un stp déclaré sans routeur : sans lui, aucun pont racine ne peut être désigné. Sans stp, la section signale l'absence de protection au lieu de disparaître.

Réserve consignée : la forme binardat des lignes de spanning-tree n'a pas été confrontée au matériel, comme les ip route.

2026-08-01 (suite 7) — l'underlay connaît ses fabrics physiques

Le stockage jumbo (iSCSI, Ceph) est porté par un réseau indépendant de deux switches 10G, sans câble commun avec la fabric convergée des sleipnir. Le modèle l'ignorait : le devis déclarait les VLAN 20/30/31 sur les switches convergés et les mettait dans leurs trunks. C'était faux.

Chaque réseau de l'underlay porte désormais une fabric (principal par défaut). Le devis ne configure que celle du routeur, et énonce ce qu'il ne couvre pas plutôt que de le taire :

! HORS PERIMETRE — fabric 'stockage' : stockage-iscsi (VLAN 20), ceph-public (VLAN 30), …
! Portee par des switches distincts, sans cable commun avec celle-ci :
! ni VLAN a declarer ici, ni trunk, ni spanning-tree partage.

Conséquence directe : la question du spanning-tree ne se pose que sur la fabric principale — trois switches — et pas sur le stockage, dont les deux switches forment un domaine séparé.

Le roster d'hôtes de la section 0 est filtré de la même façon : un équipement d'une autre fabric n'apparaît pas, même en commentaire. Un devis est une configuration qu'on applique, pas un inventaire. Vérifié en déclarant un hôte de la fabric stockage — il reste absent, et la sortie du jour est inchangée.

Les deny de l'ACL couvrent en revanche toutes les fabrics, y compris celles hors périmètre : la règle porte sur l'adresse de destination, pas sur le câblage. Si un chemin s'ouvre un jour vers le stockage, il est déjà fermé.

2026-08-01 (suite 6) — nommage : bifrost aux frontières, sleipnir à la fabric

bifrost-1 et bifrost-2 sont réservés aux deux frontières OPNsense — Bifröst est le pont vers l'extérieur. Les switches internes deviennent sleipnir-01…03 : le cheval qui traverse les mondes, pas le pont qui en sort. La division du nom suit celle de l'architecture.

Les deux boîtiers sont déclarés comme hôtes du lien de transit (10.0.4.1, 10.0.4.2) : hors flotte Ansible, la déclaration documente le lien et réserve les noms. Le /29 choisi plus tôt les loge tous les deux, comme prévu.

Le plan du /29 est réorganisé en conséquence : frontières en bas (.1, .2), SVI du switch en haut (.6), et .3 laissée libre pour une future IP virtuelle CARP si les deux OPNsense passent en haute disponibilité. Ce jour-là, passerelle_sortie pointera sur la VIP plutôt que sur un boîtier nommé — un seul endroit à changer.

Conséquence à traiter : la partie B du devis switch aurait listé les deux pare-feux parmi les « switches d'accès ». Elle ne retient désormais que les hôtes du réseau de management — un équipement déclaré ailleurs ne reçoit aucune ligne de configuration de switch.

Le devis frontière nomme le boîtier quand il est déclaré : frontiere 10.0.4.1 (bifrost-1).

2026-08-01 (suite 5) — deux incohérences du devis switch

Corrigé — le routeur avait deux adresses de gestion contradictoires

bifrost-01 portait le SVI Vlan10 → 10.0.0.1 et était déclaré dans underlay.hotes à 10.0.0.2. Une interface VLAN n'a qu'une adresse primaire : les deux ne pouvaient pas être vraies. L'entrée datait d'avant la désignation du routeur, quand 10.0.0.1 était une passerelle abstraite.

Le routeur est désormais déclaré à l'adresse du SVI qu'il porte, et make underlay refuse la divergence : ip 10.0.0.2 != passerelle 10.0.0.1 — le routeur porte ce SVI, les deux doivent coïncider. Le roster des trois switches reste complet.

Corrigé — VLAN de transit déclaré sur les switches d'accès

La partie B créait vlan 40 alors que le trunk B3 ne le transporte pas — le transit ne relie que le routeur à la frontière. Un VLAN qui n'aurait jamais vu de trame. Il est exclu de la partie B, comme il l'est déjà des trunks généraux.

2026-08-01 (suite 4) — un seul switch route, les autres en L2 pur

Décidé — underlay.routeur

Sans MLAG, le routage est porté par un unique switch (bifrost-01 chez Chezlepro) ; les autres restent en L2 pur. Le devis émettait jusqu'ici un jeu unique de SVI sans dire à quel switch il s'adressait : appliqué sur les trois, il aurait créé autant de conflits d'adresses qu'il y a de zones.

Ajouté — le devis se scinde en deux parties

  • Partie A — switch routeur : VLANs, SVI, ACL d'isolation, trunks, routes. Va sur lui seul, et l'en-tête le nomme.
  • Partie B — switches d'accès (L2 pur) : mêmes VLANs pour commuter les trames étiquetées, une adresse de gestion par switch (tirée de underlay.hotes) avec ip default-gateway vers le routeur, et les trunks. Aucun SVI de zone, aucune ACL, aucune route.

Deux gardes : make underlay refuse un routeur qui ne nomme aucun hôte déclaré ; et la partie B avertit qu'un seul de ses blocs de gestion va sur chaque machine — les coller tous écraserait l'adresse. Sans routeur désigné, l'en-tête signale explicitement le risque de duplication au lieu de laisser croire que le devis est applicable partout.

Reste ouvert et consigné : la syntaxe des ip route n'est pas dialecte-consciente, contrairement aux ACL.

2026-08-01 (suite 3) — les tenants n'atteignent plus l'underlay

Corrigé — ACL de switch : deny vers la fabric physique

L'ACL d'isolation bloquait l'autre tenant, puis se terminait par permit ip <tenant> any. Ce any autorisait 10.27.x → 10.0.0.0/24 : le management des switches, celui de Proxmox et l'OOB/IPMI, plus les réseaux iSCSI et Ceph. Une VM compromise atteignait la console physique des hyperviseurs.

Le commentaire du générateur disait « Reste → passerelle OPNsense », mais c'est faux pour l'underlay : ce trafic est routé localement par le switch et ne passe jamais par la frontière, donc il n'est jamais filtré par elle.

devis_reseau.py émet désormais un deny par sous-réseau underlay avant le permit final, dérivé de underlay.yml — dialecte respecté (masque normal ou wildcard). Aucun flux du registre ne vise l'underlay : le blocage ne casse rien de déclaré.

Deux limites consignées dans docs/frontiere-opnsense.md §6 : le registre des flux n'a pas de mot-clé underlay, donc un besoin légitime (superviser l'hyperviseur) ne pourrait pas être déclaré ; et devis_reseau.py émet un jeu unique de SVI pour trois switches sans MLAG, ce qui reste une décision d'architecture ouverte.

2026-08-01 (suite 2) — le port vers la frontière, et l'ordre d'application

Ajouté — section 4b : le port du switch vers la frontière

Le devis étiquetait le VLAN de transit sur le trunk <PORT-VERS-PROXMOX> et n'émettait aucune interface vers le pare-feu. Deux erreurs en une : les hyperviseurs n'ont pas d'interface sur le transit, et le pare-feu n'a rien à faire des VLAN tenants — il route vers eux, il ne les étiquette pas. Le VLAN de transit sort donc du trunk Proxmox et prend son propre port, dérivé du transit déclaré.

Ajouté — avertissement d'ordre en tête de la section 5

Les routes de la section 5 déplacent la sortie du switch, y compris celle de ses propres réponses. Tant que l'adresse de la frontière ne répond pas, elles coupent l'accès d'administration au switch lui-même — le mécanisme qui a rendu une VM muette le 2026-07-29, appliqué cette fois à l'équipement depuis lequel on travaille.

Le devis crachait ces lignes à la suite des autres comme si elles étaient équivalentes. Il énonce désormais les préalables (boîtier câblé, adressé, joignable depuis le switch, session console ouverte) — inacceptable autrement pour un outil censé être exploitable sans IA.

2026-08-01 (suite) — le prochain saut dérive du transit

opnsense_prochain_saut n'est plus un intrant du panneau : il dérive du réseau de transit de l'underlay (la passerelle du réseau portant passerelle_sortie).

Un seul bloc de six lignes alimente désormais les deux devis : 10.0.4.1 devient le SVI côté switch et le prochain saut des routes tenants côté frontière ; 10.0.4.2 devient la route par défaut du switch. Le saisir en doublon dans le panneau rouvrait la possibilité de deux valeurs contradictoires pour un seul lien — précisément le mode de panne qu'on venait de fermer pour les réseaux d'administration.

Le devis frontière gagne au passage un bloc transit (JSON compris) et cesse de réclamer en section 0 des routes que make devis-reseau émet maintenant : il dit lesquelles sont déjà émises, ou signale l'absence de transit déclaré. Sans underlay, le marqueur <PROCHAIN-SAUT-SWITCH> revient — le repli reste explicite.

Reste un seul intrant à figer au câblage : opnsense_if_transit, le nom de l'interface qui porte le VLAN 40 sur le boîtier — la seule valeur que rien ne peut deviner.

2026-08-01 — le lien de transit et les deux routes (la boucle est fermée)

Ajouté — réseau de transit dans l'underlay (clé passerelle_sortie)

Le lien entre le routeur est-ouest (switches L3) et la frontière nord/sud manquait dans tous les fichiers : le devis switch ne contenait pas une seule ip route.

Il vit dans l'underlay et non dans un tenant, pour une raison qui tranche : la frontière route vers tous les supernets tenants par le même prochain saut. Le lien est donc partagé et ne peut dériver d'aucun index. Un réseau underlay portant passerelle_sortie (l'adresse du pare-feu sur le lien) le déclare — chez Chezlepro : VLAN 40, 10.0.4.0/29, SVI 10.0.4.1, frontière 10.0.4.2. Un /29 et non un /30 parce que deux pare-feux cohabitent pendant la transition.

underlay.py valide la nouvelle clé (sortie sur le lien, distincte du SVI, SVI obligatoire, un seul transit) — preuve P23, cas de rejet exercés un par un.

Ajouté — devis-reseau émet les routes (section 5)

Deux routes dérivées, et il en faut impérativement deux :

  • l'aller : ip route 0.0.0.0 0.0.0.0 <sortie> — sans elle, aucun hôte n'a de sortie ;
  • le retour : une route par réseau d'administration — sans elle, la réponse d'une VM revient au pare-feu par une autre interface que celle où l'état a été créé, et se fait jeter en silence. C'est le piège qui a coûté la passe de déploiement du 2026-07-29.

Les réseaux d'administration viennent de l'intrant nftables_admin_ssh : même source unique que la garde anti-lockout des nftables et l'alias SETOPS_ADMIN de la frontière — les trois pare-feux et les routes ne peuvent pas diverger. Sans transit déclaré, la section s'affiche en clair comme manquante plutôt que de disparaître silencieusement.

Corrigé — le panneau refusait d'enregistrer les intrants de la frontière

group_vars/opnsense.yml porte à la fois des paramètres anodins et deux références de voûte ({{ vault_opnsense_api_key }}). La fusion « préserve les clés non gérées » les relisait, et le garde-fou, qui ne regardait que les noms, les prenait pour des secrets soumis. Il regarde désormais la valeur : une référence de voûte est un pointeur et passe ; toute valeur réelle sur ces clés fait toujours échouer l'écriture. Ajout d'un refus explicite à l'entrée pour une requête forgée, au lieu d'un abandon silencieux.

Retiré — reliquat proxmox.vault.yml

La voûte est unique (group_vars/all/vault.yml) ; l'ancien fichier séparé n'était plus chargé automatiquement (aucun groupe proxmox dans l'inventaire) et entretenait la confusion. Supprimé de l'instance, avec son gabarit.

Au passage : supprimer_vm_debian.yml ne chargeait que ce reliquat pour ses secrets. Le supprimer tel quel aurait cassé make detruire, l'outil même du rebuild from-zero. Sa liste est alignée sur celle du playbook de clonage (all/vault.yml en dernier, il l'emporte), et la résolution du jeton depuis la voûte unique est vérifiée en exécution réelle.

2026-07-29 (suite) — la frontière OPNsense, dérivée du registre des flux

Ajouté — make devis-opnsense (+ preuve P24)

La bordure nord/sud devient un artefact dérivé, comme le devis switch. Rien de saisi à la main : ni port, ni adresse, ni nom d'hôte n'apparaît dans le générateur.

Le constat qui rend la chose évidente : resoudre_flux.py saute volontairement les flux pair: externe (scripts/resoudre_flux.py:184) parce qu'ils ne concernent pas le pare-feu d'hôte. Plusieurs raison du registre disent déjà « filtré à l'OPNsense ». La politique de la frontière était donc déjà écrite — il ne restait qu'à la dériver.

  • scripts/devis_opnsense.py — agrège les flux externe, résout les destinations depuis l'inventaire (hôtes actifs et planifiés : la frontière se prépare avant les VM), les supernets depuis les nomenclatures fédérées, et l'accès d'administration depuis l'intrant nftables_admin_ssh. Sort un devis relisible ou --json (destiné à l'API OPNsense).
  • Garde anti-lockout (P24) — --verifier refuse un devis dont nftables_admin_ssh est vide : la règle SSH entrante n'aurait aucune source et le block in final fermerait l'accès d'administration. Même intrant que les nftables d'hôte : source unique, donc pas de divergence possible entre la bordure et les hôtes.
  • Section 0 du devis : les routes de retour à poser sur les switches. C'est le piège qui a coûté la passe de déploiement du jour — la passerelle de zone répond au ping, mais aucun hôte derrière elle n'est joignable, parce que la réponse revient au pare-feu par une autre interface que celle où l'état a été créé.
  • docs/frontiere-opnsense.md — les décisions d'architecture (frontière nord/sud, les SVI restent sur les switches L3), le partage des rôles entre les trois pare-feux, le chemin d'application par l'API, et ce qui reste ouvert (câblage, second VPN, sortie générale).

Corrigé — intrant nftables_admin_ssh vide sur l'instance Chezlepro

Il valait [] alors que group_vars/hotes_actifs.yml active nftables_baseline_enabled. Autrement dit : la flotte se serait mise en policy drop sans aucune règle autorisant le contrôleur, qui arrive par le VPN hors du sous-réseau de la flotte. Renseigné à 192.168.255.0/24. Le sous-réseau du second VPN (OPNsense) devra y être ajouté.

Ajouté — la frontière devient réglable depuis la console (section Frontière)

Les valeurs non sensibles du pare-feu de bordure sont de vrais intrants, pas un fichier YAML tenu à la main : un opérateur règle la frontière depuis le panneau « Intrants de base », sans IA et sans éditeur.

  • INTRANTS_SCHEMA — nouvelle section Frontière : opnsense_api_url, opnsense_api_verifier_certs, opnsense_if_wan, opnsense_if_transit, opnsense_prochain_saut. Cible d'écriture group_vars/opnsense.yml, en fusion (comme Proxmox) pour préserver les références de voûte du fichier. Le panneau rend ses sections génériquement : aucune modification d'interface n'a été nécessaire.
  • Garde-fou — opnsense_api_key / opnsense_api_secret ajoutés à INTRANTS_CLES_INTERDITES : le GUI refuse de les écrire, donc impossible de coller un secret d'API dans le panneau par mégarde. Ils n'apparaissent qu'en lecture seule, par leur nom, dans SECRETS_ATTENDUS.
  • devis_opnsense.py lit désormais ces intrants et n'affiche ses marqueurs (<IF-TRANSIT>, <PROCHAIN-SAUT-SWITCH>) qu'en repli : le devis se complète de lui-même dès que la console est renseignée.

Ajouté — les identifiants d'API de la frontière, dans la voûte

Même partage que Proxmox : l'anodin en clair, le secret dans la voûte unique de l'instance.

  • group_vars/opnsense.yml (instance, en clair) — URL de gestion, interfaces et prochain saut à figer au câblage, plus les références par nom {{ vault_opnsense_api_key }} et {{ vault_opnsense_api_secret }}. Aucune valeur de secret n'y figure.
  • Gabarit de voûte — les deux clés ajoutées à vault.yml.example. scripts/voute.py les a recensées tout seul depuis les group_vars (il ne lit jamais la voûte, il ne compare que des noms) : le gabarit passe de 23 à 25 secrets, et la preuve P18 reste verte.

Validation : make verifier vert — 24 preuves CONFORME, 0 échec, 0 sauté.

2026-07-29 — dette documentaire soldée (un README par rôle) + carte revue

Ajouté — les 12 README de rôles manquants

Tous les rôles ont désormais un README. Les 12 restants sont écrits, au format maison (intention → rôle → variables → notes/limites → prérequis), et documentent surtout ce qui ne se lit pas dans les tâches :

  • Socle et résolution — serveur_debian (rôle-catégorie sans tâches : il ne porte que le flux SSH du plan de gestion, sans quoi les nftables couperaient l'accès Ansible), hosts_statiques (le plancher /etc/hosts qui rend l'ordre de reconstruction possible).
  • Rôles utilitaires — resoudre_base et resoudre_annuaire : entrées/sorties (facts), et pourquoi le FQDN plutôt que le nom court (fédération + verify-full).
  • Courriel — serveur_dovecot (les trois réglages Dovecot 2.4 qui conditionnent la remise ; le local-part seul comme chemin commun LMTP/IMAP), serveur_postfix (liens mailstore/milter, recopie de /etc/hosts dans le chroot), serveur_rspamd (clé DKIM idempotente ; le domaine signé doit être public en prod).
  • Sauvegardes — client_backup (jobs déclaratifs, chiffrement côté client, le dépôt neuf est vide tant qu'une première sauvegarde n'a pas tourné) et serveur_backup (hors-nœud ≠ hors-site).
  • SSO et supervision — serveur_oauth2_proxy (le patron réutilisable Keycloak-devant- n'importe-quoi ; pourquoi allow_unverified_email est nécessaire avec un annuaire LDAP) et serveur_icingaweb2 (modes ldap vs external, et l'écoute à restreindre en SSO).
  • client_unbound — le garde-fou de bascule du résolveur (apply et confirm, validation avant de toucher /etc/resolv.conf).

Corrigé — docs/carte-set-ops.md ne décrivait plus l'état du code

  • expose est consommé au déploiement (la carte l'annonçait encore comme « Phase 3 à venir ») : vhosts nginx dérivés via expositions_des_applications + expositions.conf.j2, alias /etc/hosts, SANs d'edge dérivés par instancier.py.
  • Trois mécanismes transverses ajoutés au catalogue : résolution d'annuaire (resoudre_annuaire), plancher de résolution (hosts_statiques), et resoudre_base nommé dans la ligne des bindings app→base.
  • Cinq entrées d'index ajoutées : réseau/pare-feu, ordre de déploiement, preuve/recette, exploitation courante, wiki — des pans entiers du corpus n'étaient pas indexés.
  • Reste ouvert, explicitement : meta/liens.yml sur le seul serveur_postfix, et requiert non consommé au déploiement (indice applicatif ; l'ordre, lui, vient des couches+graphe).

Validation : make verifier vert (ansible-lint 514 fichiers, tests, syntax-check, 23 preuves CONFORME).

2026-07-28 — figures annotées dans le wiki (console d'exploitation)

Ajouté — les 8 vues de la console illustrées, dans le wiki

La série des figures annotées (une par vue du GUI, en SVG auto-contenu : capture + repères intégrés en base64) est désormais intégrée à l'unité wiki Le GUI (console d'exploitation), en fin de section ②. Vues éditables (Serveurs, Applications, Bases × 2, Domaines) puis dérivées (Flux, Couches, Réseau).

  • Symlink wiki/img → ../docs/img — foyer unique des figures dans docs/img/ ; les pages wiki y réfèrent en relatif (img/*.svg) sans duplication dans l'arbre source.
  • make wiki-publier embarque désormais les docs/img/*-annote.svg (déréférencés) dans le wiki Forgejo publié — c'étaient jusqu'ici les seules .md qui voyageaient, donc aucune image.

2026-07-24 — underlay (fabric physique, cluster-global)

Ajouté — l'underlay comme concept de premier plan

Le modèle dérive l'adressage par tenant (VLAN 1000+index×10+zone), mais la fabric physique qui porte la flotte — mgmt des switches, mgmt Proxmox/OOB, iSCSI, Ceph — n'appartient à aucun tenant et ne dérive d'aucun index. Elle vit dans le sous-sol du modèle. Jusqu'ici elle n'était pas codifiée. Elle l'est.

  • scripts/underlay.py + make underlay — charge/affiche/valide underlay.yml : réseaux (nom, VLAN, sous-réseau, passerelle optionnelle → SVI, MTU/jumbo) et hôtes fixes documentés (les switches). La validation refuse toute collision avec la plage tenant : VLAN < 1000, sous-réseaux hors des supernets 10.(10+index).0.0/16.
  • underlay.yml (racine du moteur, gitignore comme le vault ; gabarit public underlay.yml.example), surchargeable par SETOPS_UNDERLAY. Absent → tout reste inchangé.
  • make devis-reseau émet désormais une section 0. Underlay (VLANs, SVI, hints jumbo, IP des switches en commentaire) et ajoute les VLAN underlay au trunk Proxmox, avant les tenants. Respecte le dialecte (cisco/binardat).
  • Preuve P23 — underlay.py --verifier : la fabric n'empiète pas sur la plage tenant. Sautée (⚪) si underlay.yml est absent (dépôt public), comme P16 sans vault.

Le plafond tenant (245) est inchangé : l'underlay occupe 10.0.0.0/16 .. 10.10.0.0/16, laissé libre par la dérivation (index ≥ 1 → 10.11+).

2026-07-23 (suite 7)

Ajouté — dialecte de CLI du commutateur (devis-reseau)

Constat de l'opérateur : son switch est un Binardat, dont la CLI diffère de Cisco sur deux points que le devis généré ignorait — et deux pièges qui font passer un VLAN mais fuir un tenant :

  • Masque d'ACL — Cisco veut un masque inversé (wildcard 0.0.255.255), Binardat un masque normal (255.255.0.0). Le SVI (ip address … 255.255.255.0) était déjà normal, donc valide sur les deux.
  • remark — Binardat n'a pas la commande de commentaire d'ACL de Cisco. Ces lignes faisaient rejeter le bloc.

scripts/devis_reseau.py gagne un dialecte (cisco par défaut, binardat) : masque_acl() choisit wildcard ou masque normal ; remarque() omet les remark en Binardat. Réglable par --dialecte, par SETOPS_DIALECTE, ou make devis-reseau DIALECTE=binardat. Le GUI (lecture seule) suit l'env. Le code public reste générique (défaut cisco).

2026-07-23 (suite 6)

Ajouté — plan de recette (le pendant manuel de make prouver)

Constat de l'opérateur : les 78 exercices « ④ À toi de jouer » du wiki forment, ensemble, un plan de tests d'acceptation. Formalisé, sans dupliquer :

  • scripts/plan_recette.py + make plan-recette — génère docs/audit/plan-de-recette.md depuis les exercices du wiki : une grille auto-contenue (le geste inline) par unité, colonnes Ce qu'on éprouve · Le geste · Type (observe/casse-répare) · Preuve auto. La dernière colonne est extraite du texte (le Pxx que l'exercice mentionne) : elle montre quels gestes manuels sont aussi gardés par la machine. Étant générée, la grille ne peut pas dériver du wiki.
  • Preuve P22 — plan_recette.py --verifier échoue si le fichier committé n'est plus à jour (le wiki a changé sans régénérer). Le plan de recette devient un artefact auto-gardé.
  • Honnêteté de couverture assumée dans le document : un « — » = manuel seul (aucune preuve machine ne le double) ; le plan ne prétend pas à l'exhaustivité au-delà des exercices du wiki.

C'est le pendant humain de make prouver : le harnais prouve le moteur (P01–P21), la recette valide l'exploitation — et sert de checklist au protocole-operateur-independant.md (« exploitable sans IA »).

Validé : 78 gestes sur 19 unités, 5 doublés d'une preuve Pxx ; P22 testée (détecte une dérive) ; make verifier → CONFORME 22/22 (contre une instance cohérente).

2026-07-23 (suite 5)

Ajouté — wiki : l'axe « méthode » (KB enrichie)

Le wiki enseignait les fondamentaux services (identité, PKI, courriel…) mais pas la méthode de Set-OPS. Cinq pages ajoutées, au moule à 4 temps (concept → Set-OPS → transférable → à toi de jouer), avec exercices concrets :

  • Le plan & l'adressage dérivé — un seed (index), tout en découle (DRY, source unique).
  • Multi-instance & fédération — un moteur, N écosystèmes ; découverte par convention.
  • La preuve — « ne jamais affirmer plus que ce qu'on prouve » ; le registre, make prouver, P01–P21.
  • Le GUI (console d'exploitation) — éditer la source, dry-run avant apply, l'invalide impossible à saisir (le <select> sans hôte fantôme) ; la flotte et la bascule.
  • Glossaire — 24 concepts en une phrase (était « à venir »).

Raccordées dans Home.md (deux unités-pilotes : services et méthode) et _Sidebar.md (section « Flotte & preuve »). Fidèle à la doctrine : le wiki pointe vers docs/, ne recopie pas. Publié via make wiki-publier. Wiki : 855 → 1573 lignes, 22 pages, aucun lien mort.

2026-07-23 (suite 4)

Ajouté — créer un MODÈLE (make model-creer, dépôt privé)

Symétrique de instance-creer, mais produit un modèle réutilisable dans le dépôt PRIVÉ (Set-OPS-Modeles/, jamais exemples/modeles/). scripts/model_creer.py, deux modes :

  • MODE=base BASE=<modele> NOM=<x> — copie un modèle déjà générique (copier + éditer).
  • MODE=instance SOURCE=OPS-<x> NOM=<y> — promeut une instance éprouvée en modèle : généralise l'identité (domaine → exemple.internal, organisation → Exemple, realm → exemple), fixe index → 1, setops_production → false, nftables_admin_ssh → [], vide la clé publique de sauvegarde, générique proxmox.yml (host/nœud/stockage/VMID vidés, golden template → modele-debian13).

Sûreté : aucun secret ne sort (vault.yml/proxmox.vault.yml jamais copiés ; refus si l'un subsiste ; le .example est conservé). Copie ciblée (plan/ + inventories/ seulement, symlinks résolus) — robuste aux dépôts imbriqués et boucles de symlinks. Le modèle produit doit valider (modeles.py verifier), sinon il est annulé. make model-creer.

Validé : les deux modes testés en isolement (destination tmp, dépôt privé jamais touché) — promotion du labo (cohérent) réussie + validée, aucun chezlepro résiduel, aucun secret, proxmox.yml génériqué ; copie base (socle) OK ; refus d'une instance INVALIDE (le garde-fou a détecté un hôte fantôme dans une instance en cours d'édition). node --check, make verifier rc=0 CONFORME 21/21.

2026-07-23 (suite 3)

Ajouté — créer une instance depuis un modèle (CLI + GUI)

Le moteur est indépendant des instances (le symlink instance/ est gitignoré, aucun artefact d'instance n'est committé). Créer une instance existait seulement à la main (cp -r + ln -s, QUICKSTART). C'est désormais une capacité de premier ordre.

  • scripts/instance_creer.py — copie un modèle (exemples/modeles/* + SETOPS_MODELES) vers un dépôt frère ../<nom>, y fixe l'index (le seed), et refuse : un nom déjà existant (rien n'est écrasé), un modèle inconnu, un index en collision avec une instance fédérée (le garde-fou vérifie AVANT toute copie). Le .git et hosts.genere.yml du modèle ne sont pas copiés.
  • make instance-creer NOM=OPS-X MODELE=socle [INDEX=N] et make instance-modeles (liste les modèles + les index déjà pris).
  • GUI, vue Réseau — formulaire « Créer une instance depuis un modèle » sous la flotte : modèle (liste), nom, index. POST /api/instance-creer ; /api/instances renvoie aussi modeles et index_pris. Ne bascule pas l'active (geste explicite).

Corrigé

  • Gabarit de voûte du labo complété. P18 (voûte) a échoué en passant l'active sur le labo : son vault.yml.example était resté à l'ancienne version (17 clés, sans vault_restic_password, vault_oauth2_cookie, etc.). Aligné sur le gabarit complet (23 secrets). P18 fait exactement son travail — attraper un gabarit incomplet, quelle que soit l'instance active.

Validé : garde-fous de création testés en isolement (collision d'index refusée avant copie, écrasement refusé, modèle inconnu refusé, aucune pollution des dépôts frères) ; node --check du GUI ; make verifier rc=0 CONFORME 21/21 (active = labo).

2026-07-23 (suite 2)

Ajouté — bascule d'instance depuis le GUI (vraiment multi-instance)

Demande explicite et répétée de l'opérateur : gérer les instances depuis le GUI, pas seulement en CLI. Le plan de contrôle reste maison, mais cette capacité y entre.

  • Inventaire résolu dynamiquement. Le serveur GUI figeait l'inventaire au démarrage (args.inventaire.resolve()). Il est désormais relu à chaque requête depuis le symlink instance/ (propriété Gestionnaire.inventaire) — la bascule prend effet sans redémarrer. Les chemins de plan (FICHIER_*) étaient déjà relatifs au symlink et suivent de même. SETOPS_INVENTAIRE force encore un inventaire fixe (CI).
  • POST /api/instance-utiliser + basculer_instance(nom) — repointe le symlink avec les garde-fous de make instance-utiliser, plus une validation stricte : nom doit être une instance découverte (dossier frère), ce qui interdit toute traversée de chemin (testé : ../etc refusé).
  • GUI, vue Réseau — la table de flotte gagne un bouton « Activer » par instance (l'active affiche « active »). Il confirme (avec avertissement renforcé si l'instance est en production), bascule, puis recharge la page : toutes les vues et les déploiements visent la nouvelle instance.

L'édition du drapeau federe reste au CLI (rarement changé). La bascule, elle, est maintenant CLI et GUI.

Validé : node --check du GUI ; bascule testée de bout en bout (repoint → l'inventaire dynamique suit → traversée refusée → restauration) ; make verifier rc=0 CONFORME 21/21.

2026-07-23 (suite)

Ajouté — gestion multi-instances : vue d'ensemble + garde-fou de collision

Le mécanisme de bascule existait déjà (make instance-utiliser, make instance-courante : repointer le symlink instance). Ce qui manquait : le regard d'ensemble et le filet.

  • make instances (scripts/instances.py) — liste toutes les instances de la fédération (dépôts frères avec plan/nomenclature.yml), marque l'active (*), et montre pour chacune index, plage VLAN dérivée, statut fédéré/local (federe) et production (setops_production). Lecture seule.
  • Détection de collision d'index — signale toute paire d'instances fédérées partageant un index (donc mêmes VLAN/VMID sur le trunk convergé). C'est exactement le piège vécu (Chezlepro-prod et le labo tous deux à l'index 1) : désormais crié, pas découvert par hasard. Le mode --verifier sort en erreur (rc=2) sur collision.
  • Preuve P21 (make prouver/make verifier) — câble ce garde-fou dans le harnais : la cohérence de la fédération est vérifiée à chaque passage. No-op quand moins de deux instances fédérées sont présentes (comme P17 sans SETOPS_MODELES).

Validé : make instances liste les 3 instances (labo LOCAL, Technolibre et Chezlepro fédérées, index 2 et 13) ; détection testée en synthétique (deux fédérées au même index → collision levée ; labo federe: false → cohérent) ; make verifier rc=0 CONFORME 21/21.

2026-07-23

Changé — l'adressage se dérive du seul seed index (RUPTURE, mode compact retiré)

Principe posé par l'utilisateur : les valeurs de configuration doivent se dériver des intrants, pas se réécrire à la main. La nomenclature dupliquait ce que index détermine déjà (supernet, sous-réseaux, passerelles, VLAN). C'est corrigé, en rupture nette.

  • inventory_rules : nouvelles fonctions de dérivation, source unique — supernet_de, base3_de, sous_reseau_de, passerelle_de, vlan_de. Le modèle à 6 zones est encodé une fois : 2ᵉ octet = 10+index, 3ᵉ octet de zone = 15+catégorie, VLAN trunk = 1000+index×10+zone. deriver_nomenclature ne lit plus aucun adressage stocké ; le mode compact est supprimé (tout est ip-miroir dérivé).
  • devis_reseau importe ces helpers (plus de duplication) ; découverte des tenants sur index présent (le filtre vmid_schema disparaît).
  • Les 10 nomenclatures (3 instances + 7 modèles) passent au format maigre : index, cidr_hote, reservations, libellés de zones et fonctions seulement. Supprimés : supernet, vmid_schema, et par zone sous_reseau/passerelle/vlan. presence-web, encore en compact, gagne index: 1.
  • GUI : index devient un intrant (section « Réseau » du panneau Intrants). Il vit dans la nomenclature (le plan réseau, uniforme partout — contrairement aux intrants des modèles, hétérogènes) et le GUI l'écrit chirurgicalement (une ligne, sans reformater le fichier). Le miroir JS dérive le VLAN du seed (fin de la lecture de c.vlan stocké).

Ajouté

  • Preuve P20 (preuve_nomenclature_derivee) : aucune nomenclature ne stocke d'adressage — garde-fou permanent contre une rechute vers l'écriture manuelle. Testée en négatif (une rechute simulée est bien rejetée).

Validé : DIFF VIDE sur les 3 instances (la dérivation reproduit exactement l'adressage qui était stocké), les 7 modèles valident, devis_reseau génère les mêmes VLAN (1011-1016 dérivés), make verifier rc=0 CONFORME 20/20, node --check du GUI OK, aller-retour d'écriture de index : une seule ligne touchée.

2026-07-22 (suite)

Ajouté — trois preuves qui ferment les angles morts du harnais

Le constat : make prouver ne vérifiait qu'une instance et le seul modèle socle. Tout ce qui vit à côté du moteur dérivait en silence — d'où l'hôte fantôme d'integral, les neuf secrets absents du gabarit Chezlepro, les champs de plan que le GUI ne sait pas écrire.

  • P17 — tous les modèles valident (scripts/modeles.py). Rejoue les 4 validateurs de registres sur chaque modèle découvert (les exemples/modeles/* du dépôt, plus les chemins de SETOPS_MODELES, ex. le dépôt privé). A immédiatement trouvé 6 modèles invalides sur 7 : cinq portaient autorite: interne (valeur périmée jamais propagée depuis la correction de socle en phase 5), presence-web manquait son domaine interne. Corrigés.
  • P18 — gabarit de voûte complet (scripts/voute.py). Confronte vault.yml.example aux secrets réellement exigés par le plan (champ secret des bases + références vault_* des rôles actifs et des group_vars). Ne déchiffre jamais la vraie voûte : compare des noms. --strict signale aussi les clés devenues inutiles.
  • P19 — le GUI couvre le plan (scripts/couverture_gui.py). Croise les champs présents dans les plans réels (instance + modèles) avec CHAMPS_ECRITS_PAR_GUI, nouveau tableau de inventory_gui.py déclarant ce que les fonctions de sauvegarde écrivent. Signale tout champ non éditable par le GUI (a trouvé applications.websocket, comblé — case à cocher ajoutée), et toute dérive entre le tableau et le source du GUI. La nomenclature reste un trou connu (lecture seule), tolérable via --tolerer nomenclature.

Corrigé

  • Garde-fou contre l'hôte fantôme : valider_applications accepte désormais le registre des serveurs et refuse une application posée sur un hôte non déclaré — la faute exacte qu'integral portait. Câblé dans scripts/applications.py (les 4 appels), au POST du GUI, et vérifié : re-teste le bug d'origine → rejeté avec un message actionnable.
  • 6 modèles de Set-OPS-Modeles : autorite: interne → auto-heberge (collaboration, forge, identite, integral, observabilite) ; presence-web gagne son domaine interne pour ses expositions site.*/app.*.
  • Champ websocket dans le GUI (inspecteur d'application) — Collabora en a besoin.

Validé : make verifier → rc=0, CONFORME 19/16→19 (P01–P19), ansible-lint 0 échec, les 7 modèles valident (SETOPS_MODELES posé), gabarit de voûte complet (23/23), couverture GUI 27/27 champs (nomenclature tolérée), DIFF VIDE conservé, JS du GUI valide.

2026-07-22

Ajouté

  • Champ « Liens (bindings) » dans le GUI (scripts/inventory_gui.py). L'inspecteur d'application porte une section dédiée : une ligne par lien, rôle → cible, les deux en listes déroulantes, avec bouton d'ajout et de retrait. Les rôles proposés sont exactement ceux que le rôle porteur déclare accepter (roles/<groupe>/meta/liens.yml), et les cibles sont les autres applications du plan. Chaque ligne affiche les variables que le lien injectera. Changer le groupe d'une application vide les rôles de lien devenus inacceptés. Comble un manque relevé à l'audit du GUI : les bindings ne pouvaient se déclarer qu'en éditant plan/applications.yml à la main, ce qui rendait la configuration du courriel (Postfix → Dovecot, Postfix → rspamd) inaccessible sans éditeur.
  • liens_acceptes(groupe) et catalogue_liens() dans scripts/inventory_rules.py — source unique de « quels liens un rôle accepte », partagée par le validateur, le GUI et instancier.py. Nouvelle clé liens_acceptes dans la charge utile de /api/inventaire.

Modifié

  • valider_applications valide désormais les liens : liste de tables {vers, role}, champs non vides, cible connue, pas de lien vers soi-même, et rôle accepté par le rôle porteur (avec la liste des rôles acceptés dans le message d'erreur). Un rôle sans meta/liens.yml reste toléré au validateur — instancier.py tranche avec le même message. Bénéficie aussi à make inventaire-verifier et à scripts/applications.py verifier.
  • scripts/instancier.py — sa copie locale _liens_acceptes() est retirée au profit de la fonction partagée. Un seul endroit lit meta/liens.yml.
  • docs/bindings-conception.md — la Phase 4 passe à 🟡 : l'éditeur est fait, le graphe des liens reste à faire.

Validé : make verifier → rc=0, ansible-lint 0 échec sur 485 fichiers, prouver.py --verifier → CONFORME 16/16, verifier_gui.py (node --check) OK, DIFF VIDE conservé et les deux variables de binding du courriel toujours générées (serveur_postfix_mailstore_hote, serveur_postfix_rspamd_milter). Aller-retour d'écriture testé : les liens survivent au cycle chargement → sauvegarde → relecture. Sept garde-fous du validateur éprouvés (rôle inconnu, cible inexistante, lien vers soi-même, champ manquant, types invalides).

2026-07-21 (suite)

Modifié

  • Palette aurore enrichie sur les quatre pages promo/ — ajout de deux teintes, --rose:#ff6fc4 (frange magenta) et --vert:#5cff9d (vert fluo), présentes uniquement dans les dégradés foncés : fond fixe du corps, nappe .aurora dérivante, et filets de séparation .rule. Opacités de 5,5 % à 8,5 % — une teinte, pas un motif. Les dégradés de texte et de boutons (--aurora) sont inchangés : l'identité de marque ne bouge pas. Appliqué identiquement aux quatre fichiers (0 conflit CSS après coup).
  • Thème Forgejo étendu au wiki (roles/serveur_forgejo/files/custom/public/assets/css/alliance.css, 29 → 118 lignes). La feuille étant injectée par templates/custom/header.tmpl sur toutes les pages, le wiki est couvert sans feuille distincte. Réorganisée en deux sections de risque explicite : §1 Variables (surcharge des variables de couleur officielles, sûr, résiste aux mises à jour) et §2 Décor (fond aurore, filet sous les titres, citations, tableaux et code en ligne de .markup — sélecteurs internes, à revérifier à chaque montée de version majeure). Supprimer §2 ramène au thème sobre d'origine. Le ciel nocturne ne s'applique qu'aux thèmes sombres, pour ne pas casser le thème clair. roles/serveur_forgejo/README.md documente le tout.

Ajouté

  • docs/theme-forgejo-hors-flotte.md — pose manuelle du thème sur une instance Forgejo non gérée par Set-OPS (la forge historique qui héberge ce dépôt et son wiki). Forgejo n'ayant aucun réglage web pour le CSS, il faut déposer la feuille et référencer header.tmpl sur le serveur. La procédure ne duplique aucun fichier : elle pointe vers ceux de roles/serveur_forgejo/files/custom/. Comprend la détection du répertoire custom, un garde-fou pour ne pas écraser un header.tmpl existant (ajout de ligne, pas remplacement), la vérification par curl, et la marche arrière.

Corrigé

  • État de forge-01 élucidé — ce n'était pas une contradiction. L'hôte a été créé, puis supprimé. Le plan dit donc etat: planifie (état courant) et le CHANGELOG du 2026-07-03 dit le branding « prouvé sur forge-01 » (état passé) : les deux disent vrai. roles/serveur_forgejo/README.md l'énonce désormais ainsi ; la preuve reste valable, simplement non rejouable tant que l'hôte n'est pas recréé. (La note précédente, qui présentait l'écart comme une contradiction à trancher, était erronée.)
  • Teintes rose et verte trop concentrées dans les coins — nappes élargies d'environ ×2 (780×540 → 1500×1020 px pour le rose, 840×580 → 1620×1120 px pour le vert), ramenées vers l'intérieur (97 % → 76 %, 3 % → 20 % ; nappe dérivante 95 % → 79 % et 6 % → 22 %), extinction repoussée de 62 % à 82 % avec un arrêt intermédiaire pour une rampe douce, et flou de .aurora porté de 64 px à 86 px. La couleur est répartie au lieu d'être tassée aux bords.
  • Opacités des teintes réduites d'environ 40 % dans la foulée — l'étalement les rendait trop présentes. Rose .075 → .044, vert .058 → .034, violet .062 → .036 (arrêts intermédiaires abaissés dans la même proportion), et opacité de la nappe .aurora .44 → .32. Géométrie inchangée : seule l'intensité baisse.
  • Statut du thème précisé, section par section : §1 (variables) est éprouvée sur forge-01 ; §2 (décor), ajoutée aujourd'hui, n'a jamais été rendue par un Forgejo réel. L'affirmation précédente (« non rendu par un Forgejo réel », tous les cas confondus) était fausse pour §1.

Validé : équilibre des accolades et parenthèses du CSS, 0 conflit CSS entre les quatre pages promo, ansible-lint 0 échec, prouver.py --verifier → CONFORME 16/16.

2026-07-21

Ajouté

  • Protocole d'épreuve de l'opérateur indépendant (docs/audit/protocole-operateur-independant.md). Met AFF-002 (« Set-OPS s'exploite entièrement à la main, sans aucune IA ») à l'épreuve d'un sysadmin qui n'est pas l'auteur, sur sa propre grappe Proxmox, à froid depuis le modèle public socle. Définit : la règle du silence (l'observateur journalise, n'aide pas ; trois niveaux N0/N1/N2), les interdits (aucune IA, aucun accès aux dépôts privés, aucune lecture de docs/audit/ qui divulguerait les pièges connus), les critères de réussite R1→R6 fixés d'avance, le périmètre matériel et la sécurité, et le gabarit de rapport (operateur-independant-AAAA-MM-JJ.md). Le registre peut perdre : le protocole prévoit explicitement la redescente d'AFF-002 en 🟡 ou ❌ selon le verdict.

Modifié

  • docs/audit/affirmations.md — nouvelle limite consignée : AFF-002 est ✅ par inspection, pas par démonstration. Ses écarts bloquants sont soldés, mais aucun opérateur indépendant ne l'a exécutée ; ✅ y signifie « plus aucun défaut connu ». Renvoi vers le protocole.
  • docs/audit/README.md — « Les trois pièces » → « Les pièces » : ajout du protocole comme quatrième pièce du dispositif (l'épreuve humaine, hors harnais automatisable).

Validé : python3 scripts/prouver.py --verifier → CONFORME 16/16 (voûte exportée) ; équilibre des blocs de code du nouveau document vérifié. Aucun code touché.

2026-07-20

Modifié

  • make verifier inclut désormais les preuves (make prouver). make verifier se termine par python3 scripts/prouver.py --verifier : nouveau mode qui exécute toutes les preuves du registre (verdict + code de sortie) sans écrire de rapport, pour ne pas écraser la pièce justificative committée docs/audit/preuve-<date>.md. make verifier échoue donc si une preuve échoue. make prouver seul (sans --verifier) continue d'écrire le rapport horodaté. Vérifié : make verifier → CONFORME 16/16 (voûte exportée), aucun churn du rapport committé ; make prouver écrit toujours. Doc mise à jour (docs/audit/README.md § « Rapport avec make verifier »).
  • Conformité Phase 2 — 3 incohérences internes corrigées (documentation d'autorité). Aucun code Ansible touché ; le code SSH était déjà conforme à AGENTS.md.
    • CLAUDE.md réduit à un pointeur mince : autorité unique d'AGENTS.md + les cinq règles absolues (agent unique ; hosts.yml généré jamais édité ; aucun secret ; confirmation des actions destructives ; rien n'est prêt sans validation). Toute la doctrine dupliquée (template, SSH, pare-feu, cloud-init, handlers, Makefile…) retirée.
    • Contradiction SSH levée : CLAUDE.md prescrivait PasswordAuthentication yes pendant la construction ; le code applique en réalité PasswordAuthentication no + AuthenticationMethods publickey dès le départ (prouvé, cf. docs/audit/affirmations.md AFF-037/050). La doctrine SSH périmée de CLAUDE.md est supprimée — la duplication en était la cause racine.
    • AGENTS.md : « Comportement attendu de Codex » → « …des agents IA » (la section vaut pour tout agent IA, Claude inclus).
    • Suivi dans docs/audit/affirmations.md (§ Journal des traitements) : AFF-040, AFF-050, AFF-052 résolus.

Corrigé

  • Conformité Phase 3 — lot A « doc de démarrage » (le parcours QUICKSTART marche seul).
    • Modèle absent (AFF-020/021). QUICKSTART.md proposait cp -r exemples/modeles/presence-web … — modèle inexistant dans le dépôt public (seul socle l'est). Étape 2 réécrite sur socle ; les modèles assemblés renvoyés au dépôt privé Set-OPS-modeles, cohérent avec exemples/modeles/README.md.
    • Chemin de voûte faux + piège de shadowing (AFF-023). Le chemin lab/ (QUICKSTART, exemples/vault.exemple.yml, docs/config-proxmox.md) est corrigé en production/ (le modèle socle n'a qu'un inventaire production/ ; make config résout lab > principal > production). Piège corrigé : le socle livrait ses intrants en production/group_vars/all.yml (forme fichier) ; y ajouter all/vault.yml (forme dossier) fait ignorer silencieusement all.yml par Ansible (le dossier masque le fichier — vérifié empiriquement). Le modèle est converti en forme dossier (group_vars/all/10-intrants.yml), alignée sur l'instance prouvée. Validé : ansible-inventory --host charge domaine_interne depuis la nouvelle disposition.
    • Commandes make périmées (AFF-080/081/082). make help → make ; make syntax-template → make syntaxe-modele (dans docs/config-proxmox.md, docs/modeles_vm/debian13-proxmox.md, docs/MISE-A-JOUR-CODEX-CLAUDE.md).
    • Découvert et consigné (AFF-097, non traité ici) : lab/ codé en dur restant dans les docs template/clone (vm-lifecycle, procedure-template…, proxmox/README, message de cloner_vm_debian.yml) — lot séparé à prévoir.
  • Conformité Phase 3 — lot B « doc (prose) ».
    • Prérequis Vault rappelé (AFF-026). QUICKSTART.md : note que make inventaire-verifier / make verifier chargent l'inventaire complet et exigent ANSIBLE_VAULT_PASSWORD_FILE, sinon « no vault secrets found ».
    • Sous-dossiers playbooks/ (AFF-039). AGENTS.md : précisé que seuls groupes/, maintenance/, modeles_vm/, proxmox/ sont peuplés ; les autres sont prospectifs.
    • SOLUTION.md remis au présent (AFF-060/061). Bannière « document historique » (supplanté par README/QUICKSTART/make) ; arborescence complétée ; commande de construction --ask-pass (mot de passe) → make preparer-modele (accès par clé).
    • Découvert et consigné (AFF-098, traitement B, non traité ici) : contradiction fonctionnelle — make config écrit le token Proxmox dans all/vault.yml, mais cloner_vm_debian.yml ne le lit que depuis proxmox.vault.yml/env. Couplé à AFF-097 dans un futur lot « voûte Proxmox ».

Corrigé

  • Conformité Phase 3 — lot « voûte Proxmox » (AFF-098 bug fonctionnel + AFF-097).
    • Le clonage lit la voûte unifiée (AFF-098, option a). make config écrit le token Proxmox dans instance/inventories/<env>/group_vars/all/vault.yml, mais playbooks/proxmox/cloner_vm_debian.yml (lancé -i localhost,) ne le chargeait que depuis proxmox.vault.yml → make creer-vm échouait l'assert proxmox_api_token_secret pour qui suivait la voûte unifiée. Corrigé : all/vault.yml ajouté aux sources de secrets du playbook (autoritaire) ; la détection de voûte chiffrée du Makefile (cloner-vm) cherche d'abord all/vault.yml puis proxmox.vault.yml. proxmox.vault.yml reste accepté en compatibilité. Validé : --syntax-check OK, ansible-lint 0 échec, test fonctionnel (token chargé depuis all/vault.yml, assert vert).
    • Docs Proxmox/template alignées (AFF-097). lab/ codé en dur → production/ + voûte unifiée dans playbooks/proxmox/README.md, docs/procedure-template-debian13-proxmox.md, docs/vm-lifecycle.md, docs/modeles_vm/debian13-proxmox.md. Plus aucune référence inventories/lab/group_vars dans les fichiers suivis.
  • Conformité Phase 3 — lot C « make verifier vert » (AFF-006). ansible-lint passe de 33 échecs à 0 (profil min → production), donc make lint rc=0. Trois causes :
    • site.yml généré lint-propre : scripts/orchestrer.py émet un name: avant chaque import_playbook (30× name[play]) ; site.yml régénéré. Orchestration inchangée.
    • risky-shell-pipe : set -o pipefail + executable: /bin/bash sur les deux tâches shell à pipe de playbooks/valider.yml.
    • name[template] : Jinja déplacé en fin de name dans supprimer_vm_debian.yml. Validé : make lint rc=0 ; toutes les étapes de make verifier vertes (lint, test, site-verifier, flux-verifier, syntaxe) — seule inventaire-verifier requiert la voûte de l'opérateur (prérequis documenté, AFF-026). Débloque AFF-002 (« exploitable sans IA » → ✅).

Corrigé

  • Conformité Phase 5 — boucle documentaire (le parcours QUICKSTART marche seul, prouvé). Relecture de cohérence bout-en-bout (README/QUICKSTART/docs/wiki ↔ code final). Deux bogues du modèle public/outillage débusqués et corrigés, en plus des alignements de prose :
    • Modèle socle invalide (AFF-099). exemples/modeles/socle/plan/domaines.yml : autorite: interne (périmé, rejeté par le validateur) → auto-heberge. Le modèle valide.
    • Split-brain d'inventaire (AFF-100). scripts/instancier.py et scripts/inventory_gui.py retombaient sur principal/ quand aucun hosts.yml n'existe encore ; or le socle est en production/ → la 1ʳᵉ génération écrivait dans principal/, à côté des group_vars restés en production/. Corrigé : le repli vise le répertoire d'inventaire déjà présent (comme config_proxmox.py). Instances existantes (avec hosts.yml) inchangées (non-régression vérifiée sur principal).
    • Alignements de prose. docs/intrants-communs.md (group_vars/all.yml → all/10-intrants.yml, forme dossier que le GUI écrit déjà) ; QUICKSTART.md étape 8 (serveur_postgresql → serveur_powerdns, groupe présent dans le socle).
    • Preuve : parcours QUICKSTART rejoué hors-ligne sur une copie du socle (instancier generer→comparer→appliquer) — écrit production/hosts.yml, diff vide ensuite, 4 validateurs verts. Nouvelle preuve récurrente P15 dans make prouver (« modèle public socle valide ») : make prouver = 15 OK, 0 échec, 1 sautée.

Ajouté

  • Harnais de preuve make prouver (Phase 4). Nouveau scripts/prouver.py — un orchestrateur mince qui rejoue les preuves automatisables du registre en appelant l'outillage existant (les mêmes scripts que make verifier : lint, tests, diff-vide, validateurs de registres, cohérence groupes/playbooks, handlers, orchestration, flux, syntaxe, existence des runbooks, invariants structurels) — aucune validation réimplémentée. Produit docs/audit/preuve-AAAA-MM-JJ.md : rapport horodaté, rejouable, reliant chaque preuve aux affirmations couvertes, listant à part les déclarations d'intention (⚪). Sort en erreur si une preuve échoue ; la preuve P15 (inventaire Ansible complet) est sautée proprement sans mot de passe Vault (prérequis AFF-026). Documenté dans README.md (une phrase) et docs/audit/README.md (mode d'emploi complet + comment ajouter une preuve). affirmations.md : section « Couverture par make prouver » reliant chaque ✅ à sa preuve. 1re exécution : 14 preuves OK, 0 échec, 1 sautée → CONFORME.
  • Registre des affirmations (audit de conformité, Phase 1). Nouveau docs/audit/affirmations.md : chaque affirmation publique vérifiable du dépôt (README, AGENTS, CLAUDE, QUICKSTART, SOLUTION, docs/, wiki/, aide du Makefile, GUI) est tracée vers une commande de preuve reproductible et un statut (✅ prouvée / 🟡 partielle / ❌ fausse / ⚪ invérifiable localement). 54 affirmations enregistrées : 30 ✅, 13 🟡, 8 ❌, 3 ⚪. Audit sans aucun correctif (les traitements relèvent des phases suivantes). Preuves exécutées localement, hors production : diff-vide du plan, recoupement notify↔handlers, validateurs de registres (serveurs/applications/bases/domaines/GUI/orchestrateur/flux), --syntax-check de tous les playbooks, test unitaire. Dix écarts majeurs classés par risque pour un opérateur suivant la doc à la lettre — dont : QUICKSTART renvoie à un modèle absent (presence-web), make verifier échoue (ansible-lint : 33 failures), contradiction SSH CLAUDE.md ↔ code/AGENTS.md, chemin de voûte faux, commandes make périmées dans docs/.

2026-07-07 (soir)

Modifié

  • Nomenclature ip-miroir : longueurs UNIFORMES. Le VLAN dérivé passe à 1000 + index×10 + zone → toujours 4 chiffres (1011..4094), donc le VMID (VLAN·octet·seq) fait toujours 9 chiffres. Longueurs uniformes et mnémotechniques (retirer 1000 redonne index×10+zone). Les deux tenants sont convergés sur le réseau fédéré 10/8 : Chezlepro (idx 1) → VLANs 1011-1016 / 10.11.x ; Technolibre (idx 2) → VLANs 1021-1026 / 10.12.x. Chezlepro quitte le bac-à-sable plat 192.168.15/VLAN 15.

Ajouté

  • make devis-reseau : config switch dérivée du plan. Nouveau scripts/devis_reseau.py qui découvre les instances fédérées (../*/plan/nomenclature.yml, schéma ip-miroir) et émet la config Cisco-like du réseau convergé — VLANs + SVIs (passerelles) + ACLs d'isolation inter-tenant — dérivée des nomenclatures, jamais saisie à la main (toujours synchrone avec le plan). One-shot précédemment ; maintenant reproductible.
  • GUI : vue « Réseau » (devis switch) + bouton Copier. Nouvel onglet lecture seule qui affiche le devis make devis-reseau (VLANs + SVIs + ACLs), via GET /api/devis-reseau, avec un bouton Copier (prêt à coller sur le switch). Un opérateur génère et transmet la config sans CLI.
  • GUI : erreurs de déploiement en LANGAGE CLAIR. Quand une action (créer/vérifier/déployer) échoue, le GUI n'oblige plus à lire le dump Ansible : un encadré résume les tâches en erreur — étape · hôte · type · message clair — avec un bouton « Copier » (pour transmettre le résumé à un humain / au mainteneur). Le backend (_extraire_echec) parse les fatal:/UNREACHABLE!, privilégie la vraie cause (stderr plutôt que le générique « non-zero return code »), gère no_log (« sortie masquée — secret ») et les injoignables. Éprouvé sur les 4 échecs réels du from-zero du jour. node --check OK.
  • GUI : éditeur « Domaines » (6ᵉ onglet éditable). Le registre des domaines publics — le seul sans édition dans le GUI — a désormais son onglet : ajouter/retirer une zone DNS, autorité (sélecteur primaire-cache/auto-heberge/delegue), edge, DNSSEC, mail, secondaires, et affichage des expositions (FQDN) sous la zone. Backend : route /api/domaines, ecrire_domaines, validation valider_domaines (rejette une autorité inconnue). Éprouvé : round-trip backend OK, node --check OK, smoke serveur OK. (Le autorite: interne périmé des instances Chezlepro/Technolibre a été corrigé en auto-heberge au passage.)
  • GUI : persistance des journaux d'exécution. Chaque action lancée depuis l'interface (créer-vm / vérifier / déployer) écrit désormais, en plus du streaming live, un journal horodaté <instance>/logs/<hôte>-<action>-<date>.log (gitignoré). Bonus : si le navigateur se déconnecte, l'exécution continue et le journal est capturé jusqu'au bout (au lieu d'être interrompue) — utile pour un déploiement qu'on ne veut pas voir avorter à la fermeture d'un onglet.
  • GUI : vues « Flux » et « Couches » (lecture seule). Deux nouveaux onglets rendent visibles dans l'interface deux registres jusque-là en ligne de commande seulement : Flux = la matrice d'audit réseau (rôle · sens · port · pair · chiffrement coloré · raison, depuis les meta/flux.yml), et Couches = l'ordre de reconstruction (socle → pki → services → apps → agents, avec les groupes triés topologiquement par couche). Alimentées par resoudre_flux/orchestrer via l'API. Éprouvé : node --check OK, smoke test serveur (63 flux, 6 couches servis).

Modifié

  • GUI : intégration des intrants de session. Le panneau « Intrants de base » expose désormais nftables_admin_ssh (type liste, section « Sécurité ») — la garde anti-lockout du pare-feu était éditable en fichier mais absente du GUI. Retrait de vault_step_ca_fingerprint des secrets attendus (empreinte du root CA désormais dérivée dynamiquement, plus un secret). node --check OK.

2026-07-07 — Reconstruction from-zero PROUVÉE (preuve de portabilité « sans réserve »)

Les 14 VM du lab (+ sauvegardes + AC) supprimées, puis make myDay a reconstruit l'écosystème POC de rien : 13 hôtes déployés (0 échec), et make valider entièrement vert (Prometheus 6/6 UP, 6 vhosts HTTPS, courriel bout-en-bout remis, 6 dépôts restic restaurés) — le tout sous pare-feu actif. 4 bugs de portabilité débusqués et corrigés dans le moteur :

Corrigé

  • client_pki : empreinte du root CA dérivée dynamiquement (au lieu d'une valeur figée en Vault). Une AC régénérée (from-zero) a une empreinte neuve ; le rôle la lit désormais de l'autorité elle-même (step certificate fingerprint, délégué au nœud step-ca), source de vérité. client_pki_ca_fingerprint_override permet un épinglage explicite.
  • serveur_keycloak : assignation de rôle tolérante aux utilisateurs absents. Sur un annuaire vide (from-zero), assigner un rôle à un user inexistant échouait ; on vérifie désormais son existence (Keycloak fédère LDAP à la demande) et on saute proprement sinon.
  • serveur_prometheus : ne scrute que les hôtes ACTIFS (client_metrique ∩ hotes_actifs). Un hôte planifié (non déployé) n'est plus une cible morte.
  • Pare-feu nftables compatible Docker. Le ruleset résolu (a) remplace uniquement la table setops_flux au lieu de flush ruleset (préserve les tables Docker : DNAT/forward des conteneurs) et (b) autorise docker0 + ct established,related dans la chaîne forward. Sans ça, forward policy drop coupait Collabora (conteneur). Diagnostic prouvé au niveau paquet.

Ajouté

  • playbooks/proxmox/supprimer_vm_debian.yml — suppression de VM par VMID (from-zero), avec garde-fous : n'agit que sur les VMID présents dans le cluster, refuse si le modèle est ciblé, secrets no_log.

2026-07-07

Ajouté

  • make valider — recette d'acceptation fonctionnelle (phase 4, v1). Vérifie que les services fonctionnent, pas juste qu'ils sont déployés (complète les *-verifier statiques). playbooks/valider.yml, lecture seule : cibles Prometheus toutes UP (API /targets) + vhosts HTTPS exposés répondent (dérivés des server_name réels de l'edge, filtrés sur domaine_interne — générique) + courriel bout-en-bout (envoi via le MTA → LDAP → LMTP → Maildir, remise vérifiée par doveadm sur le compte testmail, message de test nettoyé) + restauration de sauvegarde (pour chaque nœud client_backup : restic restaure le dernier snapshot dans un dossier temporaire — lecture seule sur le dépôt — et vérifie que des fichiers en sortent). Éprouvé sur la flotte vivante sous pare-feu actif : 7 cibles UP, 8 vhosts OK, courriel remis, 6 dépôts restaurables. La recette a trouvé un vrai trou (collab-01/Nextcloud sans client_backup_jobs → service de sauvegarde en échec, fichiers non protégés), corrigé côté instance.
  • Pare-feu nftables activé sur TOUTE la flotte (14 nœuds, activation prudente). Les 14 hôtes actifs tournent sous policy drop avec leur ruleset résolu moindre-privilège (flux est-ouest déclarés autorisés par source, reste refusé). Vérifié en conditions réelles : flux déclarés OPEN (keycloak→pg, postfix→dovecot LMTP, prometheus→node_exporter…), flux non déclaré DROP (forge→redis), 14/14 active+enabled+policy drop, contrôleur toujours joignable.
    • Intrant nftables_admin_ssh (garde anti-lockout) — CIDR d'administration TOUJOURS autorisés en SSH, indépendamment des flux. Le résolveur (resoudre_flux.py) l'injecte en tête de chaque ruleset. Bug de conception rattrapé avant activation : le contrôleur Ansible arrive par VPN (192.168.255.2, hors sous-réseau flotte) — sans cette règle, activer = lockout immédiat.
    • Rollout prudent : d'abord infra-pki-01 seul (test dead-man switch systemd-run, SSH re-vérifié sous drop, puis permanent), puis les 13 autres par lot (dead-man 5 min + sonde des flux est-ouest avant de persister). Activation pilotée par nftables_baseline_enabled: true (group_vars hotes_actifs ; le golden template n'y est pas → reste sans pare-feu, voulu).
    • Le déploiement dépose instance/flux-genere/<hôte>.nft dans /etc/nftables.conf + service enabled (survit reboot ET futurs make myDay).

Corrigé

  • Détection du coffre Vault : production/ codé en dur → inventaire réel. deployer, deployer-tout et verifier-deploiement cherchaient le coffre chiffré dans inventories/production/group_vars, alors qu'une instance en principal/ (cas courant) n'a pas ce chemin → l'invite du mot de passe Vault ne se déclenchait jamais et le déploiement échouait au déchiffrement. Corrigé : la garde vise désormais le group_vars de l'inventaire résolu ($(dir $(INVENTAIRE_PRODUCTION))group_vars). Vérifié : le coffre de principal/ est bien détecté.

Modifié

  • nftables_baseline branché sur les flux résolus (reconstruction, phase 0). Le rôle déploie désormais le ruleset résolu généré par make flux (instance/flux-genere/<hôte>.nft — règles par source, ip saddr = moindre privilège) quand il est présent ; sinon repli sur le gabarit plat. Toujours nftables_baseline_enabled: false par défaut → aucune activation (l'activation reste un geste dédié, testé par nœud). Nouveau var nftables_baseline_ruleset_genere. Syntax-check OK.

Ajouté

  • Reconstruction from-zero en une commande (reconstruction, phase 3 — outillage). La création de VM était unitaire (creer-vm HOTE=…) ; on comble le trou entre créer (2a) et configurer (2b) :
    • make flotte-creer CONFIRMER=true — boucle creer-vm sur tous les hôtes actifs du plan (clone Proxmox). Nouvelle sous-commande inventory_host.py lister-actifs.
    • make reconstruire CONFIRMER=true — enchaîne flotte-creer → attente SSH de la flotte (_attendre-flotte, ATTENTE_MAX réglable) → deployer-tout. La reconstruction complète en une commande, idempotente de bout en bout : le clone (proxmox_kvm) saute une VM déjà présente (par nom), le réseau/disque sont present/resized (grow-only), le déploiement Ansible converge. Re-lançable sans risque, qu'il reste des VM ou non.
    • make myDay repointé sur reconstruire (le vrai « bouton rouge » ; n'était qu'un alias de deployer-tout). Distinction assumée : deployer-tout = converger la config d'une flotte existante (2b, avec MODE_CHECK=1) ; reconstruire/myDay = créer les VM manquantes puis déployer (2a+2b). Gardes CONFIRMER=true sur les trois. Non testé contre Proxmox/lab (validé : énumération des 14 hôtes actifs, refus sans CONFIRMER, enchaînement make -n).

2026-07-06

Ajouté

  • make wiki-publier — fin du dernier geste manuel (reconstruction, phase 0). Le wiki pédagogique (wiki/, source versionnée) se publie désormais dans le wiki Forgejo par make wiki-publier WIKI_REMOTE=…<dépôt>.wiki.git : clone superficiel du wiki, synchronisation des pages (wiki/*.md sauf README.md ; suppressions propagées), commit + push seulement s'il y a du changement. Refuse sans WIKI_REMOTE. Éprouvé de bout en bout contre un dépôt bare local (17 pages publiées = 17 source, diff vide, README exclu, _Sidebar inclus, idempotent au 2e passage).

  • Registre des flux réseau complété (reconstruction, phase 0). Transcription du travail zéro-confiance est-ouest dans meta/flux.yml : 16 rôles remplis (step_ca, openldap, powerdns, prometheus, loki, redis, rspamd, backup, dovecot, postfix, keycloak, forgejo, grafana, icinga, icingaweb2, nextcloud, oauth2_proxy + le socle serveur_debian pour le plan de gestion SSH, + les 5 clients pki/journal/smtp/backup/unbound). Le registre couvre désormais 29 rôles, 63 flux (qui-parle-à-qui : port, sens, pair, chiffrement, raison) — la base de génération nftables/OPNsense et la matrice d'audit. Schéma enrichi (docs/flux-conception.md) : valeurs ssh (transport SSH, restic/backup + SSH de gestion) et tls-cible (TLS visé, feuille de route edge→backends). Point critique traité : SSH (22) déclaré au socle, sinon les nftables générés couperaient l'accès Ansible. Validé : schéma conforme (0 erreur) et matrice cohérente (tout egress vers un service a l'ingress correspondant en face). Reste phase 0 : la cible make wiki-publier.

  • Résolveur de flux (reconstruction, phase 0 — §Séquence 2). scripts/resoudre_flux.py agrège les meta/flux.yml, résout les pair, et produit deux artefacts, hors-ligne, sans activation :

    • docs/registre-flux.md (généré) — la matrice d'audit source→destination (rôle, sens, port, chiffrement, raison) + synthèse chiffrement. Artefact du label de certification.
    • aperçus nftables par hôte (instance/flux-genere/<hôte>.nft, gitignorés) — règles résolues avec IP réelles, ip saddr = moindre privilège (ex. LMTP 24 sur le mail n'accepte que l'IP du nœud Postfix), policy drop. Aperçus inspectables, NON activés (l'activation reste un geste dédié testé par nœud, cf. flux-conception §Activation prudente).
    • Cibles make flux (registre + aperçus) et make flux-verifier (schéma + matrice, branché dans make verifier). Validé : 29 rôles / 63 flux cohérents, 14 aperçus générés. Reste : brancher nftables_baseline (modèle plat aujourd'hui) sur ces aperçus, et le test lab.
  • Orchestrateur ordonné (reconstruction, phase 2). playbooks/site.yml n'est plus un stub : c'est désormais un point d'entrée ordonné généré, qui déploie l'écosystème couche par couche, dans l'ordre de reconstruction, sans intervention manuelle. Nouveautés :

    • docs/couches-deploiement.yml — registre central des couches ordonnées (socle → pki_racine → pki_client → services → apps → agents), les 30 groupes déployables classés.
    • scripts/orchestrer.py — trie les groupes par couche (clé primaire) puis topologiquement intra-couche via dependances-groupes.yml (ex. dovecot avant postfix, icingaweb2 après icinga, nextcloud en dernier). Génère site.yml comme une séquence d'import_playbook. Artefact du moteur (déterministe, sans donnée d'instance ; Ansible saute les groupes sans hôte actif). Deux gardes anti-dérive (refus si violé) : bijection univers↔couches (un nouveau rôle non classé casse la génération) et aucune arête « en arrière » (un prérequis dans une couche plus tardive = classification fausse). Éprouvées par test négatif.
    • make site (régénère + syntax-check), make site-verifier (cohérence, branché dans make verifier), make deployer-tout CONFIRMER=true (déploiement orchestré de la flotte, limité à hotes_actifs ; garde CONFIRMER car action impactante ; MODE_CHECK=1 pour l'essai idempotent à blanc). Validé : verifier OK (30 groupes, aucun cycle/arête arrière), --syntax-check du site.yml généré OK, refus deployer-tout sans CONFIRMER (rc=2).

Modifié

  • Audit exhaustif du codé-en-dur (reconstruction, phase 1b). Balayage complet tasks + templates + defaults de tous les rôles (noms de tenant, IP, domaines, emails, orgs). Résultat : le moteur ne porte plus aucun nom de tenant en dur. Corrigé — les labels/slug OIDC dérivent désormais de l'intrant organisation : serveur_forgejo_oidc_nom (slug de callback, organisation | lower | replace(' ','-')), serveur_grafana_oidc_nom, serveur_nextcloud_oidc_nom, serveur_nextcloud_theme_nom (labels d'affichage). Commentaires « Se connecter avec Chezlepro » → génériques. Non-régression SSO : organisation: Chezlepro → slug chezlepro, identique à l'URI de redirection Keycloak de l'instance (pas de casse). Conservés intentionnellement : realm default('chezlepro') (décision identite_realm actée) et le thème visuel Alliance Boréale (identité par défaut assumée du réseau, pas un tenant). Validé : re-balayage vide, rendu Jinja du slug testé (Chezlepro/Alliance Boréale/Ma Coop), --syntax-check OK (playbook forgejo via inventaire principal).
  • Audit du graphe de dépendances (reconstruction, phase 1a). docs/dependances-groupes.yml gagne les prérequis inter-groupes confirmés dans le code, en vue de l'orchestrateur trié en topologie. Ajouts : serveur_keycloak → serveur_openldap (fédération LDAP via resoudre_annuaire_uri, en plus de PostgreSQL) ; serveur_dovecot → serveur_openldap (userdb/passdb LDAP) ; serveur_postfix → serveur_dovecot (remise LMTP au mailstore) ; serveur_icingaweb2 → serveur_icinga + serveur_postgresql + serveur_openldap (IcingaDB + auth LDAP) ; serveur_nextcloud → serveur_postgresql + serveur_keycloak (OIDC) + serveur_collabora (validation WOPI). Réconciliation meta/liens.yml : le seul lien structurel (serveur_postfix mailstore → Dovecot) coïncide avec le graphe. Conclusion d'archi : la règle « TLS vérifié ⇒ client_pki aux deux bouts » ne devient PAS des arêtes par-groupe (client_pki est quasi universel) — c'est une couche de l'ordre de reconstruction (socle → step_ca → client_pki → services → apps → agents) ; dependances-groupes.yml ne capture que le fin ordonnancement intra-couche. Validé : YAML conforme, aucun cycle, tri-topo réussi (19 nœuds), chargeur charger_dependances accepte (12 groupes, est_groupe_operationnel OK), tous les groupes ont un rôle.

2026-07-05

Modifié

  • VLAN dérivé du tenant (réseau convergé). deriver_nomenclature (schéma ip-miroir) dérive désormais le VLAN = index × 10 + zone — unique globalement sur un trunk convergé (chaque tenant son bloc de 10 ; 1-9 réservés à l'infra partagée). Le VLAN contenant déjà le tenant (1er chiffre = index), le VMID mène avec le VLAN (VLAN·octet·seq, ≤ 9 chiffres Proxmox). Ex. Technolibre (index 2) → VLANs 21-26, VMID 2101101. Le schéma compact (lab, sandbox) reste inchangé. Champs vlan: codés en dur retirés des catégories ip-miroir (désormais dérivés).

Ajouté

  • Zéro-confiance est-ouest — flux Métriques, Logs et Courriel chiffrés. Suite du chantier (après PostgreSQL) : métriques (node_exporter sert en HTTPS via cert step-ca + --web.config.file + cert-sync owned prometheus ; Prometheus scrape scheme: https + tls_config), logs (Loki http_tls_config + cert-sync owned loki ; Alloy push https + tls_config), courriel (LMTP edge-mta→infra-mail:24 en lmtp_tls_security_level=verify + lmtp_tls_CAfile ; client_smtp en STARTTLS vérifié). Chacun prouvé de bout en bout (200 HTTPS, cibles UP, livraison status=sent, HTTP rejeté). Motif cert-sync .path industrialisé. Reste : edge→backends + DNS (DoT).
  • VMID 9 chiffres mnémotechnique (schéma ip-miroir, opt-in). vmid_schema: ip-miroir dans la nomenclature → VMID I·VVV·HHH·NN (index·VLAN·octet-hôte·séquence) : le VMID contient l'IP (10.(10+index).VLAN.hôte) + le tenant, lisible d'un coup d'œil. Défaut compact rétro-compatible (instances déployées inchangées).
  • Instance partenaire Technolibre. Écosystème complet (12 VM, etat: planifie) dans 10.12.16.0/20, 6 zones de sécurité (Frontière/Identité/Données/Services-infra/Observabilité/ Applications, un /24 + VLAN chacune), index de fédération 2, VMID ip-miroir. Preuve de portabilité d'un tenant.

Modifié

  • Références par FQDN partout (fin des IP codées en dur). Décision d'archi : FQDN pour toute référence inter-services (non ambigu en fédération, canonique pour TLS ; nom court = hostname OS). resoudre_base renvoie le FQDN (→ keycloak/forgejo/icinga) ; client_journal_loki_url dérivé du groupe serveur_loki ; defaults db_host IP morts nettoyés. Aucune IP littérale dans les defaults.
  • Découplage du tenant d'origine. Realm SSO centralisé sur l'intrant identite_realm (défaut chezlepro ; les 4 rôles keycloak/forgejo/grafana/oauth2_proxy en dérivent ; exposé dans la GUI). Vars brandées renommées génériques : chezlepro_timezone→fuseau_horaire, chezlepro_organisation→organisation. Le moteur ne porte plus le nom d'un tenant.
  • Modèles d'instance rafraîchis. integral régénéré depuis le cas prouvé (6 zones, fonctions éprouvées data-sql/id-ldap/id-sso/sup, ip-miroir, nouveaux intrants) ; socle/identite/ observabilite/forge réalignés sur le même moule (prouvés : dérivation + valider_serveurs). presence-web marqué aspirationnel (rôles web-frontal/dorsal absents) plutôt que faussement prêt.

2026-07-04

Ajouté

  • Zéro-confiance : flux PostgreSQL entièrement chiffré et vérifié. PG sert désormais son cert step-ca (vérifiable contre root_ca) au lieu du snakeoil, et refuse toute connexion non-TLS du réseau (hostssl dans pg_hba). Les 3 clients passent en verify-full : keycloak (db-url-properties sslmode=verify-full), forgejo (SSL_MODE=verify-full + PGSSLROOTCERT), IcingaDB (tls: true + ca). Prérequis posés : client_pki sur data-sql-01 (cert), et root_ca.crt en 0644 (cert public, requis par les clients TLS non-root). cert-sync PG (motif .path, owned postgres) + reload de l'instance postgresql@NN-main. Vars : serveur_postgresql_tls_actif/_tls_force, serveur_*_db_sslmode/_ca. Prouvé de bout en bout (cert Set-OPS CA servi, apps 200, non-TLS rejeté « aucun chiffrement », TLS accepté).

Corrigé

  • PG : détection de version robuste (collision avec un répertoire non-numérique). Placer le tls_dir sous /etc/postgresql/ faisait choisir tls comme « version » de cluster (find | sort | last) → configs déployées au mauvais endroit (verrou hostssl inopérant). Corrigé : détection filtrée aux dossiers numériques (^[0-9]+$) + tls_dir déplacé sous /var/lib/postgresql/tls.
  • Renouvellement de cert : recharger le VRAI consommateur (bug latent de flotte). Le cert-renewer@.service (client_pki) renouvelait le cert sur disque mais son ExecStartPost rechargeait un service nommé d'après le cert (%i = FQDN), inexistant → nginx (et postfix, dovecot, slapd) n'étaient jamais rechargés et servaient l'ancien cert jusqu'à expiration. Symptôme vécu : cert edge expiré en mémoire (renouvelé sur disque), échec TLS de l'échange code→jeton OIDC → login Grafana/SSO cassé (tous les services derrière l'edge). Correctif : client_pki_reload_services (liste des vrais consommateurs), câblée par groupe (edge→nginx, mail→postfix/dovecot, annuaire→slapd). Appliqué + vérifié sur les 4 hôtes. Fix immédiat de l'incident : systemctl reload nginx sur l'edge.

Ajouté

  • Doc à jour : unité wiki « Autorisation & RBAC », leçon renouvellement, runbooks. Fermeture des dettes de doc : nouvelle unité wiki authZ/RBAC (pendant d'Identité & SSO, avec l'exemple Grafana), section « le renouvellement est un système » versée dans l'unité PKI (comparer cert servi vs fichier ; recharger le consommateur), et docs/runbooks-exploitation.md (cert expiré, RBAC Grafana, branding Forgejo). 15 unités wiki désormais.
  • UI des logs Loki : dashboard Grafana provisionné. Loki n'a pas d'UI ; son UI est Grafana. Ajout d'un dashboard « Journaux de la flotte » (dossier Set-OPS) : sélecteur d'hôte multi + filtre regex insensible à la casse + panneau logs + débit par hôte. Référence Loki par une variable de datasource (ds_loki), pas un UID codé en dur (leçon : ajouter un uid explicite à une datasource déjà provisionnée casse le démarrage de Grafana). Visible par les Viewers (dont testmail) sans accès Explore. Prouvé sur obs-01 (dashboard chargé, Grafana actif).
  • RBAC via SSO : rôle de realm → niveau Grafana. Machinerie additive et idempotente dans serveur_keycloak (rbac-oidc.yml) : rôles de realm (serveur_keycloak_realm_roles), mapper roles sur les clients choisis (_role_mapper_clients, rôles de realm → claim roles dans ID token + userinfo), assignations rôle→utilisateur (_role_assignments). Côté Grafana, role_attribute_path (grafana-admin→Admin, grafana-editor→Editor, sinon Viewer). kcadm à chaud, zéro coupure SSO. Prouvé (idempotence changed=0) sur id-sso-01 : rôles créés, mapper présent, testmail = grafana-editor (→ Explore). Illustre l'authZ (vs authN du SSO).

2026-07-03

Ajouté

  • Identité visuelle Alliance Boréale sur Forgejo (léger, officiel). Branding via le dossier custom/ de Forgejo (mécanisme officiel — pas de fork, résistant aux MAJ) : accent aurore par variables CSS (--color-primary…, aucune classe interne touchée), logo/favicon étoile (réutilisés du thème Keycloak), page d'accueil brandée (home.tmpl : hero aurore + accroche), thème sombre par défaut, nom + méta. Codifié dans serveur_forgejo (serveur_forgejo_branding, _app_name, _theme), déployé dans {{ data }}/custom/. Prouvé sur forge-01 : accueil rend (200, « Forge Chezlepro »), alliance.css servi (cyan aurore), lint OK.
  • Wiki pédagogique Forgejo — 14 unités d'apprentissage. Set-OPS comme compagnon pédagogique : chaque service = une lentille sur un fondamental TIC, méthodes génériques (on apprend OIDC, pas Keycloak). Source versionnée dans wiki/, publiée dans le wiki Forgejo (eregion). Moule à 4 temps (concept → Set-OPS → transférable → à toi de jouer, avec casse-répare).
  • GUI : boutons cohérents. « Pousser » (surchargé : clonait et déployait) → « 🖥 Créer la VM » pour le clone, « Déployer » partout pour le déploiement, dry-run obligatoire partout. GUI 100 % française.
  • Agents d'observabilité/ops éprouvés — observabilité flotte-complète. Les 3 intégrations « agent » (liaisons nœud × optionnelles) déployées sur 4 nœuds (obs-01, data-sql-01, id-ldap-01, forge-01) et prouvées : client_metrique (node_exporter → Prometheus scrape les 4 cibles, toutes UP) ; client_journal (journald → Loki reçoit les logs des 4 nœuds) ; client_smtp (msmtp → courriel système d'un nœud relayé par l'edge-MTA et livré). Comble le trou : les serveurs d'observabilité (Grafana/Prometheus/Loki) étaient prouvés, mais pas la collecte fleet-wide — désormais Grafana voit toute la flotte. Cibles corrigées (bac à sable) : Loki→obs-01, relais→edge-mta-01 (pas infra-mail-01, qui est le store Dovecot sans SMTP :25).
  • Thème de connexion Keycloak à l'identité Alliance Boréale. Thème de login alliance-boreale (roles/serveur_keycloak/files/themes/, parent=keycloak + overlay CSS) reprenant l'identité du site de l'Alliance (extraite de site-alliance-boreale) : ciel nocturne aurore (#05060f/ #0a0d24 + dégradés), carte glassmorphism, logo étoile aurore (le favicon.svg du site), bouton dégradé aurore (teal→cyan, pilule), liens cyan, police système (souveraineté, zéro dépendance externe). Déployé dans {{ keycloak_home }}/themes/, appliqué au realm via kcadm ... -s loginTheme (var serveur_keycloak_login_theme, idempotent), Keycloak rechargé (flush_handlers avant la config realm). Prouvé : la page de login charge alliance.css (HTTP 200) + le logo.svg (200), loginTheme=alliance-boreale actif sur chezlepro. Constellation animée en fond (scripts=js/constellation.js) : le JS crée son propre ciel (canvas + aurore, le template n'en ayant pas) — étoiles scintillantes qui dérivent, liens de constellation cyan, blob d'aurore ondulant ; respecte prefers-reduced-motion. Prouvé : constellation.js référencé + servi (200). Console de compte thémée aussi (thème account, parent=keycloak.v3) : overlay CSS surchargeant les variables PatternFly 5 (fond aurore, cartes en verre, accent aurore) + la même constellation animée. Var serveur_keycloak_account_theme via kcadm -s accountTheme. Prouvé : console charge (HTTP 200, keycloak.v3 intact), account.css servi (200).
  • Soumission courriel :587 interne (authentifiée) — la boucle souveraine est bouclée. Postfix (edge-mta) sert la soumission :587 (bloc master.cf : STARTTLS requis, SMTP AUTH, seuls les authentifiés relaient) ; l'auth SASL est déléguée à Dovecot (infra-mail, passdb LDAP prouvé) via un auth-listener réseau (service auth { inet_listener sasl }, port 12345). Aucune sortie internet : interne→interne uniquement (l'externe = déliverabilité, Étape B). Prouvé (swaks) : testmail s'authentifie (235 Authentication successful), Postfix accepte (250 queued), et le courriel est livré dans la boîte (LMTP→Dovecot). Le courriel souverain fait maintenant recevoir ET envoyer. Vars : serveur_dovecot_sasl_reseau, serveur_postfix_submission_actif. Pièges : Dovecot 2.4 exige un nom de section inet_listener ; ajouter un service master.cf (nouveau listener) → handler restart (pas reload) ; et une config cassée peut bloquer un redéploiement si la synchro cert/restart précède le template (corriger la config à la main pour débloquer).
  • Sauvegardes applicatives (logiques) — serveur_backup + client_backup (restic), Tier 0 prouvé. Choix : sauvegarder la donnée d'état (non régénérable) plutôt que les VM (reconstructibles par le code + le template). Outil restic (chiffrement côté client, déduplication, rétention). serveur_backup (nœud backup-01) = cible SFTP/SSH (utilisateur restic, clé autorisée, dépôts sous /srv/restic/<nœud>). client_backup (intégration par nœud) = restic + jobs déclaratifs (client_backup_jobs : {nom, commande?, chemins}), clé SSH + mot de passe restic en voûte, script + timer systemd (quotidien) + rétention forget --prune. Prouvé de bout en bout sur le Tier 0 (infra-pki-01 → /etc/step-ca, l'ancre de confiance) : sauvegarde hors-nœud vers backup-01, puis restauration byte-identique des clés CA (root_ca_key, intermediate_ca_key, ca.json). Piège corrigé : le plancher /etc/hosts d'un nœud existant ignore un nœud nouvellement ajouté → rafraîchir le socle.
  • Sauvegardes Tier 1 généralisées — 5 nœuds, restauration prouvée. client_backup étendu (jobs déclaratifs en host_vars) à : data-sql-01 (pg_dumpall — keycloak/forgejo/icingadb), id-ldap-01 (slapcat LDIF — les identités), infra-mail-01 (/var/vmail — les boîtes), forge-01 (/var/lib/forgejo + /etc/forgejo — dépôts Git ; la BD est déjà couverte par PG). Prouvé par restauration : dump PostgreSQL restauré contient bien les 3 bases (CREATE DATABASE forgejo/icingadb/keycloak) ; LDIF restauré contient testmail. Les 5 dépôts restic (pki, sql, ldap, mail, forge) sont hors-nœud sur backup-01, chiffrés. Ajouter un service à sauvegarder = déclarer un job. Reste : cible offsite (3-2-1, Étape B — le dépôt n'est qu'une URL swappable).
  • Consolidation — binding annuaire (resoudre_annuaire) + retrait de la cruft. Cruft : supprimés les 8 dossiers-catégories inertes (roles/{applications,backup,database, identity,monitoring,proxmox,storage,web}/, README seuls) et les 5 playbooks-échafaudages debug sans rôle (nextcloud, collabora, client_supervision, web_frontal, web_dorsal) ; l'intention reste documentée dans docs/catalogue-services.md. Binding annuaire : nouveau rôle utilitaire partagé resoudre_annuaire (comme resoudre_base) qui dérive la connexion OpenLDAP du domaine_interne
    • un hôte d'annuaire surchargeable — LE seul endroit où le nom d'hôte de l'annuaire est fixé, au lieu d'être répété. Facts resoudre_annuaire_{uri,port,base_dn,users_dn,bind_dn,bind_password} (secret déréférencé, no_log). Migrés + prouvés (config neutre, changed=0) : serveur_keycloak (fédération LDAP — testmail token HTTP 200), serveur_dovecot + serveur_postfix (flux courriel Postfix→LDAP→LMTP→Dovecot livré de bout en bout). Piège appris : les defaults d'un rôle inclus ne persistent pas hors de son exécution — publier via set_fact.
  • Binding annuaire complété — icingaweb2 + client_ldap migrés vers resoudre_annuaire. Fin des 2 loose ends : serveur_icingaweb2 (connexion LDAP dormante en mode SSO) résout via resoudre_annuaire (redéploiement changed=0, SSO intact) ; client_ldap (SSSD, dormant) ne pointe plus sur un idm-01 périmé. Plus AUCUN rôle ne code en dur l'hôte d'annuaire — un seul point de vérité (resoudre_annuaire).
  • Rôle serveur_oauth2_proxy — passerelle SSO OIDC générique (Keycloak) + Icinga Web 2 au SSO. oauth2-proxy (v7.15.3, binaire GitHub) place Keycloak devant n'importe quelle app sans OIDC natif : elle reçoit l'utilisateur authentifié via en-tête, en auth external. Rôle paramétrable (client, secret voûte, redirect, upstream, cookie voûte) — réutilisable pour toute app OIDC-less. Éprouvé sur sup-01 devant Icinga Web 2 : client Keycloak icingaweb2, oauth2-proxy :4180 (exposé par l'edge) → upstream nginx local :8080 → icingaweb2 backend = external (REMOTE_USER depuis X-Forwarded-Preferred-Username). Prouvé (flux authorization code headless) : testmail → oauth2-proxy → Keycloak → icingaweb2 /dashboard, connecté (« Se connecter avec Chezlepro », comme Grafana/Forgejo). Ferme le gap LDAP-direct d'icingaweb2. Réglages appris : insecure_oidc_allow_unverified_email (les users LDAP n'ont pas email_verified ; IdP interne de confiance) ; en reverse-proxy oauth2-proxy passe X-Forwarded-* (pas X-Auth-Request-*) ; handler nginx en restart (pas reload) car un changement d'adresse d'écoute n'est pas pris par un reload gracieux.
  • Rôle serveur_icingaweb2 — Icinga Web 2 (UI native) + module IcingaDB : éprouvé. App PHP (php8.4-fpm) servie par un nginx local, exposée par l'edge (icinga.lab.chezlepro.internal, auto-dérivé : vhost + cert SAN + A PowerDNS + alias plancher). Config par fichiers .ini (config/resources/authentication/roles + module icingadb), pas d'assistant de setup. Base IcingaDB via resoudre_base (registre). Auth LDAP direct vers OpenLDAP (LDAPS, client_pki sur sup-01) — icingaweb2 n'a pas d'OIDC natif ; SSO-par-proxy = raffinement futur. Prouvé : testmail (LDAP) se connecte (/dashboard), et le module IcingaDB affiche la supervision (hôte icinga). Déploiement failed=0 (le rôle est bon ; les frictions étaient dans le simulateur de login curl : contrôle de cookie _checkCookie, champs uid/submit_login, valeur CSRF avant name).
  • Module BPM (Business Process) éprouvé + codifié — pile Icinga complète. serveur_icingaweb2 installe + active icingaweb2-module-businessprocess (serveur_icingaweb2_modules), crée le répertoire des processus (éditable via l'UI, groupe icingaweb2, setgid) et sème des processus métier en IaC (serveur_icingaweb2_bpm_processes, nom → contenu .conf). Format des feuilles host;service (éprouvé via les fixtures du module). Prouvé : un processus « Supervision Chezlepro » (agrège load/procs/swap/ping4/ssh du host icinga en logique ET) rend un état dans l'UI (testmail connecté), avec le backend IcingaDB (pas d'IDO). Rôle re-prouvé (reset → recrée le processus, idempotent). BPM n'est pas remplaçable par Grafana (roll-up d'impact métier). Pile Icinga = moteur + Web 2 + BPM, complète.
  • Cœur Icinga éprouvé (supervision active). serveur_icinga (cœur : icinga2 + icingadb + icingadb-redis) déployé sur sup-01 (🔧→⭐), base icingadb PostgreSQL via le registre. Prouvé : 3 services actifs, et le moteur supervise — IcingaDB peuplée (1 hôte, 12 services, résultats de checks persistés en base). Zéro bug de déploiement (rôle bien bâti). Icinga Web 2 + module BPM restent différés (phases dédiées : UI native + vues d'impact métier ; le BPM n'est pas remplaçable par Grafana). Confirme aussi le DRY resoudre_base sur icinga (les 3 rôles consommateurs validés).
  • Forgejo branché au SSO OIDC (« Se connecter avec Chezlepro ») — 2e app SSO, prouvée. serveur_forgejo (10.0.0) éprouvé sur forge-01 (🔧→⭐), adossé à PostgreSQL (base forgejo auto-provisionnée depuis le registre), exposé par l'edge (vhost + cert SAN + A PowerDNS + alias plancher, tout auto-dérivé de expose). Source OAuth2 vers Keycloak (forgejo admin auth add-oauth, idempotent, realm chezlepro), client OIDC forgejo enregistré via serveur_keycloak_clients. Auto-enregistrement OIDC ([oauth2_client] ENABLE_AUTO_REGISTRATION + ALLOW_ONLY_EXTERNAL_REGISTRATION : identités depuis l'annuaire seulement). Prouvé (flux authorization code headless) : testmail (LDAP) se connecte, compte auto-créé (testmail@lab.chezlepro.internal), atterrit sur le tableau de bord. 5 bugs de 1er déploiement corrigés : dépendance périmée serveur_sendmail→serveur_postfix (le vrai MTA) ; app.ini doit appartenir au user git (Forgejo persiste des secrets générés) ; ordre admin/migrations (flush_handlers + wait_for avant admin user create) ; HTTP_ADDR 127.0.0.1→0.0.0.0 (l'edge nginx est sur un autre hôte, 502 sinon) ; auto-enregistrement OIDC.
  • DRY : rôle utilitaire partagé resoudre_base (résolution BD depuis le registre). Le bloc copié-collé dans serveur_keycloak, serveur_forgejo et serveur_icinga (charger le registre, filtrer par consommateur, déréférencer le secret via lookup('vars', ...), résoudre hôte/port) est extrait dans roles/resoudre_base (facts resoudre_base_entree/db_password/db_host/db_port, no_log). Les 3 rôles l'incluent (include_role) et adoptent les facts. Le secret ne quitte toujours pas le rôle (déréférencé au déploiement). Fait « sur la preuve » : re-déploiement keycloak + forgejo failed=0, idempotent, testmail token Keycloak HTTP 200. Ferme le reste noté de la Phase 2 des bindings (cf. docs/bindings-conception.md).
  • PowerDNS — A d'exposition auto-dérivés (le DNS de la Phase 3 des bindings). serveur_powerdns génère désormais, dans la zone interne, un enregistrement A pour chaque FQDN d'exposition (champ expose des applications) vers l'edge qui le sert (domaines.edge) — via expositions_des_applications (même source que les vhosts nginx et les SANs du cert edge). Déclarer expose produit maintenant vhost + SAN de cert + enregistrement DNS, tout dérivé. Prouvé : dig @infra-dns-01 grafana.lab.chezlepro.internal et keycloak.… → 192.168.15.21 (edge). Option serveur_powerdns_publier_expositions (défaut true). Limite / reste : PowerDNS est autoritatif, pas récursif — pour que les nœuds utilisent ces A sans casser la résolution Internet, il faut un récursif (pdns-recursor : forward de la zone interne + récursion du reste) ou garder le plancher /etc/hosts. Ne PAS repointer naïvement client_dns vers l'autoritatif.
  • hosts_statiques — alias d'exposition dans le plancher /etc/hosts (résolution client, sûre). Le plancher pose désormais, sur chaque nœud, <IP edge> <FQDN exposé> pour chaque expose (dérivé de domaines.edge, même source que nginx/PowerDNS). Indépendant du DNS, aucun risque de couper la résolution (choix retenu vs pdns-recursor). Chargement du plan best-effort (stat delegate_to: localhost + become: false — les registres vivent sur le nœud de contrôle ; ignoré si le plan est absent, ex. préparation du template). Prouvé : /etc/hosts d'obs-01 régénéré avec keycloak/grafana → edge (ligne manuelle éliminée), getent OK, et le flux SSO Grafana fonctionne via la résolution du plancher (login: testmail). Boucle Phase 3 fermée : déclarer expose → vhost + SAN cert + A PowerDNS + alias plancher, tout dérivé. Bugs corrigés en chemin : serveur_loki (groupe loki manquant), stat sur cible→contrôle, become inutile sur le contrôle.
  • Rôle client_unbound — résolveur local (DNS dynamique) : éprouvé sur un nœud. Unbound par nœud (127.0.0.1) avec stub-zone vers l'autoritatif interne (PowerDNS) + récursion Internet (ou forward via client_unbound_transitaires). Alternative dynamique au plancher /etc/hosts statique, sans casser Internet. Bascule de /etc/resolv.conf protégée (client_unbound_apply + client_unbound_confirm) et validée AVANT (Unbound doit résoudre interne + Internet, sinon pas de bascule → nœud jamais coupé). Prouvé sur data-sql-01 (rayon d'impact minimal) : dig @127.0.0.1 keycloak/id-sso-01.lab.chezlepro.internal → PowerDNS, deb.debian.org → récursion, apt OK. Rôle sûr par défaut (apply: false : installe Unbound sans toucher au resolver). Rollout flotte = opt-in par nœud. Note direction : OPNsense embarque Unbound → à terme, l'Unbound réseau peut vivre sur l'appliance de bordure (nœud public, Étape B) ; le rôle par-nœud reste portable et complémentaire (cache local).
  • Bindings — Phase 1 : résolveur de liens dans instancier.py (relations service→service déclaratives). Une application déclare ses liens: [{vers, role}] dans plan/applications.yml ; chaque rôle décrit les liens qu'il accepte dans meta/liens.yml (setops_liens.accepte, comme meta/empreinte.yml). instancier résout la cible (FQDN interne dérivé de la nomenclature + domaine_interne), substitue les gabarits ({cible.fqdn}, {cible.hote}, {cible.ip}) et injecte les variables en host_vars du consommateur. Validation : rôle accepteur, cible existante, genre attendu. Migration prouvée : les liens mail Postfix→Dovecot (mailstore) et Postfix→rspamd (milter) passent de group_vars codés en dur à des liens déclaratifs — make instancier donne DIFF VIDE (mêmes variables générées), puis les group_vars sont retirés. La topologie mail devient déclarative et portable. Cf. docs/bindings-conception.md. Suite : bases (Phase 2), exposition/domaines (Phase 3), GUI (Phase 4).
  • Bindings — Phase 2 (bases) : constat + réconciliation de la note (docs/bindings-conception.md §5/§9). Inspection du code réel : le binding app→base existe déjà — côté base (consommateur/portee dans bases-donnees.yml), résolu dans le rôle au déploiement (include_vars + filtre + lookup('vars', secret)), sur 4 rôles (postgresql, forgejo, keycloak, icinga). Délibérément conservé (le secret ne quitte jamais le rôle) — ne PAS dupliquer en app-side/instancier. Deux directions assumées : app→app côté app (instancier), app→base côté base (registre). Reste (reporté à l'épreuve de Keycloak) : factoriser le bloc de résolution copié-collé en include partagé (DRY).
  • docs/carte-set-ops.md — carte d'orientation (index + mécanismes transverses). Après audit du dépôt : point d'entrée « à lire d'abord » (index du corpus, ~22 docs), et catalogue des mécanismes dispersés dans le code (les 2 directions de binding, pont de cert, résolution BD par registre, socle-first, sûreté check-mode, voûte, dimensionnement) avec où ils vivent. But : ne plus re-découvrir l'existant. Constat : la cruft était déjà inventoriée dans catalogue-services.md (rôles-catégories inertes, échafaudages) — non dupliquée, référencée. catalogue-services.md « État d'implémentation » rafraîchi (rôles éprouvés sur VM réelles : socle, PKI, LDAP, DNS, nginx, pile courriel). Pointeur ajouté depuis architecture-set-ops.md.
  • serveur_postgresql et serveur_keycloak éprouvés sur VM réelles (🔧→⭐). PostgreSQL déployé (data-sql-01), écoute réseau + pg_hba VLAN, et provisionne la base keycloak depuis le registre (bases-donnees.yml) — binding app→base prouvé en réel (base + rôle créés, mot de passe = vault_bd_keycloak). Keycloak 26.0.7 déployé (id-sso-01), mode prod, connecté à PostgreSQL (87 tables du realm master écrites), token admin obtenu (auth adossée à la BD). Lacunes connues (documentées catalogue-services.md) : fédération LDAP et edge nginx pas encore câblés. Reste : DRY du bloc de résolution BD (keycloak/forgejo/icinga).
  • serveur_keycloak — fédération LDAP (modèle d'identité A) : automatisée et prouvée. Le rôle configure, via kcadm (idempotent), un realm applicatif (serveur_keycloak_realm, déf. chezlepro) et un provider de stockage LDAP READ_ONLY vers OpenLDAP (LDAPS, uid/entryUUID, inetOrgPerson). TLS LDAPS validé via le truststore système (truststore-paths → /etc/ssl/certs/ca-certificates.crt, racine step_ca posée par client_pki, désormais requis sur le nœud). Secrets par environment + no_log. Éprouvé avant codification puis prouvé par le rôle : un utilisateur LDAP (testmail) obtient un token via le realm (HTTP 200), et le redéploiement est idempotent (changed=0). Nouveau : tasks/federation-ldap.yml. Reste : edge nginx (accès HTTPS par nom), mappers d'attributs/groupes fins.
  • Edge nginx + exposition (Phase 3 des bindings) — prouvés avec Keycloak. Sans changement de code : la machinerie serveur_nginx_publier_expositions existait déjà (lit expose des applications + edge de domaines.yml, dérive amont = http://<IP hôte>:<port>, génère le vhost avec X-Forwarded-*). Le bac à sable déclare keycloak.expose: [keycloak.lab.chezlepro.internal] + le domaine interne lab.chezlepro.internal (edge serveur_nginx). Prouvé : le vhost s'auto-génère (keycloak.lab.chezlepro.internal → http://192.168.15.81:8080), et la découverte OIDC via l'edge renvoie "issuer":"https://keycloak.lab.chezlepro.internal/..." (les X-Forwarded passent, Keycloak se sait derrière HTTPS). Limite connue : le cert TLS de l'edge est encore le snakeoil auto-signé (avertissement navigateur). Raffinement recommandé (réutilise l'existant, pas de nouveau mécanisme) : ajouter les FQDN d'exposition aux client_pki_sans de l'edge (client_pki demande + renouvelle déjà le cert d'hôte), puis pointer serveur_nginx_certificat sur le cert client_pki (/etc/step/certs/<edge>.crt).
  • Cert de l'edge : snakeoil → step_ca (HTTPS valide). Appliqué le raffinement ci-dessus : client_pki ajouté à l'edge, ses client_pki_sans incluent le FQDN d'exposition (keycloak.lab.chezlepro.internal), et serveur_nginx_certificat/_cle pointent sur le cert client_pki. Prouvé : HTTPS HTTP 200 avec ssl_verify_result=0 (chaîne validée contre la racine step_ca, nom correct), émetteur Set-OPS Internal CA. Sans nouveau code (client_pki + group_var). Gaps notés : (1) recharger nginx au renouvellement du cert (le cert-renewer renouvelle en place, nginx ne recharge pas seul — hook à ajouter) ; (2) auto-dériver les SANs d'exposition de l'edge depuis le plan (au lieu de les lister dans le group_var).
  • Grafana branché au SSO OIDC (« Se connecter avec Chezlepro ») — prouvé de bout en bout. serveur_grafana : config OIDC via GF_AUTH_GENERIC_OAUTH_* (client confidentiel grafana, realm chezlepro, secret vault_grafana_oidc). serveur_loki + serveur_prometheus + serveur_grafana déployés sur obs-01 (🔧→⭐). Bug de rôle corrigé : serveur_loki créait le répertoire en group: loki alors que le paquet crée l'utilisateur en nogroup sans groupe loki → ajout de la création du groupe. Prouvé (flux authorization code headless, via l'edge HTTPS) : testmail (user LDAP) se connecte à Grafana par le SSO — /api/user renvoie login: testmail, email et nom fédérés depuis LDAP. Chaîne complète LDAP → Keycloak → Grafana. Gaps notés (pour rendre 100 % déclaratif) : (1) l'enregistrement du client OIDC dans Keycloak a été fait via kcadm à la main (à codifier — rôle grafana ou liste de clients côté keycloak) ; (2) la résolution keycloak.…internal → edge sur obs-01 est un /etc/hosts manuel (PowerDNS devrait porter les A d'exposition — chaînon récurrent) ; (3) mapping de rôles Grafana (tous Viewer par défaut).
  • serveur_keycloak — enregistrement des clients OIDC codifié (gap précédent fermé). Le rôle gère une liste déclarative serveur_keycloak_clients (clientId, redirect_uris, web_origins, secret) et enregistre chaque client confidentiel via kcadm idempotent (tasks/clients-oidc.yml, create-si-absent, no_log). Décision : côté Keycloak (les creds admin restent dans le seul rôle Keycloak, pas répandus dans chaque rôle app) ; le secret référence la même variable de voûte que l'app. Prouvé : client grafana supprimé → rôle → recréé → testmail se connecte à Grafana (login: testmail) ; redéploiement idempotent (changed=0). Le déploiement de Grafana au SSO est désormais autonome.

2026-07-02

Décidé

  • Bindings — conception des relations app/base/serveur/domaine (docs/bindings-conception.md). Les relations service→service sont aujourd'hui codées en dur, éparpillées dans des group_vars (ex. Postfix→Dovecot/rspamd/LDAP), ce qui casse la portabilité multi-tenant. Direction retenue : liens déclarés côté application (liens: [{vers, role}]), résolus par instancier.py en variables Ansible ; chaque rôle décrit les liens qu'il accepte dans meta/liens.yml (comme meta/empreinte.yml) ; FQDN cible dérivé de la nomenclature (jamais codé en dur). Domaines publics traités comme lien exposition (écrit sur l'edge). Réconcilie l'existant (bases consommateur, domaines.edge). Preuve de migration ciblée : les 3 liens mail. Implémentation à suivre (phasée).
  • Licence : passage de CC BY-NC-SA 4.0 à AGPLv3. Les licences Creative Commons ne sont pas faites pour du logiciel (position de CC elle-même) et la clause NonCommercial contredisait le principe fondateur « tout est libre » — en plus de bloquer les artisans/coopératives visés. LICENSE remplacé par le texte officiel intégral de l'AGPLv3 (verbatim, non modifié). Attribution + modèle libre + services/certification documentés dans le README (méthode d'attribution recommandée, sans toucher au texte de la licence). L'AGPLv3 protège la souveraineté (anti-captation propriétaire en SaaS) sans interdire l'usage commercial.
  • Architecture d'identité/SSO (docs/identite-sso.md). Modèle A : OpenLDAP source de vérité, Keycloak fédéré (SSO web OIDC, MFA, self-service), mail en bind LDAP direct. Une identité, un mot de passe, deux chemins d'auth (web→Keycloak, mail→LDAP), tout adossé au même OpenLDAP. Sert la portabilité multi-tenant (chaque tenant = son LDAP + son Keycloak fédéré). Pas Keycloak-source (casse-tête mail + moins portable).
  • Service courriel : pivot de Stalwart vers Postfix + Dovecot + rspamd. La Phase 1 Stalwart (serveur_stalwart) avait été prototypée et déployée (v0.16.11, install + démarrage en mode récupération). Le prototypage a révélé un projet trop jeune/volatil pour un pilier mail critique : config cassée entre 0.15 et 0.16, outil IaC stalwart config apply annoncé mais non livré dans le binaire, API REST supprimée (JMAP), gros backlog. Pivot vers la stack mature Postfix/Dovecot/rspamd, en prime 100 % configurable par fichiers (alignée au modèle déclaratif Set-OPS). Le rôle serveur_stalwart est retiré (git en garde la trace) ; docs/courriel-conception.md mis à jour. Réévaluer Stalwart ~2028.

Ajouté

  • docs/pouvoirs-set-ops.md — bilan des capacités du moteur. Inventaire structuré (moteur/plan, plan de contrôle GUI, socle durci, piliers d'infrastructure, services outillés, patrons d'ingénierie), distinguant honnêtement « prouvé sur cluster réel » de « outillé ».
  • Rôle serveur_rspamd (rspamd 3.x) — antispam + DKIM, en milter sur Postfix. Installé sur le nœud edge-mta (avec Postfix), backend Redis local, worker proxy en mode milter auto-scan (:11332), signature DKIM sortante (clé générée par le rôle de façon idempotente ; enregistrement DNS public affiché pour l'Étape B). Config par surcharges /etc/rspamd/local.d/. Postfix branché via smtpd_milters (option serveur_postfix_rspamd_milter, milter_default_action = accept → tolérant si rspamd indisponible). Prouvé : un courriel traversant le milter ressort scanné (rspamc stat : 1) et signé DKIM (DKIM-Signature: d=…), puis livré et lu en IMAP. Étape A (courriel interne) complète : dovecot + postfix + rspamd.
  • Flux courriel interne PROUVÉ de bout en bout (Étape A). Envoi → Postfix (edge-mta, validation LDAP) → LMTP réseau → Dovecot (mail-store) → boîte Maildir → lu en IMAP (auth LDAP, TLS step_ca) : status=sent, message lu (sujet + corps). Réglages Dovecot 2.4 qui débloquent la remise LMTP : userdb static { static_allow_all_users = yes } (sinon NOTFOUND pour l'expéditeur/raw-mail-user externe), mail_inbox_path = vidé (le défaut mbox /var/mail root refusait l'autocréation de l'INBOX), Maildir explicite (mail_home + mail_path = %{home}/Maildir), chemin par nom d'utilisateur (home identique côté LMTP local-part et IMAP adresse complète ; mono-domaine, multi-domaine = raffinement Étape B).
  • Rôle serveur_postfix (Postfix 3.x) — MTA du nœud edge-mta. Réception :25, cartes LDAP (validation des boîtes via l'attribut mail), remise LMTP réseau vers le nœud mail-store Dovecot (virtual_transport = lmtp:inet:[…]:24), TLS via step_ca (pont de cert), aucune boîte locale. Config main.cf + carte ldap-mailboxes.cf, validée par postfix check. Secret de bind : vault_openldap_admin. Nécessite serveur_postfix_mailstore_hote (FQDN du mail-store). Validé statiquement ; déploiement réel à suivre.
  • Rôle serveur_dovecot (Dovecot 2.4) — déployé et prouvé. IMAP :993/:143 + LMTP, auth/annuaire LDAP (vers serveur_openldap, filtre mail), stockage Maildir (user système vmail), TLS via step_ca (pont de cert + resync au renouvellement), neutralisation de l'auth système par défaut. Config en drop-in syntaxe Dovecot 2.4 (mail_driver, ssl_server_cert_file, passdb ldap/userdb static, %{user}), validée par doveconf au déploiement. Sockets d'intégration Postfix conditionnels (rendus si l'utilisateur postfix est co-localisé). Prouvé : doveadm auth test — bon mot de passe accepté, mauvais refusé, sur cert step_ca. Secret de bind : vault_openldap_admin.
  • serveur_openldap durci pour la prod : TLS via step_ca + organisation en intrant.
    • TLS (LDAPS + STARTTLS) : le certificat d'hôte step_ca (déposé par client_pki, root:root 600) est synchronisé vers un emplacement lisible par openldap (/etc/ldap/tls) par un script + une unité path systemd qui re-synchronise et recharge slapd à chaque renouvellement ; olcTLS* configuré dans cn=config, SLAPD_SERVICES expose ldaps://. Dégrade proprement (slapd en clair local) si client_pki n'a pas encore posé le cert.
    • Organisation : nouvel intrant chezlepro_organisation (remplace le « Exemple Inc » codé).
    • Écrit en code de prod, éprouvé statiquement ; validation par déploiement réel à suivre.
  • docs/courriel-conception.md — cadrage du futur service de courriel souverain : full self-host, suite Stalwart (adoptée, enveloppée par un rôle mince), topologie MX primaire + MX secours, intégration identité (LDAP) / DNS / PKI (Let's Encrypt public vs step_ca interne), enregistrements DNS publics, décisions ouvertes (IP/PTR, secours, stockage) et phasage. Conception seulement — aucun rôle livré.

2026-07-01

Ajouté

  • État RÉEL vs plan dans le GUI (sonde de vie + auto-actif). Le badge planifié/ actif décrit l'intention du plan, pas l'existence de la VM — d'où la confusion « serveur planifié mais vivant ». Deux ajouts :
    • Sonde de vie : le GUI teste la joignabilité SSH de chaque hôte en arrière-plan (/api/sondes, en parallèle) et affiche un état réel — ● vivante / ● injoignable — sur les tuiles et dans le détail (« Plan : … · Réel : … »), distinct du plan.
    • Auto-actif : un hôte qu'on matérialise (clone creer réussi) ou qu'on déploie passe automatiquement actif dans le plan (matérialisé = actif), puis l'inventaire est régénéré pour que le changement se voie partout (en-tête inclus).
    • Compteur « vivantes » dans l'en-tête (depuis la sonde), à côté de actifs/planifiés.

Corrigé

  • nginx ne validait pas sur Debian 13 (server_tokens en double). Debian 13 livre server_tokens off; actif dans /etc/nginx/nginx.conf (avant : commenté). Le drop-in conf.d/99-setops.conf du rôle le redéclarait → nginx -t échouait (« directive is duplicate ») et le déploiement plantait au handler de validation. Le rôle serveur_nginx neutralise désormais la ligne distro (le drop-in reste l'unique source). Trouvé en déployant nginx pour de vrai sur un hôte edge.

  • Une voûte chiffrée cassait instancier / « Appliquer le plan ». ansible-inventory --list (utilisé pour la comparaison sémantique du plan) tente de déchiffrer group_vars/all/vault.yml et échoue sans mot de passe (exit 4) — alors que l'opération est structurelle, sans secret. instancier utilise désormais automatiquement le fichier conventionnel ~/.config/setops-vault-pass (si ANSIBLE_VAULT_PASSWORD_FILE n'est pas déjà défini).

  • Secrets des rôles non câblés à la voûte (échafaudage manquant). Les rôles à secrets déclaraient serveur_X_password: "" avec, en commentaire seulement, la variable de voûte attendue ({{ vault_X }}) — sans mapping réel. Résultat : remplir la voûte selon vault.exemple.yml ne suffisait pas, le secret restait vide et l'assertion « secrets requis » échouait. Les 12 secrets des 8 rôles (serveur_step_ca, serveur_forgejo, serveur_grafana, serveur_keycloak, serveur_openldap, serveur_redis, client_ldap, client_pki) pointent désormais vers leur variable de voûte : serveur_X_password: "{{ vault_X | default('') }}". Chaque instance n'a plus qu'à remplir ses vault_* dans sa voûte chiffrée ; aucun mapping par instance.

  • « Vérifier » (dry-run --check) échouait faussement sur un hôte frais. Les tâches « démarrer service » et les handlers « redémarrer / recharger / valider » des rôles applicatifs touchent un paquet que --check n'installe pas réellement → le service (ou le fichier de zone/conf) n'existe pas encore → faux fatal, qui bloquait le déploiement (le dry-run doit réussir pour débloquer « Déployer »). Ajout de when: not ansible_check_mode sur ces tâches et handlers des 13 rôles serveur_* (29 gardes). En dry-run elles sont sautées ; en vrai déploiement, inchangées.

  • Le bouton ⚙ « Appliquer le plan » du GUI refusait en silence dès que le plan divergeait de l'inventaire (il appelait instancier appliquer sans --force). Résultat : après une édition (disque, état, auto-actif), l'inventaire n'était jamais régénéré et les compteurs restaient figés. Le clic « Appliquer » est l'intention explicite → le GUI force désormais (git reste le filet).

  • Bouton « Pousser » dans le GUI — le flux devient 100 % cliquable. Chaque objet du détail se matérialise sur son hôte, en streaming console (avec mot de passe vault

    • confirmation renforcée en prod) :
    • Serveur → 🖥 Pousser clone la VM depuis le golden template (make creer-vm), disponible même sur un hôte planifié — comble le trou : le GUI ne clonait pas.
    • Application → Pousser déploie l'hôte porteur (make deployer HOTE=<hôte>).
    • Base → Pousser déploie l'hôte du serveur de BD (crée la base). Nouveaux modes creer / pousser dans executer_flux + routes /api/creer et /api/pousser. Flux complet depuis l'interface : éditer → ⚙ Appliquer → 🖥 Pousser → Vérifier → Déployer.
  • Premier déploiement RÉEL validé de bout en bout (cluster asgard) : flux canonique plan-piloté clonant + durcissant + faisant tourner un PowerDNS qui résout. Voir les 3 correctifs ci-dessous.

2026-06-30

Ajouté

  • Plancher de résolution /etc/hosts (indépendant du DNS). Nouveau rôle de socle hosts_statiques (dans serveur_debian) : génère /etc/hosts sur chaque VM depuis l'inventaire (nom + FQDN interne → IP réelle). Tout l'écosystème se résout par nom même serveur DNS éteint, et le bootstrap ne dépend plus du DNS. PowerDNS devient une commodité (zone/externe/dynamique) ; client_dns est rendu tolérant (inerte si aucun DNS interne) et sa dépendance à serveur_powerdns passe molle.
  • Adressage fédéré : index d'instance. Le VMID n'est plus codé 9CSNN en dur : il prend le préfixe d'un index déclaré en tête de plan/nomenclature.yml ({index}{catégorie}{service}{séq}). Convention : supernet = 10.(10+index).0.0/16, VMID = index·CSNN. Permet à N écosystèmes de coexister/s'interconnecter sans collision (Chezlepro=1 → 10.11/1xxxx, Technolibre=2 → 10.12/2xxxx). Sans index → 9CSNN (rétro-compatible ; bacs à sable, plages ad-hoc 172.19.x). Code mort retiré (deriveServeur JS). Voir docs/multi-instances.md.
  • Doc docs/multi-instances.md : cadrage « un moteur, N écosystèmes » — l'instance comme dépôt autonome, la bascule, l'isolation, et le socle multi-tenant.
  • Bascule d'instance (make instance-utiliser NOM=…). Repointe le symlink instance vers un autre dépôt d'instance (prod ↔ bac à sable) ; make instance-courante affiche l'instance montée. Permet d'exploiter plusieurs instances (séparation par instance) depuis un seul moteur.
  • Identité des intrants relative à l'inventaire. Le panneau « Intrants » lit/écrit l'identité dans group_vars/all/10-intrants.yml de l'inventaire monté (fichier réel pour une instance autonome, symlink vers une source partagée sinon — l'écriture suit le symlink). Fonctionne donc aussi bien pour une instance « par instance » que pour l'ancien partage lab/production.
  • Inventaire d'instance neutre et configurable (principal / SETOPS_INVENTAIRE). Le moteur (Makefile + inventory_gui, instancier, config_proxmox, serveurs, applications) ne code plus en dur inventories/lab / inventories/production : il vise un inventaire par instance, détecté de façon rétro-compatible (principal > production > lab) et surchargeable par SETOPS_INVENTAIRE. Les instances existantes (découpage lab/production) continuent de fonctionner à l'identique ; les nouvelles peuvent adopter inventories/principal/. Deuxième pierre de la séparation par instance (la 1re étant le drapeau setops_production).
  • Garde-fou de prudence par instance (setops_production). Le déploiement réel est désormais possible sur toute instance (un bac à sable déploie sur son infra lab — il est isolé et fonctionnel). Le drapeau setops_production dans group_vars/all/ ne bloque plus rien : il marque la PRODUCTION pour exiger une confirmation renforcée au déploiement (bannière/badge rouge « PROD », bouton Déployer en rouge, re-saisie du nom d'hôte) ; un bac à sable (false) affiche « bac à sable » et déploie sans cette étape. Rétro-compatible (à défaut de drapeau, ancien repère « inventaire production »). Le GUI affiche toujours quel type d'instance est monté.

Modifié

  • Voûte de secrets unique par environnement. Fini les voûtes éparpillées : tous les secrets de l'instance (token Proxmox + 17 vault_* pour PKI, LDAP/SSO, bases, forge, observabilité) vivent dans un seul fichier chiffré, inventories/<env>/group_vars/all/vault.yml. Gabarit committé exemples/vault.exemple.yml. make config (config_proxmox.py) écrit/édite désormais cette voûte (semée depuis le gabarit si absente). .gitignore durci (**/vault.yml). Docs mises à jour (config-proxmox.md avec étapes de migration, intrants-communs.md §H, QUICKSTART). Le GUI ne stocke toujours aucun secret.

Corrigé

  • Trois bugs trouvés au premier déploiement réel (cluster asgard).
    • Clonage/Makefile codaient inventories/lab en dur (config Proxmox + voûte) — vestige du modèle env qui cassait les instances principal. Le playbook de clonage et le Makefile détectent maintenant l'inventaire (lab > principal > production).
    • Redimensionnement disque non idempotent : quand le disque dérivé du plan est plus petit que le golden template, Proxmox refuse (shrinking disks is not supported) et le clone échouait. Le resize est désormais grow-only (tolère le cas, la VM garde le disque du template — le dérivé est un minimum).
    • PowerDNS refusait de démarrer (multiple backends 'bind') : le rôle redéclarait launch+=bind que le paquet pdns-backend-bind pose déjà. Le rôle ne déclare plus launch (seulement bind-config).
  • chezlepro_timezone n'était appliqué nulle part. Cet intrant de base global était défini mais aucun rôle ne s'en servait. Le rôle chrony (appliqué à tout hôte via serveur_debian) règle désormais le fuseau horaire à partir de chezlepro_timezone (chrony_timezone par défaut, vide = ne pas toucher). Les autres défauts globaux (nœud / stockage / pont Proxmox) étaient déjà réutilisés comme valeurs par défaut, surchargeables par hôte.

Modifié

  • Détail GUI : section « Groupes (dérivés) » retirée (redondante). Depuis la fusion en atelier maître-détail, elle ne répétait que le socle (universel), les rôles des applications (déjà dans « Applications ici ») et les intégrations (déjà cochées). Son seul signal unique — le prérequis bloquant — est désormais nommé dans le pied (« Prérequis manquant : … ») ; le panneau Dépendances reste pour la vue d'ensemble. Code mort retiré (renduGroupe, detailGroupe, titreGroupe, CSS .groupe*).
  • Nettoyage CSS/HTML du GUI après la refonte : retrait des règles et éléments morts (.message, .chips-filtre/.chip-f, .onglet*, .base-ligne, .bases-liste, .ch-grp*/.ch-fleche/.ch-roles, .champ-val, divs #message et #chips).
  • GUI refondu en atelier maître-détail unifié. Toutes les vues suivent le même motif : tuiles à gauche, détail + saisie à droite, le panneau droit reflétant la sélection de la vue courante (fin du panneau « figé » au changement de vue). Les vues Applications et Bases passent de tableaux pleine largeur à ce motif ; la saisie se fait dans le panneau droit, avec liens cliquables entre objets (serveur → application → base). Le panneau droit est élargi (~38 %).
  • Fusion Serveur/Hôte. Les vues « Inventaire » (hôtes, lecture seule) et « Serveurs » (plan) faisaient doublon : elles sont fusionnées en une seule vue Serveurs. Sa tuile porte le statut de réconciliation (réconcilié / divergent / non instancié) ; son détail réunit l'identité éditable (plan), les dérivés (VMID/IP/VLAN), les groupes, les applications et bases hébergées, et Vérifier/Déployer. La navigation clavier (1-3, j/k, v/d, /) et le filtre opèrent désormais sur les serveurs. Un bandeau « Comment lire ce parc » explicite le modèle serveur·hôte·groupe·application·base. Code mort retiré (carte, renduChaine, ONGLETS, champLecture, sélection d'hôte, etc.).

Ajouté

  • Fluidité d'exploitation du GUI (sans dépendance, stdlib pure) :
    • Notifications empilées (toasts) auto-effaçables au lieu d'une bannière unique écrasée ; les erreurs restent plus longtemps, les opérations longues mettent à jour leur propre toast.
    • Durée d'exécution affichée en direct dans la console (Vérifier / Déployer) et suivi vivant de « Appliquer le plan ».
    • Navigation clavier : 1-5 changent de vue, j/k parcourent les hôtes, v/d vérifient/déploient l'hôte sélectionné, / cible le filtre, Ctrl+S sauvegarde la vue éditable courante (ignorés pendant la saisie).
    • Validation inline des champs du plan (vue Serveurs) : nom fonction-NN, mémoire, cœurs, disque — liseré rouge et blocage avant l'envoi.
    • Garde-fou anti-perte : confirmation beforeunload si des éditions de plan ne sont pas sauvegardées.
    • Le bouton Sauvegarder de l'en-tête devient contextuel (sauve la vue éditable, désactivé en lecture seule) — fin de l'impasse 409 ; suppression du code mort.

Ajouté

  • Vue Serveurs : champs alimentés par les paramètres globaux. À + Serveur, Nœud et Stockage deviennent des listes déroulantes (catalogues proxmox_noeuds / proxmox_stockages, vide = défaut global) et Intégrations une rangée de cases à cocher des rôles client_* disponibles (au lieu d'une saisie texte). Les catalogues Nœuds / Stockages / Ponts sont de nouveaux intrants (classe « Catalogue ») éditables dans le panneau « Intrants de base » et stockés dans group_vars/proxmox.yml ; l'API expose integrations_disponibles (scan de roles/client_*). Le type liste (chaîne virgulée → liste YAML dédoublonnée) est ajouté au schéma des intrants.

Supprimé

  • Vue « Chaîne » du bandeau (redondante). Son contenu (renduChaine) était déjà rendu, par hôte, dans l'onglet « Chaîne » du détail de la vue Inventaire ; la vue ne faisait que le répéter pour tous les hôtes à la fois, sans réseau/proxmox ni boutons d'opération. Retirée (bouton, rendu dessinerArbre, CSS .arbre-*) ; la chaîne reste consultable hôte par hôte. Raccourcis clavier ramenés à 1-4.

Modifié

  • Vue Inventaire (cartes) : détail converti en inspection lecture seule. La vue affichait « généré depuis le plan (lecture seule) » tout en exposant une surface d'édition (+ Hôte, ✕, bascule actif/planifié, ✨ Proposer, champs et cases de groupes éditables) qui ne pouvait pas être persistée (l'inventaire est généré ; /api/inventaire renvoie 409). Ces contrôles orphelins sont retirés : nom, champs Réseau/Proxmox et groupes en lecture seule, état affiché en badge. Vérifier / Déployer et les onglets d'inspection sont conservés. L'édition reste dans les vues Serveurs / Applications / Bases. Code mort supprimé (ajouterHote, supprimerHote, definir, definirNom, definirEtat, basculerGroupe, proposer, autoProposer, prochainSeqLibre, marquerModifie, état modifie).
  • Référence des paramètres de make config (docs/config-proxmox.md). Explique un par un les 16 paramètres Proxmox non sensibles + les secrets API demandés par l'assistant : sens, valeur par défaut, quoi saisir, et quels champs sont des constantes vs des défauts surchargeables par hôte. Renvois ajoutés depuis make help et QUICKSTART.md. Comble un trou : ces invites n'étaient expliquées nulle part de façon pérenne (impératif « exploitable sans IA »).
  • Panneau « Intrants de base » dans le GUI. Un bouton ⚙ Intrants ouvre une fenêtre unique pour saisir les valeurs communes à tout l'écosystème, avec une distinction visible entre constantes (valeur unique, non surchargeable) et défauts (valeurs proposées, surchargeables dans les instances). Les secrets ne sont jamais saisis ni affichés ici (garde-fou INTRANTS_CLES_INTERDITES + filtrage par schéma) : ils restent dans le Vault Ansible, édités en CLI ; le panneau les liste seulement à titre informatif. Un changement de domaine_interne (clé de voûte) demande une confirmation explicite ; un domaine_interne vide est refusé côté serveur. La nomenclature reste en lecture seule (modifiable dans le plan). Voir docs/intrants-communs.md et docs/intrants-base-gui-conception.md.
  • Source unique d'identité partagée. domaine_interne et chezlepro_timezone vivent désormais dans inventories/partage/intrants-identite.yml, référencé par symlink depuis chaque environnement (group_vars/all/10-intrants.yml) — fin de la duplication lab/production.
  • Info-bulles d'aide sur les champs du GUI. Survoler la description d'un champ à saisir affiche une bulle avec des instructions sommaires.

2026-06-28

Ajouté

  • Document de présentation de l'écosystème Chezlepro (docs/ecosysteme-chezlepro.md). Description vulgarisée à destination client/partenaire : les piliers de l'écosystème souverain, le modèle reproductible (plan → génération → clonage → conformité), et un inventaire des mesures de renforcement réellement en place (SSH durci, fail2ban, sysctl, nftables, AppArmor, auditd, mises à jour automatiques, gestion des secrets, garde-fous destructifs, sécurité par l'architecture). Honnête sur le statut « défini/validé vs déployé ».

Ajouté

  • Dimensionnement dérivé des ressources VM. Les cœurs/RAM/disque d'une VM sont désormais estimés depuis les logiciels hébergés + le socle SE, au lieu d'hériter des specs du golden template (CPU/RAM identiques pour toutes les VM auparavant). Chaque rôle déclare son empreinte (roles/<rôle>/meta/empreinte.yml) ; le générateur somme par hôte (marge + arrondis) et écrit proxmox_coeurs/ proxmox_memoire/proxmox_disque_taille ; le clonage passe cores/memory à Proxmox (omit si absent → aucune régression). Override par hôte possible dans le plan (serveurs.yml). Voir docs/dimensionnement-ressources.md.

Corrigé

  • make instancier-appliquer FORCE=1 n'honorait pas --force. La recette Makefile lançait instancier.py appliquer sans relayer FORCE ; le message « Utilise FORCE=1 » était donc trompeur (un changement de plan intentionnel restait bloqué). La recette passe désormais $(if $(FORCE),--force). Trouvé en dogfooding.

2026-06-24 — Première publication publique

Première mise à disposition publique de Set-OPS, moteur Ansible d'écosystèmes numériques souverains sur Proxmox — offert à la communauté québécoise par l'Alliance Boréale, à la Saint-Jean-Baptiste 2026.

  • Moteur générique, piloté par un plan déclaratif : on édite le plan (instance/plan/*.yml), l'inventaire Ansible se génère, les VM se clonent depuis un golden template Debian 13, les rôles s'appliquent par groupes. Tout passe par make, le GUI local et la documentation.
  • Piliers d'un écosystème souverain : socle Debian durci, AC/PKI interne, DNS interne, identité (LDAP + SSO), relais courriel, bases de données, observabilité, forge.
  • Catalogue de modèles prêts à déployer (exemples/modeles/) : un hébergeur copie un modèle, le renseigne à ses couleurs, et instancie.
  • Souveraineté jusqu'au bout : Set-OPS s'exploite entièrement à la main, sans aucune IA.

Pour démarrer : QUICKSTART.md.