# CHANGELOG — Set-OPS ## 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..29.0/24`, port `52000+index`, instance `wg`. 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 `: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_`), 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..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.` 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.`, 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 `_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 `_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.//.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=`. 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//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=`. 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 `_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 `, 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 `.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 `_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 `_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@.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 `` 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= NOM=`** — copie un modèle déjà générique (copier + éditer). - **`MODE=instance SOURCE=OPS- NOM=`** — **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 `../`, 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//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-.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//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é `/logs/--.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/.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/.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=….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/.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/`). `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**, ` ` 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://:`, 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/.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=`). - **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//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//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`**.