`segment_physique: true` retire la cle `vlan`. devis_reseau.py ecrivait `r['vlan']` en une vingtaine d'endroits : KeyError, /api/devis-reseau en 500, et LA VUE RESEAU DU PANNEAU RESTAIT VIDE — une panne a deux couches de sa cause. Filtre a la source plutot que colmatage : devis_reseau definit son propre `reseaux_de_fabric` qui ecarte les reseaux sans etiquette, et les dix appels y passent. Colmater les vingt occurrences aurait laisse la vingt-et-unieme. Le bloc de gestion d'un switch echappait au filtre (il lit son reseau directement). Sans etiquette, aucune `interface VlanN` n'existe : l'adresse va sur l'interface de gestion native du boitier. Le devis le DIT au lieu d'inventer une syntaxe. Expose au passage que bifrost-3 et bifrost-4 declarent leur gestion sur un segment qui ne les traverse pas, a des adresses qui n'ont jamais repondu. Les quatre devis du panneau repassent. 42 preuves vertes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
575 KiB
CHANGELOG — Set-OPS
2026-08-25 — La vue Réseau du panneau était vide : un KeyError deux couches plus bas
segment_physique: true — introduit le matin même pour dire qu'un réseau n'a pas
d'étiquette VLAN — retire la clé vlan. Or devis_reseau.py écrivait r['vlan'] en une
vingtaine d'endroits.
Le premier segment non étiqueté a levé un KeyError, /api/devis-reseau a rendu une 500,
et la vue Réseau de l'interface est restée vide. Une panne qui ne ressemble à rien, à
deux couches de sa cause — c'est le genre qu'on cherche partout sauf là où elle est.
Filtré à la source, une seule fois. Colmater les vingt appels aurait laissé le
vingt-et-unième. devis_reseau définit désormais son propre reseaux_de_fabric qui écarte
les réseaux sans étiquette, et les dix appels y passent : ce fichier ne configure que des
commutateurs, et un commutateur n'a rien à dire d'un segment qui ne l'atteint pas.
Le bloc de gestion d'un switch échappait au filtre — il lit son réseau directement. Quand
ce réseau n'est pas étiqueté, il n'existe aucune interface VlanN : l'adresse appartient à
l'interface de gestion native du boîtier, dont le nom dépend du modèle. Le devis le dit
plutôt que d'inventer une syntaxe — un devis qui promet une commande fausse est pire qu'un
devis qui se tait.
Au passage, ça expose une incohérence de la carte : bifrost-3 et bifrost-4 déclarent
leur adresse de gestion sur management, un segment qui ne les traverse pas. Ces deux
adresses sont d'ailleurs celles de l'ancien plan et n'ont jamais répondu.
Les quatre devis du panneau — switches, frontière, SDN, pare-feu est-ouest — repassent.
2026-08-25 — Un renommage de rôle laissait son ancien fichier aux commandes
Mesuré sur forge-01, par l'agent invité — donc sans dépendre du réseau. Quatre
drop-ins SSH coexistent là où il devrait y en avoir deux :
10-chezlepro.conf 10-setops.conf
20-chezlepro-hardening.conf 20-setops-hardening.conf
Les rôles se sont appelés chezlepro avant de porter le nom du moteur. Le renommage a
changé le fichier déposé sans retirer le précédent. Et les paires ne disent pas la même
chose : MaxSessions 2 contre 10, MaxStartups 5:30:20 contre 10:30:60.
C'est l'ancien qui gagne. sshd retient la première valeur rencontrée pour chaque
mot-clé, et lit les drop-ins dans l'ordre lexical : 20-chezlepro-hardening.conf passe
avant 20-setops-hardening.conf. Une configuration qu'on croit avoir remplacée reste donc
aux commandes, en silence — et sa trace se lirait un jour comme une lenteur inexplicable
pendant un déploiement parallèle, cherchée partout sauf là.
ssh_baseline et ssh_hardening retirent désormais chacun le fichier de la nomenclature
qu'il a abandonnée, avant de déposer le sien, et notifient la même validation. La liste
est déclarative (ssh_baseline_fichiers_perimes, ssh_hardening_fichiers_perimes) et ne
contient que des noms qu'on a réellement déposés un jour : supprimer un fichier qu'on n'a
jamais écrit serait effacer la configuration de quelqu'un d'autre.
Pas encore éprouvé. Les seules machines portant les anciens fichiers sont celles de patient 0, actuellement hors d'atteinte depuis le poste — leur écosystème est sain, c'est le chemin d'entrée qui est coupé. Le correctif est écrit et validé, il reste à le voir retirer un fichier pour de vrai.
2026-08-25 — Le clonage inter-nœuds : quatre défauts que la première VM ailleurs a révélés
Toutes les VM naissaient jusqu'ici sur le nœud du gabarit, puis migraient. site-cache-01
est la première à être placée ailleurs — et elle a fait tomber quatre défauts enchaînés du
moteur de clonage. Aucun n'était visible avant, et aucun ne disait son nom.
1. Le clonage visait le nœud de destination. L'URL était
nodes/{{ proxmox_clone_noeud }}/qemu/<gabarit>/clone, alors que l'API veut le nœud qui
détient le modèle ; la destination se dit par target. Le gabarit vit sur asgard,
la VM allait sur gandalf → 500 Configuration file 'nodes/gandalf/qemu-server/99998.conf' does not exist. Le nœud du gabarit se découvre désormais dans l'inventaire du cluster.
2. Le refus de l'API était avalé. failed_when: false + no_log: true protégeaient
l'en-tête d'authentification, mais masquaient aussi le 500. Une assertion le relève
maintenant, message de l'API compris — le masque reste, l'erreur sort.
3. L'attente cherchait la tâche au mauvais endroit. Elle interrogeait
nodes/<destination>/tasks/<UPID> ; un clonage inter-nœuds s'exécute sur le nœud du
gabarit. L'UPID n'existait pas là, l'API répondait en erreur, until n'était jamais
satisfait : 60 tentatives × 10 s pour un clonage terminé en 87 s, puis la suite qui
reprend sans un mot. Un UPID s'écrit UPID:<nœud>:<pid>:… — le nœud y est déjà, on le lit
là plutôt que de le redemander à une variable.
4. La garde d'après-attente ne pouvait pas échouer. exitstatus | default('OK')
faisait passer pour un succès une attente qui n'avait rien observé : sans json.data,
pas d'exitstatus, donc « OK ». Elle exige désormais d'avoir vu la tâche s'arrêter, et
bien s'arrêter.
La taille des disques porte toujours son unité
disque: 40 produisait un resize à « 40 » que Proxmox lisait comme un rétrécissement
(shrinking disks is not supported). Le playbook tolère cette erreur — à raison, un disque
déjà assez grand n'est pas une panne — donc la machine naissait avec les 16 Go du gabarit
au lieu de 40, sans que rien ne le dise. Les plans des tenants écrivent 40G depuis
toujours ; le site écrit pareil, et un entier nu se voit rattacher son unité.
Le site déclare ce qu'il matérialise
Le playbook prenait le gabarit et le stockage dans les group_vars du tenant actif :
make site-creer aurait cloné depuis un modèle différent selon le symlink instance,
sans rien dire. materialisation: (vmid du gabarit, stockage, clone_complet) vit
désormais dans l'underlay du site, et site-creer les passe explicitement.
Ce que le chronomètre a dit
Le clonage réel : 59 s et 87 s pour 3,3 Gio effectivement alloués (le gabarit en
déclare 16). Ce n'était donc pas le disque du modèle qui coûtait — c'était les dix minutes
d'attente aveugle du défaut n° 3. Le clone lié reste le vrai levier (rbd clone depuis
@__base__, instantané), mais il exige CephNVMe en destination : TrueNAS est du LVM
épais, sans copy-on-write.
2026-08-25 — La frontière voit le site : fabric, alias et règles
Un mot manquait au vocabulaire des flux
Le runner de SITE déclarait son API Proxmox en pair: externe. Or « externe » se rend
par !SETOPS_INTERNES — tout sauf les espaces privés — et les hyperviseurs sont en
RFC 1918. La règle sortante les aurait exclus tout en ayant l'air d'ouvrir le flux.
Un flux qui a l'air ouvert et qui ne l'est pas est pire qu'un flux fermé : il ne se
cherche pas.
D'où fabric : le matériel de l'hébergeur — hyperviseurs, frontière, commutateurs. Ce
n'est ni flotte (les machines d'un écosystème) ni voisins_site (les tenants d'à côté),
c'est le socle sur lequel les uns et les autres reposent. Le seul à s'y adresser est
le runner du site, et c'est tout son objet.
Deux flux qui n'existaient pas
Le cache racine ne pouvait rien aller chercher. serveur_cache_site ne déclarait
qu'un ingress 3142 : la chaîne entière se terminait sur un cache vide. Ça ne se serait
vu qu'au premier apt update d'un écosystème neuf — c'est-à-dire au pire moment.
Le runner de site n'avait que l'API. Déplacer un disque, poser un pont, lire une configuration réseau passe par le shell du nœud ; ouvrir les flux du tenant qu'on matérialise passe par l'API de la frontière. Trois pouvoirs distincts, déclarés à part.
Ce que le devis ignorait
Les machines du site ne sont dans aucun plan de tenant : la boucle du devis ne pouvait pas les voir, et il rendait zéro règle pour elles sans rien signaler. Un devis muet sur une machine qui existe n'est pas un devis.
Le devis émet désormais SETOPS_SITE (le réseau), SETOPS_FABRIC (ce que le runner
pilote), SETOPS_ADMIN_SITE (seule source du SSH) et un alias par rôle du site. Leur
trafic arrive par opt2, pas par le lien de transit — une règle posée sur la mauvaise
interface ne correspond jamais, et c'est la panne la plus silencieuse de cette couche. Le
SSH de gestion, lui, arrive par lan. Plus le NAT sortant du site : son réseau est
directement attaché, mais le mode automatique d'OPNsense a été quitté en déclarant les
tenants à la main — compter sur un mode qu'on a soi-même quitté se paierait au premier
téléchargement.
Le socle s'applique aussi au site. site_inventaire.py range ses machines dans
serveur_debian : même durcissement SSH, mêmes horloges, mêmes règles que n'importe
quelle machine de la flotte. L'oublier aurait donné à la machine la plus puissante du
site la protection la plus faible.
Patient 0 rend ses responsabilités de site
Corrigé le 2026-08-25. Cette section affirmait que « patient 0 est un tenant comme les autres : c'est même tout ce qu'il prouve ». C'est faux, et le texte est corrigé plutôt qu'effacé. Un tenant ordinaire existe pour ses gens ; patient 0 n'héberge que la lignée. Il est l'écosystème d'origine et le détenteur du génome, et il le reste. Le geste était juste, la raison ne l'était pas — et une doctrine juste appuyée sur une raison fausse finit toujours par se retourner.
Son plan déclarait encore serveur_ops_site et serveur_cache_site. Il les tenait non
par nature mais faute d'un SITE capable de les porter : à l'époque, un site n'était
qu'un fichier de carte, sans machines à lui.
Le SITE existe désormais comme objet à part entière — machines déclarées dans son underlay, sur leur propre réseau de fabric, avec leur voûte et leur runner. Matérialiser et servir le cache aux voisins lui reviennent. Porter la lignée reste chez patient 0.
Bilan au devis : 26 règles à créer, 5 à retirer (dont les trois périmées de patient 0).
2026-08-24 — Un SITE n'est pas un plan : ses machines vivent dans l'underlay
J'avais d'abord fait entrer le site dans le générateur des tenants, avec une branche « si l'adresse est déclarée, elle gagne ». Cette branche est retirée.
Le symptôme était visible tout de suite. Un tenant se dérive : de son seul index
descendent son supernet, ses VLAN, ses VMID, ses VNet SDN. Un site ne dérive de rien —
il n'a pas d'index, il est le terrain sur lequel les tenants dérivent. Les faire
passer par la même moulinette donnait un nomenclature.yml de site réduit à une coquille
vide, et un site exclu des devis par absence d'index plutôt que par nature. Une
exclusion fondée sur un manque casse au premier ajout innocent.
Les machines de l'hébergeur se déclarent donc dans underlay.yml, à côté des switches et
des hyperviseurs qui les portent : même carte, même fichier, mêmes secrets. Ce qui se
partage entre un site et un tenant, ce sont les rôles, pas la forme du plan.
Six gardes neuves, chacune éprouvée par un contrôle négatif : réseau inconnu, réseau sans pont, nœud qui n'est pas un hyperviseur déclaré, IP hors sous-réseau, IP déjà prise, vmid absent ou en double, machine sans service.
Ce que la mesure a corrigé
Le réseau d'administration 10.17.0.0/24 n'a pas d'étiquette VLAN, et n'en a jamais
eu. vlan: 10 était une supposition héritée, et elle était fausse : la frontière le porte
sur igb0, un port physique à elle. Il existe bien un VLAN 10 sur vmbr1, mais c'est
l'ancien plan 10.0.0.0/24, aujourd'hui vide — deux choses différentes que le même
chiffre confondait. D'où segment_physique: true.
Aucun pont d'hyperviseur ne touche ce segment : trois sondes non persistantes depuis
asgard (bond0 étiqueté 10, bond3 étiqueté 10, vmbr1 non étiqueté) sont restées muettes,
témoin positif réussi dans le même script. Une VM y naîtrait sourde. Le validateur refuse
désormais toute machine déclarée sur un réseau sans pont:.
Le premier jeu de sondes avait pour cible la frontière, et concluait faux : elle ne répond pas à une source qu'elle ne connaît pas — son default-deny travaillait. C'est le témoin qui l'a révélé, pas la sonde.
Les machines du site vivent sur site-services — VLAN 30, 10.0.3.0/24, pont
vmbr3 (bond3, créé sur les trois nœuds), passerelle 10.0.3.1 portée par la frontière
sur son interface OPT2. site-ops-01 en .11, site-cache-01 en .21.
Le repli intermédiaire était grappe-controle (192.168.11.0/24) : porté par un vrai
pont, mais de l'hérité — des adresses sans avenir, derrière une passerelle qui n'est même
pas la frontière. Ancrer le site là aurait figé les deux défauts dans la carte. Le VLAN 30
lève les deux : adressage de fabric cohérent avec le transit (vlan 40 → 10.0.4.0/24), et
sortie par la frontière, donc policée.
Le chemin qui matérialise un site
site_machines.py traduit une déclaration d'underlay en les mêmes SETOPS_* que
make cloner-vm consomme déjà : le clone reste le seul chemin éprouvé, il n'y en a pas
deux. site_inventaire.py est un inventaire dynamique — un site ne dérivant de rien,
sa déclaration est déjà sa forme finale, et un hosts.yml généré ne rendrait rien de
plus inspectable. Les groupes sont les services : playbooks/groupes/<rôle>.yml trouve
ses hôtes sans qu'on câble quoi que ce soit.
Quatre cibles, indépendantes de toute instance montée — le site existe avant tout tenant
et doit pouvoir naître quand aucun n'est encore là : site-decrire, site-inventaire,
site-creer (CONFIRMER=true), site-appliquer GROUPE=….
Le premier garde-fou de site-appliquer refusait un GROUPE vide — il ne pouvait
jamais se déclencher, GROUPE ayant un défaut global. Le vrai risque, observé en le
testant : un playbook de tenant lancé contre l'inventaire du site n'y trouve aucun hôte,
n'a rien à faire, et sort avec 0 — un succès qui n'a rien fait. La cible refuse désormais
tout groupe absent de cet inventaire-ci.
passerelle_amont, rôle d'hôte neuf : un routeur qui existe mais que nous
n'administrons pas. Le déclarer n'est pas l'adopter — c'est distinguer une passerelle
étrangère d'une passerelle fantôme.
2026-08-24 — Quinze invites pour un seul mot de passe
flotte-creer appelait make creer-vm une fois par hôte, et chaque appel ajoutait
--ask-vault-pass. Quinze machines, donc quinze invites — quatre en parallèle, avec la
sortie redirigée vers des journaux : l'exploitant est harcelé par des invites qu'il ne voit
même pas.
Mesuré en reconstruisant Chezlepro. Ce n'est pas une fatalité de l'outil, c'est une faute de l'outil.
L'idiome existait déjà — je ne l'avais pas cherché
deployer-tout demande une seule fois depuis longtemps, et refuse proprement quand
personne n'est au clavier. Ma première correction inventait une seconde façon de
manipuler un secret, à côté de la première.
C'est exactement la faute que P41 combat : deux résolutions d'une même question finissent par diverger, et pour un secret la divergence ne se remarque qu'après.
Les deux cibles partagent désormais un seul bloc, VAULT_UNE_FOIS : lecture unique,
fichier mktemp en 0600, trap au retrait, et refus explicite en entrée non
interactive.
Une garde que ma première version n'avait pas
-t 0 : ne demander que si un humain est au clavier. Sans elle, un appel non interactif —
CI, ordonnanceur, agent — resterait bloqué sur une invite que personne ne lit. Je m'en
suis aperçu en vérifiant ma propre correction : la commande a pendu deux minutes.
Vérifié : flotte-creer sans mot de passe et sans terminal refuse maintenant en une
ligne, au lieu d'attendre indéfiniment.
2026-08-24 — Le dénominateur extrait : origine devient un modèle
La hiérarchie se lit maintenant sans ambiguïté :
Set-OPS le moteur — méthodes, rôles, preuves
SITE-xxx le terrain — UN par hébergeur
Set-OPS-Modeles les plans-types, dont `origine` est le dénominateur commun
OPS-xxx les écosystèmes réels : un modèle, posé sur un SITE
Chezlepro et Technolibre sont frères — deux OPS au même niveau.
Patient 0 conflait trois rôles
La mesure l'a montré : il portait le dénominateur commun et un index: 29, une voûte
réelle, une parenté, cinq VM en marche — quatre choses qu'un modèle n'a pas.
socle public pki · edge · mail · dns
patient 0 pki · edge · dns · forge · ops
+ artefacts + cache_site + ops_site + resolveur
Trois rôles, dont un seul est générique :
| rôle | où il vit désormais |
|---|---|
| le dénominateur commun | le modèle origine |
l'écosystème de l'hébergeur de SITE-Chezlepro |
reste dans OPS-Patient0 |
| le détenteur du génome | reste dans OPS-Patient0 |
Ce que origine ne porte pas
serveur_ops_site et serveur_cache_site — les rôles de l'hébergeur. Un modèle qui
les porterait donnerait à chaque client un pouvoir sur ses voisins.
Ni index réel, ni voûte, ni parenté. Un modèle est une recette : index: 1 est un
gabarit que l'instanciation remplace par le seed dont tout l'adressage dérive.
C'est la même séparation que pour les runners et pour les voûtes — distinguer la recette de la machine qui l'applique.
Et le même défaut, trouvé une fois de plus
Les six modèles privés portaient encore setops_plan_dir: instance/plan — le lien du
moteur, en dur. Corrigé chez les instances et le modèle public il y a deux jours, il avait
survécu ici. Un déploiement lancé par SETOPS_INSTANCE y aurait lu le plan d'une autre
instance, comme l'edge de patient 0 avait publié les noms de Chezlepro.
Les sept modèles génèrent leur inventaire.
2026-08-24 — Les deux pare-feu appliqués, et un derive qui n'était compris que d'un côté
frontiere a creer : 0 | a retirer : 0 | inchange : 65 + 15 routes
est-ouest a creer : 0 | a mettre a jour : 0 | a retirer : 0
Flotte vérifiée après coup, sur les cinq hôtes : DNS interne, Internet, apt sans erreur,
et le cache d'artefacts joignable (HTTP 200).
Un port derive que seul un générateur sur deux savait lire
J'avais déclaré le port de PowerDNS derive — il vaut 53 seul sur son hôte, 5300 derrière
le résolveur. verifier_ports traite depuis toujours un port non numérique comme « pas une
écoute fixe ». Le générateur est-ouest, lui, l'envoyait tel quel à l'API Proxmox :
dport: derive -> 400 « invalid format - invalid port 'derive' » six règles refusées
Une même notion, comprise d'un côté et pas de l'autre. Elle l'est désormais des deux.
Et sauter est la bonne réponse, pas un contournement : depuis que le résolveur du
tenant est la seule porte, l'autoritatif n'écoute que sur 127.0.0.1:5300. Aucune règle
est-ouest n'a d'objet pour lui — les trois groupes t*-srv-powerdns sont devenus périmés
et ont été retirés.
Mais un flux qu'on n'applique pas doit se voir. Le devis recense et affiche les ports sautés, avec la raison. Sans cette note, sauter proprement serait devenu un trou silencieux — la faute que ce dépôt traque sous tous ses déguisements.
Le même piège qu'à la frontière, une heure plus tôt
Là aussi le premier essai a signalé des échecs, et là aussi une partie du travail était déjà écrite : le devis suivant est passé de « 6 à créer, 10 à mettre à jour » à « 0 à créer, 1 à mettre à jour ». Un refus partiel n'est pas un refus.
2026-08-24 — La frontière appliquée, et une contrainte qui n'était écrite nulle part
a creer : 0 | a retirer : 0 | inchange : 65 + 15 routes — le devis est clos. La flotte de
patient 0 vérifiée après coup : DNS interne, Internet et apt sans erreur sur les cinq.
OPNsense refuse un nom d'alias de 32 caractères ou plus
Le schéma SETOPS_<tenant>_<ROLE> ne laisse que 17 caractères au rôle :
SETOPS_PATI29_SERVEUR_ARTEFACTS_SITE 36 refusé
SETOPS_PATI29_SRV_ARTEFACTS_SITE 32 refusé encore, d'un caractère
SETOPS_PATI29_SRV_CACHE_SITE 28 passe
La contrainte n'était écrite nulle part, et elle s'est manifestée à l'application,
pas au devis. Un devis qui promet un objet que la cible rejettera n'est pas un devis :
nom_alias abrège désormais (SERVEUR_ → SRV_, comme le pare-feu est-ouest) et refuse
bruyamment si le nom déborde encore. Le rôle est renommé serveur_cache_site.
« RIEN N'EST APPLIQUÉ » voulait dire autre chose que ce que j'ai lu
Le premier essai a signalé cinq échecs et affiché « RIEN N'EST APPLIQUÉ, la config reste en attente ». J'ai d'abord compris que le boîtier n'avait pas été touché. Le devis suivant disait autre chose : 62 objets inchangés au lieu de 49.
Les objets étaient bien écrits dans la configuration ; c'est le rechargement qui n'avait pas eu lieu — « en attente » au sens d'OPNsense. Une différence qui compte : entre les deux, la configuration contenait des objets que le pare-feu en marche ignorait encore.
Et la garde du boîtier injoignable a servi
Une tentative a échoué sur une poignée TLS expirée — le premier paquet après une pause, mesuré tout au long de la journée. Le code a refusé de lire un boîtier injoignable comme un boîtier vide, au lieu de proposer de tout recréer. C'est exactement ce pour quoi cette garde avait été écrite.
2026-08-24 — Le chaînage des caches, et la règle qui appartient au SITE
Le runner de SITE est le seul à toucher la configuration système du matériel. Sa responsabilité est de préparer le terrain — VNets, routes, flux — pour que le plan d'un OPS puisse tenir. Ensuite le runner du tenant fait la suite.
Cette mise au point tranche une question restée ouverte : une règle inter-tenant n'appartient ni à l'émetteur ni au récepteur, mais au site. Ce n'est pas « Chezlepro ouvre une porte chez patient 0 » — c'est le site qui autorise un flux entre deux de ses tenants.
Ce que je croyais, et qui était faux
J'avais annoncé que le générateur construisait « tenant par tenant, sur les interfaces de ce tenant », et que des règles croisées changeraient sa forme. Faux : il fait une seule passe sur tous les tenants du site, et il n'y a qu'une interface de transit. Les tenants s'y distinguent par leur alias source, pas par leur interface.
Deux silences fermés dans le générateur
flux_frontiere() ne retenait que les flux externe. Or deux tenants d'une même fabric
vivent sur des VLAN distincts, routés par la frontière : leur trafic la traverse, donc
elle doit le porter. Sans ça, le mot voisins_site était accepté par la validation et
rendait zéro règle — un flux déclaré que personne n'applique.
Et un egress vers un voisin recevait !SETOPS_INTERNES en destination, comme tout flux
sortant — c'est-à-dire le port ouvert vers l'Internet. La destination est désormais
nommée.
Deux rôles, parce que les flux sont statiques
Un rôle unique aurait dû déclarer l'ingress inter-tenant pour tous les caches. Le devis l'a montré avant toute application :
+ TENANT_PATI29 -> CHEZ17_SERVEUR_ARTEFACTS un maillage complet,
+ TENANT_TECH23 -> CHEZ17_SERVEUR_ARTEFACTS chaque cache acceptant chaque autre
serveur_artefacts_site porte donc l'ingress, serveur_artefacts l'egress vers son amont.
Résultat au devis : l'ingress ne vise plus que le cache du site.
Ce que ça donne
VM du tenant -> cache du tenant -> cache du SITE -> Debian
Debian téléchargé une fois pour toute la fabric. Un seul flux nouveau par écosystème, entre deux caches — jamais d'une VM vers le cache d'un voisin. Et le cache du site ne voit que des requêtes agrégées : jamais quelle machine installe quoi.
Vider serveur_artefacts_amont, c'est s'émanciper.
Ce qui reste large, et que je ne cache pas
L'egress autorise chaque cache de tenant à joindre le 3142 de tous ses voisins, alors
qu'un seul lui sert d'amont. La déclaration étant statique, le rôle ne sait pas lequel de
ses voisins est le cache du site. La portée effective reste juste — seul le cache du site
accepte — mais la règle sortante est plus permissive que nécessaire.
Rien n'a été appliqué. Le devis se lit avant.
2026-08-24 — voisins_site : le vocabulaire d'un flux entre tenants, et le mur qu'il révèle
Le registre des flux connaissait flotte (mon écosystème), edge, admin et externe.
Il ne savait pas dire « les autres tenants de ma fabric » — et ingress + externe
signifie depuis l'Internet : déclarer ainsi un cache partagé l'aurait publié au monde.
voisins_site existe maintenant dans resoudre_flux. Il rend des CIDR — les supernets
des tenants que ce site héberge — et réutilise devis_reseau.decouvrir_du_site()
plutôt que d'en écrire un second recensement. Deux listes de tenants finiraient par
diverger, et la divergence se lirait « tout va bien ».
Vérifié : depuis patient 0, il rend 10.17.0.0/16 et 10.23.0.0/16. Le lab, federe: false, est correctement exclu.
Une faute évitée de justesse. Ma première version lisait un federe absent comme
« non fédéré » et excluait Chezlepro et Technolibre — leur nomenclature est antérieure à
cette clé. devis_reseau dit n.get("federe", True) : l'absence vaut fédéré. Réutiliser
sa découverte plutôt que d'en réécrire une a fermé le piège au passage.
Ce qui n'est PAS fait, et pourquoi
Le chaînage des caches — chaque écosystème garde le sien, qui prend celui de l'hébergeur comme amont, pour que Debian ne soit téléchargé qu'une fois par site — n'est pas livré.
Le vocabulaire est accepté, mais aucun générateur ne le rend : make frontiere-plan
produit zéro règle pour le port 3142 entre tenants. Un flux déclaré que personne
n'applique est précisément le piège que ce dépôt traque, alors les déclarations ont été
retirées plutôt que laissées à moitié.
La difficulté est structurelle, pas cosmétique. Les règles d'OPNsense s'évaluent sur l'interface d'arrivée : un paquet venant de Chezlepro vers le cache de patient 0 arrive sur l'interface de Chezlepro, donc la règle doit être posée là. Or le générateur construit ses règles tenant par tenant, sur les interfaces de ce tenant. Émettre des règles croisées change la forme de ce qu'il produit — et ses propres commentaires répètent qu'une règle posée sur la mauvaise interface ne correspond jamais à un paquet.
Ce qui reste en place : le mot voisins_site, éprouvé ; et serveur_artefacts_amont, la
capacité de chaînage côté rôle. Il manque le rendu, dans les deux générateurs de pare-feu.
2026-08-24 — Un résolveur par tenant, et non plus un par machine
client_unbound posait un Unbound sur chaque VM. C'était étanche, et c'était N démons
identiques pour un service unique.
Mesure prise avant de décider : ~21 Mo de RSS par VM, et 28 à 189 requêtes servies depuis le démarrage. Le gain de cache mutualisé est donc négligeable ; ce qu'on récupère, c'est un démon au lieu de cinq — et un endroit à regarder au lieu de cinq.
Pourquoi pas sur la frontière
C'était la proposition, et elle aurait été la plus économe. Mais un résolveur partagé par tout le SITE devrait connaître la zone interne de chaque tenant : sans vues par réseau soigneusement réglées, le tenant A résoudrait les noms du tenant B. Et un tenant dont le résolveur vit chez l'hébergeur ne peut plus s'émanciper avec.
La récursion est générique ; la zone interne ne l'est pas. C'est ce qui fait du résolveur un service de tenant, et non de fabric.
La cohabitation avec l'autoritatif
Les deux vivent sur infra-dns-01 et voudraient le port 53. Le partage est dérivé, pas
déclaré — l'exploitant n'a pas à se souvenir de déplacer un port parce qu'il a ajouté un
rôle :
résolveur (Unbound) 10.29.19.11:53 + 127.0.0.1:53 la seule porte
autoritatif (PowerDNS) 127.0.0.1:5300 derrière lui
Le port de PowerDNS est déclaré derive dans son meta/flux.yml — verifier_ports traite
un port non numérique comme « pas une écoute fixe », ce qui est exactement le cas : les
deux lient le port 53, mais sur des adresses différentes.
Deux pièges, dont un que le harnais seul a vu
Unbound refuse d'interroger une loopback. do-not-query-localhost vaut yes d'origine
— une protection contre les boucles. Or l'autoritatif vit désormais sur 127.0.0.1:5300,
derrière le résolveur. Sans la lever, toute la zone souveraine rendait SERVFAIL.
Et la panne était masquée par le plancher. getent hosts forge.genese.internal
répondait 10.29.16.11 sur les cinq machines — c'était /etc/hosts, pas le DNS. J'ai
d'abord conclu que la résolution fonctionnait. Seul un dig explicite a montré le
SERVFAIL. Le plancher fait son travail — mais il rend une panne DNS invisible à qui
mesure avec le mauvais instrument.
C'est la tâche de validation du rôle qui a refusé de se dire satisfaite, et elle avait raison contre moi.
Le client sait se retirer
client_resolveur désinstalle l'Unbound local après avoir basculé — jamais avant,
sinon l'hôte perdrait toute résolution au milieu du play. L'hôte qui porte le résolveur est
épargné : c'est le même paquet.
Sans cette tâche, le rôle aurait changé le resolv.conf sans rien économiser, et la raison
même du changement aurait été perdue.
Le renommage
client_unbound n'installant plus Unbound, son nom mentait. Il devient client_resolveur
— une vingtaine de fichiers, quatre plans d'instance et six modèles. Le harnais a rattrapé
chaque oubli : playbook homonyme manquant, inventaires en écart, plan de recette périmé,
README absent.
2026-08-24 — Collabora en natif : la dernière exception conteneurisée tombe
serveur_collabora lançait collabora/code dans Docker — seule exception de la
flotte au principe fondateur : logiciel libre en natif, systemd + paquets + nginx. Elle
traînait une dépendance entière, community.docker, qui n'était même pas déclarée dans
requirements.yml et donc absente de tout runner.
Plus aucun rôle du dépôt n'a besoin de Docker. La collection est retirée.
Éprouver l'outil avant le rôle
Sur une Debian 13.6 réelle, sans rien installer :
apt-get install --simulate coolwsd -> résout jusqu'à « Conf coolwsd (26.04.3.1-1) »
libgcc1 (exigé par le paquet) -> fourni par libgcc-s1 sur trixie
le dépôt CODE-deb -> PLAT : Packages et Release à la racine, pas de dists/
Le paquet livre aussi /etc/nginx/snippets/coolwsd.conf et un profil AppArmor — deux
choses que cette flotte sait exploiter, contrairement à une image opaque.
La configuration par surcharge
Le coolwsd.xml livré fait 439 lignes richement commentées. Le remplacer par un
gabarit maison obligerait à suivre son évolution amont et ferait perdre ses explications.
coolwsd acceptant des surcharges --o:<clé>=<valeur>, le rôle pose un fragment
systemd : la configuration de Set-OPS tient en un fichier, celle du paquet reste intacte.
La console d'administration est fermée
Elle expose un formulaire hors de tout SSO, avec un secret à créer, faire tourner et
surveiller — pour une fonction dont personne n'a besoin. Même raisonnement que
serveur_forgejo_connexion_locale.
Trois outils du harnais ont eu raison, et l'un avait tort
voute.py lisait les commentaires. Expliquer en commentaire « pour ouvrir cette
option, fournir vault_x » suffisait à rendre vault_x obligatoire dans le gabarit ET
dans la voûte réelle. Documenter le nom d'une clef ne doit pas l'exiger : le scanner
ignore désormais ce qui suit un #. Contrôle négatif fait — une référence réelle, dans du
code, reste exigée.
Le message d'erreur d'un assert comptait comme une référence. Il nommait la clef de
voûte ; il nomme maintenant la variable que l'exploitant règle, ce qui est plus utile de
toute façon.
Et verifier_intrants.py avait raison. Un assert de la forme var | length > 0
déclare un contrat — et l'outil ignore délibérément les when:, parce qu'un intrant
exigé sous condition reste un intrant exigé. Écrite en deux morceaux, ma garde promettait
un secret pour une console fermée. Réécrite en implication — si ouverte, alors un mot
de passe — elle dit ce qu'elle veut vraiment dire.
Ce qui n'est pas prouvé
L'installabilité est établie. Le fonctionnement ne l'est pas : aucun écosystème de la flotte ne fait tourner Collabora aujourd'hui. La première mise en service devra vérifier l'édition d'un document de bout en bout, depuis Nextcloud.
2026-08-24 — La clé du runner, et trois silences fermés en chemin
Le poste d'exploitation fabriquait sa propre paire SSH depuis son premier déploiement, et
n'avait jamais pu joindre un seul hôte. Deux verrous, pas un : la clé n'était autorisée
nulle part, et nftables_admin_ssh ne listait pas son adresse. Les deux vont ensemble —
c'est tout le sens de P24, un seul intrant pour la flotte et la frontière.
ssh_baseline gère désormais les clés d'administration déclarées au plan. Chaque entrée
porte son etat : passer à absent révoque sur toute la flotte au prochain passage. Une
liste purement additive ne sait pas retirer, et « révoquer » voudrait alors dire se
connecter à la main sur chaque machine.
Pas d'exclusive: true : il effacerait la clé posée par cloud-init si le plan ne la
reprenait pas, et fermerait la flotte à tout le monde d'un seul déploiement. Le prix
assumé : une clé posée hors du plan n'est pas détectée ici.
Vérifié depuis ops-01 : les cinq hôtes répondent leur FQDN.
Cloud-init effaçait le plancher de résolution à chaque démarrage
manage_etc_hosts: true traîne dans le gabarit doré. À chaque boot, cloud-init régénère
/etc/hosts depuis son propre modèle et efface le plancher dérivé de l'inventaire —
la seule façon qu'a une machine de nommer ses voisines sans DNS.
Constaté sur infra-dns-01, redémarré lors d'un déplacement de disque : six entrées de
flotte perdues. Le défaut s'est manifesté deux jours plus tard sous la forme d'un
apt update qui ne résolvait plus le cache d'artefacts — un message qui ne parlait pas du
tout du vrai problème. infra-edge-01 avait subi le même sort et s'était fait réparer
sans qu'on le sache, par un redéploiement.
hosts_statiques dépose maintenant un fragment cloud.cfg.d qui neutralise cette
réécriture. Les cinq hôtes : six entrées, manage_etc_hosts: false, apt sans erreur.
Trois collections utilisées, aucune déclarée
requirements.yml ne nommait que community.postgresql et community.general. Or les
rôles utilisent aussi ansible.posix (serveur_backup) et community.docker
(serveur_collabora). Ça marchait chez le mainteneur, où un gros lot est installé — et
échouait partout ailleurs.
Le runner n'a que ce que ce fichier déclare : il n'aurait pas pu déployer les sauvegardes. C'est le genre d'écart qu'on ne voit qu'en portant le moteur sur une autre machine.
À trancher : community.docker contredit le principe « zéro conteneur ». La dépendance
est déclarée parce qu'elle existe — mieux vaut une contradiction visible qu'un rôle qui
échoue en silence — mais la question reste ouverte : Collabora en natif, ou l'exception
assumée ?
Et le cache suivait la mauvaise chose
Le téléchargement des collections était gardé par creates: <cache>/requirements.yml :
une collection ajoutée au fichier n'aurait jamais été récupérée, le témoin existant
déjà. Le cache est désormais indexé sur l'empreinte du fichier — changer une ligne
crée un cache neuf, donc un téléchargement.
Un when en écrase un autre
En ajoutant une condition, j'en ai laissé une seconde juste en dessous. YAML garde la
dernière clé et jette l'autre, en silence : not ansible_check_mode avait disparu.
ansible-lint l'a vu ; personne d'autre ne l'aurait vu. Les conditions vont dans une liste.
2026-08-24 — Deux runners, deux pouvoirs, aucun omnipotent
Le travail d'un runner se divise en trois, et la ligne de partage est celle des voûtes :
calculer plan -> inventaire aucune voûte portée TENANT
configurer rôles sur ses machines voûte du TENANT portée TENANT
matérialiser créer/détruire des VM voûte du SITE portée FABRIC
Un runner par tenant qui matérialiserait mettrait la voûte du SITE en N exemplaires — le secret le plus dangereux du système, recopié autant de fois qu'il y a de locataires.
Un runner unique qui ferait tout devrait entrer en SSH chez tous les tenants, donc traverser le default-deny inter-tenant — et rendrait l'émancipation impossible : un écosystème dont le runner appartient à l'hébergeur ne peut plus se rebâtir sans lui.
D'où serveur_ops_site, additif : il crée des VM vides et n'entre jamais chez un
tenant ; serveur_ops habille des machines et ne touche jamais la fabric. Réservé à
l'écosystème de l'hébergeur — un tenant ordinaire qui le déclarerait s'arrogerait un
pouvoir sur ses voisins.
La voûte, de droit plutôt que par emprunt
La cérémonie du prêt que j'avais bricolée en ligne de commande masquait une pièce
manquante. Le rôle dépose maintenant la voûte du SITE, chiffrée, en 0600 — et le mot
de passe n'est toujours pas stocké : le Makefile ajoute --ask-vault-pass quand aucun
fichier n'est défini.
Une garde née d'une faute
decrypt: false est obligatoire sur la copie : sans lui, Ansible déchiffre la source
quand il détient le mot de passe. Mesuré le jour même, en la déposant à la main — 776
octets en clair au lieu de 3465 chiffrés, sur une machine où ils n'avaient rien à faire.
Le rôle relit l'en-tête après avoir écrit et refuse si le fichier n'est pas chiffré.
Contrôle négatif fait : pointé sur un fichier en clair, il échoue ; rétabli ensuite, la
voûte déposée est bien $ANSIBLE_VAULT, 3465 octets, 0600.
Une fuite silencieuse aurait été le pire des cas : le fichier existe, le rôle se dit satisfait, et les clés du cluster dorment en clair.
2026-08-24 — Filiation, mutualisation, émancipation : nommer un motif que le moteur avait déjà
Un écosystème ne naît pas autoportant. Il lui faut une forge pour lire son génome, une source d'artefacts pour ses paquets, un dépôt pour ses sauvegardes — et rien de tout cela n'existe la première seconde. Il emprunte donc à son hôte.
Le moteur avait rencontré ce besoin trois fois sans le reconnaître :
client_artefacts_actif dérivé : « une source existe-t-elle chez moi ? »
serveur_ops_forge_externe « je lis mon génome ailleurs »
client_backup_cible patient 0 sauvegarde chez eregion — mutualisé, en production
Trois astuces, une seule notion — désormais déclarée dans docs/filiation-emancipation.md.
Ce que ça change pour les modèles
Quatre modèles sur six n'ont pas de forge : collaboration, identite, observabilite,
presence-web. On pouvait lire ça comme une lacune — leur ops-01 n'aurait rien à cloner.
C'en est une autre : ce sont des écosystèmes au premier âge, qui lisent leur génome
chez leur hôte. L'ajout d'une forge n'est pas une correction, c'est une émancipation.
Les quatre le déclarent maintenant explicitement. forge et integral restent émancipés
par défaut.
Et personne ne l'aurait vu : le harnais ne regarde pas les modèles privés — P17 n'en découvre qu'un seul, celui du dépôt public. Encore un vert sur un périmètre vide.
Une promesse sans substance
serveur_ops_forge_externe était citée par docs/dependances-groupes.yml et par le
README du rôle, et définie nulle part. L'exemption qu'elle portait ne pouvait donc
jamais s'appliquer : le registre refusait un écosystème sans forge tout en documentant
comment l'autoriser. La variable existe maintenant, avec son amont obligatoire — lever le
drapeau sans nommer chez qui l'on emprunte, c'est ne rien déclarer du tout.
Ce que l'émancipation devra prouver
Trois temps, dont le dernier est celui qu'on oublie :
- déclarer — lever le drapeau, déployer le service chez soi ;
- migrer l'état — génome, sauvegardes, certificats ;
- prouver que le lien est coupé.
Sans le troisième, on croirait s'être émancipé en restant dépendant sans le savoir. Une émancipation non prouvée est une émancipation non faite. L'instrument de cette preuve n'existe pas encore — c'est la prochaine pièce.
Ce que ça ouvre, côté commerce
L'hébergeur ne vend plus seulement des machines : il vend l'abri pendant la jeunesse. Chaque service mutualisé est une ligne de facture ; chaque émancipation est une décision du client, jamais une rupture technique. Pour une clientèle d'OBNL et de coopératives : on entre à bas coût, on grandit vers l'autonomie, et on n'est jamais captif — le génome est chez soi dès le premier jour.
2026-08-24 — Le poste d'exploitation entre dans les modèles, et un silence de plus est fermé
serveur_ops n'existait que chez patient 0. Aucun modèle ne le portait — donc aucun
écosystème livré n'aurait su se relire lui-même : il aurait détenu son génome en
dépendant, pour l'exécuter, de la machine de quelqu'un d'autre. Les six modèles déclarent
désormais la fonction ops, la machine ops-01 et son application.
Un défaut du rôle, corrigé avant qu'il ne serve
Les valeurs par défaut de serveur_ops nommaient patient 0 :
serveur_ops_depots:
- { depot: "ops-patient0", dest: "OPS-Patient0", role: "instance" }
Tout écosystème déployant un poste sans déclarer ses propres dépôts aurait donc cloné le génome de patient 0 — silencieusement, en croyant piloter le sien. Le défaut ne nomme plus que le moteur, qui est le même pour tous ; le dépôt d'instance doit être déclaré, et la tâche d'exigence refuse de poursuivre sans lui.
Le silence que presence-web a révélé
Ce modèle range tout son socle en zone 1 (« Fondations ») et n'a pas de catégorie 4. La
machine ops-01 y a donc été posée sans que sa fonction existe — et le moteur a produit
un inventaire en se déclarant réussi :
ops-01 -> adresse=None vlan=None
deriver_nomenclature(...) or {} avalait l'échec : une fonction absente rendait un
dictionnaire vide, et la machine entrait dans l'inventaire sans adresse. La panne ne
serait apparue qu'au déploiement, sous une forme incompréhensible — Ansible tentant de
joindre une adresse qui n'existe pas.
instancier.py refuse maintenant, en bloc et en nommant ce qu'il faut corriger :
Machines sans adresse derivable — leur `fonction` n'est pas declaree :
- ops-01 : fonction « ops »
Fonctions connues de ce plan : data, infra-dns, infra-edge, infra-mail, infra-pki, …
Contrôle négatif fait, puis contrôle positif sur les six modèles.
Ce que ça dit de la méthode
Le défaut ne s'est pas montré pendant qu'on écrivait le rôle, ni pendant qu'on le déployait chez patient 0 — où la catégorie 4 existe. Il a fallu porter la pièce dans un contexte différent pour qu'il apparaisse. C'est la même leçon que Technolibre avait donnée en révélant six défauts moteur invisibles avec un seul écosystème : un moteur ne se prouve qu'au pluriel.
2026-08-23 — La source d'artefacts : la forge sert le code, il manquait qui sert les binaires
Pour poser une seule machine, un écosystème allait chercher chez six serveurs
étrangers : deb.debian.org, security.debian.org, packages.smallstep.com,
apt.grafana.com, packages.icinga.com, codeberg.org. La forge héberge le code ; rien
n'hébergeait les binaires.
Deux rôles neufs — serveur_artefacts (apt-cacher-ng) et client_artefacts, intégration
universelle qui s'éteint d'elle-même quand aucun hôte ne porte le service, et qui
retire la direction posée auparavant : une intégration qui ne sait pas se retirer est
un piège différé.
Chez patient 0, le service est colocalisé sur forge-01. C'était l'intuition de départ,
prise au mot : la forge est la source — du code et des binaires.
Ce qui était déjà couvert, et ce qui ne l'était pas
| téléchargements directs (Forgejo, Keycloak, Nextcloud, oauth2-proxy, collections) | déjà couverts — le contrôleur télécharge une fois et pousse par SSH |
| dépôts apt | c'est ce qui manquait |
Deux mécanismes, parce que ce sont deux problèmes : on ne sert pas un dépôt apt par scp.
La preuve : couper l'amont
Tant qu'internet répond, un apt update qui réussit ne dit pas d'où vient l'octet. D'où
le mode hors ligne, qui est autant une fonction qu'un instrument :
paquet DÉJÀ en cache 235 ko réceptionnés en 0s (0 o/s) ← servi localement
paquet ABSENT du cache 503 Unable to download in offline mode ← refusé
Le 0 o/s est le témoin : rien n'a traversé le réseau. Contrôle positif et négatif dans
la même minute.
Trois choses apprises en le construisant
apt fait hériter Acquire::https::Proxy de la valeur HTTP. Poser le seul proxy HTTP
envoyait donc aussi les dépôts tiers en HTTPS dans le cache, qui refuse les tunnels — à
juste titre : 403 CONNECT denied. packages.smallstep.com devenait injoignable pour
toute la flotte. Il faut écrire DIRECT explicitement.
Un service ne doit pas dépendre de lui-même pour se réparer. La première version
faisait apt update à chaque passage. Sur l'hôte qui porte le cache, cet apt update
passe par le cache — et en mode hors ligne, il est refusé. Le rôle qui devait remettre le
service en ligne ne pouvait plus s'exécuter, et il a fallu réparer la machine à la main.
L'index n'est désormais rafraîchi qu'à la première installation.
Mes sondes ont menti deux fois de plus. apt-get update >/dev/null 2>&1 && echo ok a
rendu « ok » sur cinq hôtes où le proxy était injoignable : rediriger la sortie d'erreur,
c'est choisir de ne pas voir. Et grep -c rend un code de sortie 1 quand il compte
zéro — un instrument qui crie à l'échec en constatant le succès attendu.
Ce que ça ne règle pas encore
Les dépôts tiers en HTTPS vont toujours en direct. Les faire passer par le cache
demande de réécrire leurs sources en http://cache/<remap>/… — propre, faisable, pas dans
cet incrément.
Et un cache ne sert jamais la machine qui le construit : client_artefacts est
déployé en dernière couche, donc lors d'une construction from-zero les premières
machines vont encore à l'amont. Il sert dès le deuxième passage, et à chaque
reconstruction — c'est-à-dire exactement le scénario pour lequel il existe.
2026-08-23 — Le résolveur : un service qui répondait à des questions que personne ne posait
Les hôtes de patient 0 interrogeaient Quad9, alors que infra-dns-01 fait tourner un
PowerDNS autoritatif pour genese.internal. Chaque requête DNS de l'écosystème sortait
chez un tiers — une fuite au niveau le plus fondamental de la pile, pour une plateforme
dont le principe est la souveraineté.
Le mécanisme n'était pas absent : client_unbound était désarmé, deux booléens à
false, avec une garde qui refuse la bascule sans confirmation et valide qu'Unbound
répond déjà — zone interne et Internet — avant de toucher /etc/resolv.conf.
La zone était fausse, et rien ne le disait
C'est le troisième effet, et le pire. En armant la bascule, la zone servie contenait :
genese.internal SOA/NS/ns1 · dns CNAME
forge-01 · infra-dns-01 · infra-edge-01 · infra-pki-01
Manquaient ops-01 — la machine créée le matin même — et surtout
forge.genese.internal, le nom que l'écosystème publie. Un service que personne
n'interroge n'est pas surveillé : il est muet. La zone aurait pu être fausse pendant des
mois sans qu'aucun vert ne vacille.
La cause est la même que celle du vhost nginx et du plancher /etc/hosts : le rôle
dérive ses enregistrements de setops_plan_dir, qui pointait le mauvais plan. Un
redéploiement avec la variable corrigée a suffi :
forge.genese.internal A 10.29.16.11 ← l'edge qui le sert
ops-01.genese.internal A 10.29.19.41
La bascule
Quatre hôtes — infra-dns-01 reste hors du groupe, un autoritatif ne se résout pas
auprès de lui-même par un récurseur local. Vérifié après coup :
resolv.conf search genese.internal · nameserver 127.0.0.1
interne forge.genese.internal -> 10.29.16.11 ops-01 -> 10.29.19.41
internet deb.debian.org -> 151.101.138.132
apt update ok
fetch du génome depuis sa forge, par le poste : ok
Et le point qui décide si le gain est réel ou cosmétique : forward-addr = 0. Unbound
récurse depuis les serveurs racine, il ne renvoie à aucun tiers. Patient 0 est le premier
écosystème de la flotte à ne plus poser ses questions à personne.
Une note d'outillage
grep -c rend un code de sortie 1 quand il compte zéro. Ma sonde a donc affiché
FAILED sur les quatre hôtes alors que tout livrait — le zéro était précisément le
résultat cherché. Un instrument qui crie à l'échec quand il constate le succès attendu
vaut la peine d'être relu avant d'être cru.
2026-08-23 — serveur_ops : la différence entre une archive et une matrice
Un écosystème pouvait détenir son génome sans savoir l'exécuter. Les cinq dépôts vivaient sur sa forge — moteur, plans, modèles, étiquette signée — mais Set-OPS ne tournait que depuis le poste de son mainteneur. L'écosystème possédait le livre ; personne, chez lui, ne savait le lire à voix haute.
serveur_ops est le poste d'exploitation : Ansible épinglé sur la même famille que celle
qui a servi à construire (core 2.18 — reconstruire avec une version différente, c'est
changer la recette sans le dire), le génome cloné, et une clé SSH propre au poste.
Le génome vient de SA PROPRE forge
Le poste clone https://forge.<domaine>/genome/…, pas la forge parente. Ces dépôts y sont
des miroirs resynchronisés toutes les huit heures : l'écosystème se reconstruit donc
depuis lui-même, et non depuis son ascendant. Le clone est anonyme — un secret de
moins sur une machine qui en concentre déjà beaucoup.
Les deux choses qu'il n'a pas, et qui ne sont pas des oublis
Le mot de passe de la voûte : saisi à l'exécution. Une machine détenant à la fois le plan, l'accès SSH à toute la flotte et la clé des secrets n'a plus aucune profondeur.
Le fichier de voûte : les dépôts d'instance excluent vault.yml de git. Le génome
cloné porte donc le plan sans les secrets. D'où une conséquence qu'il vaut mieux
énoncer que découvrir :
la STRUCTURE se reconstruit depuis la forge
les SECRETS se restaurent depuis la sauvegarde (restic)
Deux sources distinctes, qu'un même incident n'atteint pas ensemble.
Sa clé doit être autorisée à la main
Le poste fabrique sa propre paire, distincte de celle du mainteneur : deux exploitants, deux révocations possibles. Tant que sa clé publique n'est pas portée aux intrants SSH du plan, il ne joint aucun hôte. C'est volontairement un geste humain — donner à une machine le droit d'entrer partout mérite une décision, pas un effet de bord.
Le harnais a écrit la moitié de ce rôle
Le rôle écrit, make prouver a rendu 36 OK, 5 échecs, tous sur la pièce neuve :
P08 aucune couche de deploiement -> couches-deploiement.yml
P09 pair de flux inconnu 'hyperviseur' -> vocabulaire : edge|flotte|externe|...
P29 aucune declaration d'authentification -> meta/authentification.yml
P31 aucun README -> roles/serveur_ops/README.md
P38 le catalogue ne le nomme pas -> docs/catalogue-services.md
Aucun de ces cinq oublis n'aurait empêché le rôle de fonctionner. Tous les cinq l'auraient rendu invisible à la carte, au graphe, à la politique de pare-feu et au lecteur. Le harnais ne vérifie pas que le code marche : il vérifie que le dépôt sait encore ce qu'il contient. Retour à 41 OK, 0 échec.
Au passage, pair: hyperviseur a été refusé à juste titre : l'hyperviseur n'est pas dans
l'écosystème, il est de l'autre côté de la frontière — donc externe. C'est le seul flux
par lequel un écosystème peut en engendrer un autre.
Ce que le poste a révélé en naissant
Une machine neuve est un révélateur : elle traverse tout le moteur sans rien hériter d'un
état antérieur. ops-01 a buté sur cinq obstacles, dont deux étaient des défauts réels et
silencieux du dépôt.
Le plan lu n'était pas celui déployé. setops_plan_dir valait
{{ playbook_dir }}/../../instance/plan — le lien instance du moteur, en dur. Neuf
rôles lisent cette variable. En déployant patient 0 par SETOPS_INSTANCE, ils lisaient
donc le plan de Chezlepro. Conséquences constatées sur les machines :
/etc/hosts de ops-01 : auth.chezlepro.internal, forge.chezlepro.internal…
nginx de l'edge : server_name forge.chezlepro.internal;
Un écosystème publiait les noms d'un autre. La variable est désormais ancrée sur
{{ inventory_dir }}/../../plan : le plan qui a engendré cet inventaire. Les deux ne
peuvent plus se contredire, et l'expression reste juste par le symlink comme par
SETOPS_INSTANCE. Corrigé dans les quatre instances et dans le modèle public.
L'edge publiait derrière un certificat auto-signé. Trois instances sur quatre
portaient group_vars/serveur_nginx.yml — les SAN d'exposition, le chemin du certificat
step-ca, le rechargement de nginx. La quatrième, plus récente, ne l'avait pas ; le modèle
public non plus. Résultat : ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem devant
la forge de patient 0. Le service répondait, la page s'affichait après un avertissement,
make prouver était vert. Le premier à refuser fut git clone — et il avait raison.
C'est un oubli de recopie, pas un bug : un câblage reproduit à la main finit toujours par manquer quelque part. D'où P42 — « L'edge porte les noms qu'il publie », qui le réclame désormais pour chaque écosystème déclarant un edge. Contrôle négatif fait : le fichier retiré, la preuve passe au rouge.
Et trois obstacles d'exécution, sans mystère mais instructifs :
- la frontière ne connaissait pas
ops-01— normal, il est neuf :+ alias SETOPS_PATI29_SERVEUR_OPS,+ SETOPS_PATI29_CLIENT_UNBOUND, 0 retrait ; ansible-galaxyne joint pasgalaxy.ansible.comdepuis l'overlay, et c'est très bien ainsi. Ouvrir une règle vers un serveur étranger pour qu'un écosystème sache se reconstruire aurait été la mauvaise réponse. Le contrôleur télécharge dans son cache et pousse par SSH — même idiome queserveur_forgejodevant son propre tiers. Effet de bord recherché : le poste devient déployable hors ligne ;- le chemin du contrôleur contient une espace (« Espace Chezlepro/… ») et
command: cmd:la lit comme un séparateur. Le message d'erreur ne parlait pas du tout du vrai problème. Formeargvdésormais.
La preuve
Depuis ops-01, sous son propre compte :
8e125b7 plancher : nommer ce qui est hors de l'ecosysteme le moteur
8c4a39b amont : declarer par quelle adresse patient 0 … son plan
etiquette v2026.08.21 — SSH SIGNATURE presente
make aide -> « Set-OPS — moteur d ecosystemes numeriques souverains »
L'écosystème lit son propre génome, depuis sa propre forge, avec son propre Ansible.
Le second symlink : exploiter n'est pas engendrer
Le poste ne clonait que le moteur et son plan. Il savait donc configurer des machines existantes, pas en créer : placer une VM demande de savoir sur quel nœud, quel stockage, quel pont — c'est-à-dire l'underlay.
Patient 0 n'a pas de fabric à lui : il est tenant de SITE-Chezlepro, au même titre
qu'OPS-Chezlepro. Son poste porte donc désormais les deux symlinks de D-80 :
Set-OPS-public/instance -> /opt/setops/OPS-Patient0
Set-OPS-public/underlay.yml -> /opt/setops/SITE-Chezlepro/underlay.yml
et quatre dépôts au lieu de deux — le moteur, son plan, la fabric qui le porte, et les modèles (un descendant ne se crée pas à partir de rien).
ops-chezlepro reste volontairement non cloné : c'est le plan d'un tenant voisin,
que patient 0 n'a aucune raison de détenir. Il est présent sur sa forge par le poussage
initial du génome — à revoir.
Où passe exactement la ligne
Mesuré depuis ops-01, sous son propre compte :
make instancier DIFF VIDE : le plan reproduit exactement l'inventaire actuel
make underlay-plan Aucune voute sous /opt/setops/SITE-Chezlepro
Patient 0 régénère sa propre structure, sur sa propre machine, sans aucun secret. Et
dès qu'il s'agit de toucher la fabric, il est arrêté faute de voûte — underlay.vault.yml
est hors dépôt, donc absent du génome. Le poste lit la carte du monde physique, jamais
ses clés.
Ce que cette carte expose, en revanche, mérite d'être dit : underlay.yml décrit les
quinze équipements du site, le VLAN de gestion 10.17.0.0/24 — celui-là même où la
frontière interdit à patient 0 d'entrer — et les accès OOB/IPMI. Aucun justificatif, mais
toute la topologie. C'est le prix de l'autonomie d'un tenant sur la fabric d'autrui, et il
se paie en connaissance.
Patient 0
Fonction ops (zone Services-infra), machine ops-01 — adressage dérivé 10.29.19.41,
VMID 129404101. Pas de client_backup : le poste ne détient aucun état propre, tout ce
qu'il porte se recompose depuis la forge.
2026-08-23 — Les miroirs du génome, et un nom qui ne résout pas pareil selon d'où on le demande
La copie du génome sur patient 0 était figée au jour du poussage. Elle ne l'est plus :
les deux dépôts publics sont désormais des miroirs Forgejo, resynchronisés toutes les
huit heures depuis la forge amont. L'étiquette signée v2026.08.21 traverse le miroir
intacte — vérifié SSH SIGNATURE présente sur le dépôt reconstruit.
Les deux dépôts privés utiles suivent désormais eux aussi, authentifiés par un jeton de
lecture seule. ops-chezlepro n'en a pas : c'est le plan d'un tenant voisin, sans
usage chez patient 0.
Le premier jeton fourni pouvait ÉCRIRE — un PATCH sur le dépôt parent accepté (HTTP
200), alors qu'administration, organisation et user rendaient bien 403 :
l'instrument était fiable, et le verdict aussi. Un enfant qui peut réécrire son parent
inverse le sens de la filiation ; il a été refusé et regénéré.
Le second porte read:repository seul — mesuré : organization, package, user et
admin rendent tous 403 — et lit malgré tout les dépôts privés appartenant à une
organisation. La question qu'on avait laissée ouverte (« faut-il aussi organization ? »)
est donc tranchée par la mesure, et non par la précaution.
ÉPROUVER UN MIROIR AUTHENTIFIÉ DEMANDE LE BON INSTRUMENT. Le justificatif n'est pas dans
le git config du dépôt — Forgejo le range dans sa base et l'injecte au moment de la
synchronisation. Un git ls-remote à la main rend donc could not read Password, ce qui
ne prouve rien. Le seul juge est l'horodatage mirror_updated que la forge tient
elle-même : il a avancé pour les deux, donc l'amont privé a réellement été joint.
Le piège du jour : un nom, deux réponses
forge.alliance-boreale.ca ne résout pas pareil selon l'endroit d'où on le demande :
depuis le poste d'administration -> 192.168.14.66 (LAN de l'hébergeur)
depuis l'overlay de patient 0 -> 69.70.26.51 (adresse publique)
Et depuis patient 0, c'est l'adresse publique qui marche : le tenant a le droit de
sortir sur l'internet et pas d'entrer dans le LAN 192.168.x de son hôte — le
default-deny entre les deux mondes, qui fait exactement son travail.
La mesure, prise depuis forge-01 :
ping 100 octets -> 192.168.14.66 BLOQUÉ (même 100 octets : pas un problème de MTU)
ping / https -> internet passe, toutes tailles
curl https://69.70.26.51/api/v1/version -> {"version":"8.0.3"} la vraie forge amont
la forge locale de patient 0 -> {"version":"16.0.2"} bien distincte
8 essais sur 8 -> HTTP 200 en ~6,7 ms (retour en épingle local, stable)
Et une fois de plus, la poignée TCP a menti : 192.168.14.66:443 répondait « ouvert »
alors qu'aucune donnée ne passait. Seule la livraison compte.
hosts_statiques_externes — nommer ce qui est hors de l'écosystème
Le plancher /etc/hosts ne savait nommer que ce que l'écosystème contient. Or un
écosystème doit atteindre des noms du dehors, à commencer par la forge dont il descend.
Poser l'entrée à la main ne tiendrait pas : le fichier est regénéré intégralement à
chaque passage du rôle. La déclaration vit donc au plan :
hosts_statiques_externes:
- ip: "69.70.26.51"
noms: ["forge.alliance-boreale.ca"]
pourquoi: "forge amont du génome — l'adresse interne est bloquée par la frontière"
On épingle l'adresse dont on a prouvé qu'elle livre. Si un enregistrement à horizon partagé rendait un jour l'adresse interne, le miroir s'arrêterait sans bruit — et une copie du génome qui vieillit en silence est précisément le défaut que ce dépôt combat.
Ce que la conversion a coûté, et pourquoi elle est gardée
Forgejo ne convertit pas un dépôt ordinaire en miroir : il faut le détruire et le recréer. Trois gardes encadrent donc l'opération, et deux ont mordu pour de bon :
- l'amont contient-il déjà le local ? La première version exigeait l'égalité et a
refusé la conversion pour un simple commit de retard. Le bon critère est l'inclusion —
merge-base --is-ancestor, qui distingue « en retard » (sans risque) de « en avance » (destruction de travail). - un répertoire résiduel est-il vraiment vide ? Une migration avortée avait laissé
une coquille de
set-ops-public.git— 0 référence — qui bloquait la suivante avec « Files already exist ». On ne la retire qu'après avoir compté les références.
Leçon d'outillage, aussi : no_log: true avait masqué l'erreur 409 et l'avait
rendue indéchiffrable. La tâche qui porte le mot de passe reste muette, mais un debug
séparé rend maintenant le statut et le message.
À suivre
Les hôtes de patient 0 résolvent par Quad9 et n'interrogent pas leur propre serveur
infra-dns-01. L'écosystème fait tourner un résolveur que personne n'utilise.
2026-08-23 — Patient 0 porte le génome : la boucle est fermée
Les cinq dépôts qui fabriquent la lignée vivent désormais sur la forge de patient 0 — y compris l'étiquette signée, donc la filiation reste vérifiable depuis l'enfant.
Set-OPS-public 5547 Ko main v2026.08.21 ✓
OPS-Chezlepro 160 Ko main
OPS-Patient0 37 Ko main
Set-OPS-Modeles 23 Ko master
SITE-Chezlepro 18 Ko main
Le génome existe maintenant en trois exemplaires vivants et indépendants : eregion,
le poste de l'exploitant, et patient 0. C'est le seuil à partir duquel réécrire l'histoire
suppose de convaincre plusieurs témoins — la propriété qu'on cherchait, obtenue sans
blockchain, comme effet secondaire de la lignée.
Le chemin a dû se plier à la politique, et c'est bon signe
Le port 3000 n'est pas ouvert depuis le poste, et le SSH de forge-01 refuse le
transfert de ports (durcissement). Le versement s'est donc fait par paquets git déposés
sur l'hôte, puis poussés depuis lui à travers l'API locale de la forge — donc par ses
crochets, comme n'importe quel git push. Aucune règle n'a été assouplie pour la
commodité.
Le compte de secours ne secourait rien
Forgejo exige par défaut un changement de mot de passe au premier accès, et refuse toute requête d'API tant qu'il n'a pas eu lieu. Or ce rôle désactive la connexion locale (SSO d'abord) : il n'existait aucun chemin pour effectuer ce changement.
Le compte administrateur était donc inutilisable dès sa création — sur toutes les
forges déployées. --must-change-password=false est posé : le mot de passe vient de la
voûte et tourne déjà par empreinte, exiger un changement manuel en plus ne ferait que
faire diverger la voûte du réel.
Cinquième défaut révélé par le même écosystème. Aucun n'était visible sur une flotte debout : il fallait en construire une autre, et s'en servir.
2026-08-23 — Patient 0 est debout
Quatre machines, zéro échec, et la forge répond.
forge-01 121 tâches forgejo actif, écoute 3000, HTTP 200
infra-pki-01 109
infra-edge-01 92
infra-dns-01 84
Le premier écosystème né du moteur corrigé — et le déploiement a servi de révélateur : quatre défauts, tous invisibles sur une flotte déjà debout.
Un serveur tiers intermittent arrêtait tout
packages.smallstep.com répond une fois sur deux : la même URL pend, puis rend 200 en
0,48 s au second essai. Le défaut de get_url est 10 secondes et aucune reprise — le
socle échouait donc sur la première machine.
Avant d'accuser le réseau, on a mesuré : DNS résolvait, la poignée TLS aboutissait
(Verify return code: 0), 138 Ko depuis deb.debian.org passaient en 0,09 s, et le chemin
acceptait 1450 octets en refusant 1500 — exactement l'attendu en overlay. Le réseau n'y
était pour rien. Reprises posées sur les trois téléchargements du chemin critique.
Audit au passage : une dizaine d'autres téléchargements de tiers restent sans reprise.
Un register a écrasé un chemin de fichier
En ajoutant la reprise, j'ai enregistré dans client_pki_cle — un nom que le rôle utilise
déjà pour le chemin de la clé privée. L'écart ne s'est pas vu là : soixante-dix
tâches plus loin, step ca certificate recevait un dict sérialisé à la place du fichier
de clé et refusait « too many positional arguments ».
Un
registerécrit dans l'espace de noms de tout le rôle. Le nom est désormais long à dessein.
Le SSO se déclarait au lieu de se dériver
serveur_forgejo_oidc_actif: true en dur : la forge tentait de câbler une source OAuth2
vers un Keycloak qui n'existe pas dans un écosystème minimal. Il se dérive maintenant
de l'inventaire — un groupe serveur_keycloak sans hôte, c'est un écosystème sans identité
fédérée.
Quatrième manifestation en deux jours de la même hypothèse — le moteur supposait l'écosystème complet — après les intrants (P32), les bases (P35) et les dépendances causales.
Ce que ça vaut
Aucun de ces quatre défauts n'était visible sur Chezlepro, qui porte tout et tournait déjà. Ils ne pouvaient apparaître qu'au premier écosystème différent — c'est exactement ce qu'on attendait de patient 0, et il l'a rendu avant même de servir.
make verifier : 41 OK, 0 échec, 0 sauté.
2026-08-23 — Un boîtier injoignable n'est pas un boîtier vide
L'exploitant lance make frontiere-appliquer dans son propre terminal. Résultat :
Frontiere https://10.17.0.1 — 50 regles au devis
a creer : 0 | a retirer : 0 | inchange : 53 + 15 routes
La frontiere dit deja ce que le devis dit. Rien a faire.
La frontière était déjà conforme. Or j'avais annoncé, quelques heures plus tôt, « 89 objets à créer, 0 inchangé — donc les règles héritées sont invisibles à l'API ». C'était faux, et la cause était ailleurs.
Ce qui s'était réellement passé
L'intrant opnsense_api_url pointait encore sur 10.0.0.1, l'adresse d'avant la
migration du boîtier. Chaque lecture échouait donc et rendait {"_erreur": …}. Le plan
lisait .get("rows"), n'y trouvait rien — et concluait que la frontière était vide.
Avec CONFIRMER=true, on aurait poussé une politique entière en double sur un boîtier
qui la portait déjà.
Et l'adresse avait pu rester périmée parce qu'elle était rangée au mauvais endroit : dans
les group_vars d'un tenant, alors qu'elle décrit un boîtier que ce tenant ne possède
pas. opnsense.yml vit désormais dans le dépôt de site, avec underlay.yml.
La troisième fois en une soirée
devis_placement |
itérait un dict d'erreur comme une liste → trace Python illisible |
devis_underlay |
déclarait morts les réseaux qu'il ne joignait pas depuis le poste |
appliquer_opnsense |
lisait un boîtier injoignable comme un boîtier vide |
Trois formes d'une même confusion : « pas de réponse » pris pour « rien ». Toutes les lectures de la frontière passent maintenant par une garde qui refuse en nommant l'hôte, la cause, et le piège qu'elle évite. Éprouvée contre l'ancienne adresse : elle refuse.
Ce que ça dit de la méthode
Ce n'est pas le harnais qui a trouvé le défaut, ni moi. C'est l'exploitant, en lançant la commande dans son terminal, où il voit la sortie en direct. Mes commandes s'exécutent dans ma session : il n'en voit rien. Une commande lente ressemble alors à un blocage, et un blocage à une commande lente — j'ai conclu deux fois à tort avant qu'il ne regarde lui-même.
Pour toute écriture longue sur du matériel, c'est à l'exploitant de lancer la commande. Non par prudence formelle : parce qu'il est le seul à voir ce qui se passe.
2026-08-22 — Le moteur supposait l'écosystème complet
Troisième manifestation de la même hypothèse en cinq jours, et cette fois elle bloquait un déploiement. Le contrôle de dépendances a refusé patient 0 :
client_journal requiert serveur_loki actif
client_metrique requiert serveur_prometheus actif
serveur_forgejo requiert serveur_postgresql actif
serveur_forgejo requiert serveur_postfix actif
Quatre refus, une seule racine : le moteur suppose que tout écosystème porte tous les services. Après les intrants (P32, le 20) et les bases (P35, ce matin), c'est au tour des dépendances causales et des intégrations universelles.
Ce n'est pas un problème de patient 0. C'est le mur que rencontrerait toute offre plus petite que l'écosystème de référence — c'est-à-dire toute offre réelle.
Une intégration universelle a besoin d'un interlocuteur
« Tout hôte est mesuré » est vrai dans un écosystème qui porte un Prometheus. Dans un écosystème qui n'en a pas, la même phrase pose sur chaque machine un client qui n'a personne à qui parler.
La règle est désormais dérivée, pas déclarée : le service central d'une intégration est celui que le registre des dépendances lui donne déjà. Rien de neuf à tenir à jour, donc rien de neuf à oublier.
Chezlepro → diff VIDE : tous ses services centraux existent, rien ne change
patient 0 → client_backup, client_pki, client_unbound (journal et métrique tombent)
Une exigence n'est pas toujours absolue
Deux notions manquaient au registre, et les confondre coûtait cher :
sauf_si |
l'exigence tombe sous condition — Forgejo n'exige PostgreSQL que s'il n'a pas choisi SQLite |
utilise_si_present |
un agrément, jamais bloquant — Forgejo notifie si un MTA existe, et s'en passe sinon |
Confondre les deux obligeait une forge à déployer une pile courriel entière pour exister.
Et la leçon d'hier a servi
Trois lecteurs avaient besoin, le même jour, de lire une variable d'instance : P35 pour
l'interrupteur _bd, le contrôle de dépendances pour sauf_si, et le générateur. Trois
copies auraient recommencé exactement ce qu'on venait de refermer. Il y en a une, dans
inventory_rules.
make verifier : 41 OK, 0 échec, 0 sauté. Chezlepro : inventaire identique.
Un écosystème minimal n'est pas un écosystème incomplet. Le moteur confondait les deux — et refusait de déployer ce qui n'a besoin de rien de plus.
2026-08-22 — Neuf copies d'une même question, et la pièce qui manquait
Cinq jours, cinq défauts, tous de la même famille : quelle instance, quel inventaire ? Neuf modules portaient chacun leur réponse.
| Découvert | Ce que la copie faisait |
|---|---|
| 18 août | P03 comparait chaque instance à l'inventaire d'une autre |
| 19 août | verifier_ports codait principal/ en dur ; verifier_intrants et _frontiere_absente lisaient le symlink au lieu de la variable |
| 20 août | devis_placement rendait un verdict juste sur le mauvais tenant |
| 22 août | P35, puis P36 — la dixième, trouvée par la preuve elle-même |
Aucune n'était une faute d'inattention. Chacune avait été écrite de bonne foi, à un moment où le besoin semblait local. C'est le mode de panne de la duplication : pas l'erreur, mais la dérive — invisible depuis l'intérieur d'un fichier, parce que chaque copie a l'air correcte chez elle.
La résolution unique
inventory_rules porte désormais instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le troisième
manquait à la moitié des copies : un hosts.yml existant, puis un répertoire
existant — le cas d'une instance neuve, celui qui faisait échouer make instancier sur le
modèle public — puis le défaut.
Vingt-huit modules y sont branchés.
Ce qui rend ce refactor sûr
Avant de toucher quoi que ce soit, chaque module a été interrogé sur ce qu'il résolvait, pour les deux écosystèmes. Après refactor, la même mesure :
17 modules × 2 instances → diff vide
Aucune résolution n'a changé. Le refactor est prouvé neutre, pas supposé tel.
P41, et ses trois exemptions
La preuve échoue dès qu'un module réintroduit une copie. Éprouvée en négatif : une
copie replacée dans genome.py est signalée avec son numéro de ligne.
Trois exemptions, nommées pour rester des choix : instances.py et inventory_gui.py
manipulent le symlink lui-même — c'est la bascule d'instance —, et devis_opnsense
lit délibérément quelle instance est active pour se situer dans la fédération. Ces
trois-là parlent du lien, pas de la résolution.
Elle a d'ailleurs trouvé une dixième copie à sa première exécution : P36, dans le fichier même qui l'héberge.
make verifier : 41 OK, 0 échec, 0 sauté. Lint vert.
Une preuve qui trouve un défaut le jour où on l'écrit a payé son coût immédiatement. Celle-ci en a trouvé un dixième, dans
prouver.py— et dans ma propre docstring, qui contenait le motif qu'elle interdit.
2026-08-22 — Une forge n'a pas besoin d'un serveur de bases pour trois personnes
Doute de l'exploitant en relisant patient 0 : « je doute de la pertinence de pgsql. » Mesuré plutôt que discuté, et le résultat est allé plus loin que la question.
Redis ne servait à rien. Le rôle serveur_forgejo ne le mentionne ni dans son
app.ini, ni dans ses défauts, et ne déclare aucun lien vers lui. Il était au plan par
héritage du modèle forge. Retiré.
PostgreSQL, lui, était exigé par le rôle : DB_TYPE = postgres écrit en dur, et
resoudre_base appelé sans condition. Le doute était donc fondé mais le moteur ne savait
pas faire autrement.
L'interrupteur
serveur_forgejo_bd: sqlite # ou postgres (défaut)
En sqlite, la base devient un fichier sous serveur_forgejo_data. Ce que ça change
ailleurs : rien. Le job de sauvegarde serveur_forgejo emporte déjà ce dossier ; la
variable PGSSLROOTCERT de l'unité systemd était déjà conditionnée au mode TLS ; et P35
lit désormais l'interrupteur, donc n'attend aucune entrée de registre.
Une valeur inconnue est refusée au début du rôle. Retomber en silence sur PostgreSQL déploierait le contraire de ce qu'on croyait choisir, et l'écart se lirait au premier démarrage.
Ce que ça donne
patient 0 : 6 machines → 4 (Dovecot, Redis, PostgreSQL et sa VM)
Sur la machine dont tout le reste descend, chaque service en moins est une chose de moins
à défendre, à sauvegarder et à rebâtir un soir de reconstruction. Et l'effet dépasse
patient 0 : une offre forge pour un petit organisme cesse d'exiger une VM PostgreSQL.
La neuvième
En vérifiant P35 sur patient 0, elle a rendu un verdict juste sur le mauvais
écosystème : plan = RACINE / "instance" / "plan", le symlink en dur. C'est la
neuvième résolution d'instance codée en dur trouvée en cinq jours — après le Makefile,
verifier_ports, verifier_intrants, _frontiere_absente, devis_placement…
À ce stade la conclusion s'impose : ce n'est pas une série de bogues, c'est une pièce manquante. Une résolution unique et partagée, que chaque preuve et chaque devis appellerait au lieu d'en écrire une copie. À faire de tête reposée, en une fois.
make verifier : 40 OK, 0 échec, 0 sauté. Lint et syntaxe du rôle : verts.
Le doute d'un exploitant vaut une mesure. Celui-ci a retiré trois services, allégé une offre commerciale et révélé une neuvième occurrence d'un défaut de fond — parce qu'on est allé regarder au lieu d'argumenter.
2026-08-21 — L'intuition disait « blockchain » ; la réponse était déjà dans git
L'exploitant, en regardant la lignée s'ouvrir : « j'ai une intuition : blockchain. » L'intuition visait le bon problème — une mémoire partagée, vérifiable, sans centre — mais la réponse était à portée de main, et une vérification l'a montré :
1f47e9b N 6148877 N 254268d N (N = aucune signature)
aucune étiquette
Git est déjà une chaîne de hachage. Chaque commit porte l'empreinte de son parent :
modifier une ligne d'il y a trois mois casse toutes les empreintes suivantes. C'est un
arbre de Merkle — la même structure qu'une blockchain, sans le reste. Ce qui manquait
n'était pas la chaîne, mais l'auteur : user.name est déclaratif, et toute la soirée
du 20 des commits ont porté « Daniel Allaire » sans qu'aucune preuve ne les lie à une clé.
Trois manques, trois réponses mûres
| Manque | Posé aujourd'hui |
|---|---|
| qui a écrit | signature par clé SSH ; première étiquette signée v2026.08.21, vérifiée |
| quelles clés ont le droit | .git-allowed-signers, versionné : qui clone vérifie sans rien demander à la forge |
| de quoi on descend | parente.yml par écosystème, et la preuve P40 |
Ce que la fractale fait gratuitement
Une signature prouve l'auteur, pas que l'histoire n'a pas été remplacée — celui qui tient la forge peut réécrire et re-signer. Le seul remède est la multiplicité : si chaque enfant porte une copie du code dont il descend, réécrire suppose de convaincre tous les descendants.
C'est très exactement ce qu'une blockchain achète au prix d'une machinerie considérable, et que la lignée produit comme effet secondaire de sa forme. Une chaîne publique ajouterait une dépendance à un réseau extérieur ; une chaîne privée, une base de données distribuée exigeant plusieurs opérateurs — l'inverse de « un humain doit pouvoir la faire tourner ».
Le génome
make genome nomme les quatre dépôts sans lesquels un écosystème ne renaît pas :
moteur, instance, hébergeur, modèles. Ils sont dérivés, pas déclarés — les modèles se
reconnaissent à leur forme (des plans en sous-dossiers, aucun à la racine).
Deux critères appris d'un faux positif : sans le second, le détecteur désignait le lab, qui
porte un lien OPS-Technolibre -> ../OPS-Technolibre que le motif traversait. Un lien
vers un frère n'est pas un contenu.
Patient 0 sait désormais d'où il vient : moteur 742bcbf, étiquette v2026.08.21.
Enseigné, pas seulement posé
Nouvelle unité : Filiation, signatures & témoins — pourquoi git suffit, ce qu'une signature prouve et ce qu'elle ne prouve pas, pourquoi les témoins comptent plus que la longueur des clés, et quand un journal de transparence serait la vraie réponse (le jour où l'Alliance certifiera des écosystèmes).
Onze termes ajoutés au glossaire — génome, parenté, empreinte, Merkle, étiquette, signature, témoin, journal de transparence, horodatage… — et P39 les exige désormais : le vocabulaire de ce soir ne pourra pas rester non expliqué.
make verifier : 40 OK, 0 échec, 0 sauté.
La bonne question n'était pas « quelle technologie ». C'était : contre qui se protège-t-on, et qui, déjà, pourrait témoigner ? Les témoins existaient — ce sont les enfants. Il ne manquait qu'un nom sur les clés et un registre de filiation.
2026-08-21 — Le glossaire définissait Set-OPS et laissait dehors tout le métier
Demande de l'exploitant, après une soirée passée à croiser strophe FRR, VRF, VNet et nexthop-vrf : « il importe que cet écosystème soit pilotable par des humains, idéalement un seul. Alors révise notre glossaire, et que chacune des notions sous-jacentes soit enseignée. »
Mesuré avant d'écrire — 40 termes employés par le dépôt et absents du glossaire :
LDAP 184 fois underlay 106 fois EVPN 66 fois
playbook 165 fois VRF 33 fois LMTP 25 fois
Le glossaire expliquait le vocabulaire propre à Set-OPS — plan, index, voûte, zone — et laissait dehors tout ce qui vient du métier. Or c'est le métier qui perd le lecteur.
Ce n'est pas un défaut de rédaction
La règle fondatrice du dépôt est qu'un humain doit pouvoir piloter cet écosystème sans IA, idéalement seul. Chaque mot obscur retire une personne à la liste de celles qui peuvent reprendre le système. Un vocabulaire non expliqué est donc un défaut de conception.
Ce qui a été fait
Le glossaire est réécrit — 67 termes, groupés par famille (le plan, les machines, Ansible, le réseau, les noms, la confiance, l'identité, le courriel, l'état et sa preuve). Chaque entrée dit ce que c'est et pourquoi ce dépôt s'en sert, avec le renvoi vers l'unité qui développe.
Une unité d'apprentissage manquait : Le réseau des tenants. Dix-sept des quarante termes y vivaient sans domicile. Elle raconte le chemin dans l'ordre où les problèmes se sont posés : deux clients sur un même câble → le VLAN → ses deux limites → l'encapsulation → pourquoi 1450 → qui distribue les enveloppes → et le VRF, qui n'est pas une interdiction mais une ignorance structurelle.
P39, et ce qu'elle avoue ne pas savoir faire
Elle vérifie trois choses : chaque terme du jargon a une entrée ; chaque lien du glossaire mène à une page qui existe ; chaque page du wiki est atteignable depuis la navigation.
La liste des termes est déclarée, et c'est un choix mesuré. La dérivation automatique a
été essayée : 153 acronymes dans le wiki et le README, dont la moitié sont des mots
français en capitales — AUCUNE, AVANT, TOUS. Un contrôle qui exige une entrée de
glossaire pour « AUCUNE » finit désactivé, et une preuve désactivée ne garde rien.
Éprouvée en négatif contre le glossaire d'avant : 49 termes manquants, nommés un par un.
make verifier : 39 OK, 0 échec, 0 sauté.
Ce que la preuve ne mesurera jamais. Qu'une explication soit bonne. Elle compte des entrées ; elle ne sait pas si on comprend. Ça, seul un lecteur peut le dire — et c'est précisément le lecteur qu'on cherche à ne pas perdre.
2026-08-20 — Le devis d'avant-vol validait le mauvais réseau
Remarque de l'exploitant, en préparant patient 0 : « le pont ne me semble pas approprié du tout, depuis qu'on crée des VNets pour des tenants. » Il avait raison, et le défaut était plus grave que cosmétique.
make placement-plan confrontait proxmox_clone_pont — vmbr1 — au cluster. Or ce
n'est pas là que les VM de la flotte atterrissent : instancier pose dans chaque hôte
le pont dérivé de sa zone (le VNet du tenant), et make creer-vm le passe au clone en
écrasant ce défaut. vmbr1 n'est que le repli des clones manuels, hors plan.
Le devis mesurait donc un objet qui ne sert pas, et ne mesurait pas celui qui sert.
Ce que ça donnait sur patient 0
avant : pont vmbr1 existe → CONFORME
après : reseaux VM t29appl, t29donn, t29fron, t29serv INTROUVABLE
-> VNet(s) absent(s) : ... — passer `make sdn-appliquer` AVANT de creer les VM
Aucun des quatre VNets de patient 0 n'existe sur le cluster. Le devis d'avant-vol disait « conforme » à un tenant dont les VM n'auraient eu nulle part où naître.
D-80 avait pourtant été corrigée le 13 août : la liaison de placement est nœud, stockage et gabarit — le pont se dérive. Le devis, lui, continuait de compter quatre objets et de nommer le mauvais. Une doctrine corrigée dans un document ne se propage pas toute seule dans le code qui l'applique.
Ce qu'il mesure maintenant
Les réseaux où les VM atterriront : en sdn, les VNets dérivés confrontés à
/cluster/sdn/vnets ; en switch, les ponts du nœud retenu. Avec, quand il en manque, le
geste exact qui répare.
Non-régression vérifiée sur l'écosystème de référence : ses six VNets existent, verdict conforme, code de sortie 0. Quatre tests, Cluster simulé, aucun réseau touché.
Le vert le plus dangereux est celui qui porte sur un objet voisin du bon. Ici tout était vrai —
vmbr1existe bel et bien — et la conclusion était fausse. C'est la quatrième fois cette semaine : la frontière qui poliçait les tenants d'un autre site, P03 qui comparait à l'inventaire d'une autre instance, le catalogue qui décrivait un moteur d'il y a quatre mois, et maintenant un devis qui contrôle un pont que la flotte n'utilise pas.
2026-08-20 — Le devis d'avant-vol mourait au lieu de parler
Soir de reconstruction, VPN pas encore monté. make placement-plan — le devis qu'on lance
avant quarante minutes de déploiement, pour savoir si le terrain est bon :
AttributeError: 'str' object has no attribute 'get'
Dix lignes de trace Python pour dire « le nom asgard ne se résout pas d'ici ».
En panne, Cluster.__call__ rend {"_erreur": "…"} — un dict. Le devis l'itérait
comme une liste, et un dict itéré rend ses clés : d'où un str là où le code
attendait un objet. Cluster.rate() existait précisément pour ça, et n'était appelé
nulle part ici. Les quatre appels passent désormais par une garde qui nomme la cause,
l'hôte interrogé et le geste à tenter.
Et le devis mesurait le mauvais tenant
placement_du_tenant() lisait instance/ en dur : viser patient 0 avec
SETOPS_INSTANCE mesurait en silence le placement de l'instance montée. Le verdict était
juste — pour l'autre tenant. Les deux portaient les mêmes quatre valeurs, ce qui est
exactement la circonstance où l'erreur ne se voit pas.
C'est la huitième résolution d'inventaire ou d'instance codée en dur trouvée en trois jours. À ce compte, ce n'est plus une série de bogues : c'est une pièce manquante.
Au passage, l'en-tête annonçait « tenant instance » — le nom du lien, pas celui du tenant. Un devis doit nommer ce qu'il a mesuré.
Trois tests, aucun réseau touché
test_devis_placement.py simule le Cluster : une panne devient un refus lisible (cause,
hôte, geste), une réponse qui n'est pas une liste est refusée elle aussi — c'est le cas
silencieux, celui qui franchirait la première garde —, et le cas nominal traverse sans
gêne. Branchés sur make test.
Ce qu'on répare ici n'est pas une exception, c'est un message. Un outil de diagnostic qui échoue en langage machine transforme une panne de trente secondes (monter le VPN) en une demi-heure de fouille. Le pire moment pour ça est celui où on l'utilise : quand quelque chose ne va déjà pas.
2026-08-20 — Le harnais ne se déclenchait que par mémoire
Trente-huit preuves, des tests, un lint — et rien ne les exécutait sans qu'un humain
tape make. Le meilleur atout du dépôt dépendait de ne pas oublier. Il a maintenant une
CI (.forgejo/workflows/verifier.yml) et une cible qui la rejoue à l'identique :
make ci.
Ce que la CI a trouvé avant d'exister
Écrire le workflow supposait de répondre à une question jamais posée : est-ce qu'un dépôt public, seul, se tient ? Réponse mesurée sur un clone nu : non, à cinq endroits.
make instancier |
échouait sur le modèle public — le tout premier geste du QUICKSTART |
| P32 | exigeait les intrants d'oauth2-proxy d'une instance qui ne le déploie pas |
| P24 | le modèle ne déclarait aucun réseau d'administration |
| P33 | verifier_ports.py codait principal/ en dur |
| P32, P24 (bis) | lisaient le symlink instance/ au lieu de SETOPS_INSTANCE |
Toutes de la même famille — celle de P03 avant-hier : une résolution d'inventaire recopiée, une variable d'environnement qui déborde de sa portée. Le dépôt en compte sept ; deux de plus ont été corrigées ici, et le commentaire de la septième le dit plutôt que de le taire.
Celle qui comptait le plus
P32 parcourait les 54 rôles sans regarder ce que l'instance déploie. Elle passait sur l'écosystème de référence parce qu'il porte tout. La conséquence dépassait le modèle : les modèles sont des offres, et toute offre plus petite que l'écosystème complet — c'est-à-dire toute offre réelle — échouait son propre harnais, pour des services qu'elle ne vend pas. Le périmètre juste se lit du plan : les groupes de l'inventaire, puis les rôles que leur playbook compose.
Ce que make ci ne fait pas
Il ne touche aucun symlink. Le modèle public est monté comme instance jetable, visé
par SETOPS_INSTANCE / SETOPS_UNDERLAY, et détruit en sortant — ton instance reste
montée pendant l'exécution. Deux détails, mesurés parce que devinés faux d'abord :
- l'instance jetable est un dossier frère, pas un
/tmp: la fédération se découvre par les dossiers frères, et ailleurs quatre preuves tombent en disant « aucun tenant fédéré découvert » ; SETOPS_UNDERLAYn'est posé que pour la vérification, jamais pour l'application — sinon l'inventaire est écrit avec une fabric et régénéré avec une autre, et la commande fabrique elle-même l'écart qu'elle dénonce.
Le résultat
clone nu, aucune instance, aucun frère → make ci : 38 OK, 0 échec, 0 sauté
dépôt de l'exploitant, 3 instances → make ci : 38 OK, 0 échec, 0 sauté
Et une dernière chose, qui dit bien où on en est : le lint du dépôt a refusé mon propre
fichier de CI avant qu'il ne tourne une seule fois — on: que YAML lit comme le booléen
vrai. Le harnais mordait déjà.
Ce que le vert de cette CI dira, et ce qu'il ne dira pas. Que le moteur et son modèle public se tiennent — pas que la flotte va bien. Aucune VM n'est jointe, aucune voûte n'entre là. La santé de la flotte reste une question qu'on pose sur le poste de l'exploitant, avec
make prouver. C'est écrit en tête du workflow, pour que personne ne lise ce vert pour plus qu'il ne vaut.
2026-08-19 — La carte des services avait quatre mois de retard sur le moteur
catalogue-services.md est le document qu'on lit pour savoir ce que Set-OPS fait :
l'hébergeur d'un second site, un futur client, un mainteneur qui arrive. Il annonçait
comme « capacités futures encore à implémenter » la collaboration et la couche web —
dont les rôles existent et dont les hôtes sont actifs.
Vérifié rôle par rôle contre roles/, ce que le document disait de faux :
| Ce qu'il annonçait | La réalité |
|---|---|
| collaboration « à implémenter » | serveur_nextcloud (533 lignes) + serveur_collabora, collab-01 actif |
| couche web « à implémenter » | serveur_web_frontal / serveur_web_dorsal, codifiés depuis les spikes du 5 juillet |
| fédération LDAP « pas automatisée » | serveur_keycloak/tasks/federation-ldap.yml |
| Keycloak « pas exposé » | expose: auth.<domaine> au plan |
infra-mail-01 : « Sendmail MTA » |
Dovecot — Sendmail est retiré depuis le 4 juillet |
client_supervision |
n'a jamais existé : ni rôle, ni playbook |
rôles nextcloud, metriques… |
une colonne décorative : le rôle porte le nom du groupe |
Et neuf rôles vivants n'apparaissaient dans aucune table — le socle, toute la pile
courriel, les sauvegardes, Icinga Web 2, oauth2-proxy, Unbound. Deux d'entre eux
(serveur_backup, client_backup) n'étaient nommés nulle part dans le document.
Ce que P31 ne pouvait pas voir
La preuve de documentation vérifie que chaque script, cible make et rôle est nommé et
atteignable. Elle ne dit rien de la justesse d'un document. Une carte peut être
complète et périmée — celle-ci l'était depuis la consolidation du 3 juillet.
P38 — la table fait foi, dans les deux sens
tout rôle serveur_*/client_* doit figurer dans une LIGNE DE TABLE
tout groupe cité dans une table doit exister (rôle, ou playbook de groupe)
Le premier sens seul aurait été trop faible : la pile courriel était racontée en prose, et invisible pour qui lit le catalogue comme un index — c'est-à-dire tout le monde. Le second attrape les cases inventées, qui survivent des mois parce qu'une case de table ressemble à un fait.
Deux exemptions, nommées pour rester des choix : la prose peut citer des rôles retirés
(serveur_sendmail, client_dns, client_ldap) — sinon on ne peut plus écrire d'où l'on
vient ; et serveur_durci est accepté comme groupe sans rôle homonyme, son playbook
composant onze rôles de durcissement.
Éprouvée en négatif : rejouée contre la version d'avant les corrections, elle échoue en
nommant les neuf rôles absents et les trois cases fantômes. make prouver : 38 OK, 0
échec, 0 sauté.
Ce que la preuve ne mesure pas, et ne mesurera pas. Qu'un service soit dit « éprouvé » à bon droit se juge en revue, contre le CHANGELOG. Le tableau des capacités dit maintenant lui-même où il s'arrête : la reconstruction prouve qu'une machine nue atteint l'état voulu, elle ne dit rien de la tenue sous charge ni des mises à jour. Et pour Nextcloud, l'usage réel — déposer un fichier, éditer à deux — n'est pas consigné comme preuve ; le document le dit désormais au lieu de le laisser supposer.
2026-08-18 — make underlay confronte les tenants déclarés aux dossiers réels
Suite immédiate du filtre de portée : underlay.tenants nomme des dossiers frères.
Une faute de frappe y était invisible — le tenant disparaissait simplement des trois
devis du site, qui restaient « conformes » sur ce qu'il en restait.
Sur un site à un seul tenant — le cas de la prochaine implantation — la faute de frappe rend un devis vide : une frontière sans règle, un commutateur sans VLAN. Et rien dans le mot « conforme » ne dirait qu'on vient de dessiner le vide.
L'écart est entièrement lisible sans toucher au matériel : d'un côté une liste de noms,
de l'autre les dossiers présents. Il se dit donc à make underlay (D-75), pas au moment
où l'on pousse dans un boîtier. Quatre situations, quatre messages distincts :
dossier absent → aucun dossier frere de ce nom (attendu : …/OPS-Fantome)
dossier sans nomenclature → dossier present, mais sans plan/nomenclature.yml
nomenclature non fédérée → `index` absent, `categories` vide ou `federe: false`
plus rien ne correspond → les devis de ce site n'auraient rien a poser
Ce qu'un gabarit ne doit surtout pas subir
Un modèle décrit du matériel, pas un site déployé : il ne peut nommer aucun tenant
réel. Sans garde, tout modèle portant un exemple de tenants échouerait chez quiconque
n'a pas ce dossier — et P17 (« tous les modèles valident ») deviendrait rouge sur la
machine du voisin. La distinction existait déjà dans le code : modeles.py passe des
repères de tenants explicites, ce qui dit « gabarit » ; le site, lui, les laisse
dériver. La vérification ne s'applique qu'au second cas.
Trois tests ajoutés à test_adressage_derive.py — le nom introuvable, la clé absente, et
le gabarit épargné — avec un nom volontairement absurde pour qu'aucun test ne dépende
des dossiers de la machine qui l'exécute. make test 15 + 9 ; prouver 37/37.
2026-08-18 — Les trois devis d'un site partagent enfin la même portée
Le 14 août, la frontière a appris qu'elle ne police que les tenants de son site. Le
commit le disait lui-même : « même hypothèse ailleurs, non corrigée — devis_sdn et
devis_reseau partent du même decouvrir(). À traiter quand ils serviront sur un second
site. » C'est fait avant, pas pendant.
Les trois devis équipent le matériel d'un site :
| devis | ce qu'il pose | ce qu'un tenant d'ailleurs y ajoutait |
|---|---|---|
devis_opnsense |
règles et routes de la frontière | des routes vers des sous-réseaux inexistants |
devis_reseau |
VLAN, SVI, routes du commutateur | des VLAN qu'aucune VM ne peuplera |
devis_sdn |
zones et VNets EVPN de l'hyperviseur | des zones sans machine |
Aucun de ces objets ne fait de mal visible : le matériel les accepte, ils ne correspondent jamais à rien, et rien ne les signale. C'est la définition même du chèque vert sur un périmètre vide — sauf qu'ici, il faut le lire à l'envers : une politique qui a l'air complète et ne protège rien.
Une seule fonction, au lieu d'un filtre recopié trois fois
devis_reseau.decouvrir_du_site() — decouvrir() restreint par underlay.tenants, avec
la doctrine écrite une fois pour les trois. Le filtre inline de devis_opnsense est
retiré au profit d'elle. admin_tous_tenants() la suit : le routeur d'un site n'a aucune
raison de savoir revenir vers le plan de gestion d'un tenant qu'il ne porte pas.
Éprouvé dans les trois situations qui comptent :
underlay sans la clé → ['OPS-Chezlepro', 'OPS-Technolibre'] (identique à avant)
underlay du second site → ['OPS-Technolibre']
un nom qu'aucun dossier ne fournit → ATTENTION, et le reste est retenu
le filtre ne retient rien → refus, code 1 (jamais un devis vide)
Sans effet sur le site actuel : l'underlay de Chezlepro ne déclare pas tenants, et
clé absente = toute la fédération. prouver 37/37, make test inchangé.
Et la clé est enfin documentée
C'était le vrai trou : underlay.tenants existait depuis le 14 et n'apparaissait ni
dans underlay.yml.example ni dans l'annexe du runbook d'implantation. Un exploitant
montant un second site ne pouvait pas la découvrir — il aurait posé la politique du
premier tenant chez le second, et le seul symptôme aurait été un silence.
Traiter la deuxième occurrence quand on nomme la première. Le défaut de portée était écrit noir sur blanc dans le commit du 14, avec la liste des endroits où il restait. Quatre jours plus tard, le coût de le finir est d'une heure ; sur place, il aurait coûté une visite.
2026-08-18 — Le panneau d'intrants effaçait la mémoire écrite du dépôt
Un enregistrement du panneau « Intrants de base », à 13:48, a emporté 94 lignes de commentaire dans quatre fichiers (129 → 35 ; ce qui reste est l'en-tête que le panneau réécrit lui-même). Dont celle-ci, juste au-dessus de la valeur qu'on venait de changer :
# POURQUOI PAS ENCORE 10.17.0.0/24 (essayé puis retiré le 2026-08-12) : `devis_opnsense`
# dérive l'interface d'une règle de l'ATTACHEMENT RÉEL de sa source (D-61)… un second
# CIDR est classé « distant », et la règle atterrit sur `wan` où elle ne peut JAMAIS
# correspondre.
- 10.0.0.0/24
Ces phrases sont la seule trace de raisonnements qu'aucun code ne redit. safe_dump les
efface toutes, à chaque sauvegarde, en retriant les clés au passage — un diff illisible
par-dessus le marché.
Le dépôt connaissait déjà le geste juste
_ecrire_intrants_fabric (underlay.yml) et _ecrire_index_nomenclature remplacent la
ligne, sans toucher au reste ; leur commentaire dit même « un safe_dump les
effacerait toutes ». Les quatre fichiers d'intrants, eux, n'avaient jamais reçu ce
traitement. Ce qui manquait pour l'étendre : savoir remplacer une valeur de liste, qui
tient sur plusieurs lignes.
_fusion_chirurgicale le fait, sur trois règles :
| une clé dont la valeur ne change pas | n'est pas réécrite — zéro bruit au diff |
| les commentaires internes à un bloc remplacé | conservés, jamais jugés |
| une clé absente du fichier | ajoutée à la fin, jamais insérée au hasard |
La deuxième règle mérite d'être assumée : une explication devenue fausse survit à la valeur qu'elle explique. C'est voulu. Corriger une phrase est un geste humain ; l'effacer parce qu'un champ a bougé, non. Le même principe que la fusion des clés posée le 10 août : un panneau qui ne connaît pas une valeur n'a pas le droit de la détruire.
Éprouvé sur le fichier réel, pas sur un exemple
L'enregistrement du 13:48 rejoué sur la version d'avant, tirée de git :
lignes 41 -> 41
commentaires 30 -> 30 perdus : 0
diff 1 ligne - 10.0.0.0/24 → + 10.17.0.0/24
Neuf tests dans scripts/tests/test_gui_intrants.py, branchés sur make test : le
commentaire qui survit au changement qu'il explique, la liste multi-lignes remplacée, la
clé inchangée non reformatée, la clé que le panneau ignore, la clé nouvelle,
l'idempotence, le garde-fou des clés sensibles, et la création d'un fichier neuf.
Deux fois le même geste destructeur, sur le même chemin. Le 10 août ce panneau perdait des clés (
dns_amorcage,amorcage_acces_courriel— une VM qui naît sans résolution) ; le 18, des commentaires. La première fois avait valu une fusion, pas un test. C'est le test qui manquait.
2026-08-18 — P03 mesurait toutes les instances contre l'inventaire d'une seule
Trouvé en validant une simple mise à jour du CHANGELOG. Deux invocations de la même preuve, deux verdicts :
make prouver NON CONFORME — « lab : 17 hôtes avec écart »
python3 scripts/prouver.py CONFORME 37/37
Le lab n'avait aucun écart : SETOPS_INSTANCE=…lab instancier comparer --strict dit
DIFF VIDE. C'est l'instrument qui mesurait ailleurs.
Deux variables désignent la cible, et c'est la seconde qui gagne
Makefile:13 export SETOPS_INVENTAIRE → instance/inventories/principal/hosts.yml
instancier.py:68 SETOPS_INVENTAIRE FORCE la cible, par-dessus SETOPS_INSTANCE
prouver.py:505 env = {**os.environ, "SETOPS_INSTANCE": str(chemin)} ← rien de retiré
P03 générait donc le plan de chaque instance fédérée et le comparait à l'inventaire appliqué de la seule instance active. D'où un rouge sur un lab sain.
Le rouge n'était pas le problème — le vert l'était
Sous make, l'inventaire appliqué de lab et de Technolibre n'était jamais lu. Or P03
a été écrite le 2026-08-12 pour exactement cet angle : un tenant qu'on ne regarde pas —
parce qu'il n'a aucune VM, précisément — imposant ses vieilles adresses au pare-feu
partagé. La preuve était aveugle au cas pour lequel elle existe, quand on l'invoque de
la façon documentée. Les rapports du 13 et du 14 sortent de cette invocation-là.
La signature était visible sans lire une ligne de code : sous make prouver, les
hosts.genere.yml de lab et de Technolibre ne bougeaient pas — tout était écrit dans
le répertoire de Chezlepro.
Corrigé aux cinq sites, et rendu bruyant
env.pop("SETOPS_INVENTAIRE", None) partout où l'on redirige SETOPS_INSTANCE : P03 et
P15 (prouver.py), et les trois applicateurs appliquer_opnsense / appliquer_proxmox_fw
/ appliquer_sdn, qui pointent SETOPS_INSTANCE vers l'hébergeur. Ces trois-là sont
sans effet tant qu'hébergeur et tenant actif coïncident — c'est-à-dire jusqu'au second
site. Le geste correct existait déjà dans le dépôt (modeles.py:96) ; il n'avait
simplement jamais été repris.
Et pour que la classe cesse d'être silencieuse, inventory_rules.inventaire_force()
refuse une cible hors de l'instance visée, en nommant les deux valeurs :
REFUS : SETOPS_INVENTAIRE designe un inventaire HORS de l'instance demandee.
SETOPS_INSTANCE …/OPS-Chezlepro-lab
SETOPS_INVENTAIRE …/OPS-Chezlepro/inventories/principal/hosts.yml
Éprouvée dans les deux sens : la contradiction sort en code 1, une cible légitime dans
l'instance passe. Branchée sur les quatre résolutions de _inventaire (instancier,
serveurs, applications, config_proxmox). Le GUI garde la sienne : il ne redirige
jamais SETOPS_INSTANCE pour un fils, et sa résolution suit le symlink à chaque requête.
make prouver : 37 OK, 0 échec, 0 sauté — et cette fois les hosts.genere.yml des
trois instances portent l'horodatage du passage, preuve que chacune a été lue chez elle.
Vérifier d'où l'instrument mesure. Le dépôt porte déjà la règle ; c'est ici la quatrième fois qu'elle paye. Une preuve qui change de verdict selon qu'on l'appelle par
makeou à la main ne mesurait pas ce qu'elle annonçait dans au moins un des deux cas.
2026-08-14 — Une frontière ne police que les tenants de son site
Premier make frontiere-plan sur le second site. Le devis voulait poser sur la frontière
de Technolibre les règles et les routes de Chezlepro : trente objets de plus, dont
six routes vers des sous-réseaux 10.17.x qui n'existent pas là-bas.
Le défaut est de portée, et il est silencieux
Les devis partaient de devis_reseau.decouvrir(), qui rend toute la fédération — tout
dossier frère portant une nomenclature avec un index. C'était juste tant qu'il n'y avait
qu'un site : l'hébergeur unique portait bien tous les tenants. Dès le second, c'est faux.
Et rien ne l'aurait dit. Le boîtier aurait accepté ces trente objets ; aucun n'aurait jamais correspondu à un paquet ; aucune erreur, aucun avertissement. Une politique qui a l'air complète et ne protège rien — encore le chèque vert sur un périmètre vide.
Le correctif : l'hébergeur nomme ce qu'il porte
underlay:
tenants: [OPS-Technolibre] # les tenants HÉBERGÉS ici, pas la fédération
devis_opnsense s'y limite (underlay.tenants_du_site()). Clé absente = ancien
comportement, toute la fédération : un site unique n'a rien à déclarer, c'est le second
qui doit se nommer. Un nom déclaré qu'aucun dossier frère ne fournit est signalé, pas
ignoré silencieusement ; et si le filtre ne retient aucun tenant connu, le devis refuse
plutôt que de rendre une politique vide.
Même hypothèse ailleurs, non corrigée : devis_sdn et devis_reseau partent du même
decouvrir(). À traiter le jour où ils serviront sur un second site — dit ici pour ne pas
le redécouvrir.
Au passage, un défaut du document écrit la veille
Le squelette d'underlay.yml d'implanter-un-tenant-sur-un-site.md
omettait index. Sans cette clé, make underlay refuse le réseau de gestion en le prenant
pour le supernet d'un autre site : message déroutant, cause triviale. Trouvé en s'en
servant, moins de vingt-quatre heures après l'avoir écrit.
prouver 37/37, make test 0.
Ce qui marche avec un seul écosystème n'est pas prouvé. Comme les six défauts moteur qu'avait révélés le second tenant, celui-ci n'existait que parce qu'un second site existe enfin. Une hypothèse implicite ne se voit qu'au moment où elle cesse d'être vraie.
2026-08-13 — La frontière poste en formulaire encodé : hasPost() et le failed nu
Mesuré sur un OPNsense 24.7 (version ancienne, mise à jour depuis) : toute écriture
du moteur y échouait. Alias, règles, NAT, routes — make frontiere-appliquer n'aurait rien
posé sur ce boîtier. Ce n'est donc pas un défaut universel du moteur : il écrit correctement
sur la frontière de Chezlepro, plus récente. C'est un problème de compatibilité, et le
correctif vaut surtout comme garantie de portabilité — le jour où l'on arrive sur un
site dont on ne choisit pas le firmware, ce qui est exactement le cas.
Le symptôme mérite d'être retenu, lui, quelle que soit la version
Le contrôleur d'OPNsense lit ses champs avec hasPost(<racine>). Si le corps arrive en
application/json et que le boîtier ne le décompose pas en variables de POST, ce test est
faux :
HTTP 200 {"result":"failed"} ← nu : aucune redirection, aucune validation
Le contrôleur ne dit pas quel champ manque, parce que de son point de vue il n'y avait aucun champ.
Trois fausses pistes avant la bonne : valeur invalide (un corps vide échouait pareil), racine de payload erronée (elle était juste), privilèges de la clé (la lecture passait). Ce qui a tranché : un corps vide aurait dû produire des validations. Leur absence disait que le contrôleur n'avait rien reçu.
Le correctif
Frontiere poste désormais racine[champ]=valeur — la forme que poste l'interface web
elle-même. Aucune version d'OPNsense ne la refuse, alors que le JSON dépend du boîtier.
Tous les corps du moteur sont des dicts plats de chaînes (alias, rule, route) : un seul
niveau d'imbrication suffit.
Éprouvé sur le boîtier, dans les deux sens : addItem d'un alias sonde → saved,
delItem → deleted, aucune trace laissée, lecture intacte (11 alias). Reste ouvert :
ré-éprouver après la mise à jour, pour vérifier que le formulaire reste bon sur la version
récente.
Un
failedsans validation n'est pas un refus, c'est une absence. Quand un contrôleur ne nomme aucun champ fautif, cesser de chercher le mauvais champ : il n'a rien reçu.
2026-08-13 — Implanter un tenant sur un site neuf : le runbook de l'intervention
Le dépôt couvrait déjà « l'hébergeur prépare son matériel » et « un tenant vivant change
d'hébergeur, sans coupure ». Il manquait le troisième cas, qui est celui qu'on s'apprête à
faire : un tenant dont le plan existe déjà prend corps sur un site qui n'a jamais rien
porté. Rien à migrer, rien à interrompre — donc ni la séquence de gel/bascule de
migration-tenant.md, ni le bootstrap du QUICKSTART.
docs/implanter-un-tenant-sur-un-site.md,
six phases, chacune fermée par une commande qui interroge le système :
0 bureau index du site = index du tenant, dépôt hébergeur, gabarit, voûte
1 reconnaître make underlay, make placement-plan
2 frontière make frontiere-plan
3 gabarit convertir en template — un clone de VM vivante en ferait 14 copies
4 composer les DEUX symlinks (D-80) + les 4 valeurs de placement
5 matérialiser make sdn-appliquer, puis make reconstruire
6 recette devis, restauration éprouvée, supervision
Ce que le runbook porte et qu'aucun autre document ne dit
- Un site neuf se bâtit d'emblée dans l'adressage cible (D-77/D-78). Le site historique
est encore en
10.0.xet migrera par les runbooks §6 ; sur un site vierge, la cible ne coûte rien. Deux sites en10.0.0.0/24rendraient la reprise mutuelle impossible : deux plans de gestion identiques ne peuvent pas s'atteindre. - L'ordre de
reconstruire, ligne à ligne, dont le piègemake flux: sans lui, nftables tombe enpolicy dropsans aucune règle — la flotte monte, SSH répond, et tout le reste est mur (trouvé le 2026-08-10). - Un site à un seul nœud : ce nœud est aussi nœud de sortie, aucun pont ne peut être partiel, aucune haute disponibilité. À dire, pas à laisser supposer.
- PVE 9 n'a jamais été éprouvé ici : tout écart est inconnu jusqu'à mesure.
- « Ce qui n'est PAS fait en repartant » — resserrer le compte d'API, le lien inter-sites, le gabarit des deux côtés, la voûte hors de son propre site.
En annexe, les squelettes d'underlay.yml et de proxmox-hebergeur.yml pour un site à
un nœud, à remplir depuis la reconnaissance et jamais de mémoire — c'est en recopiant
des listes chez chaque tenant qu'elles avaient divergé.
Sixième porte dans la table de routage du README. Les 21 cibles make citées ont été
vérifiées comme existantes. prouver 37/37 (dont P34 : 40 documents déclarent leur
lecteur), make test 0.
La règle qui commande tout l'ordre du document : rien n'est fait tant que ce n'est pas mesuré sur place. Une valeur transmise par courriel, une liste relevée dans l'interface web, un
vmbrcité de mémoire — chacun des trois a déjà produit une panne ici.
2026-08-13 — La machine d'épreuve jetable existait, et n'était écrite nulle part
L'exploitant a découvert par hasard, en creusant l'intrant « pont réseau », qu'on peut
fabriquer une VM hors du plan. Vérification : cloner-vm n'était mentionné qu'une
fois dans tout le dépôt, comme note de plomberie dans dimensionnement-ressources.md.
L'usage, lui, n'était nulle part.
Une doctrine sans son instrument
Le dépôt porte déjà la règle « éprouver l'outil avant d'écrire le rôle qui l'enveloppe » — elle a évité les bugs de premier déploiement de rspamd et tranché le pivot Stalwart → Postfix/Dovecot. Mais l'instrument de cette règle n'était pas nommé.
make cloner-vm HOTE=essai-nginx VMID=99123 PONT_PROXMOX=t17appl \
ADRESSE_IP=10.17.21.99 CIDR=24 PASSERELLE=10.17.21.1
Une Debian issue du gabarit doré, sur le réseau choisi, en deux minutes, sans toucher au
plan. C'est ainsi que modeleSetOPS a lui-même été recapturé.
Documenter la discipline, pas seulement la capacité
Une telle VM est nue — et c'est à la fois l'intérêt et le danger :
| l'inventaire | ne la contient pas |
make raser |
ne la détruira jamais — il dérive du plan |
| DNS, certificat, sauvegarde, pare-feu, nftables | aucun |
| son VMID | gardé par aucune preuve contre une collision |
Elle ne disparaît que si on la détruit soi-même. Un VMID oublié squatte le cluster sans que rien ne le signale — c'est très exactement ainsi qu'un pont disparu a survécu dix jours dans une déclaration, le matin même.
Écrit à trois endroits, pour trois lecteurs : le geste dans vm-lifecycle.md §4bis, la
capacité dans pouvoirs-set-ops.md (qui évalue le moteur), et le réflexe dans la
discipline de carte-set-ops.md (qui modifie le moteur).
Le symptôme valait le diagnostic. Découvrir une capacité de son propre outil par accident, en creusant autre chose, est le signe qu'elle manquait à la documentation — pas au code.
2026-08-13 — Le « pont réseau » n'était pas un réglage, et l'intitulé le dit maintenant
Question de l'exploitant après la découverte de vmbr3 : à quoi sert l'intrant « pont
réseau » ? Mesuré, et la réponse est : à presque rien.
proxmox_pont 14 occurrences dans l'inventaire → DÉRIVÉ par hôte (le VNet de sa zone)
proxmox_noeud 0 → proxmox_clone_noeud est la vraie valeur
proxmox_stockage 0 → proxmox_clone_stockage est la vraie valeur
instancier pose le VNet de chaque zone dans proxmox_pont, et l'hôte l'emporte sur le
défaut. proxmox_clone_pont n'est donc consulté que par un make cloner-vm manuel,
hors flotte — utile pour dépanner, sans effet sur les quatorze VM du plan.
C'est exactement pourquoi vmbr3 a pu y être faux dix jours sans que rien ne bronche.
Et c'est le pire genre d'intrant : on le voit dans le GUI, on le corrige, on redéploie, et
rien ne change.
L'intitulé dit désormais sa portée — « Pont réseau — clones manuels seulement (les VM du plan reçoivent le VNet de leur zone) ». Un intrant dont on comprend la portée cesse d'être un piège.
D-80 corrigée
J'y avais écrit « trois clés : nœud, stockage, pont ». Faux pour le pont. La liaison de placement réelle est nœud, stockage et gabarit ; le pont se dérive comme le reste.
Vérifier avant d'énumérer. J'avais listé les clés de placement en lisant le fichier du tenant, sans regarder lesquelles sont réellement consultées. Deux le sont, une ne l'est pas — et c'est celle qui était fausse.
2026-08-13 — make placement-plan : et le gabarit, justement
J'avais écarté le gabarit de P37 — « objet du cluster, pas une liste déclarée, donc pas vérifiable statiquement ». L'exploitant a relevé que ce n'était pas une raison de ne pas le vérifier : ça déplace la question du statique vers le devis.
devis_placement.py interroge donc le cluster et confronte les quatre valeurs :
noeud le nœud existe-t-il
stockage existe-t-il ET accepte-t-il le contenu `images`
pont existe-t-il SUR LE NŒUD retenu (un pont partiel est un piège)
gabarit le VMID existe-t-il ET porte-t-il `template=1`
Ce dernier contrôle compte : cloner une VM ordinaire fonctionnerait, et produirait quatorze copies d'une machine vivante.
Au premier passage, il a trouvé une déclaration périmée
ponts réels sur les 3 nœuds vmbr0, vmbr1, vmbr2
proxmox-hebergeur.yml vmbr0, vmbr1, vmbr2, vmbr3
vmbr3 n'existe plus — disparu au passage du transport VXLAN sur les interfaces VLAN.
La déclaration du 3 août a survécu à sa disparition, et P37 la validait : elle valide
la déclaration, pas le cluster.
Invisible jusqu'ici parce que flotte-creer surcharge le pont avec le VNet dérivé de
chaque zone — le défaut n'aurait mordu que sur un make cloner-vm manuel. Corrigé des
deux côtés : la liste de l'hébergeur, et le défaut des trois tenants vers vmbr1, que
le cluster nomme lui-même « VM (5Gig) ».
Ce que ça démontre. P37 et ce devis ne se remplacent pas : l'un garde la cohérence entre deux déclarations, l'autre confronte la déclaration au réel. Il fallait les deux pour voir un pont qui n'existait plus depuis dix jours.
Et P31 a refusé le script tant qu'aucune cible make ne l'atteignait.
2026-08-13 — D-80 : un tenant est agnostique de son underlay, à trois clés près
Formulation de l'exploitant, meilleure que celle du dépôt. Le commentaire disait « la fabric reste celle de l'hébergeur, quel que soit le tenant actif » — vrai, mais centré sur l'hébergeur. Le cadrage juste est centré sur le tenant : les deux symlinks composent deux axes indépendants.
instance -> quel tenant
underlay.yml -> sur quelle fabric
Tout l'adressage d'un tenant dérive du seed index : son plan se déplace d'une fabric à
l'autre sans y toucher. Ce qui ne se déplace pas tient en trois clés :
proxmox_clone_noeud proxmox_clone_stockage proxmox_clone_pont
Elles vivent côté tenant — c'est lui qui choisit où se poser — mais elles nomment des objets de l'hébergeur. Trois, pas trente : c'est ce qui sépare « portable » de « théoriquement portable ».
P37 — le placement est confronté à ce que l'hébergeur offre
L'écart est entièrement lisible : le tenant déclare trois noms, l'hébergeur déclare ses
listes dans proxmox-hebergeur.yml, trouvé par dérivation du symlink underlay.yml. Un
nom absent est un écart statique (D-75). Sans cette preuve, une faute de frappe ne se
découvre qu'au premier clone — après quarante minutes de déploiement.
Éprouvée en négatif sur les trois clés : un nœud, un stockage et un pont inexistants sont refusés, avec la liste de ce qui est réellement offert.
Non vérifié, et dit comme tel : le gabarit (_vmid_modele) est un objet du cluster,
pas une liste déclarée. Seul le cluster peut dire s'il existe.
2026-08-13 — Reconstruction d'un trait : 43 groupes, 0 échec, 37 minutes
DEBUT 08:05:17 FIN 08:42:11 code=0
43 groupes 0 fatal 0 hote en echec
Chezlepro rasé puis reconstruit sans une seule intervention. Les sept correctifs de la
nuit tiennent sur une flotte entièrement neuve. Sept devis CONFORME, prouver 36/36,
make test 0, neuf sauvegardes réussies et cinq sans objet.
La bataille contre NodeName n'avait qu'une cause
Toute la lutte d'hier soir — aligner NodeName avant api setup, après, dériver de
l'inventaire, puis adopter le nom court — visait un symptôme.
icinga2 api setup écrit NodeName d'après hostname -f. Tant que /etc/hosts plaçait
le nom court en premier, hostname -f mentait et le certificat devenait invérifiable.
Depuis que le FQDN est en tête, Icinga s'émet spontanément un certificat CN et SAN
= FQDN. Il n'y avait rien à forcer : il suffisait que la machine sache comment elle
s'appelle.
Le contournement par le nom court est donc retiré — il était devenu faux dès que la cause réelle a été corrigée. Le commentaire du rôle dit maintenant la causalité, pas ma fausse piste.
Ce que ça enseigne sur le diagnostic. J'ai passé trois heures à corriger un symptôme visible (le nom du certificat) alors que la cause vivait deux couches plus bas et affectait toute la flotte. Le signe qui aurait dû alerter : chaque correction était effacée au passage suivant. Un correctif qu'on doit reposer sans cesse ne corrige pas.
Une dette notée, pas traitée
Le clone de mon-01 a dépassé proxmox_clone_timeout (600 s) pendant la génération de
son ISO cloud-init — la VM était debout quelques minutes plus tard. Quatre clones complets
simultanés saturent le stockage. À régler par le délai ou le parallélisme, pas en urgence.
2026-08-13 — Reconstruction complète en 10.17.x.x : sept défauts, tous invisibles avant
Chezlepro reconstruit depuis zéro sur l'adressage dérivé sans décalage. Sept défauts sont tombés, et aucun n'était détectable sur une flotte déjà debout — c'est tout l'argument de la reconstruction comme preuve.
| # | Défaut | Pourquoi il était invisible |
|---|---|---|
| 1 | aucune route vers le tenant sur le poste | posée à la main un jour, jamais persistée ; disparue à la réactivation de l'interface |
| 2 | backup-01 exigeait l'AC d'Icinga |
l'AC existait déjà sur l'ancienne flotte |
| 3 | ma correction inversait les couches | refusée par P08 avant d'entrer au dépôt |
| 4 | base Forgejo à moitié initialisée | séquelle de l'arrêt du défaut n°2 |
| 5 | /etc/hosts : nom court avant le FQDN |
hostname -f faux sur les quatorze machines, depuis toujours |
| 6 | API Icinga jamais activée | la garde creates: d'api setup la saute dès que le fichier existe |
| 7 | restic refuse tout le lot si un chemin manque | /srv/web n'existe qu'après la première webapp |
Le cinquième dépassait Icinga
/etc/hosts déclarait 10.17.18.21 backup-01 backup-01.chezlepro.internal. Or
hostname -f rend le premier nom : toute la flotte se croyait appelée mon-01, jamais
mon-01.chezlepro.internal. Conséquence visible sur Icinga (certificat CN=mon-01
invérifiable en appelant par le FQDN) — mais le même piège attendait Postfix (myhostname),
les journaux et tout ce qui se nomme ainsi. FQDN d'abord, nom court en alias.
Ce qu'on ne corrige pas : la convention d'Icinga
icinga2 api setup réécrit NodeName d'après le nom court et nomme ses certificats
d'après lui. Aligné avant, il est écrasé ; aligné après, les certificats portent le mauvais
nom. On a essayé les deux. On adopte donc sa convention : le dépôt appelle l'API par le
nom court, celui que le certificat porte, résolu par le plancher /etc/hosts.
serveur_icinga_node_name dérive désormais de l'inventaire et non d'ansible_fqdn —
un fait qui dépend du résolveur de la machine, et qui a rendu mon-01 alors que le FQDN
était correct.
Une leçon presque comique. Le commentaire écrit pour avertir qu'une séquence accolade-dièse casse les gabarits Jinja… contenait cette séquence, et cassait le gabarit. Neuf hôtes en échec pour un avertissement mal rédigé.
Verdict
Sept devis CONFORME, prouver 36/36, make test 0. Neuf sauvegardes réussies, cinq
sans objet, et la supervision reçoit les rapports du dépôt avec vérification du pair.
2026-08-12 — D-79 : l'hyperviseur filtre encore en iptables legacy
Question de l'exploitant : « pourquoi les règles de sécurité au pare-feu global, alors que chaque VNet peut en porter ? » La réponse honnête a demandé trois corrections successives — et la dernière était la bonne.
Ce que j'avais tort d'affirmer
« Une règle de VNet serait trop grossière. » Faux : elle porte source, dest,
dport, proto. Éprouvé — règle créée, relue, retirée. L'exploitant avait raison.
« C'était un arbitrage. » Faux, et pire : zéro mention du pare-feu SDN dans le dépôt — ni doc, ni script, ni CHANGELOG. Ce n'était pas un choix, c'était une omission, présentée comme un raisonnement.
« Sur Debian 12, iptables c'est iptables-nft, donc c'est déjà du nftables. » Faux
ici. Mesuré sur asgard : iptables v1.8.9 (legacy). Proxmox force l'alternative sur
legacy, parce que pve-firewall dépend de l'ancien sous-système. Le défaut d'une
distribution n'est pas une mesure.
Ce que la mesure établit
asgard iptables v1.8.9 (legacy) nftables v1.0.6 installé, inutilisé par Proxmox
proxmox-firewall 0.7.1 INSTALLÉ, absent des services en cours
pve-firewall 5.1.3 en service
node firewall enable: 0 cluster firewall enable: 1
VNet fw type/action/proto/source/dest/dport + policy_forward ∈ {ACCEPT, DROP}
Le pare-feu de VNet est entièrement expressif — et implémenté par le seul moteur nftables. Posée aujourd'hui, une règle serait acceptée, stockée, visible dans l'interface, et n'appliquerait rien. C'est le motif de la journée, une quatrième fois.
Trois gains dans le même geste
Activer proxmox-firewall fait passer le moteur de xtables à nftables natif (la
doctrine « tout est nftables », que les invités respectent déjà et que l'hyperviseur
trahissait), rend le pare-feu de VNet réel, et donne des règles qui survivent à la
reconstruction — là où les 38 groupes par VM doivent être ré-attachés à chaque cycle.
Ce n'est donc pas une entorse à évaluer : c'est une dette à rembourser.
Différé après la reconstruction, sur un nœud d'abord, avec preuve de blocage et
contrôle négatif. Deux réserves consignées : le jeu de règles chargé n'a pas été inspecté
(nft list tables exige root, le compte ansible ne l'a pas), et aucun blocage réel n'a
été éprouvé — aucune VM n'était en service.
2026-08-12 — Technolibre 11→23, lab 1→13 : sortir des plages qu'occupait le matériel
Le retrait du décalage de +10 a déplacé les tenants vers 10.<index>. Ces plages
n'étaient pas vierges : les hyperviseurs portent des interfaces VLAN héritées que le
plan ne connaît pas.
asgard vlan5 = 10.11.5.41 vlan6 = 10.11.6.41 vlan7 = 10.11.7.41 ← dans 10.11 = Technolibre
vlan1110 = 10.1.110.254 ← dans 10.1 = lab
Changer l'index plutôt que déloger le matériel : Technolibre passe à 23, le lab à
13. 10.23 et 10.13 sont libres partout — frontière, poste de l'exploitant, dépôt —
et les VLAN dérivés (1231-1236, 1131-1136) ne croisent rien.
Les devis ne suppriment jamais ce qu'ils ne possèdent pas
Cette garde est juste — un devis ne doit pas détruire la zone d'un autre — mais elle laisse des orphelins quand un tenant change de nom dérivé :
| Objet | Sort du devis | Retiré explicitement |
|---|---|---|
Zone SDN t11 + 6 VNets + 6 sous-réseaux |
« zone hors devis, LAISSÉE INTACTE » | oui |
18 groupes de sécurité t11-* (31 règles) |
« à retirer : 0 » | oui |
Un VNet ne se supprime pas tant qu'il porte un sous-réseau, ni un groupe tant qu'il porte une règle : l'ordre est contenu d'abord, contenant ensuite.
Vérifié sur le réel, pas sur les devis
SDN zones t17, t23 — 12 VNets, 12 sous-réseaux, aucun t11
Pare-feu 19 groupes t17, 17 groupes t23, aucun t11
Frontière 10.0 (underlay), 10.17, 10.23 — aucun 10.11, 10.21, 10.27
Les trois devis répondent « rien à faire », prouver et make test à 0.
Ce qui reste, et qui n'est pas une anomalie :
vlan5/6/7etvlan1110vivent toujours sur les hyperviseurs. Ils n'appartiennent à aucun plan et ne collisionnent plus — c'était le but. Les déloger est un geste séparé, à faire avec le reste du déplacement physique.
2026-08-12 — D-78 : destination ou chemin, et le VLAN 50 pour éviter une collision
D-77 sortait déjà le stockage de l'espace dérivé, mais par une exception nommée plutôt que par une règle. Une question de l'exploitant — « le VLAN 40 n'a aucune VM, pourquoi ne pas lui donner une adresse non routée ? » — a fait apparaître le critère juste.
Ce n'est pas « y a-t-il des machines dedans » : le VLAN de gestion n'en a pas plus qu'un autre. C'est :
Ce réseau est-il jamais une destination, ou seulement un chemin ?
| Réseau | Rôle réel | Adressage |
|---|---|---|
| Gestion (10) | le poste, un VPN, demain le lien inter-sites doivent l'atteindre | 10.<index>.0.0/24 — unique |
| Transit (40) | rien que des prochains sauts, deux extrémités adjacentes | 192.168.40.0/24 |
| Transport VXLAN (50) | VTEP ↔ VTEP, destination de rien | 192.168.50.0/24 |
| Stockage (20/30/31) | baie ↔ hyperviseurs | 192.168.20/30/31.0/24 |
Sur six réseaux, cinq cessent d'exiger la moindre coordination entre deux hébergeurs — et « unique » redevient signifiant : seul ce qui doit l'être l'est.
L'os : le VLAN 11 aurait collisionné
Sous la règle 192.168.<vlan>, le transport VXLAN (VLAN 11) aurait produit
192.168.11.0/24 — déjà occupé par la gestion des hyperviseurs sur vmbr0,
passerelle .254. Trouvé par l'exploitant avant écriture.
Le VLAN 11 se libérera lorsque cette gestion rejoindra 10.<index>.0.x — mais faire
dépendre un plan d'adressage de l'ordre d'une migration est le genre de dette qui se paie
un an plus tard. Le transport passe donc au VLAN 50 : libre, 192.168.50.0/24 libre,
et ne dépend de rien. Vérifié : rien ne code le 11 en dur, c'est de la donnée d'underlay.
Ce qui n'est PAS fait, et pourquoi
underlay.yml décrit le matériel tel qu'il est. Y écrire les nouvelles plages avant le
déplacement physique le ferait mentir : P23 validerait une fiction et les devis émettraient
une configuration pour un état inexistant. La décision est consignée ; les adresses
changeront avec le matériel, dans l'ordre du runbook §6.
Un site neuf, lui, se monte directement au schéma final — le document de préparation
d'un site hébergeur porte déjà le VLAN 50 et les 192.168.
2026-08-12 — P03 regarde TOUTES les instances, et trouve une collision au premier essai
P03 ne vérifiait la fraîcheur de l'inventaire que pour l'instance active — laissant un
tenant qu'on ne regarde pas imposer ses adresses à la frontière partagée. Elle boucle
désormais sur les instances découvertes, chacune vérifiée avec SETOPS_INSTANCE.
Au premier passage, elle a trouvé une troisième instance périmée — et une collision franche que personne n'avait vue :
OPS-Chezlepro-lab (index 1) applique : 10.11.18.21 ← ancienne derivation (10+1)
OPS-Technolibre (index 11) derive : 10.11.x.x ← nouvelle derivation
Le lab occupait exactement la plage désormais attribuée à Technolibre. Sans cette
preuve, la collision serait apparue le jour où les deux auraient tourné ensemble — c'est
P21 qui garde les index, rien ne gardait les inventaires appliqués.
Les trois instances sont maintenant alignées : 10.1 (lab), 10.11 (Technolibre),
10.17 (Chezlepro).
Ce que la preuve ne fait pas : vérifier que le boîtier porte ce que le devis dit — c'est
make frontiere-plan. Elle garde l'intrant de ce devis, pas sa sortie. Les deux sont nécessaires, et c'est l'intrant qui manquait.
2026-08-12 — Un tenant périmé injecte ses vieilles adresses dans le pare-feu partagé
Après le renumérotage de Chezlepro, la frontière portait encore 17 adresses 10.21.x
— l'ancienne plage de Technolibre. Et frontiere-plan répondait « la frontière dit
déjà ce que le devis dit ».
Les deux étaient vrais. La frontière construit deux natures d'objets :
| Objet | Source | Comportement |
|---|---|---|
SETOPS_TENANT_<T> (réseau) |
la formule (sous_reseau_de) |
s'est recalculé seul → 10.11 |
SETOPS_<T>_SERVEUR_* (hôtes) |
le hosts.yml du tenant |
est resté à 10.21 |
Le devis lisait donc fidèlement une entrée périmée, et l'annonçait conforme. Le contrôle n'était pas faux — son intrant l'était.
Ce que ça révèle
La frontière est partagée entre tous les tenants, mais P03 ne vérifie la fraîcheur
de l'inventaire que pour l'instance active. Un tenant qu'on ne regarde pas — parce
qu'il n'a aucune VM, précisément — continue d'imposer ses adresses au pare-feu de tout le
monde, sans qu'aucune preuve ne s'en aperçoive.
Corrigé en régénérant l'inventaire de Technolibre puis en réappliquant. Vérifié non pas
sur le devis mais sur la configuration réelle du boîtier, téléchargée et relue : il ne
reste que 10.0 (underlay), 10.11 et 10.17. Zéro 10.21, zéro 10.27.
Ce qui manque encore : rien ne prouve que chaque tenant fédéré a un inventaire à jour. P03 devrait boucler sur les instances découvertes, pas seulement sur l'active.
2026-08-12 — P03 peut enfin échouer, et elle échoue
instancier.py comparer affichait l'écart puis renvoyait toujours 0. La preuve P03
« Diff-vide du plan » ne pouvait donc pas échouer : elle a passé au vert pendant que
quatorze hôtes divergeaient du plan.
Deux appelants, deux besoins — d'où un mode plutôt qu'un changement de comportement :
| Appelant | Attente |
|---|---|
make instancier |
inspection : voir le diff avant de décider. Un code d'erreur y transformerait la lecture en panne |
| P03 | affirmation : « le plan reproduit l'inventaire ». Sans --strict, elle n'affirmait rien |
prouver sort désormais à 1, et c'est exact : depuis le retrait du décalage de +10,
le plan dérive 10.17.x.x tandis que l'inventaire appliqué — et les quatorze VM qui
tournent — portent 10.27.x.x. Le dépôt est sciemment dans cet état jusqu'au
renumérotage. Une preuve rouge qui dit vrai vaut mieux qu'une verte qui ne regarde rien.
2026-08-12 — L'index est borné : 10.300.0.0/16 n'est pas un réseau
Rien ne bornait index. supernet_de(300) rendait "10.300.0.0/16" — une chaîne qui
ressemble à un réseau. Elle traverse tout le moteur sans bruit et n'échoue qu'au premier
ip_network() qui la lit, très loin de l'index fautif.
La borne est posée à la source (valider_index), pas dans un validateur de plan :
toutes les fonctions dérivées y passent — supernet_de, base3_de, vlan_de — donc
aucune ne peut fabriquer une adresse invalide, d'où qu'on l'appelle : plan, GUI, devis ou
test.
Elle protège un second plafond, moins visible : à l'index 255 le VLAN vaut 3550+zone,
sous les 4094 du 802.1Q. Un index à trois chiffres débordait aussi là.
Et une garde statique dans le contrôle de fédération (P21), qui nomme le dépôt fautif au lieu de laisser l'erreur remonter d'une bibliothèque.
Le test a trouvé ce que la relecture n'avait pas vu
valider_index n'attrapait que TypeError et ValueError. Or int(float('inf')) lève
OverflowError : un infini flottant passait la garde en la faisant planter au lieu de
la faire refuser. Corrigé — et c'est le cas de test qui l'a levé, pas ma relecture.
test_underlay_bande_basse.py devient test_adressage_derive.py : il ne parlait plus
seulement de la bande basse. 12 cas, dont les refus.
Un piège de structure, au passage. Les nouveaux cas, ajoutés après le bloc
if __name__ == "__main__":, ne s'exécutaient pas — le bloc tourne avant que les fonctions suivantes ne soient définies, et le compte affichait tranquillement « 7 tests » au lieu de 12. Un harnais qui compte ses propres tests doit être lu : sept était la bonne réponse à la mauvaise question.
2026-08-12 — Le décalage de +10 est retiré : l'index se lit dans l'adresse
supernet_de(index) rendait 10.(10+index).0.0/16. Personne ne savait plus pourquoi —
ni le commentaire de la constante, ni le wiki de l'adressage, ni le commit fondateur
36a882b ne le justifiaient. Trois endroits consultés, zéro raison écrite.
Ses deux effets constatés :
- il réservait
10.0–10.9sous la plage tenant. Utile tant que l'underlay vivait là — mais D-77 l'a fait entrer dans la bande basse de son propre/16, ce qui a vidé cette réserve de son rôle la veille ; - il éloignait le premier tenant de
10.0.0.0/16, la plage la plus répandue en réseau domestique. Ce risque revient donc aux index bas, et c'est assumé : choisir un index, c'est choisir sa plage — autant que ce soit lisible.
En échange, l'index se lit directement dans l'adresse — index 17 → 10.17.x.x — et le
plafond passe de 245 à 255 écosystèmes fédérés.
| Instance | Avant | Après |
|---|---|---|
| Chezlepro (17) | 10.27.0.0/16 |
10.17.0.0/16 |
| Technolibre (11) | 10.21.0.0/16 |
10.11.0.0/16 |
| lab (1) | 10.11.0.0/16 |
10.1.0.0/16 |
Documentation alignée partout : les trois pages du wiki, multi-instances.md (dont le
plafond et l'exemple, devenus faux arithmétiquement), sdn-evpn.md, le libellé de la GUI,
la docstring d'underlay.py, D-77, et le document de préparation d'un site hébergeur. Les
constats de terrain datés — incidents dans les commentaires, CHANGELOG, rapports
d'audit — sont laissés tels quels : ce sont des mesures, pas des formules.
Ce que ce commit ne fait PAS
Il ne renumérote rien. Il change ce que le plan dérive ; l'inventaire appliqué, lui,
porte toujours 10.27.x.x, et les quatorze VM tournent dessus.
14 hote(s) avec ecart — ansible_host, proxmox_passerelle, setops_supernet
Appliquer cet inventaire sans reconstruire la flotte la rendrait injoignable : Ansible
chercherait des machines à des adresses que personne ne porte. Le renumérotage est une
opération à part — instancier-appliquer, puis SDN, puis reconstruction, puis frontière —
à mener à froid.
Au passage : une preuve qui ne peut pas échouer
P03 « Diff-vide du plan » ne prouve rien. instancier.py comparer affiche l'écart puis
renvoie toujours 0 : la preuve passe quel que soit le nombre d'hôtes divergents. Elle
aurait dû crier ici, sur quatorze. Non corrigé dans ce commit — le corriger ferait échouer
prouver jusqu'au renumérotage, ce qui est exact mais bloquerait tout le reste. À traiter
avec le renumérotage, pas avant.
2026-08-12 — P23 outille D-77 : la bande basse devient une règle, pas une convention
D-77 disait où l'underlay doit vivre. Rien ne le vérifiait — et une convention qu'on n'outille pas pourrit en silence (D-70). C'est exactement ce qui a laissé la sauvegarde vide pendant un mois.
Le contrôle disait l'inverse de la décision. underlay.py refusait tout
chevauchement avec un supernet tenant. Il fallait le rendre plus fin, pas plus strict :
| Situation | Verdict |
|---|---|
| dans son propre supernet, bande basse | conforme — c'est la règle |
| dans son propre supernet, bande haute | refusé — collision avec ses propres zones |
| dans le supernet d'un autre site | refusé — les deux ne pourront jamais être reliés |
hors de tout supernet (10.0.x, 192.168.x) |
conforme — héritage, et stockage |
La frontière est dérivée de OCTET_ZONE, jamais écrite en dur : déplacer la règle des
zones déplace la borne avec elle. Le site déclare son index dans underlay.yml ; sans
lui, on retombe sur la règle stricte d'avant D-77 — le comportement sûr pour un underlay
qui n'a pas encore migré. Chezlepro reste donc conforme aujourd'hui, en 10.0.x.
Le piège que le test attrape
Un préfixe peut commencer dans la bande basse et déborder : 10.21.0.0/19 couvre les
octets 0 à 31. Une vérification qui ne regarderait que le premier octet le laisserait
passer. La borne est donc évaluée sur toute l'étendue du préfixe.
Deux de mes propres cas d'épreuve étaient mal choisis — 10.21.14.0/23 et 10.21.12.0/21
se normalisent entièrement dans la bande basse, et « conforme » y était la bonne réponse.
Il a fallu construire un préfixe qui franchit réellement la frontière pour éprouver la
garde.
scripts/tests/test_underlay_bande_basse.py — 7 cas, câblé dans make test.
2026-08-12 — modeleSetOPS, et une porte pour l'hébergeur
Le gabarit portait le nom du mauvais propriétaire
modeleChezlepro était déclaré par les trois instances — Chezlepro, Technolibre et le
lab — qui pointaient déjà toutes sur le même VMID 99998. Le commentaire du rôle
affirmait pourtant « chaque tenant a SON golden template » : c'était faux depuis
longtemps, et personne ne pouvait le voir en lisant un seul fichier.
Renommé modeleSetOPS — sur le cluster et dans les trois instances. Le gabarit est un
artefact du moteur, pas d'un tenant, et le nom d'un tenant sur le gabarit d'un autre
était un piège qui n'attendait qu'un troisième hébergeur pour se refermer. Les scripts
d'amorçage (model_creer.py, config_proxmox.py) proposaient encore
modele-debian13 : alignés eux aussi.
Sans risque : le clonage se fait par VMID depuis la correction du 2026-08-10 — le nom ne sert plus qu'à l'affichage et aux vérifications. Rien n'empêche un tenant d'en désigner un autre ; il change le champ et le VMID.
Une porte de plus dans l'aiguillage : l'hébergeur
docs/preparer-un-site-hebergeur.md — pour quelqu'un qui prête son matériel sans rien
connaître de Set-OPS. Il ne décrit que ce que la machine ne peut pas deviner : le plan
d'adressage à respecter, la frontière, le stockage, l'hyperviseur, le gabarit, et la liste
exacte de ce qu'il doit transmettre en retour.
Écrit à partir du dépôt, pas de conventions générales : les VLAN et MTU viennent
d'underlay.yml, le bloc par tenant de inventory_rules.supernet_de(), les privilèges du
jeton de config-proxmox.md, et le dimensionnement (~460 Go, ~37 Go de RAM pour
quatorze VM) d'une mesure sur la flotte vivante.
Deux avertissements y sont écrits parce qu'ils ont déjà coûté cher ici : un gabarit personnalisé recopie son identité dans chaque clone, et un blocage contourné en silence se paie en heures — la panne est alors cherchée au mauvais endroit.
Le rôle Administrator sur le jeton Proxmox y est recommandé et signalé comme tel,
avec le minimum documenté en regard : mieux vaut un privilège large assumé et resserré
ensuite qu'un privilège serré qu'on élargit en panique au milieu d'un déploiement.
2026-08-12 — La donnée revient : restauration éprouvée, pas seulement sauvegarde
On savait que la donnée partait et arrivait. On ne savait pas qu'elle revenait — et c'est le seul test qui compte le jour venu.
Les trois charges critiques, éprouvées pour de vrai
| Charge | Preuve | Résultat |
|---|---|---|
Clés de l'AC (infra-pki-01) |
restauration + comparaison octet pour octet avec le vivant | 12 fichiers, 11 identiques, root_ca_key et intermediate_ca_key compris |
Annuaire (idm-01) |
slapadd -u (essai à blanc) sur le LDIF restauré |
rejouable, 7 entrées dont uid=sysadmin |
Bases (data-sql-01) |
section forgejo rejouée dans une base d'épreuve |
0 erreur, 130 tables, comptes réels (forgejo-admin, sysadmin) |
Le seul écart sur l'AC est db/000000.vlog — le journal badger de step-ca, qui avance à
chaque émission de certificat. Attendu, pas un défaut.
Contrôles négatifs, parce qu'un test qui dit toujours oui ne teste rien : un LDIF
volontairement corrompu fait sortir slapadd en 1 ; la garde SQL a refusé une section mal
découpée (voir ci-dessous). Production vérifiée intacte après le rejeu.
Le piège de pg_dumpall, trouvé par la garde
pg_dumpall écrit CREATE DATABASE <suivante> avant le \connect correspondant.
Découper « du \connect X au \connect suivant » emporte donc un ordre visant une
autre base. Ma première découpe l'a fait ; la garde a refusé de rejouer. Sans elle, un
essai de restauration aurait touché icingadb. Consigné dans runbooks-exploitation.md §5.
La recette ne ment plus
playbooks/valider.yml exigeait une restauration de tous les nœuds client_backup —
elle échouait donc sur ceux qui ne détiennent légitimement rien, et sur les dépôts vides.
Elle distingue désormais quatre verdicts : OK, À CONFIRMER (restauré mais vide),
SANS OBJET, ÉCHEC (la restauration elle-même). Alignés sur ceux de la supervision :
deux verdicts opposés sur le même fait apprendraient à en ignorer un.
Elle prouve en outre que l'annuaire restauré est rejouable, pas seulement présent.
make valider : 0 échec sur toute la flotte.
2026-08-12 — Le curl -k est mort, mais pas comme prévu
Objectif : que le rapporteur vérifie le pair en appelant l'API Icinga, au lieu de sauter la vérification. C'est fait — et la manière a été imposée par la mesure, pas par le plan.
Servir un certificat step-ca sur l'API est IMPOSSIBLE
La tentative était directe : client_pki dépose déjà sur mon-01 un certificat portant
serverAuth + clientAuth et le bon SAN. Il suffisait de le faire servir. Icinga le
refuse, et le dit lui-même :
information/ApiListener: Our certificate will expire soon, but we own the CA. Renewing.
Icinga renouvelle tout certificat expirant sous 30 jours. Les certificats Set-OPS vivent 24 h. Possédant une AC, il ré-émet donc avec la sienne — écrasant le nôtre à chaque démarrage. Ce n'est pas réparable par configuration : c'est une collision entre deux politiques de PKI, et la nôtre (certificats courts) n'est pas négociable.
Deux découvertes en chemin, toutes deux par le garde-fou icinga2 daemon -C ajouté au
rôle — qui a arrêté le déploiement avant de redémarrer la supervision :
cert_path/key_path/ca_pathsont dépréciés depuis 2.8 ; les poser réveille un chemin de code hérité qui exige en plus un objetEndpoint.- L'identité de l'API est le CN du certificat.
NodeNamevalaitmon-01: le certificat auto-émis portait doncSAN=mon-01alors qu'on appelle par le FQDN, et aucune vérification n'aurait pu réussir.NodeNameest désormais aligné sur le FQDN.
Ce qu'on fait à la place
Icinga garde son AC — un domaine de confiance fermé, ce qui est légitime — et
backup-01 vérifie le pair contre cette AC-là, récupérée depuis mon-01 au
déploiement. Le pair est authentifié ; seule la racine diffère. Le -k a disparu, ce qui
était le vrai problème.
Contrôle négatif, parce qu'une vérification qu'on ne teste pas est un ornement : avec
la mauvaise AC (celle de step-ca), curl refuse — unable to get local issuer certificate. Avec la bonne, les neuf rapports passent.
Et la sous-AC step-ca ?
Écartée, et pas par prudence de principe. Elle poserait sur l'hôte de supervision une clé
capable d'émettre pour n'importe quel nom de l'écosystème — alors qu'on a justement
choisi le sens du flux (le dépôt parle à la supervision, jamais l'inverse) pour que
compromettre mon-01 ne donne rien. Une AC isolée pour un domaine isolé est le bon
design, pas une entorse à la souveraineté.
2026-08-11 — Les sauvegardes sont surveillées, et c'est le dépôt qui parle
Icinga ne surveillait rien : aucun objet Host ni Service de Set-OPS, seulement la
configuration Debian d'origine pointant sur localhost.
On ne supervise pas l'unité — on supervise ce qui est arrivé
Superviser setops-sauvegarde.service aurait reproduit le défaut du jour même : l'unité
était verte sur onze nœuds pendant qu'elle n'emportait rien. Le nœud sait qu'il a
lancé sa sauvegarde ; il ne sait pas qu'elle est arrivée. Seul le dépôt le voit.
backup-01 évalue donc ses dépôts restic et pousse un résultat passif par nœud vers
l'API Icinga. Trois critères, parce qu'un seul suffit à mentir :
| Critère | Ce qu'il attrape |
|---|---|
| l'instantané existe | la sauvegarde n'arrive pas |
| il est récent (26 h / 50 h) | elle a cessé d'arriver |
| il contient au moins un fichier | elle arrive mais ne porte rien |
Le sens du flux est délibéré : le dépôt parle à la supervision, jamais l'inverse. Un seul
flux nouveau, et compromettre mon-01 ne donne aucun accès aux sauvegardes.
Le silence alerte
Le ttl de 6 h porté par chaque envoi fait la fraîcheur : si le rapporteur se tait,
Icinga périme les services tout seul. C'est le silence qui a laissé le défaut vivre un
mois — il devait devenir la première chose qui alerte. Le rapporteur, lui, refuse
d'avaler ses propres échecs et sort en erreur.
Deux erreurs de conception, corrigées par la mesure
Le corps --data-urlencode était refusé en Bad Request : l'API veut du JSON. Le flux,
le TLS et l'authentification fonctionnaient — seule la charge était perdue. Sans lecture du
journal d'Icinga, un curl silencieux aurait été pris pour un succès.
Le seuil « vide » en octets était faux. Il signalait idm-01 (2 363 octets) alors qu'un
export LDIF d'un annuaire à un compte pèse légitimement cela. « Vide » se mesure en
fichiers, pas en taille : zéro fichier, c'est exact quelle que soit la taille. Et le
verdict est un avertissement, pas un critique — la machine ne peut pas distinguer « les
données ont disparu » de « il n'y en a pas encore », mais l'humain doit le voir.
Mesuré de bout en bout
idm-01 OK 1 fichier, 2 363 o (l'annuaire) infra-mail-01 AVERT. aucun fichier
infra-pki-01 OK 20 621 o (les clés de l'AC) web-frontal-01 AVERT. aucun fichier
collab-01 OK 67 129 221 o web-dorsal-01 AVERT. aucun fichier
data-sql-01 OK 1 085 158 o (toutes les bases)
Réserve assumée : le rapporteur appelle l'API en curl -k. L'API Icinga présente le
certificat de sa propre AC (icinga2 api setup), pas celui de step-ca — la liaison est
chiffrée mais le pair n'est pas vérifié. C'est la réserve connue sur 5665, et elle reste
ouverte.
2026-08-11 — La sauvegarde emporte enfin quelque chose
Correction de l'entrée précédente : j'y attribuais le défaut à la reconstruction
from-zero. C'est faux. Le commit fondateur 7476a54 (2026-07-03) le disait lui-même —
« Reste : jobs Tier 1 (pg_dump/slapcat/vmail/forgejo) ». Ces jeux n'ont jamais été
écrits. Le Tier 0 était prouvé sur infra-pki-01, mais l'hôte a ensuite perdu son
intégration client_backup sans que rien ne le dise.
Le catalogue vit dans le rôle, dérivé de l'appartenance aux groupes
C'est le rôle qui possède la donnée qui dit comment la sortir. client_backup_jobs est
l'intersection du catalogue et des group_names du nœud : un tenant qui déplace un service
emporte sa sauvegarde avec lui, sans rien redéclarer. On sauvegarde l'état non
régénérable — ni les zones PowerDNS ni les tableaux de bord Grafana n'y figurent, ils se
redéploient.
Les chemins ne peuvent pas référencer les defaults du rôle propriétaire : make deployer
déroule un play par groupe, et ceux de serveur_forgejo ne sont pas chargés pendant le play
de client_backup. D'où la forme var | default(littéral).
L'unité qui ment est retirée, pas rendue bloquante
Refuser le déploiement d'un nœud sans jeu aurait cassé infra-edge-01, infra-dns-01 et
mon-01, qui ne détiennent légitimement rien. Le défaut n'était pas là : il était dans le
timer qui échouait chaque nuit en donnant l'apparence d'une sauvegarde. Le rôle installe
donc la sauvegarde si et seulement si un jeu s'applique, et retire celle qui
existerait. Une sauvegarde qui ne sauvegarde rien est pire que pas de sauvegarde : elle
rassure.
P36 — tout détenteur d'état porte une sauvegarde
L'écart était lisible dans le plan depuis un mois (D-75). La preuve lit les groupes
détenteurs dans client_backup_catalogue : ajouter un rôle au catalogue étend la preuve du
même geste. Elle a immédiatement attrapé infra-pki-01, corrigé au plan.
Mesuré, hors-nœud
| Hôte | Emporté |
|---|---|
collab-01 |
64,0 MiB · 272 fichiers |
edge-mta-01 |
4,4 MiB · 139 (bayes rspamd appris) |
data-sql-01 |
1,0 MiB · pg_dumpall de toutes les bases |
forge-01 |
26,4 KiB · 68 |
infra-pki-01 |
20,1 KiB · 21 — les clés de l'AC |
idm-01 |
2,3 KiB · 5 — l'annuaire par slapcat |
infra-mail-01, web-frontal-01, web-dorsal-01 |
vides, et c'est exact : /var/vmail, /srv/web et /srv/webapp n'ont rien depuis la reconstruction du 2026-08-10 |
9 hôtes, 9 success, 5 hôtes sans sauvegarde parce qu'ils ne détiennent rien.
Ce qui reste : rien ne surveille encore l'unité. C'est ce silence qui a laissé le défaut vivre un mois — Icinga devrait voir une unité systemd en échec.
2026-08-11 — En consignant l'effet du rasage, la sauvegarde s'est révélée vide
Il s'agissait d'écrire une conséquence connue : raser l'hôte qui porte openldap détruit
l'annuaire, donc le compte sysadmin est recréé depuis le jeton de la voûte et le
changement forcé est réarmé. Mesuré après le rasage de idm-01 : pwdReset: TRUE, et le
mot de passe choisi par l'exploitant n'existe plus.
La perte réelle est ailleurs, et le §2 la rendait prévisible : les appartenances ne sont
jamais réconciliées. Set-OPS crée un compte. Tout ce que l'exploitant a construit
depuis est détruit et ne sera pas recréé — contrepartie exacte du régime qui protège ces
décisions du prochain make deployer.
Le contrôle qui devait rattraper ça ne fonctionne pas
En cherchant où pointer pour la restauration, mesure sur les 14 hôtes :
| Hôtes | État |
|---|---|
11 (dont idm-01, data-sql-01, forge-01, collab-01) |
setops-sauvegarde.service en échec chaque nuit — Fatal: nothing to backup |
infra-pki-01, obs-01, backup-01 |
aucune sauvegarde déployée — et infra-pki-01 porte les clés de l'AC |
client_backup_jobs vaut [] par défaut et rien ne le surcharge dans l'instance : le
timer tourne, restic initialise son dépôt, puis échoue faute de source. Aucune donnée de
cet écosystème n'est sauvegardée. Vraisemblablement une victime de la reconstruction
from-zero — les déclarations par nœud n'ont pas été redéclarées dans l'instance régénérée.
Consigné tel que mesuré dans autorisation.md §3.1, avec la sortie manuelle de l'annuaire
en attendant la correction. Le défaut n'est pas corrigé par ce commit : il est rendu
visible, et une unité en échec qui n'alerte personne est le second défaut à traiter.
2026-08-11 — L'ancre Keycloak existe (et la commande que j'avais donnée ne prouvait rien)
La vérification de signature était en place, mais elle ne prouvait que « la même clé qu'hier ». Restait à établir que cette clé est bien celle de Keycloak.
La première tentative était circulaire. gpg --recv-keys <empreinte> demande la clé
par son empreinte — or une empreinte est le condensat du matériel de la clé : le serveur
ne peut rien renvoyer d'autre. Confirmer que la clé reçue porte l'empreinte demandée
n'établit donc rien. Et un serveur de clés n'est pas une autorité : n'importe qui y
téléverse n'importe quelle clé avec n'importe quel UID (GPG l'affiche : [ unknown ]).
La manœuvre a tout de même révélé l'identité : Keycloak Bot <keycloak.bot@gmail.com>,
ed25519 créée le 2024-02-13, expirant le 2027-02-12. Et, mesuré localement, la clé est
auto-signée uniquement — aucune certification tierce, aucune toile de confiance.
L'ancre réelle : https://www.keycloak.org/keys publie
861ab50e8cc6611fb6bc01a6b8f12ea26fd6eeba, identique à l'épinglage. Ce canal
(keycloak.org) est distinct de celui qui livre l'archive (github.com) — la
propriété qu'avait déjà Forgejo et qui manquait ici.
Deux réserves consignées dans roles/serveur_keycloak/defaults/main.yml plutôt que
passées sous silence : la page décrit la clé comme servant aux artefacts Maven (c'est
notre vérification qui établit que le .asc de l'archive est validé par elle), et
l'ancrage vaut ce que vaut le contrôle de keycloak.org — DNS et TLS.
2026-08-11 — Les épinglages éprouvés en vrai, par une reconstruction ciblée
Les rôles installent, ils ne mettent pas à jour (creates:). Relever une version ne
change donc rien tant qu'une machine neuve ne la rencontre pas. Restait à l'éprouver sans
raser les quatorze VM pour trois.
make raser accepte HOTE= et ne peut que restreindre. Trois VM concernées, rasées ;
leurs trois bases supprimées puis recréées vides par serveur_postgresql depuis le
registre — 7425 Ko chacune, la taille d'une base neuve.
| VM | Version obtenue | Livraison réelle, via le frontal, TLS validé |
|---|---|---|
idm-01 |
Keycloak 26.7.1 | document OIDC complet du realm chezlepro |
forge-01 |
Forgejo 16.0.2 | {"version":"16.0.2+gitea-1.22.0"} |
collab-01 |
Nextcloud 34.0.2.1 | installed:true, needsDbUpgrade:false |
33 couches, 0 échec, 0 injoignable, en ~15 minutes contre 54 pour une reconstruction complète.
Ce que la manœuvre a réellement prouvé
Les deux vérifications de signature PGP se sont exécutées en conditions réelles, sans
ignore_errors ni failed_when: false : un refus aurait cassé le play avant le dépôt de
l'archive. L'épinglage sur la clé primaire de Forgejo tient — la 16.0.2 est signée par
une sous-clé différente de celle de la 12.0.0, et la vérification passe sans qu'on ait eu à
baisser la garde.
Supprimer les bases n'était pas une commodité : occ maintenance:install refuse une base
peuplée. Garder les bases aurait fait échouer Nextcloud, et fait traverser six majeures à
Forgejo.
Verdict : sept devis CONFORME, prouver.py 35 OK / 0 échec.
Deux points restent ouverts, et il faut le dire : l'ancre de confiance Keycloak repose
encore sur la continuité — rien dans la machine n'établit que l'empreinte épinglée est
la bonne, c'est la décision humaine que verifier_signature.py dit explicitement ne pas
pouvoir prendre. Et Collabora tourne toujours dans Docker sur collab-01.
2026-08-11 — Forgejo à 16.0.2, et une ancre de confiance qui existe vraiment
Six versions majeures d'un coup — mais la découverte importante est ailleurs.
La « rotation de clé » n'en était pas une
Quatre versions, trois signataires différents :
10.0.0 → B3B1F60AC577F2A2 14.0.0 → C4186DF66F4B6750
12.0.0 → D0A820050E1609E5 16.0.2 → C4186DF66F4B6750
Ce ne sont pas des clés distinctes : ce sont des sous-clés de signature sous une clé
primaire stable depuis 2022 — EB114F5E…C5923710, Forgejo <contact@forgejo.org>. La
sous-clé 0F527CF9…0E1609E5 est bien celle qui avait signé la 12.0.0.
D'où une correction du vérificateur : il comparait l'empreinte du signataire, donc une
sous-clé. Il aurait échoué à chaque rotation légitime — et on aurait appris à lever la garde
pour avancer, ce qui est la pire chose qui puisse arriver à un contrôle. Il accepte désormais
la clé primaire (dernier champ de VALIDSIG), qui survit aux rotations tout en refusant
une clé étrangère.
Forgejo a l'ancre que Keycloak n'a pas
forgejo.org/download publie l'empreinte — et le binaire vient de codeberg.org. La
source de confiance est donc indépendante du canal de livraison, exactement ce qui
manquait pour Keycloak. Forgejo publie en outre une somme sha256, vérifiée conforme.
Le projet annonce lui-même la rotation : « the GPG key is updated on a regular basis » — ce qui confirme qu'épingler la primaire est le bon choix.
Vérifié, dans les deux sens
Nominal 0. Binaire altéré d'un octet 1. Empreinte de Keycloak appliquée à Forgejo 1.
Signature d'un autre artefact 1. Et Keycloak ne régresse pas après la modification du
comparateur.
make versions-mesurer : 0 en retard. Rôle appliqué de bout en bout sur forge-01.
Rappel du modèle : forge-01 tourne toujours 10.0.0 — l'épinglage décrit ce qu'on
installe, pas ce qui tourne.
2026-08-10 — Keycloak : vérifier QUI a produit l'archive, pas seulement qu'elle est intacte
L'exploitant : « j'ai besoin d'une confiance réelle. Keycloak est probablement l'élément le plus dangereux de cet écosystème. » C'est exact — Keycloak signe les jetons de tout l'écosystème. Une archive substituée là, et l'identité entière tombe.
Une correction, d'abord
J'avais écrit que « Keycloak ne publie aucune somme de contrôle ». Faux, et l'exploitant l'a relevé. Mesuré ensuite :
26.6.2 : .sha1 200 .md5 200 .asc 200
26.7.0 : .sha1 404 .md5 404 .asc 200
26.7.1 : .sha1 404 .md5 404 .asc 200
Les sommes existaient jusqu'à 26.6.2, puis ont disparu. Et surtout : j'avais raté le
.asc — une signature PGP, présente sur toutes les versions, et plus forte qu'une somme.
Une somme prouve qu'un fichier n'a pas été corrompu ; une signature prouve qui l'a
produit.
Ce qui est établi, et ce qui ne l'est pas
La même clé 861AB50E…6FD6EEBA a signé 26.0.7 (la version alors en production),
26.3.0, 26.6.2 et 26.7.1. C'est une continuité réelle.
Mais aucune source indépendante ne publie cette empreinte : ni keycloak.org/downloads,
ni la page getting started, ni SECURITY.md, ni un fichier KEYS. Elle est absente de
keys.openpgp.org ; on la trouve sur keyserver.ubuntu.com, qui n'est pas une autorité. Et
la somme .sha1 n'ajoute rien : même canal que l'archive et la signature.
On peut donc prouver la continuité, pas l'origine. L'ancre est une décision humaine — et elle est maintenant écrite, versionnée, et vérifiée à chaque téléchargement.
scripts/verifier_signature.py
Trois exigences, chacune contre un contournement précis :
- la clé publique vit dans le dépôt (
roles/serveur_keycloak/files/keycloak-release.asc), versionnée et relue — aucune interrogation de serveur de clés au déploiement ; - l'empreinte est épinglée à côté de la version : une rotation de clé en amont devient un échec bruyant qui exige une relecture, pas un remplacement silencieux ;
- trousseau jetable (
GNUPGHOMEtemporaire) : le trousseau personnel n'est ni lu ni modifié, et deux machines donnent la même réponse.
Il lit VALIDSIG et compare l'empreinte du signataire réel à celle épinglée — se
contenter de « bonne signature » laisserait passer une signature valide faite par une autre
clé du trousseau.
Éprouvé sur cinq cas : nominal 0 ; artefact altéré d'un octet 1 ; empreinte épinglée
différente 1 ; clé du dépôt corrompue 1 ; signature absente 1.
Ce que ça ne prouve pas
Que l'empreinte épinglée soit la bonne. Aucune machine ne peut l'établir. Le script garantit seulement qu'on ne s'en écarte plus sans le voir.
2026-08-10 — make versions-mesurer : l'écart avec l'amont devient lisible
Question de l'exploitant : « ne devrait-on pas prendre les versions les plus récentes ? »
Non, et la réponse tient en trois points. Résoudre « la dernière » au moment du
déploiement détruirait la reproductibilité — celle-là même qu'on vient de prouver en
rasant et remontant deux écosystèmes. Ça transformerait chaque déploiement en loterie :
une heure de reconstruction ne doit pas dépendre de ce qu'un tiers a publié cette nuit. Et
six versions majeures de Forgejo ne s'avalent pas en effet de bord d'un make — ça se fait
délibérément, avec une sauvegarde avant et une vérification après.
Mais le vrai problème était ailleurs, et l'exploitant avait raison de tirer le fil : l'écart était invisible. Il a fallu quatre requêtes à la main pour découvrir l'état réel :
| Composant | Épinglé | Publié |
|---|---|---|
| oauth2-proxy | v7.15.3 |
à jour |
| Nextcloud | 34.0.1 |
34.0.2 |
| Keycloak | 26.0.7 |
26.7.1 |
| Forgejo | 10.0.0 |
v16.0.2 |
Le devis ne juge pas, il renseigne. Un retard n'est pas une faute ; il sort donc en 0.
Ce qui le distingue d'une liste écrite à la main : les versions épinglées sont dérivées
du dépôt (roles/*/defaults/*_version). La source amont, elle, ne peut pas se dériver —
elle dépend de l'éditeur — et vit dans une table. Toute version épinglée sans entrée dans
cette table fait sortir en erreur. Sans cette garde, un épinglage ajouté demain vieillirait
sans que personne ne le voie, et on croirait tout surveillé alors qu'on ne verrait plus rien.
Une exemption reste possible, mais écrite et motivée — deux le sont déjà (la série PHP de
Debian, la version PostgreSQL vide par défaut).
Éprouvé dans les deux sens : un serveur_redis_version ajouté temporairement fait sortir en
1 en le nommant ; retiré, retour à 0.
Et il rappelle ce qu'on n'épingle pas. Grafana n'a aucune version dans le dépôt — le
13.1.1 vu dans l'historique apt était simplement ce que le dépôt Grafana servait ce
jour-là. Debian, Grafana, smallstep et Icinga prennent tous ce qu'on leur donne au moment du
déploiement. Deux régimes coexistent dans la même flotte, et les taire donnerait
l'illusion que tout est maîtrisé.
2026-08-10 — raser n'annonce plus des destructions qui n'ont pas eu lieu
Le défaut noté la veille est corrigé. raser lisait l'accusé de réception de l'API et
concluait au succès : le DELETE rend un UPID et la main immédiatement, la destruction se
fait en tâche de fond, et elle peut échouer après. Le 2026-08-10, six VM ont été
rapportées « détruites » alors qu'elles étaient toujours là — la tâche sortait sur
VM is locked (clone), un verrou laissé par des clonages interrompus.
Confondre « demande acceptée » et « travail fait » est le pire mensonge possible pour la seule commande destructive du moteur : on croit la place libre, on relance la construction, et rien ne se crée sans qu'on comprenne pourquoi.
_attendre_tache() relit l'UPID, interroge l'état jusqu'à stopped, et rend l'exitstatus
réel. Chaque VM est annoncée détruite ou en échec, avec la cause telle que le cluster
l'a donnée — et le compte final ne ment plus.
Un test qui exerce le défaut, pas seulement le correctif
test_raser_resultat.py fabrique la situation exacte : un faux cluster qui accepte tout,
puis rend une tâche terminée en erreur. raser doit sortir en 1, nommer la cause, et
n'annoncer aucune destruction.
Éprouvé dans les deux sens — c'est ce qui distingue un test d'une décoration. Avec
l'ancien comportement rétabli temporairement, il échoue en désignant précisément le défaut
(« raser a rendu 0 alors que la destruction a ÉCHOUÉ ») ; avec le correctif, il passe.
Raccordé à make test, donc rejoué par P02.
C'est le même motif que le clonage corrigé une heure plus tôt, dans l'autre sens : une opération asynchrone dont on ne vérifie pas l'issue. Les deux venaient du passage à des appels d'API directs, où plus rien n'attend à notre place.
2026-08-10 — Le clonage ne s'attendait plus lui-même, et ça a saturé le stockage
Mon optimisation de la veille au soir a mis le cluster à genoux, et la faute est entière.
En remplaçant proxmox_kvm par un appel d'API direct (pour corriger la résolution par nom),
j'ai perdu quelque chose que le module faisait pour moi : attendre la fin de la tâche
(timeout: 600). POST .../clone rend un UPID et la main immédiatement ; Proxmox copie le
disque en tâche de fond.
En séquentiel ça ne se voyait pas — l'attente de SSH qui suit absorbait le délai. En
parallèle, c'est tout autre chose : make creer-vm rendait la main pendant la copie, la
limite de concurrence ne retenait plus que des processus vides, et les clones
s'empilaient. Mesuré : limite à 4, quatorze copies intégrales du gabarit simultanées.
Le symptôme trompait : CPU de l'hyperviseur à 2 %, RAM à 13/62 Gio — et tout ramait. Ce
n'était pas la machine, c'était TrueNAS (LVM sur iSCSI). Les 14 hôtes ont échoué, et
flotte-creer a refusé de continuer — la garde ajoutée le matin même a fait son travail.
Le clonage attend désormais la fin réelle : il relit l'UPID rendu par l'API et interroge
l'état de la tâche jusqu'à stopped, avec un message clair si la sortie n'est pas OK. La
limite de concurrence retrouve alors un sens — quatre clones réels, pas quatre coquilles.
Au passage : raser annonçait des destructions qui échouaient
En nettoyant, make raser a rapporté « 6/6 VM détruites » alors que les six étaient toujours
là. L'API accepte le DELETE, rend un UPID… et la tâche échoue ensuite sur
VM is locked (clone). raser ne lit que la réponse immédiate, jamais le résultat.
C'est exactement le même défaut, dans l'autre sens — noté ici, pas encore corrigé.
Ce que les optimisations ont réellement donné
Reconstruction complète de Chezlepro, gabarit déplacé sur CephNVMe (proposition de
l'exploitant), concurrence à 3 : 54 min 02 s contre 1 h 12 min 48 s — 26 % de moins,
zéro échec, 2 838 tâches.
| Phase | Avant | Après |
|---|---|---|
| création des 14 VM | 22m08s | 17m57s |
| amorçage PKI + DNS | 6m01s | 6m07s |
| six couches | 44m39s | 29m58s |
Le cache d'artefacts est le plus rentable, et de loin : Nextcloud 10m55s → 4m14s,
Forgejo 3m42s → 1m33s. Vérifié — skipping sur chaque téléchargement, les fichiers du
cache portent toujours leur horodatage d'origine. J'avais annoncé un gain « limité à la part
téléchargement » : cette part était bien plus grosse que je ne le croyais, et la
décompression bz2 n'était pas le mur que je décrivais.
forks = 20 : socle + durcissement + AC + enrôlement PKI des 14 hôtes, 3m09s → 1m37s.
Gabarit sur NVMe + 3 clones : 1m35s → 1m17s par VM. Le gain le plus modeste — les
disques écrivent toujours sur TrueNAS, donc seule la moitié du chemin a été traitée.
L'amorçage n'a pas bougé, et c'est cohérent : deux hôtes l'un après l'autre, aucun levier ne s'y applique.
Sept devis CONFORME après coup, dont le MTU : les quatorze invités naissent à 1450 sans le moindre geste.
2026-08-10 — Le contrôleur télécharge et pousse ; la cible ne tire plus d'Internet
Inventaire mesuré de ce qu'une reconstruction de tenant télécharge — environ 1,5 Gio :
| Quoi | D'où | Taille |
|---|---|---|
nextcloud-34.0.1.tar.bz2 |
download.nextcloud.com | 230 Mio |
keycloak-26.0.7 |
github.com/keycloak | 140 Mio |
forgejo-10.0.0-linux-amd64 |
codeberg.org | 101 Mio |
oauth2-proxy v7.15.3 |
github.com/oauth2-proxy | 18 Mio |
image collabora/code |
Docker Hub | 471 Mio |
paquets apt |
Debian + Grafana + smallstep + Icinga | le reste, ×14 hôtes |
Les quatre premières sont épinglées en version et vont chacune sur UN SEUL hôte. Les retélécharger à chaque reconstruction est un gaspillage, et une dépendance de plus sur le chemin critique — un serveur tiers lent a déjà fait tomber un déploiement de flotte le 2026-08-09, sur le binaire Forgejo précisément.
Pourquoi pousser plutôt que servir un cache
L'exploitant proposait son poste comme cache HTTP. L'intention est juste, mais elle butait
sur ce qu'on avait fermé le matin même : les règles sortantes visent !SETOPS_INTERNES,
donc les trois blocs privés. Une VM de tenant ne peut plus atteindre le poste. Servir un
cache aurait exigé de rouvrir un flux vers le plan d'administration.
L'inversion évite le problème entier : le contrôleur télécharge dans son cache
(~/.cache/setops, gardé par un stat — une fois, jamais deux), puis pousse par le canal
SSH qui existe déjà. Aucun port, aucun service, aucune règle, aucun couplage.
Effet recherché en prime : ces artefacts deviennent déployables hors ligne une fois le cache rempli. Sur une plateforme qui se veut souveraine, ce n'est pas un détail.
Ce que ça ne couvre pas, et qu'il faut nommer
collabora/code, 471 Mio depuis Docker Hub.serveur_collaborainstalledocker.ioet tire une image — la seule entorse à la doctrine « plateforme native, zéro Docker » du dépôt. C'est aussi ce qui explique les règlesdocker0dunftablesgénéré. Elle mérite sa propre décision, pas un contournement discret.- Les paquets
apt, quatorzeapt updatecontre les mêmes dépôts.apt-cacher-ngest la bonne réponse, mais il lui faut un hôte toujours allumé et un flux déclaré : sa place est côté hébergeur, partagé par les tenants — pas sur le poste.
Une mesure qui a contredit mon hypothèse
J'avais avancé que le .zip de Nextcloud décompresserait plus vite que le .tar.bz2.
Vérification : 271 Mio contre 230. Il télécharge donc plus pour décompresser moins
lentement. Le gain net n'est pas établi — le changement de format reste en attente d'une
mesure, pas d'une intuition.
2026-08-10 — Deux optimisations, choisies sur la mesure et non sur l'intuition
Le chronométrage d'une reconstruction complète (1 h 12 min 48 s, phase par phase) a
désigné où part le temps. Deux leviers, pris dans l'ordre du gain mesuré.
forks = 20 — les plays multi-hôtes tournaient en trois vagues
ansible.cfg ne déclarait pas forks : défaut 5, pour 14 hôtes. Chaque couche qui
balaie la flotte — socle, durcissement, enrôlement PKI, les cinq agents — s'exécutait donc
en trois vagues successives.
Les gros rôles n'en profitent pas : Nextcloud (10 min 55 s), Forgejo, Grafana et Keycloak sont sur un seul hôte. C'est bien les couches larges qui payaient.
La création des VM se faisait une par une
14 clones × 1 min 35 s = 22 min 08 s, soit 30 % d'une reconstruction — et ce temps
est surtout de l'attente : clone, démarrage, SSH, verrou dpkg, quatorze fois sans
recouvrement. flotte-creer en lance désormais quatre à la fois (PARALLELE=n pour
ajuster).
La sortie de chaque hôte va dans son propre fichier, recopiée en bloc à la fin. Quatre
clones écrivant simultanément sur la même sortie donneraient un journal illisible — un
comble après une journée passée à traquer des diagnostics masqués. Le marqueur
=== Creation VM: <hôte> === reste émis en direct pour suivre l'avancement ; le détail
arrive ordonné.
Et un échec n'est pas avalé : le code de retour de chaque hôte est relu, et la cible sort
en erreur si l'un d'eux a échoué — sinon _attendre-flotte partirait sur une flotte
incomplète.
Gains estimés, à vérifier par la prochaine mesure : ~15 min sur les clones, ~5 min sur
les couches larges. Le troisième levier identifié — l'archive Nextcloud en .tar.bz2,
décompressée sur un seul cœur — est laissé de côté tant qu'il n'est pas mesuré.
2026-08-10 — Le jeton Keycloak vivait 60 s, et Grafana rendait son échec illisible
Technolibre remonté depuis zéro une seconde fois — 14 hôtes · 2 588 tâches ok · 0 failed.
Deux défauts trouvés en chemin, dont un dont la cause était restée non établie le matin
même.
Le jeton d'administration Keycloak était pris une fois pour toutes
Il était obtenu dans politique-mdp.yml — le 2ᵉ des neuf fichiers du rôle — et réutilisé
jusqu'au 8ᵉ. Or le jeton admin-cli du realm master vit 60 secondes. Entre les
deux : six fichiers de travail, dont groupes-ldap.yml et ses reprises espacées de 15 s.
Sur une construction neuve, le temps écoulé dépasse la minute et l'appel suivant se prend un 401. Sur un rejeu, tout est convergé, ça va vite, ça passe. D'où deux échecs le matin même, suivis chaque fois d'un succès au rejeu — ce qui donnait l'illusion d'une course au démarrage de Keycloak. J'avais écrit alors ne pas avoir de mesure qui le prouve ; c'était la bonne prudence, et la cause était l'âge du jeton.
jeton-admin.yml prend désormais un jeton frais là où on s'en sert. C'est gratuit :
Keycloak répond en quelques millisecondes en local.
Grafana : un échec transitoire, rendu illisible par systemd
Au premier démarrage, après 67 secondes de migrations, grafana-server a échoué sur
failed to create admin user: SQL logic error: no such column: uid — alors que la migration
qui ajoute cette colonne était journalisée comme réussie. Base neuve : tout remigre
correctement, service actif, colonne présente. L'incident ne s'est pas reproduit, et
Chezlepro ne l'a jamais eu.
Je n'ai donc pas corrigé la cause — je ne l'ai pas reproduite. J'ai corrigé ce qui la rendait indéchiffrable :
Restart=on-failurevenait du paquet sansRestartSec, donc 100 ms : systemd a relancé six fois en une seconde, chaque relance rejouant les migrations sur la même base SQLite. Un échec unique se présentait comme un désastre, et il a fallu remonter tout le journal pour retrouver la première erreur — la seule qui disait quelque chose.RestartSec=10;- la rotation du compte de secours échouait cinq fois sous
no_logen annonçant « the output has been hidden », alors que la vraie cause était ailleurs et parfaitement lisible : le serveur ne démarrait pas. Une attente explicite sur le port précède maintenant la CLI, avec un message qui dit d'aller chercher la première erreur du journal.
Troisième fois dans la journée que no_log masque la cause au moment où elle sert. Le
motif est constant, et il mérite d'être retenu : une garde qui protège un secret ne doit pas
emporter le diagnostic avec lui.
Sept devis sur Technolibre
MTU, identité, certificats, PostgreSQL, courriel et frontière : CONFORME. Les expositions
répondent depuis l'edge ; seul le plancher /etc/hosts du poste manquait.
Le devis du MTU mérite une mention : c'est la première flotte du dépôt à naître au bon
MTU sans une seule intervention — le gabarit porte mtu=1, les quatorze invités sont à 1450
dès leur premier démarrage.
2026-08-10 — Le MTU de la zone n'atteignait pas les invités
Question de l'exploitant : « je ne vois nulle part un MTU à 1450 ». Elle était fondée.
Ce qui existait déjà : MTU_OVERLAY_DEFAUT = 1450 dans scripts/underlay.py, dont
P23 dérive sa garde (transport ≥ overlay + 50), et que le devis SDN pose sur chaque
zone. Vérifié sur le cluster : zones t11 et t17 bien à 1450.
Ce qui manquait : le MTU d'une zone ne se propage pas à la carte de l'invité. Les
quatorze VM tournaient à 1500, et cloner_vm_debian.yml ne contenait aucune occurrence
de mtu. La VM émettait donc des trames que son propre chemin ne pouvait pas encapsuler —
la connexion s'établit, les petites requêtes passent, les grosses réponses restent
suspendues. C'est la panne que le registre des flux décrit comme « la plus coûteuse à
diagnostiquer », et pour laquelle il déclare l'ICMP « fragmentation nécessaire ».
Pourquoi personne ne l'avait vue : les quatorze VM vivent sur asgard. Deux VM du même
hyperviseur communiquent par le pont local, sans encapsulation — rien ne rencontre le
1450. Le défaut serait apparu au premier éclatement de la flotte sur plusieurs nœuds, c'est-
à-dire exactement quand il faudra héberger les deux tenants ensemble.
Corrigé en deux endroits, et mtu=1 plutôt que 1450 — la valeur Proxmox qui signifie
« hérite du pont » : juste en SDN (1450) comme hors SDN (1500), et encore juste le jour où la
fabric passera aux trames jumbo.
- le gabarit le porte (
net0 … mtu=1), ce qui couvre les clonages qui ne passent pas par le playbook — un clone fait à la main, par exemple. Proposition de l'exploitant, et elle est meilleure : elle attrape tous les chemins ; cloner_vm_debian.ymlle repose à chaque clone, parce qu'un gabarit se recapture (fait la veille) et que ce qui n'est pas versionné se perd en silence.
make mtu-mesurer — septième devis
Il rattache chaque hôte à sa zone par son pont dérivé (t17serv → zone t17) et lit le
MTU attendu dans devis_sdn.py, la source qui configure les zones. Rien n'est saisi. Il a
trouvé l'écart sur 14 hôtes du premier coup, et l'a confirmé corrigé.
Ce que j'ai cassé en corrigeant
Appliquer mtu=1 aux cartes de VM en marche a coupé le réseau des quatorze machines
d'un coup : Proxmox détache et rebranche la carte, l'invité ne reconfigure pas son
interface. Flotte à 0/14 pendant trois minutes.
Ce qui a permis d'en sortir : les VM tournaient et l'agent qemu répondait — un canal indépendant du réseau invité. Redémarrage par l'API, la configuration s'est appliquée proprement au démarrage, 14/14 ensuite.
La faute est d'avoir appliqué à la flotte entière un changement dont je n'avais pas mesuré l'effet à chaud. Dans le playbook, la même tâche s'exécute avant le démarrage du clone : aucun risque. Sur une VM déjà en service : poser la configuration, puis redémarrer — et sur une seule d'abord.
2026-08-10 — Technolibre est debout : six devis, et P35
L'épreuve de portabilité est passée. Un second écosystème souverain complet, monté depuis
zéro par le même moteur : 14 hôtes · 2 583 tâches ok · 331 changed · 0 failed. Plan
distinct, voûte séparée, realm technolibre, sa propre autorité de certification — et une
topologie différente, LDAP et SSO sur des machines séparées là où Chezlepro les co-localise.
Les six devis, sur le déployé :
| Devis | Verdict |
|---|---|
| identité | CONFORME — realm, fédération, mappeurs, politique |
| certificats | CONFORME — aucun certificat servi en fin de vie (2 réserves latentes, identiques chez Chezlepro) |
| PostgreSQL | CONFORME — chiffrement imposé, aucun réseau hors du supernet dérivé |
| courriel | CONFORME — la chaîne tient, de la résolution LDAP à la boîte |
| frontière | CONFORME — 55 lignes, 0 écart, 17 services livrés comme déclarés |
| expositions | les 6 services répondent depuis l'edge ; le poste ne résout pas encore technolibre.internal (6 entrées /etc/hosts absentes — le « plancher ») |
Le devis d'identité lisait l'annuaire par un socket local, depuis l'hôte SSO
Sixième défaut, et le plus instructif : le play tourne sur serveur_keycloak et interrogeait
LDAP en ldapi:/// — un socket UNIX local. Cela ne fonctionnait que par co-location
accidentelle. Un tenant qui sépare l'annuaire du SSO faisait échouer le devis sur
« Failed to import the required Python library (python-ldap) » : l'hôte SSO n'a évidemment
pas de client LDAP. Les deux lectures sont désormais déléguées à l'hôte dérivé par
resoudre_annuaire. Co-localisés, la délégation est un aller-retour sans effet ; séparés,
elle est la seule façon que ça marche.
P35 — une application qui exige une base en a une, et on le sait en deux secondes
resoudre_base porte déjà la garde (D-72), mais elle s'est déclenchée à la 92ᵉ tâche de
collab-01, après quarante minutes, pour un écart entièrement lisible dans le plan.
D-75 : ce qui est statiquement lisible se prouve statiquement.
Rien n'y est codé en dur — et c'est ce qui la rend juste. Les rôles qui exigent une base sont
ceux qui incluent resoudre_base ; le groupe qu'ils réclament est lu dans le défaut de
la variable qu'ils passent, jamais déduit de leur nom : serveur_icingaweb2 réclame la base
de serveur_icinga, et une preuve qui aurait supposé « rôle = groupe » aurait crié sur un cas
parfaitement sain. Les noms acceptables suivent la même règle que le résolveur : le groupe, ou
toute application qui déclare ce groupe.
Éprouvée dans les deux sens et sur les deux tenants — dont les registres n'ont pas la même
portée (noms courts chez l'un, noms de groupe chez l'autre) : base retirée → ÉCHEC la
nommant ; restaurée → OK. P01–P35.
2026-08-10 — Épreuve de portabilité : monter un SECOND tenant révèle trois défauts invisibles
Les deux reconstructions from-zero de la semaine rebâtissaient Chezlepro sur son propre
matériel : une preuve de reproductibilité, pas de portabilité. La vraie épreuve est un
second tenant — Technolibre, index 11, plan distinct (id-ldap-01, id-sso-01,
sup-01… là où Chezlepro a idm-01, mon-01), voûte séparée, sur le même cluster.
Elle a trouvé en une heure trois défauts qu'un seul tenant ne pouvait pas révéler.
1. Le clonage résolvait par NOM — et ne faisait rien
community.general.proxmox_kvm cherche d'abord une VM portant le name demandé. S'il en
trouve une, il conclut « elle existe déjà », rend ok et ne clone rien — aucune tâche
n'apparaît même côté cluster. Or les noms courts sont volontairement identiques d'un tenant
à l'autre : même fonction, même nom, c'est le pool qui restitue l'appartenance. Le premier
clone de Technolibre, backup-01, est donc tombé sur le backup-01 de Chezlepro, n'a rien
fait, et l'attente a expiré sur une configuration qui n'existerait jamais.
Mesuré, pas déduit : id-ldap-01 et sup-01 — noms que Chezlepro n'a pas — se sont
créés du premier coup ; backup-01 échouait systématiquement. Zéro VM créée, zéro tâche
qmclone au cluster.
Le clonage passe désormais par un appel d'API ciblé par VMID : recensement des VM, puis
POST /nodes/<n>/qemu/<gabarit>/clone seulement si le VMID cible est libre. Plus aucune
résolution par nom.
Deux défauts de ce correctif, trouvés en le mesurant — et tous deux du même genre que ce qu'il corrige :
- le corps de la requête était assemblé en Jinja avec
>-, ce qui rend une chaîne : lepools'est perdu en route et la VM est née hors de son pool, sans un mot. Réécrit en mapping YAML avecomit; - l'application du gabarit de calcul expirait à 5 s de lecture — le nœud vient de terminer un clone complet. La VM restait aux valeurs du gabarit (2 cœurs / 2 Go au lieu du plan), en silence. Six tentatives espacées de 10 s.
Et no_log: true a masqué la cause au moment précis où elle servait : l'échec se lisait
« the output has been hidden », et il a fallu interroger le cluster à la main. Les deux
attentes disent maintenant ce qu'elles ont constaté, sans révéler l'en-tête d'autorisation.
2. Le GUI détruisait des intrants
Dans ecrire_intrants, la branche identite était la seule sur quatre à écrire par-dessus
le disque au lieu de fusionner. Un enregistrement du panneau a supprimé dns_amorcage et
amorcage_acces_courriel de Technolibre. Sans le premier, une VM naît sans résolution et
apt ne peut rien installer ; sans le second, le déploiement s'arrête sur la garde de
amorcage_acces (D-72). Le même geste sur Chezlepro aurait mangé les mêmes clés.
3. Le verrou de raser n'était prouvé que pour un tenant
Le faux cluster de scripts/tests/test_raser.py codait en dur les VMID de Chezlepro. Monté
sur un autre tenant, le test rendait 0 au lieu de 2 — « aucune VM du plan n'est présente,
rien à faire ». Le verrou de la seule commande destructive du moteur passait donc au vert
sans rien éprouver. Le faux cluster fabrique désormais la collision sur le plan courant,
quel qu'il soit.
4. Le repli nftables survivait à la bascule — et annulait tout
Le socle pose l'un de deux fichiers dans /etc/nftables.conf : le ruleset dérivé
(make flux, table setops_flux) s'il existe, sinon un gabarit de repli plat (table
setops_filter) qui n'ouvre que le 22. Ce sont des alternatives, jamais des couches.
Mais le rechargement est nft -f, qui ajoute sans purger, et le fichier dérivé retirait
soigneusement setops_flux… jamais setops_filter. Un hôte passé du repli au dérivé se
retrouvait donc avec deux chaînes input sur le même hook, toutes deux en policy drop.
Le paquet traverse les deux : seule l'intersection de leurs accept passait — le 22 et
l'ICMP, rien d'autre.
Le symptôme était parfaitement trompeur : l'AC debout, son port 8443 en écoute, sa
règle ip saddr { … } tcp dport 8443 accept posée et acceptante, l'ICMP entre les deux
VM à 0,15 ms — et step ca bootstrap qui expire. Il a fallu lire le ruleset entier pour
voir la seconde table.
Le fichier dérivé retire désormais les deux tables (le repli, lui, portait déjà
flush ruleset). Pas de flush ruleset côté dérivé : c'est un choix du dépôt pour ne pas
détruire de tables étrangères, et il est respecté.
Ce qui l'avait rendu possible : make reconstruire ne générait jamais les flux.
Sans eux, flux-genere/ est vide, le repli est posé, et l'hôte passe ensuite au dérivé —
exactement la bascule qui casse. make flux est maintenant la première étape de
reconstruire. Invisible sur un écosystème déjà construit, dont les .nft traînent d'une
exécution précédente.
no_log a masqué la cause trois fois dans la même journée
Le clonage, l'attente de configuration, et la déclaration des URI de déconnexion Keycloak : trois échecs lus « the output has been hidden », dont un au terme d'un déploiement de 157 tâches. Le mot-clé protège de vrais secrets — un jeton d'API porté par un en-tête — et on ne peut pas simplement l'enlever.
Les trois tâches extraient donc désormais le verdict à part : ce qui a échoué, avec quel code et quel message, sans jamais toucher aux en-têtes. Une garde qui protège un secret ne doit pas emporter le diagnostic avec lui.
Ce qui relevait des données du tenant, pas du moteur
L'instance datait d'avant plusieurs évolutions, et les preuves statiques les ont toutes
attrapées avant le déploiement : client_unbound déclaré au plan alors qu'il est devenu
une intégration universelle ; amorcage_acces_courriel absent ; gabarit 99999 alors que le
recapturé porte 99998 — le premier clone aurait échoué ; parefeu_interface: false, qui
aurait laissé le pare-feu est-ouest inerte sans le dire ; collab-01 à 1 cœur / 1 Go au lieu
du dimensionnement dérivé.
2026-08-10 — P34 : la convention « chaque document déclare son lecteur » devient une garde
La refonte de ce matin posait une convention. Une convention qu'on n'outille pas tient tant que quelqu'un y pense — c'est exactement le raisonnement de D-70, et voici son application au corpus documentaire. D-74, gardée par P34.
L'état de départ, mesuré : 2 documents sur 34 déclaraient leur lecteur. Les 32 autres
disaient leur sujet. C'est ce qui avait enfoui le runbook de reprise le plus utile du dépôt
au §6 de autorisation.md.
Les 38 documents le déclarent désormais, et le lecteur a été déterminé document par
document — pas collé au gabarit. Trois familles : l'exploitant (les devis, la migration
de tenant, le cycle de vie des VM, le gabarit d'or, autorisation.md §6…), le mainteneur
(les conceptions, les registres, la carte), et deux cas à part — ecosysteme-chezlepro.md
s'adresse au lecteur externe, MISE-A-JOUR-CODEX-CLAUDE.md à l'agent IA qui reprend
le dépôt.
Deux exemptions, dérivées et non listées — un chemin en dur aurait vieilli à la première page ajoutée :
- un document qui s'annonce généré ne se lit pas, il se régénère. On le reconnaît à sa propre en-tête (« Généré par », « ne pas éditer à la main ») : 13 documents, tous réellement générés — vérifié un par un, aucun document écrit à la main n'est exempté par accident ;
- un fragment sans titre
#n'est pas un document.
La preuve ne lit que l'en-tête, jamais le corps : une mention de « Pour qui » perdue au
milieu d'une page ne serait pas une porte. C'est aussi ce qui empêche frontiere-opnsense.md
et plan-et-generation.md — qui parlent de génération dans leur corps — d'être exemptés à
tort.
Éprouvée dans les deux sens, parce qu'une garantie qu'on n'a jamais vue dire non est une
habitude, pas une garantie. Elle a d'abord échoué toute seule à sa première exécution, en
nommant deux documents que mon inventaire avait manqués (protocole-operateur-independant.md,
reference-avant-reconstruction-2026-08-08.md — tous deux dans docs/audit/, hors de mon
motif). Puis test négatif délibéré : déclaration retirée de meta-classe.md → ÉCHEC le
nommant précisément ; restaurée → OK.
Ce qu'elle ne teste pas : que le lecteur déclaré soit le bon. Ça se juge en revue. Elle garantit qu'on a dû y penser — ce qui est précisément ce qui manquait.
P01–P34, et les comptes périmés corrigés au passage (AGENTS.md et devis-services.md
annonçaient encore 30 preuves).
2026-08-10 — Refonte documentaire : on n'arrive pas avec un sujet, on arrive avec une situation
La documentation était organisée par sujet — identité, courriel, DNS, PKI, sauvegardes. C'est l'organisation juste pour de la référence. Mais personne n'arrive avec un sujet. Il y a exactement quatre situations, trois avaient déjà une porte, et deux de ces trois ne s'annonçaient pas :
| Situation | Lecteur | Porte |
|---|---|---|
| « c'est quoi ? » | qui découvre | README.md |
| « je viens d'hériter » | l'exploitant | wiki/Reprendre-l-écosystème.md — n'existait pas |
| « je dois modifier » | le mainteneur | docs/carte-set-ops.md — le dit désormais |
| « j'apprends le métier » | l'apprenant | wiki/Home.md — le dit désormais |
Une seule page créée, et elle ne contient presque rien en propre : un ordre et des renvois, en cinq temps. Dans quel état tu hérites (les six devis avant tout geste) ; entrer (la clé de voûte, l'amorçage, la racine qui mène à la mauvaise console, l'AC) ; de quoi c'est fait (à demander au plan, pas à lire) ; quand ça casse ; ce qui va te mentir.
Aucun fichier déplacé, aucune réécriture du wiki. Les liens, l'historique git et les renvois croisés valent plus qu'un rangement.
La convention qui empêche la rechute : chaque document déclare son lecteur en première
ligne — pas un sujet, un lecteur et sa situation. C'est ce qui manquait vraiment :
autorisation.md contient un runbook de reprise parce que le sujet est l'autorisation, et
personne ne va l'y chercher. Un document qui déclare son lecteur se range tout seul, et un
intrus s'y voit.
Le wiki devient la porte unique du lecteur, le dépôt reste la source. wiki-publier fait
un delete puis recopie : une page modifiée dans l'interface de la forge est détruite à
la publication suivante. La règle est maintenant écrite dans README.md, Home.md et la page
de reprise — elle ne l'était nulle part.
Ce qui n'a finalement pas été écrit, et pourquoi. La page « Ce qui va te mentir » était
prévue. Trois des cinq pièges qu'elle devait cataloguer ont trouvé un meilleur domicile
pendant qu'on travaillait — le connect() vers le vide (frontiere-opnsense.md, et
frontiere-mesurer porte désormais le contrôle qui tranche), le make prouver vert
(Vérifier le déployé), le banner exchange. Les deux orphelins s'adressent à qui écrit du
code, pas à qui reprend l'exploitation. Une page séparée aurait redit ce que trois autres
disent déjà.
Sa substance survit : la section ⑤ de la page de reprise porte la règle qui les relie — vérifier l'instrument avant d'accuser le composant, une sonde porte toujours un contrôle — et renvoie chaque signal faux à son domicile.
Un trou trouvé en vérifiant mes propres renvois. Le banner exchange n'était documenté
nulle part où on le cherche : un commentaire du Makefile et trois entrées de ce fichier.
Écrit en runbook §3, avec ses trois causes par fréquence et ce qui tranche dans l'ordre.
Vérifié : chaque cible make et chaque lien contrôlés un à un (make ca-installer n'existe
pas — c'est ca-racine + ca-empreinte) ; les cinq liens du README résolvent ; prouver.py
0 ; plan de recette inchangé.
2026-08-09 — La frontière est étanche : 56 lignes conformes, dans les deux sens
L'exploitant a retiré la dernière règle héritée, celle qu'il avait lui-même étiquetée
PAS SUPPOSÉ -> ACTION REQUISE. make frontiere-mesurer : CONFORME, code 0. Tout ce
qui est déclaré est livré, tout le reste est refusé — y compris collab-01:9980, le seul
qui livrait vraiment un HTTP/1.1 200 OK depuis le poste.
Et mon instrument avait tort, pas la frontière. Il comptait 38 écarts. Il concluait
depuis le client : connexion établie ⇒ la bordure a relayé. Faux, et vérifié à la
destination — pendant que le poste tenait une connexion « établie » vers idm-01:389,
idm-01 n'en voyait aucune ; collab-01 n'en voyait aucune sur 9980. La frontière répond
elle-même à la poignée TCP, pour toute destination qu'elle route, sans jamais relayer.
Le devis raisonne désormais sur la livraison seule : un port est conforme s'il livre quand il doit livrer et ne livre rien quand il ne doit pas. Ce que fait la poignée TCP ne regarde personne. Le contrôle, en conséquence, ne rend le relevé NUL que s'il livre des données — qu'il ressorte AMBIGU est attendu ici, et le rapport le dit en toutes lettres à chaque exécution. Cette relaxation rend aussi le sens sortant mesurable : il était déclaré NUL en permanence.
Un flux publié n'est pas forcément fait pour un poste de travail. Nouveau mot-clé
poste: false dans meta/flux.yml : le 25 entrant de Postfix est un flux serveur à
serveur (les MX distants). La frontière l'étendait au VLAN d'administration, où le
nftables de l'hôte le refusait — deux couches qui ne déclarent pas la même politique, et
une politique qu'on ne peut plus lire. Deux règles retirées. Le mot-clé vit avec le rôle,
qui sait ce que son port veut dire ; le générateur ne connaît toujours aucun numéro de port.
Vérifié : frontiere-plan sans écart (41 règles, 12 routes), flotte 14/14,
frontiere-mesurer CONFORME, prouver.py 0.
2026-08-09 — Un sixième devis : « ce qui n'est pas déclaré est-il refusé ? »
devis_expositions.py pose la question positive — chaque exposition déclarée répond-elle.
Il manquait la négative, et ce n'est pas la même : un pare-feu peut très bien servir tout
ce qu'on lui demande et laisser passer tout le reste. make frontiere-mesurer la pose.
Les cibles ne sont pas saisies : ce sont les ports réellement en écoute dans la flotte,
relevés par le playbook. Sonder un port fermé ne prouverait rien du pare-feu — le refus
viendrait de la machine. La politique attendue non plus : elle est lue dans
devis_opnsense.py, la source même qui configure la frontière.
scripts/sonde_tcp.py refuse de conclure. Deux principes, tirés des trois faux
diagnostics de la semaine :
- Un contrôle avant tout verdict — une adresse où personne n'écoute. Si elle répond, le relevé est déclaré NUL et aucun verdict n'est rendu. Mieux vaut pas de mesure qu'une mesure fausse.
- Établir n'est pas livrer. La sonde fait parler le service : bannière, sinon requête HTTP minimale, sinon poignée TLS. Si rien ne revient, le verdict est AMBIGU — pas « ouvert ». LDAP et PostgreSQL attendent un message bien formé qu'on ne fabrique pas ici, et un synproxy se comporte exactement pareil.
Le verdict distingue deux natures d'écart, qui n'appellent pas le même geste : refusé attendu mais connexion établie = la bordure a relayé, c'est le trou ; livré attendu mais rien livré = la bordure autorise et l'hôte refuse, rien ne fuit mais les deux couches ne déclarent pas la même politique.
Deux pièges rencontrés en le construisant, tous deux corrigés et commentés sur place :
- La sonde « tenant » s'exécutait en réalité sur le poste : dans un play
connection: local, undelegate_tohérite de cette connexion. Elle rendait donc la frontière joignable depuis un tenant — ce qu'elle est, depuis le VLAN d'administration. Vérifié à la main avant de la croire :TimeoutErrordepuisbackup-01comme depuisinfra-dns-01. Même famille que leconnect()— vérifier d'où l'instrument mesure. - Le délai de lecture de 2 s faisait ressortir le
25d'un Postfix parfaitement sain en AMBIGU :postscreenretarde sa bannière exprès. Porté à 8 s, compensé par du parallélisme — raccourcir aurait fabriqué de faux écarts.
Premier verdict, la frontière étant encore en l'état : 38 écarts. Trente-sept sont
« la bordure a relayé » (la règle héritée étiquetée PAS SUPPOSÉ -> ACTION REQUISE laisse
passer le VLAN d'administration vers tout port en écoute), dont un livre vraiment —
collab-01:9980, Collabora, répond HTTP/1.1 200 OK depuis le poste. Le trente-huitième
est de l'autre nature : la frontière autorise edge-mta-01:25 depuis le VLAN
d'administration, mais l'hôte le refuse. Le relevé sortant est déclaré NUL, son contrôle
ayant répondu.
2026-08-09 — « La frontière ne doit jamais laisser passer de trafic impertinent » — validé, et deux trous fermés
Exigence de l'exploitant, validée à l'instrument : de vraies requêtes applicatives contre
des destinations que la politique interdit, jamais un connect().
Le transit tenant était déjà correct. Depuis une VM : 192.168.11.41:22 (hyperviseur),
10.0.0.1:22 (frontière), 10.0.0.17:22 (poste), 192.168.11.41:8006 (Proxmox) — tous
muets. Seul ce qui est déclaré passe. Mieux : le 25 sortant passe depuis edge-mta-01
(bannière 220 mx.google.com ESMTP) et est refusé depuis infra-dns-01 — le filtrage
est bien par hôte source, pas par tenant.
Trou n° 1 — nos flux sortants visaient any. « Vers Internet » n'excluait ni le plan de
gestion, ni la frontière, ni le supernet du voisin. Mesuré : depuis une VM du tenant,
https://10.0.0.1/ — la console d'administration du pare-feu — répondait. Autorisé par
notre propre règle. Les dix-huit règles sortantes visent désormais !SETOPS_INTERNES, une
destination niée valant les trois blocs privés RFC 1918. Pas la liste de nos réseaux :
elle laissait dehors 192.168.11.0/24, le plan de gestion hérité — et une exclusion
incomplète ne protège rien. Un réseau interne ajouté demain est couvert sans rien changer.
Vérifié après application : 10.0.0.1:443 bloqué depuis les deux VM testées, sortie web,
DNS public et SMTP vers un MX public toujours passants, flotte 14/14.
Trou n° 2 — le défaut-deny du LAN n'avait aucun effet. Mesuré depuis le poste : le 443
d'un nginx répondait alors que seul le 22 est déclaré. La cause est la règle d'usine
Default allow LAN to any rule, qui autorise tout depuis le VLAN d'administration. Elle
n'est pilotable par aucune API — vérifié : zéro règle non-Set-OPS visible côté API. Sa
désactivation appartient donc à l'exploitant, dans l'interface.
Ce que Set-OPS pouvait faire, et fait : déclarer les flux d'administration légitimes
pour que cette désactivation ne coupe pas l'exploitant de ses propres services. Un service
publié est joignable depuis Internet par le WAN, mais aussi depuis le VLAN d'administration
où se trouve son poste ; ce second chemin ne reposait jusqu'ici que sur la règle d'usine.
Dix règles ajoutées sur l'interface de gestion (80, 443, 993, 25 et ICMP frag-needed vers
les hôtes concernés, par tenant). Vérifié : curl vers le nginx du tenant rend 302.
Ce qui reste, et qui n'est pas à nous : tant que Default allow LAN to any rule est
active, le VLAN d'administration atteint tout. Les règles qui la rendent superflue sont
maintenant en place — la désactiver est un geste d'interface, à faire les yeux ouverts.
2026-08-09 — La frontière ne déclare plus que ce qui existe, et l'applicateur possède enfin ses routes
Suite directe de l'enquête ci-dessous, menée jusqu'au bout à l'instrument plutôt qu'à l'hypothèse. Trois corrections, dans l'ordre où la mesure les a imposées.
1. Retrait des deux routes /16 de la frontière. Les douze /24 réellement attribués
étant en place, les supernets ne servaient plus qu'à envoyer vers le nœud de sortie des
destinations qui n'existent nulle part. Retirées par l'API après vérification que les
quatorze hôtes planifiés tombent tous dans les six /24. Flotte : 14/14 avant, 14/14 après.
2. Les alias de tenant valaient le supernet. Le retrait des /16 n'a rien changé au
symptôme, ce qui a désigné le vrai coupable : SETOPS_TENANT_CHEZ17 valait 10.27.0.0/16,
donc nos propres règles autorisaient admin → tout le /16:22. L'état pf portait la
description de notre règle. Les alias énumèrent désormais les sous-réseaux attribués —
même geste que pour les routes, et pour la même raison. Ils servent à la fois de
destination aux règles et de source au NAT sortant : les deux se resserrent ensemble.
3. Une garde pour que les deux ne divergent plus. verifier() exige maintenant que
l'ensemble des réseaux routés et l'ensemble des réseaux autorisés coïncident
exactement. Un alias plus large laisse le filtre approuver l'inexistant ; un alias plus
étroit fait acheminer vers ce que le filtre refuse. Les deux pannes se voient au devis,
plus à l'usage. Attachée à P24, qui ne vérifiait jusqu'ici que la traduction NAT.
Et le symptôme, alors ? Il subsiste, et il n'est ni dans nos règles ni dans nos routes.
L'état pf porte désormais le nom de la règle d'usine Default allow LAN to any rule, qui
répond au SYN à la place de la destination. Mesure qui tranche : depuis une VM du
tenant, 10.99.99.99 et 172.31.99.99 — des adresses qui n'appartiennent à personne —
« s'établissent » en 1 ms, et aucune ne rend de bannière SSH, quand la vraie VM rend
SSH-2.0-OpenSSH_10.0p2. Le connect() ne mesure rien sur ce chemin, quel que soit le
point de départ ; seule une requête applicative tranche. Les règles héritées appartiennent
à l'exploitant : Set-OPS n'y touche pas.
Les routes sont enfin réconciliées. Je les avais posées avec un script hors dépôt :
rien ne les comparait au devis, et leur disparition n'aurait été vue par personne — le
défaut exact que cet applicateur existe pour empêcher. appliquer_opnsense.py les traite
maintenant comme les règles et le NAT : identité portée par la description
(setopsroute:<tenant>:<réseau>-><saut>), création avant retrait, périmètre strict. Le nom
de la passerelle est résolu depuis l'adresse du prochain saut plutôt que redemandé en
intrant. Les douze routes existantes ont été réétiquetées en place — aucune coupure.
Vérifié : make frontiere-plan → « la frontière dit déjà ce que le devis dit », 12 routes
inchangées, flotte 14/14, prouver.py code de sortie 0.
2026-08-09 — Le connect() qui « ne prouvait rien » n'était pas de l'anti-usurpation
L'exploitant : « je ne trouve pas ça normal ». Il avait raison, et pendant deux jours nous avons tous les deux attribué ce comportement à une fonction d'anti-usurpation de la frontière — moi le premier, et je l'avais même consigné comme tel.
Mesuré, pas raconté. Depuis le poste, quatre connexions sur quatre s'établissaient, y compris vers une adresse où aucune machine n'existe. Mais vers des réseaux hors du tenant, tout était refusé — donc rien n'interceptait globalement. Et depuis l'intérieur du tenant, le comportement était correct partout où un VNet existe : hôte absent → refusé, port fermé → refusé. L'anomalie ne touchait que les portions de supernet non couvertes par un VNet.
La cause était une route manquante, pas un pare-feu.
vrf_t17 : les six /24 des VNets, puis default -> 10.0.4.1
RIEN pour le reste de 10.27.0.0/16
Une adresse non attribuée sortait donc du VRF par le défaut, atteignait la frontière, qui
la renvoyait à l'hyperviseur — où elle arrivait dans la table principale, pas dans le
VRF, et repartait vers 192.168.11.254, la passerelle du réseau d'administration.
ip route get 10.27.99.99 le disait en une ligne.
Corrigé où le dépôt a la main : strophe_frr pose désormais, dans chaque VRF, un puits
sur le supernet du tenant — moins spécifique que les /24 de ses VNets, donc invisible
au trafic légitime. Dérivé du seed, comme tout le reste.
depuis le tenant, apres : 10.27.99.99 refusee 10.27.18.99 refusee 10.27.18.21 ETABLIE
Une machine du tenant ne peut plus atteindre le réseau de gestion par une faute de frappe. C'était le vrai risque, et il est fermé.
Ce qui reste, et que je ne corrige pas sans arbitrage. Depuis le VLAN d'administration,
le connect() réussit encore : ce trafic n'entre jamais dans le VRF. OPNsense route tout
10.27.0.0/16 vers l'hyperviseur, dont la table principale ne connaît que les six /24.
Deux remèdes possibles — un puits symétrique dans la table principale, ou n'annoncer à la
frontière que les /24 réellement attribués. Le premier touche la table qui porte
l'administration des hyperviseurs ; ce n'est pas un geste à faire de sa propre initiative.
La leçon dépasse la route. Nous avons expliqué pendant deux jours un symptôme par une cause plausible et fausse, et cette explication est entrée dans la documentation. Ce qui l'a défaite n'est pas un raisonnement plus fin : c'est d'avoir mesuré depuis deux points de vue différents. Un seul point de vue donne une histoire cohérente — souvent la mauvaise.
2026-08-09 — Le wiki rattrape ce que la reconstruction a appris
Le wiki datait du 3 août — six jours avant tout ce qui précède. Vérifié avant d'y toucher :
toutes les cibles make qu'il cite existent, aucune commande morte. Il était
factuellement plus sain que craint.
Deux corrections, dont une qui compte.
La-preuve.md annonçait « P01–P21 » ; le harnais est à P33. Les trois nouvelles
sont ajoutées au tableau des classes d'erreur — et surtout, la page dit désormais ce que
ces preuves ne font pas : elles sont toutes statiques, elles lisent le dépôt, et c'est
dans cet angle mort qu'une AC est restée expirée huit heures sous un harnais vert.
Infra-as-Code-et-idempotence.md enseignait l'idempotence comme acquise — « redéployer
un rôle déjà en place → changed=0 ». Vrai rôle par rôle, faux à l'échelle de la flotte, ce
que personne n'avait mesuré. La page enseigne maintenant depuis les chiffres (924 → 17 → 0)
et raconte ce que les 17 cachaient : un service mort depuis des semaines, et un secret que
le dépôt faisait tourner à chaque déploiement. Elle explique aussi pourquoi il faut
vérifier le zéro — 2266 tâches exécutées contre 2152, donc convergence et non silence.
Nouvelle unité : Vérifier-le-déployé. C'est la notion que la journée a mise au jour et
qu'aucune page ne portait : la différence entre valider du code et vérifier un système.
Elle enseigne les trois règles qui séparent un devis utile d'un devis décoratif — ne jamais
redéclarer ce qu'on vérifie, faire une vraie requête plutôt qu'un connect(), et se méfier
d'un code de retour pris pour un verdict — puis renvoie au vocabulaire commun
(drift detection).
Sa dernière consigne est celle que je retiens de ces deux jours : chercher, dans son propre outillage, une vérification qui n'a jamais échoué, et se demander si c'est parce que tout va bien ou parce qu'elle ne regarde rien.
2026-08-09 — Idempotence de la flotte : zéro, et c'est un zéro qui veut dire quelque chose
plays taches ok changed
rejeu depuis zero 61 2152 924
2e passage 30 2249 17
3e passage 30 2266 0
Zéro tâche changed, zéro échec, sur les quatorze hôtes. Le dépôt n'avait jamais fait
ce test à l'échelle de la flotte.
Vérification du zéro, parce qu'un zéro peut aussi signifier que les rôles ne font plus rien : plus de tâches se sont exécutées au passage à vide qu'au rejeu — 2266 contre 2152. Elles ont toutes tourné et toutes trouvé le système conforme. Un zéro obtenu avec moins de tâches aurait dit l'inverse.
Ce que ce test aura coûté et rapporté. Trois défauts trouvés, dont deux n'étaient pas
des défauts d'idempotence mais des pannes silencieuses : node_exporter mourait à chaque
renouvellement de certificat sur les quatorze hôtes, et chaque déploiement invalidait les
jetons OAuth2 de la forge. Aucune des deux ne se signalait autrement — c'est le compte de
changed qui les a fait apparaître.
Ce chiffre devient la ligne de base. Un déploiement futur qui rapporte changed sur
une flotte non modifiée signale désormais quelque chose. Tant que le fond était à 17, ce
signal était noyé.
2026-08-09 — Les deux dernières tâches non idempotentes, et ce qu'elles cachaient
Prometheus — une liste non ordonnée. intersect rend un ensemble, dont l'ordre
d'itération n'est pas stable d'un processus Python à l'autre. Le fichier de configuration
se rendait donc différemment à chaque passage — mêmes quatorze cibles, ordre différent — et
Prometheus redémarrait pour rien. Trié sur les hôtes : ordre déterministe et lisible.
Second passage à changed=0.
Forgejo — le dépôt faisait tourner un secret du service. JWT_SECRET est généré par
Forgejo au premier démarrage et ajouté par lui à la fin d'app.ini. Le gabarit ne le
portait pas : chaque rendu l'effaçait, Forgejo en générait un nouveau au redémarrage,
et le passage suivant recommençait.
Ce n'était donc pas du bruit : chaque déploiement invalidait les jetons OAuth2 émis par
la forge. Le rôle relit maintenant le secret avant de rendre et le repose. Vérifié : même
empreinte avant et après un déploiement, changed=0 aux deuxième et troisième passages.
Trois erreurs de méthode de ma part, dans cette seule enquête, et elles méritent d'être écrites :
- J'ai conclu « le diff est vide, donc le contenu est identique » — alors que
no_logmasquait le diff. Toute mon hypothèse sur le mode0640reposait là-dessus. - J'ai appliqué un
str.replacesur le gabarit sans vérifier qu'il avait pris. La section[oauth2]n'existait pas : le remplacement n'a rien fait, j'ai affiché un message de succès, et j'ai interprété trois passages d'essai sur cette base. - J'ai gardé une expression Jinja qui fonctionnait en isolation mais échouait sur l'hôte,
sans pouvoir la diagnostiquer parce que
no_log— indispensable, c'est un secret — masquait l'erreur. Remplacée par une forme plus simple.
La leçon commune est celle de la journée, retournée contre moi : vérifier l'effet, pas
l'intention. Un replace qui ne trouve rien réussit silencieusement, exactement comme
kcadm -s sur une map.
2026-08-09 — Le passage d'idempotence trouve une panne, pas une imperfection
17 tâches changed au second passage, contre 924 au rejeu depuis zéro. La flotte
converge à 98 %. Mais les 17 restantes ne sont pas du bruit — l'une d'elles cachait un
service mort.
13x client_metrique : Activer et demarrer node_exporter
1x serveur_prometheus : Deployer la configuration Prometheus -> redemarrage
1x serveur_forgejo : Deployer app.ini -> redemarrage
Treize hôtes sur quatorze redémarraient node_exporter à chaque passage. Pas parce que
la tâche est mal écrite : parce qu'Ansible le trouvait arrêté et le ressuscitait. Le
dump du module le disait sans ambiguïté — ActiveState: inactive, SubState: dead,
ExecStart ... code=killed ; status=1/HUP.
La cause. Le script de synchronisation du certificat faisait
systemctl try-reload-or-restart prometheus-node-exporter. Cette commande recharge si
l'unité déclare un ExecReload — et Debian en déclare un : kill -HUP $MAINPID. Or
node_exporter ne sait pas se recharger : il meurt sur SIGHUP.
Le commentaire du script énonçait l'hypothèse inverse — « node_exporter relit le cert à chaud ; un reload suffit ». C'est l'hypothèse qui était fausse, pas le code.
Conséquence, jusqu'à aujourd'hui : à chaque renouvellement de certificat — toutes les 24 h — la collecte de métriques s'arrêtait sur toute la flotte, et rien ne le disait. Elle repartait au déploiement suivant, ce qui rendait la panne invisible à qui déploie souvent.
Mesuré plutôt que supposé, sur backup-01 :
systemctl reload alloy -> active
systemctl reload loki -> active
systemctl reload prometheus-node-exporter -> INACTIVE
Seul node_exporter est concerné ; alloy et loki honorent leur ExecReload. Le motif
try-reload-or-restart reste donc valable ailleurs — mais il fait confiance à une
promesse de l'unité que le binaire peut ne pas tenir, en silence.
Corrigé en restart. Preuve : synchronisation déclenchée sur les quatorze hôtes,
quatorze active.
2026-08-09 — Plus aucun get_url sans garde : la dépendance externe est comptée
Arbitrage rendu par l'exploitant : garder aussi les clés de signature. Zéro get_url
sans garde dans le dépôt, contre neuf ce matin.
avant : 6 serveurs tiers x 14 hotes recontactes a chaque deploiement
apres : 0
Conséquence assumée, écrite dans chaque rôle : une rotation de clé amont n'est plus
récupérée toute seule. Elle ne passe pas inaperçue pour autant — apt refuse alors le
dépôt, bruyamment, et le remède est d'une ligne : supprimer le fichier et rejouer le rôle.
C'est un défaut sonore, pas un défaut silencieux ; toute la journée a consisté à
transformer les seconds en premiers.
Ce que ça change vraiment : un déploiement de flotte ne dépend plus d'aucun serveur étranger pour ce que la machine possède déjà. La question posée le matin — combien de serveurs tiers doivent être joignables pour redéployer ce que l'on possède ? — a maintenant une réponse mesurée, et c'est zéro.
Reste à éprouver sur un rejeu depuis zéro : sur un hôte neuf le fichier n'existe pas, donc la garde laisse passer le téléchargement. Correct par construction, pas encore mesuré.
2026-08-09 — Dépendances externes sur le chemin critique du déploiement
Le passage d'idempotence a échoué sur Telecharger le binaire Forgejo :
« Connection failure: The read operation timed out » — pour 106 Mo déjà présents sur la
machine. Un déploiement de flotte tombait parce qu'un serveur tiers était lent.
Recensement de tous les get_url du dépôt : 9 sans aucune garde (ni checksum, ni
condition d'existence), 1 avec.
| Nature | Rôles | Traitement |
|---|---|---|
| artefact épinglé à une version | forgejo, keycloak, nextcloud, oauth2-proxy | gardé |
| clé de signature de dépôt apt | client_journal, client_pki, grafana, loki, step_ca | à arbitrer |
trousseau .deb |
icinga | à arbitrer |
Les quatre premiers sont immuables par construction : leur chemin de destination porte
la version. forgejo-10.0.0 ne peut pas désigner un autre contenu demain. Les
retélécharger n'a aucun sens, et les recontacter encore moins.
Ce qui reste à trancher, et ce n'est pas à moi. Cinq rôles récupèrent une clé de
signature apt à chaque passage — cinq serveurs externes × quatorze hôtes, soit soixante-dix
allers-retours par déploiement. Les garder par existence supprimerait cette dépendance,
au prix de ne plus détecter une rotation de clé. L'argument contraire : une clé tournée
casse apt bruyamment, donc l'oubli se voit.
Sur une plateforme qui se veut souveraine, la question mérite d'être posée explicitement : combien de serveurs tiers doivent être joignables pour redéployer ce que l'on possède déjà ?
2026-08-09 — Reconstruction complète d'un seul trait, et une correction que je me dois
Deuxième reconstruction from-zero : zéro échec, zéro injoignable, sur les quatorze
hôtes. La première avait demandé six corrections et autant de reprises ; celle-ci est
allée au bout d'un seul trait, make myDay compris — création, amorçage du socle,
trente couches.
D-71 se lit dans les chiffres : infra-pki-01 à changed=3 et infra-dns-01 à
changed=1, parce qu'ils étaient déjà debout, montés par _amorcer-socle avant que la
flotte ne démarre. Les couches sont passées à vide sur eux.
Les cinq devis : CONFORME.
La correction. J'ai écrit hier que MaxStartups et MaxSessions « ne viennent d'aucun
rôle, elles sont dans le gabarit doré ». C'est faux. J'avais grepé ssh_baseline seul.
C'est ssh_hardening qui les pose — depuis toujours, dans
templates/20-setops-hardening.conf.j2, en dur :
MaxSessions 2
MaxStartups 5:30:20
Le dépôt déclarait donc bien son durcissement. Ce qu'il ne faisait pas, c'est l'exposer :
des valeurs écrites dans un fichier de rendu sont invisibles à qui lit les defaults, et
inajustables sans toucher au template. Elles sont désormais des variables
(ssh_hardening_max_startups, ssh_hardening_max_sessions).
Et ma première correction avait empiré les choses : en ajoutant ces mêmes clés à
ssh_baseline, j'avais créé deux fichiers gérés par deux rôles déclarant la même directive
avec des valeurs différentes. Retiré.
Mesure finale, sur les quatorze : logingracetime 20 maxsessions 10 maxstartups 10:30:60 — identique partout, et conforme à ce qui est déclaré.
2026-08-09 — « Banner exchange » : ce n'était ni le réseau, ni l'hôte, ni sshd
La course qui avait interrompu deux déploiements est comprise, cette fois — parce que j'ai gardé le journal.
Ce que la machine dit d'elle-même : un seul démarrage, toujours en cours ; aucune
coupure réseau ; aucun redémarrage de sshd. Et le « trou » de 72 secondes dans son
journal n'en était pas un — l'entrée qui le referme est ma propre commande de
diagnostic. L'hôte n'a rien fait pendant ce temps parce que plus personne ne lui
parlait. Ce n'est pas lui qui a disparu, c'est Ansible qui n'entrait plus.
La cause, mesurée :
maxstartups 5:30:20 defaut Debian : 10:30:100
maxsessions 2 defaut Debian : 10
Au-delà de cinq connexions non authentifiées simultanées, sshd en refuse une partie
sans envoyer de bannière. Le client attend une bannière qui ne viendra pas et rapporte
« Connection timed out during banner exchange » — un message qui accuse le réseau pour un
refus applicatif. Les sessions arrivaient à une par seconde juste avant la coupure.
Ces valeurs ne viennent d'aucun rôle : ssh_baseline ne les pose pas. Elles sont dans le
gabarit doré, où l'exploitant les a durcies. C'est un réglage de sécurité qui ne vit que
dans une image disque — invisible du dépôt, invisible des preuves, et qui gouverne pourtant
la voie d'administration.
Corrigé côté client, pas en affaiblissant l'hôte. ansible.cfg n'avait aucune section
[ssh_connection] : ni pipelining, ni ControlPersist explicite. Ajoutés, avec un
control_path_dir court — un chemin trop long dépasse la limite des sockets UNIX et fait
retomber Ansible sur une connexion par tâche, ce qui ramènerait le problème sans qu'on le
voie.
Mesure avant / après, sur le même play de 30 tâches :
avant : une session SSH par seconde, des centaines par hôte
après : 0 nouvelle session pour tout le play
Ce qui reste ouvert : MaxStartups et MaxSessions devraient être déclarés par
ssh_baseline plutôt que dormir dans le gabarit. Un durcissement qu'aucun fichier du dépôt
ne mentionne ne peut être ni revu, ni prouvé, ni expliqué à qui reprend la machine.
2026-08-09 — L'ancien gabarit supprimé, le nouveau porte son nom
Le cluster ne porte plus qu'un gabarit : modeleChezlepro, VMID 99998. Le plan le
désigne par nom et par VMID, et les deux concordent.
Trois vérifications avant de supprimer, parce qu'un gabarit doré ne se remplace pas sur une impression :
- le nouveau avait déjà produit une VM déployée et prouvée —
web-dorsal-01, rasée, recréée, déployée sans échec, cinq devisCONFORME; proxmox_clone_complet: true, et aucune VM ne dépendait du disque de 99999 (vérifié en cherchantbase-99999dans les disques de toutes les VM du cluster) : un clone lié aurait rendu la suppression destructrice pour la flotte entière ;- le nom de 99999 confirmé avant le
DELETE— le même verrou queraser, pour la même raison.
L'ordre n'était pas indifférent : supprimer d'abord, renommer ensuite. L'inverse aurait
laissé deux modeleChezlepro sur le cluster, et le clonage par nom serait devenu
ambigu — exactement la collision qui avait fait rapporter ok à proxmox_kvm sans rien
faire, le 2026-08-07.
Ce que le nouveau n'a plus, et que l'ancien portait depuis sa fabrication : un
/etc/resolv.conf pointant 192.168.12.254, un searchdomain public, et une clé privée
d'hôte SSH.
2026-08-09 — client_unbound : survivre à la perte de connexion, sans la masquer
Le déploiement de web-dorsal-01 s'était interrompu autour du redémarrage d'Unbound —
« Connection timed out during banner exchange ». Ansible marque alors l'hôte injoignable et
abandonne toutes ses couches suivantes, alors que la machine répondait de nouveau une
minute plus tard.
Ma première explication était fausse, et je l'ai vérifiée avant de coder : je pensais à
la résolution inverse de sshd au moment où le résolveur bascule. sshd -T répond
usedns no — elle n'a pas lieu. Et la cause reste inconnue : j'avais écrasé le journal
du déploiement raté en relançant, donc il n'est plus lisible. Faute de méthode, pas de
raisonnement.
Ce que le dépôt sait déjà de ce symptôme est consigné dans le Makefile (_attendre-hote) :
à travers la frontière, le TCP s'établit par proxy SYN, et l'échec se lit « banner
exchange » même quand l'hôte n'est simplement pas là. Le message accuse SSH pour un
problème d'accessibilité.
Corrigé sans prétendre connaître la cause : le handler de redémarrage tolère la perte
(ignore_unreachable), et une tâche exige ensuite le retour de l'hôte
(wait_for_connection, 180 s). On attend une condition, pas une durée.
Ce n'est pas masquer une panne : si l'hôte ne revient pas, la tâche suivante échoue franchement. Ce qui change, c'est qu'une absence d'une minute n'annule plus une heure de déploiement.
Et une règle pour moi : ne plus écraser le journal d'un échec avant de l'avoir lu. Le
diagnostic de cette course a été rendu impossible par un rm -f de confort.
2026-08-09 — Le plan bascule sur le gabarit recapturé, prouvé par une VM réelle
proxmox_clone_source_nom / proxmox_clone_vmid_modele pointent désormais sur
modeleChezlepro-travail (99998). L'ancien (99999) reste sur le cluster : il porte encore
un /etc/resolv.conf et une clé privée d'hôte SSH du réseau de fabrication. À supprimer
quand plusieurs VM seront nées du nouveau — pas avant.
Preuve par une machine réelle, et non par lecture de configuration : web-dorsal-01 —
l'hôte le moins engagé de la flotte, 12 Ko dans /var/www — rasé puis recréé par le chemin
normal (make raser HOTE=…, make creer-vm, make deployer).
Ce qu'elle a hérité du nouveau gabarit :
resolv.conf → les commentaires seuls, aucune identité de fabrication
machine-id → cc9c58c1… neuf et unique
clé d'hôte → SHA256:pES0/… régénérée
cloud-init → done
Déploiement complet, zéro échec, et les cinq devis de service CONFORME.
raser accepte maintenant --hote. Raser une seule machine sert à éprouver un gabarit
ou à reprendre un hôte ; les quatre verrous restent en vigueur — on ne fait que
restreindre la liste dérivée du plan, jamais l'élargir.
Un défaut relevé au passage, non corrigé. Le premier déploiement s'est interrompu sur
client_unbound, à l'instant où le résolveur bascule vers 127.0.0.1 : toute résolution
en vol se fige le temps qu'Unbound réponde, y compris celle que fait sshd à l'ouverture
de session — d'où « Connection timed out during banner exchange ». L'hôte répondait de
nouveau une minute plus tard et la reprise est passée intégralement, presque tout en
changed=0. C'est une course, de la même famille que celles de la reconstruction, et
elle mérite le même remède : attendre une condition plutôt que de subir la bascule.
2026-08-09 — Gabarit recapturé sur copie de travail, sans toucher à l'original
modeleChezlepro (99999) cloné en modeleChezlepro-travail (99998), préparé, vérifié,
nettoyé, converti. L'original n'a pas été touché — il reste le gabarit en service tant
que le nouveau n'a pas produit une VM qui fonctionne.
Deux défauts de mon propre outillage, trouvés en l'utilisant pour de vrai — et c'est tout l'intérêt de s'en servir plutôt que de le déclarer prêt :
- l'inventaire d'un seul hôte (
-i "<ip>,") ne porte aucungroup_vars, donc aucun utilisateur de connexion : Ansible tentait le compte local de l'opérateur.MODELE_HOTEpasse maintenantansible_user(défautansible, leciuserdu gabarit) ; - les playbooks ciblent
hosts: modeles_vm, et un inventaire d'un seul hôte place la machine dansall— pas dans ce groupe. La commande était juste et la cible introuvable : « skipping: no hosts matched », qui n'est pas une erreur. J'avais vérifié l'affichage de la commande, pas son effet. Les trois playbooks acceptent désormaiscible_modele.
Le nettoyage a gagné deux choses :
/etc/resolv.conf est vidé — il portait search chezlepro.ca et
nameserver 192.168.12.254, l'identité du réseau de fabrication.
Et les clés d'hôte SSH sont supprimées. Le gabarit transportait une clé privée : quiconque détient l'image détient de quoi se faire passer pour une VM qui n'aurait pas régénéré la sienne. La suppression n'est sûre que parce que cloud-init les recrée au premier démarrage — vérifié avant de l'écrire : les hôtes de la flotte portent des empreintes toutes différentes.
L'exploitant a par ailleurs retiré mtu=9000 des deux gabarits — le réglage mort relevé
plus tôt, qui ne se propageait pas aux clones.
Ce qui reste à faire, et qui n'est pas à moi : faire pointer
proxmox_clone_source_nom / proxmox_clone_vmid_modele sur le nouveau, une fois qu'une VM
en sera née et aura fonctionné. Tant que ce n'est pas fait, rien n'a changé pour la flotte.
2026-08-09 — modeles_vm : trois commandes qui ne pouvaient rien faire
Le groupe était toujours vide, et rien ne le signalait. instancier l'émet comme
squelette ("modeles_vm": {"hosts": {}}) et les états d'un serveur ne connaissent que
actif et planifie — aucun chemin ne permettait d'y faire entrer une machine. Les trois
cibles qui le ciblent recevaient « skipping: no hosts matched », qui n'est pas une erreur.
Le gabarit ne peut pas venir du plan, et c'est structurel. Sa configuration Proxmox le
place sur le réseau de fabrication (ip=192.168.12.99/24), pas dans le supernet. Ce n'est
pas un hôte de l'écosystème : c'est la matrice dont l'écosystème est tiré. Peupler
modeles_vm depuis plan/serveurs.yml aurait été forcer un objet dans un registre qui
n'est pas le sien.
MODELE_HOTE=<ip> le désigne explicitement, et l'inventaire d'un seul hôte (-i "<ip>,")
sert les trois playbooks. Sans lui — et sans groupe peuplé — elles refusent en
expliquant, au lieu de ne rien faire :
Refus: aucune VM de gabarit designee.
Le gabarit vit sur le reseau de fabrication, pas dans le tenant :
il ne peut pas venir du plan. Le designer explicitement —
make preparer-modele MODELE_HOTE=192.168.12.99
Les prérequis d'accès et de privilèges suivent la même cible : ils interrogeaient eux aussi le groupe vide.
C'est la même famille que tout ce que la journée a produit — une capacité déclarée dont personne ne vérifiait qu'elle est branchée. À la différence près que celle-ci ne se manifestait par aucun symptôme : elle ne faisait rien, poliment.
2026-08-09 — Le gabarit doré : ce qu'il transporte de son réseau de naissance
Question de l'exploitant : « on peut l'optimiser ? ». Mesuré avant de répondre — et la réponse n'est pas celle que la question suggère.
Rien à gagner côté performance ni paquets. La configuration Proxmox est soignée :
UEFI/q35, virtio-scsi-single avec iothread, discard=on + ssd=1 pour le TRIM,
cpu x86-64-v2-AES — ce dernier compte quand tout le trafic est chiffré. agent 1,
balloon 0. Et tous les paquets du socle sont déjà cuits dans l'image : common_packages
les trouve présents, l'optimisation évidente est déjà faite.
Ce qu'il transporte, en revanche, c'est son lieu de naissance :
ipconfig0 ip=192.168.12.99/24,gw=192.168.12.254
nameserver 192.168.10.10 192.168.10.20
searchdomain chezlepro.ca
net0 bridge=vmbr1,mtu=9000,tag=12
Les deux premiers sont surchargés au clonage. Le troisième ne l'était pas :
proxmox_clone_domaines_recherche n'était défini nulle part, donc les 14 VM héritaient de
chezlepro.ca — le domaine public — alors qu'elles vivent dans chezlepro.internal.
Désormais dérivé de domaine_interne, par le mécanisme qui existait déjà pour le DNS
(SETOPS_DOMAINE, comme SETOPS_DNS).
Honnêtement : ça ne réparait pas de panne. serveur_debian réécrit /etc/resolv.conf
au déploiement avec les seuls nameserver, sans ligne search — vérifié sur la flotte.
Le domaine hérité ne vit donc qu'entre le clonage et la première couche, et la zone
publique n'a pas de joker. C'était faux, et ça ne tenait que par chance.
mtu 9000 est un réglage MORT : les clones tournent en 1500, la valeur ne se propage
pas. Un réglage mort dans l'actif le plus central du dépôt est un mensonge pour qui le lira
ensuite — à retirer ou à rendre délibéré de bout en bout.
Et le vrai enjeu n'est pas l'optimisation, c'est l'appartenance. Ce gabarit est celui de Chezlepro : son IP, son DNS, son domaine, son pont, son VLAN. La preuve de portabilité suppose qu'il serve aussi Technolibre. Le rendre agnostique vaut plus que n'importe quel réglage de performance.
Défaut trouvé en chemin : le groupe modeles_vm est vide. make preparer-modele,
verifier-modele et nettoyer-modele n'ont aucune cible — trois commandes documentées qui
ne peuvent rien faire, et rien ne le signale.
2026-08-09 — P33 : deux rôles co-localisés ne revendiquent pas le même port
Deuxième des trois chantiers ouverts par la reconstruction. Il retrouve son défaut nº 6 à froid, sans machine.
Un port n'appartient à personne : le premier service démarré le prend, l'autre échoue.
Sur infra-mail-01, le SASL de Dovecot (12345, choix délibéré) et l'interface HTTP d'Alloy
(12345, défaut amont) se le disputaient depuis le premier jour — et c'est Dovecot qui
perdait, sans que rien ne le dise. Il a fallu inverser l'ordre de démarrage, ce que fait un
rejeu depuis zéro, pour que ça devienne audible.
Le contrôle n'était possible qu'après avoir déclaré le port d'Alloy. C'est la vraie leçon du défaut nº 6 : un port subi — le défaut amont d'un logiciel qu'on n'a pas choisi — n'existe pour aucun registre, donc aucune preuve ne peut le voir. Il faut l'imposer pour pouvoir le vérifier.
partage: true, nouveau mot du registre des flux, distingue deux situations qu'il
confondait : un rôle qui ouvre une écoute, et un rôle qui décrit celle d'un autre —
serveur_backup empruntant le sshd de serveur_debian. Sans lui, la seule co-location
légitime de la flotte (tcp/22 sur backup-01) serait signalée à tort. Une preuve qui
crie sur un cas sain finit par être ignorée : c'est pire que de ne pas l'avoir.
Vérifié dans les deux sens. Sur la flotte réelle : 32 revendications, aucune collision.
En remettant le port d'Alloy à 12345 comme hier : infra-mail-01 : tcp/12345 revendiqué par client_journal et serveur_dovecot, code 1 — le défaut nº 6 reproduit sans toucher à une
machine.
Harnais : 33 preuves, 0 échec, 0 sautée.
2026-08-09 — P32 : un assert de rôle est un contrat, et l'instance doit l'honorer
Le premier des trois chantiers que la reconstruction avait rendus évidents. Il aurait
trouvé son défaut nº 1 — amorcage_acces_courriel — sans rien détruire.
Un rôle qui assert une variable non vide déclare un contrat : sans cette valeur, le
déploiement s'arrête. Rien ne vérifiait que l'instance les honore, et le manque ne se voit
qu'au moment où la garde s'exécute pour de vrai — c'est-à-dire, pour un intrant d'amorçage,
seulement quand on repart de rien.
Un intrant est satisfait par un défaut non vide dans le rôle (y compris un
{{ vault_* }}, dont la présence réelle relève de P18), par un set_fact de résolveur, ou
par une déclaration de l'inventaire. Aucune voûte n'est déchiffrée : la preuve reste
statique, comme les 31 autres.
Deux fois mon instrument a accusé le composant à sa place, et les deux fois avant la
première exécution utile. Il criait au manque sur serveur_postfix_mailstore_hote, qui est
pourtant bel et bien fourni — d'abord parce que je ne lisais que group_vars/ et
host_vars/ en oubliant le fichier d'inventaire lui-même, ensuite parce que je n'y
cherchais que les blocs vars: alors que instancier écrit les valeurs dérivées
directement sous le nom d'hôte. Un vérificateur incomplet est pire qu'absent : il fait
douter de ce qui marche.
Vérifié dans les deux sens. Sur l'instance réelle : 30 exigences, toutes satisfaites.
Sur un double où l'on retire la déclaration ajoutée la veille : amorcage_acces_courriel
nommé, code de sortie 1 — le défaut nº 1 reproduit à froid.
Ce qu'il ne fait pas, et c'est écrit dans son en-tête : il ignore les when: qui rendent
une assertion conditionnelle, donc il peut signaler un intrant exigé seulement quand une
option est active. Signaler à tort coûte une ligne de déclaration ; ne pas signaler coûte
un déploiement.
Harnais : 32 preuves, 0 échec, 0 sautée.
2026-08-09 — La reconstruction from-zero est prouvée : cinq devis sur cinq
Écosystème chezlepro détruit — 14 VM, disques compris — puis rejoué depuis le plan
seul. Aucune sauvegarde restaurée. Les cinq devis rendent le même verdict qu'avant la
destruction, et docs/audit/reference-avant-reconstruction-2026-08-08.md porte les deux
états côte à côte pour que « identique » soit vérifiable et non ressenti.
C'est la première fois que le dépôt peut affirmer que le système reconstruit est celui que le plan décrit, au lieu d'affirmer que le dépôt est cohérent avec lui-même.
Six défauts trouvés, tous invisibles autrement — trois d'ordre, deux de course, un conflit de port. Chacun a fait l'objet de son propre commit ; ce qu'ils ont en commun mérite d'être dit : ils dormaient tous derrière un état préexistant. Un compte qui existait déjà, des rôles créés par un passage antérieur, des clients déjà là, un service qui tournait depuis toujours, un port déjà tenu. Le rejeu n'a rien cassé — il a retiré l'état qui masquait.
Le sixième est le plus instructif. Alloy et le SASL de Dovecot revendiquent tous deux le
port 12345 sur infra-mail-01. Le conflit existait depuis le premier jour, mais dans
l'autre sens : Alloy tenait le port et c'est l'écoute SASL de Dovecot qui échouait, en
silence. L'ordre des couches d'une reconstruction a inversé les rôles et rendu le défaut
audible. Le port d'Alloy est désormais imposé et déclaré — le vrai défaut n'était pas
le numéro, c'était qu'un port subi ne se déclare nulle part, donc qu'aucun contrôle ne
pouvait voir la collision.
Ce qu'il reste à construire, et que ce rejeu a rendu évident :
- une preuve « tout intrant qu'un rôle exige est fourni par l'instance » — le défaut nº 1 se serait vu sans détruire quoi que ce soit ;
- une preuve « deux rôles co-localisés ne revendiquent pas le même port » — maintenant que les ports subis se déclarent, elle devient possible ;
- vider
/etc/resolv.confà la capture du gabarit doré : il transporte encore le résolveur de son réseau de fabrication.
2026-08-08 — D-71 éprouvée : l'AC et le DNS montent seuls, sur des machines neuves
Première exécution de _amorcer-socle sur une flotte qui vient d'être clonée, aucun pair
debout. Les deux exceptions structurelles nommées la veille se sont exercées pour de vrai.
L'AC s'auto-signe, comme annoncé :
subject=O=Set-OPS Internal CA, CN=Set-OPS Internal CA Root CA
issuer =O=Set-OPS Internal CA, CN=Set-OPS Internal CA Root CA
Les deux zones sont posées et répondent — requêtes réelles, pas lecture de fichier :
zones : chezlepro.internal.zone 27.10.in-addr.arpa.zone
10.27.19.21 -> infra-pki-01.chezlepro.internal.
10.27.21.11 -> forge-01.chezlepro.internal.
10.27.18.21 -> backup-01.chezlepro.internal.
forge-01 et backup-01 ne sont pas déployés — ils viennent d'être clonés, et leur
PTR répond quand même. C'est la démonstration de l'arbitrage rendu la veille : la zone est
générée depuis le plan, pas enrôlée par la VM. Un enrôlement aurait fait dépendre le
DNS de l'état de chaque machine ; ici le nom existe parce que le plan le dit.
Deux fois de plus, ma sonde était fausse avant le système : dig @127.0.0.1 refusait la
connexion — PowerDNS écoute sur ansible_host, pas sur la boucle locale. Vérifier
l'instrument avant d'accuser le composant, encore.
2026-08-08 — D-71 : PKI et DNS debout avant tout le reste, et la zone inverse
Contrainte posée par l'exploitant en voyant backup-01 et collab-01 créés avant l'AC et
le DNS : une PKI et un DNS fonctionnels avant toute chose ; puis par VM, socle →
enrôlement PKI → enregistrement DNS (A et PTR).
Ma première réponse était incomplète. J'avais expliqué qu'un clone est inerte et que
site.yml respecte bien les couches — c'est exact, mais ça esquivait deux points justes :
un échec de création à la douzième VM coûte quarante minutes sans rien déployer, et un
journal qui montre backup-01 en tête donne l'impression que le moteur ignore ses propres
couches.
Ce qui manquait vraiment, mesuré avant de coder :
enregistrements A → DEJA derives du plan (zone generee depuis `hotes_actifs`)
zone inverse / PTR → n'existe NULLE PART — aucun role ne touche `in-addr.arpa`
ordre d'amorcage → aucun : `deployer-tout` est par couches, pas par hote
Le premier point a réduit le travail de moitié : je m'apprêtais à écrire un enrôlement DNS par hôte alors que la zone directe se dérivait déjà correctement.
La zone inverse est dérivée du supernet, comme le reste : un /16 10.(10+index).0.0
donne (10+index).10.in-addr.arpa — 27.10.in-addr.arpa ici. Les PTR viennent de la
même source que les A (hotes_actifs et son ansible_host) : deux zones alimentées
par une seule vérité, donc pas d'endroit où elles puissent diverger. Vide si le supernet
n'est pas un /16 — mieux vaut pas de zone inverse qu'une zone fausse.
_amorcer-socle monte l'AC puis le DNS complètement, hôte par hôte, avant
deployer-tout. Les deux se dérivent de applications.<app>.hote ; l'ordre entre eux
n'est pas alphabétique mais causal — le DNS a besoin d'un certificat, l'autorité n'a besoin
de personne.
Deux exceptions structurelles, nommées plutôt que découvertes à l'exécution : l'AC s'auto-signe, et le DNS pose son propre enregistrement. La règle « socle → PKI → DNS » ne peut pas s'appliquer à ceux qui la rendent possible.
Au passage, le harnais a attrapé une faute que je venais d'introduire : j'avais inventé un
handler Recharger PowerDNS qui n'existe pas — le rôle écoute Validate and reload PowerDNS. P10 l'a nommée avant tout déploiement.
2026-08-08 — Une question de l'exploitant trouve un trou dans P31, une heure après
« Pourquoi pas make myDay ? » — la cible existe, c'est un alias strict de
reconstruire. Mais elle n'avait aucun texte d'aide, et P31 la déclarait conforme.
Le motif était ^[a-z][a-z0-9_-]*: : toute cible contenant une majuscule échappait au
contrôle. myDay est citée dans l'aide du Makefile et dans la GUI ; elle n'apparaissait
dans aucun recensement. Corrigé en ^[A-Za-z][A-Za-z0-9_-]*:, et l'aide posée.
Ce n'est pas un détail sur une cible. Une preuve ne vaut que ce que vaut son motif — et celle-ci a été écrite avec la conviction d'être rigoureuse, testée dans les deux sens le jour même, et elle laissait quand même passer un cas. Le trou n'a pas été trouvé par un test mais par quelqu'un qui a demandé « et celle-là ? ».
À ranger à côté des deux critères creux de P31 (le rapport généré qui se citait lui-même, et l'inventaire généré qui aurait satisfait le critère par construction). Trois fois sur la même preuve, en une journée : la difficulté n'est pas d'écrire un test, c'est de délimiter honnêtement ce qu'il regarde.
Compte après correction : 87 cibles documentées, 36 scripts, 54 rôles.
2026-08-08 — make raser : la seule commande destructive du moteur
Ajoutée pour rendre la reconstruction from-zero répétable — un test qu'on ne peut jouer qu'une fois, à la main, n'est pas une recette. Tout le reste du dépôt crée ou réconcilie ; celle-ci détruit, et elle est écrite en conséquence.
Quatre verrous, tous éprouvés avant usage :
| Verrou | Ce qu'il empêche | Vérifié |
|---|---|---|
| VMID dérivés du plan uniquement | détruire une VM hors écosystème ; le gabarit doré est structurellement exclu, son VMID ne se dérive pas | à blanc : 14 VM listées, template absent |
| le nom doit correspondre | détruire la machine de quelqu'un d'autre sous un VMID du plan | test isolé, faux cluster |
nommer l'écosystème (INSTANCE=) |
raser la mauvaise instance : le symlink instance/ peut pointer n'importe où |
refus sans nom, et refus sur mauvais nom |
CONFIRMER=true |
tout le reste | sans lui : inventaire, rien d'autre |
Le deuxième mérite d'être détaillé, parce qu'il vient d'un fait et non d'une précaution
abstraite : le 2026-08-07, une VM héritée portait un VMID du plan sous le nom
web-frontal-01, et proxmox_kvm avait rapporté ok sans rien faire. Rasé sans ce
contrôle, on détruisait une machine étrangère. Le refus porte sur l'opération entière,
pas sur la seule VM en conflit — un cluster qui ment sur un VMID peut mentir sur d'autres.
C'est aussi le seul verrou qu'on ne peut pas éprouver sur le vrai cluster sans y fabriquer
une collision : scripts/tests/test_raser.py isole la logique derrière un faux cluster, et
le test est rattaché à P02. Le harnais passe désormais 31 preuves, 0 sautée.
2026-08-08 — État de référence figé avant la reconstruction from-zero
L'exploitant recadre : les 14 VM sont un POC, pas de la production. Ma prudence venait d'une hypothèse que je portais, pas de lui. Les constats sur les sauvegardes restent du travail à faire avant que ça devienne de la production — pas avant le test.
Et reconstruire Chezlepro est un meilleur test que construire Technolibre. On dispose
d'un état de référence : les cinq devis y sont CONFORME à l'instant. Toute divergence
après rejeu sera un défaut réel, mesurable contre une base connue. Sur Technolibre, qui n'a
jamais tourné, un échec serait ambigu — plan faux ou moteur faux ?
docs/audit/reference-avant-reconstruction-2026-08-08.md fige : les cinq verdicts, les 14
hôtes avec leur adresse et leur compte de services, et surtout ce qui sera perdu et devra
être refait à la main (clé racine de l'AC — donc la racine installée dans le navigateur —
et le mot de passe du compte sysadmin). Ce document existe pour que « identique » soit
prouvable plutôt que ressenti.
Ce qui survit et rend le rejeu possible : les deux voûtes et ~/.config/setops-vault-pass
vivent hors dépôt et hors cluster ; le code est sur eregion.chezlepro.ca
(192.168.12.201), machine distincte du tenant — vérifié, parce qu'un dépôt dont le
origin vivrait dans l'écosystème à détruire serait une dépendance circulaire fatale.
Constat au passage : le moteur n'a aucun chemin de destruction. make reconstruire
crée les VM manquantes et déploie ; il ne rase rien. C'est cohérent avec la doctrine (rien
de destructif sans garde explicite), mais ça veut dire qu'un test « depuis zéro » suppose
une suppression faite hors du moteur.
2026-08-08 — « Set-OPS trichait ? » — non, il se sous-estimait
Question de l'exploitant après avoir vu P31 manquer deux fois sa cible. Elle méritait un audit, pas une assurance.
La réponse est non, et c'est le dépôt lui-même qui la donne. Le registre des affirmations contient onze de ses propres promesses publiques marquées ❌ fausse. Il déclare son périmètre — « aucune VM / Proxmox / réseau touché ». Et D-25 en fait une règle : le dépôt n'affirme pas que ses devis s'appliquent, il affirme qu'ils dérivent. Un système qui triche n'écrit aucune de ces trois choses.
Il y avait bien un angle mort, et c'est celui fermé aujourd'hui : les 30 preuves sont
statiques. CONFORME : 30 preuves se lit comme « le système fonctionne » alors que ça
signifie « le dépôt est cohérent avec lui-même ». La restriction était écrite dans le
registre et invisible dans la sortie quotidienne — c'est ainsi que le certificat de l'AC a
pu expirer huit heures sous un harnais vert.
Les onze ❌ ont été rejugées, chacune reconfrontée au dépôt : make verifier passe
(30 preuves) ; QUICKSTART ne promet plus que le modèle socle et pointe
inventories/production/ ; PasswordAuthentication no par défaut et le texte
contradictoire a disparu ; make help et syntax-template n'existent plus nulle part ;
la voûte Proxmox est unifiée ; les domaines du socle valident ; le socle génère bien dans
production/ sans repli. Onze sur onze : résolues.
Et le rejugement a trouvé mieux qu'un registre oublié. Les résolutions étaient déjà documentées dans les sections « Phase 3 » du registre. Mais son tableau de synthèse annonçait encore « ❌ fausse : 8 ». Deux représentations du même fait, une corrigée et l'autre non, rien qui vérifie qu'elles se rejoignent — le défaut exact que ce registre existe pour traquer, appliqué à lui-même. Il penchait du bon côté, ce qui l'a rendu invisible : personne ne se plaint d'une mauvaise nouvelle périmée.
Le tableau de juillet est conservé comme photo de départ ; un bloc « état courant » le
suit. Un ❌ qui subsiste doit désormais se lire comme un signal vivant, pas un vestige.
2026-08-08 — D-70 : l'exigence de documentation devient une preuve (P31)
Directive de l'exploitant : « la doc dit et explique tout ce que Set-OPS fait, et pourquoi c'est ainsi. » Une exigence qu'on se contente d'énoncer pourrit en silence — on venait d'en avoir trois exemples le jour même dans la carte.
L'écart mesuré avant de le combler :
cibles make sans texte d'aide : 66 sur 85 → `make help` en montrait 19
scripts jamais cités en doc : 11 sur 35 → dont 3 applicateurs et 4 devis du jour
Les 66 cibles ont reçu leur aide : make aide couvre maintenant 85 commandes au lieu
de 19. C'est ce qui rend le moteur utilisable par quelqu'un qui ne lit pas le Makefile —
la règle « un sysadmin l'exploite sans IA » n'a pas d'autre traduction concrète.
P31 garde l'exigence, et le chemin pour l'écrire a été instructif : deux fois mon critère s'est révélé creux.
D'abord « le nom du script apparaît dans un document » : le rapport d'audit généré recopiait les noms manquants dans son message d'échec, ce qui les rendait cités au tour suivant. Une preuve qui se nourrit de sa propre sortie passe au vert sans qu'une ligne soit écrite.
Puis, en corrigeant, j'ai failli créer le même trou en plus grand : générer un inventaire
de l'outillage aurait satisfait le critère par construction. Un critère qu'on peut
satisfaire en générant du texte ne prouve rien. P31 teste donc que chaque script porte
une docstring qui l'explique et qu'il reste atteignable — par une cible make, ou par
un autre outil.
Vérifiée dans les deux sens, comme les devis : on retire l'aide d'une cible et la
docstring d'un script, les deux défauts sont nommés ; on restaure, CONFORME.
Ce que P31 ne garde pas, et c'est dit dans son propre code : que l'explication soit bonne. Le « pourquoi » se juge en revue. Il vit dans ce journal — qui porte le fait mesuré, pas seulement le changement — et dans le registre des décisions. Prétendre le mesurer mécaniquement serait se mentir.
2026-08-08 — Tisser le travail du jour dans les points d'entrée
Question de l'exploitant : faut-il refondre la documentation ? Non. L'état mesuré ne le justifie pas — 54 rôles, 54 README (couverture complète), 34 documents, une carte avec un ordre de lecture, un registre de décisions. Une refonte ferait courir le vrai risque : perdre le pourquoi accumulé, qui a pris des mois et ne se régénère pas.
Ce qui était réellement en retard était petit et nommable : le travail du jour était
documenté dans son coin. Les cinq devis n'existaient que dans deux fichiers — leur
propre doc et le registre des décisions. Absents de la carte, d'AGENTS.md, du runbook du
sysadmin et de la GUI. Autrement dit : découvrables uniquement par qui connaît déjà le
Makefile — ce qui contredit « un sysadmin l'exploite sans IA ».
Tissés dans les quatre points d'entrée : ligne « Conformité du déployé » dans la carte,
section « Écrire, puis relire (D-68) » dans AGENTS.md, §6.0 du runbook (le premier
réflexe avant de suivre quoi que ce soit), et la vue Reconstruction de la GUI — sa place
logique, puisque ce sont ces devis qui diront si un remontage a produit le système décrit.
Et le tissage a fait tomber trois affirmations périmées, ce qui est sa vraie utilité :
- la carte annonçait 28 décisions ; il y en a 66 en vigueur (D-01 → D-69, 3 renversées) ;
- elle disait des accès et habilitations « décidé, non construit :
ou=peopleetou=groupsexistent et restent vides ». Mesuré : un compte, un groupe, et la chaîne LDAP → Keycloak → groupe → service exercée de bout en bout sur Icinga Web 2 le jour même ; - la GUI parlait des « deux devis » d'infrastructure ; il y en a quatre depuis l'arrivée du SDN EVPN et du pare-feu est-ouest.
Ce qui n'est pas fait, et pourquoi. La formation et le wiki attendent — leur audience et leur condition de vérité sont différentes. Un runbook que personne n'a suivi sauf son auteur est une hypothèse ; la reconstruction from-zero est le test de cette documentation. Écrire la formation avant, ce serait enseigner une procédure que personne n'a exécutée.
2026-08-08 — D-68 / D-69 : la règle n'est pas « toujours l'API »
Question de l'exploitant après deux pannes causées par kcadm : ne devrait-on pas toujours
utiliser une API quand il en existe une ?
Non — et la journée le montre mieux qu'un principe. Sur six familles de défauts, deux
seulement viennent d'un CLI ; un module Ansible (ldap_entry, qui crée sans jamais
modifier) a commis exactement la même faute, et trois autres viennent d'un grep de
fichier, de la précédence Ansible, et de mon propre comparateur. Le facteur commun n'est pas
l'interface : c'est d'avoir écrit sans relire.
D-68 — on écrit, puis on relit et on compare, quelle que soit l'interface ; on choisit
celle dont le chemin de lecture parle le même langage que le chemin d'écriture. Une API
est souvent préférable pour une raison précise — elle rend la ressource entière, ce qui
permet le patron de chaque devis : fusionner l'attendu dans le réel ; si rien ne change,
c'est conforme. Mais la plupart de la flotte n'a pas d'API (Postfix, Dovecot, nginx, slapd,
nftables), et postconf -h / postconf -e sont parfaitement symétriques.
D-69 — sur Keycloak en particulier : l'API pour toute map ou collection (smtpServer,
attributes, config), où kcadm -s sort en succès sans rien écrire ; kcadm ailleurs,
parce que c'est le vocabulaire de la documentation du produit — donc lisible par un
sysadmin sans IA.
Deux raisons de ne pas systématiser l'API méritent d'être dites : le CLI est souvent le
contrat du fournisseur et encode des invariants (occ user:resetpassword hache
correctement), et chaque appel d'API demande un jeton, donc du secret manipulé dans chaque
tâche.
2026-08-08 — Déconnexion OIDC : Keycloak valide une SECONDE liste d'URI
Nextcloud se connectait parfaitement et échouait à la déconnexion, sur un « We are sorry… invalid redirect uri » qui ne dit pas de quelle liste il parle.
Keycloak valide les URI de retour après déconnexion séparément des URI de rappel. Aucun des quatre clients ne déclarait l'attribut ; Nextcloud était seulement le seul à envoyer une URI de retour, donc le seul à révéler le trou. Les trois autres l'auraient rencontré dès qu'on leur aurait câblé une déconnexion propre.
post.logout.redirect.uris est désormais dérivée de web_origins, qui porte déjà
l'URL de base de chaque service : la connaissance existait, il n'y avait pas à la
réécrire. Posée par l'API et non par kcadm -s — attributes est une map, et sur une map
kcadm accepte la commande, sort en succès et n'écrit rien (mesuré le même jour sur
smtpServer). Relu après écriture, comme il se doit maintenant.
Et le second passage a révélé un défaut dans le travail de l'heure précédente. La tâche
de journalisation se déclarait changed à chaque déploiement : écrits champ par champ,
Jinja rendait True et 1209600 en chaînes, et la comparaison au réel (booléen,
entier) ne pouvait jamais être satisfaite. Corrigé en composant le dictionnaire en une
seule expression, qui rend des types natifs. Un changed permanent n'est pas cosmétique :
c'est un bruit qui finit par masquer un vrai changement. Deux passages consécutifs à
changed=0 désormais.
2026-08-08 — 502 sur Icinga : le tampon de nginx, après une authentification réussie
Symptôme trompeur s'il en est : oauth2-proxy journalisait AuthSuccess — jeton, jeton
d'identité, rafraîchissement, tout obtenu — pendant que le navigateur recevait une erreur
de passerelle. L'authentification n'était pas en cause ; c'est la réponse qui ne passait
plus.
upstream sent too big header while reading response header from upstream
server: icinga.chezlepro.internal, request: "GET /oauth2/callback?..."
Le cookie de session d'oauth2-proxy porte le jeton d'identité, découpé en plusieurs
en-têtes Set-Cookie. Le tampon par défaut de nginx (4 Ko) ne peut pas les contenir.
serveur_nginx pose désormais proxy_buffer_size / proxy_buffers /
proxy_busy_buffers_size sur toutes les expositions : c'est une propriété du proxy,
pas de ce service-là, et le prochain service placé derrière un IdP rencontrerait le même
mur.
Trouvé uniquement parce que le journal des évènements de Keycloak venait d'être activé :
il a montré LOGIN puis CODE_TO_TOKEN réussis pour icingaweb2, ce qui a écarté d'un
coup l'identité et renvoyé l'enquête vers le chemin de retour.
Deux constats laissés ouverts, faute de pouvoir conclure.
Le journal de l'edge montre trois upstream timed out vers Keycloak en quatorze heures
(console de compte deux fois, autorisation Nextcloud une fois). Mesuré depuis l'edge,
Keycloak répond en millisecondes — ce n'est pas lui qui est lent. Cause non établie.
Et ma sonde MTU ne valait rien : ping -M do annonce 100 % de perte alors que HTTP répond
en 5 ms, parce que l'ICMP est bloqué par construction entre hôtes (seul frag-needed
est ouvert au registre des flux). Troisième fois aujourd'hui que l'instrument est le
problème et non le composant — après /dev/tcp sous sh et Maildir/new/.
2026-08-08 — Keycloak ne gardait aucune trace des connexions
Deux services n'aboutissaient pas pour l'exploitant (Nextcloud, Icinga Web 2). Tout a été
vérifié côté serveur et tout était correct : clients OIDC actifs, URI de rappel exactes,
secret d'oauth2-proxy identique à celui de Keycloak (même empreinte SHA-256), CA de
confiance depuis mon-01 (200 sur la découverte), email_domains = ["*"] donc aucune
restriction. Le premier saut de chaque parcours a été rejoué avec curl : Nextcloud
redirige vers sa page locale (qui propose bien le bouton SSO « Chezlepro »), Icinga part
correctement vers Keycloak, qui répond 200.
Et là, plus rien à examiner : eventsEnabled = False. Keycloak ne gardait aucune trace
— ni qui est entré, ni pourquoi une authentification a échoué. Impossible de savoir ce que
l'utilisateur avait rencontré.
C'est un manque d'exploitation autant que de diagnostic : « un sysadmin l'exploite sans
IA » suppose qu'il puisse lire lui-même ce qui s'est passé. Le journal des évènements
(connexions et actions d'administration, rétention 14 jours) est désormais réconcilié par
serveur_keycloak, comme le reste — pas activé à la main dans une console.
2026-08-08 — La livraison interne était en panne, et le devis disait CONFORME
Suite de l'arbitrage sur l'adressage : le courrier local est désormais routé par
identifiant, plus par l'attribut mail.
L'argument décisif n'est pas théorique — Dovecot le fait déjà :
mail_home = /var/vmail/%{user | username}, la partie locale, jamais mail. Les deux
moitiés n'utilisaient donc pas le même mécanisme et ne s'accordaient que par coïncidence,
tant que mail valait uid@<domaine_interne>. Aligner Postfix ne crée pas un modèle
nouveau : ça met fin à une incohérence. Coût assumé : l'adresse interne est dérivée de
l'identifiant et ne se choisit plus ; un alias voulu passera par virtual_alias_maps, non
câblé aujourd'hui. En échange, mail redevient libre de porter la vraie adresse de la
personne — celle que Keycloak affiche et que « mot de passe oublié » utilise.
Puis la preuve de bout en bout a révélé bien pire. Un vrai courriel envoyé à
sysadmin@chezlepro.internal n'arrivait pas :
SSL_connect error to infra-mail-01[10.27.19.31]:24: Connection timed out
status=deferred (Cannot start TLS: handshake failure)
Postfix était durci (lmtp_tls_security_level = verify), le port LMTP de Dovecot écoutait
en clair — son ssl = required global ne concerne que les services de connexion. Les
deux côtés d'un même flux avaient été traités séparément, et toute livraison interne
était différée depuis, sans qu'aucun écran ne le montre. Corrigé des deux bouts, et
accordé : ssl = yes sur l'inet_listener (TLS implicite, ce que sait faire un listener)
et lmtp_tls_wrappermode = yes côté client. L'un sans l'autre ne marche pas.
Livraison prouvée : status=sent (250 2.0.0 … Saved), message présent dans
/var/vmail/sysadmin/Maildir/.INBOX/new/.
Le devis, lui, annonçait CONFORME. Il vérifiait la résolution LDAP et les dialectes SMTP/IMAP — tout était correct — et jamais si le courrier bouge. C'est la leçon la plus chère de la série : un devis qui ne regarde que les réglages ne dit pas si le service rend son service. Il relève désormais la file d'attente et ses raisons de blocage ; le test négatif confirme qu'il aurait nommé cette panne.
Au passage, deux fois où ma sonde était fausse et non le système : /dev/tcp sous sh
(qui ne le connaît pas), et Maildir/new/ alors que l'INBOX est Maildir/.INBOX/new/.
Vérifier l'instrument avant d'accuser le composant, encore.
2026-08-08 — Devis PostgreSQL et courriel : la série est complète
PostgreSQL. Il rend visibles deux défauts déjà vécus ici : un réseau écrit dans
pg_hba.conf au lieu d'être dérivé, et une ligne host en clair là où il faut hostssl
— un verrou qui saute sans bruit, puisque les clients en verify-full continuent de
marcher. État : conforme. Test négatif rejouant les deux défauts plus un certificat
snakeoil : trois écarts nommés, code 1.
Une leçon de méthode au passage : un grep de postgresql.conf annonce
ssl_cert_file = snakeoil alors que le serveur sert bien le certificat de l'AC — la valeur
vient d'un conf.d/99-setops.conf que le grep ne voyait pas. C'était l'instrument qui
était incomplet, pas la configuration. Le devis interroge pg_settings, jamais le
fichier.
Et une erreur trouvée par le test négatif, pas par la relecture : une variable morte dans une branche que le cas nominal n'emprunte jamais. Troisième fois aujourd'hui.
Courriel. Chaque maillon interrogé là où il dit la vérité : postmap -q pour la
résolution LDAP de Postfix, doveadm user pour celle de Dovecot — précisément le maillon
où la livraison avait bloqué — et de vraies conversations SMTP/IMAP. Il vérifie aussi
qu'une adresse inexistante ne résout pas : sans ça, une boîte fourre-tout accepterait
n'importe quel nom et le devis ne mesurerait plus rien.
Il a immédiatement trouvé une divergence réelle. Dovecot connaît la boîte de
sysadmin@chezlepro.internal (mail_path = /var/vmail/sysadmin/Maildir), et Postfix ne
sait pas y router : son query_filter est (mail=%s), et l'attribut mail de l'annuaire
porte désormais sysadmin@chezlepro.ca.
La cause n'est pas un réglage mais une collision de rôles : une identité ne porte
qu'une adresse mail, et on lui en demande deux — l'adresse de notification, qui doit être
joignable par la personne hors du système qu'on amorce, et la clé de routage local, qui
doit vivre dans un virtual_mailbox_domains. Les deux ne peuvent pas être la même valeur.
Ce n'est pas une régression (le compte n'avait aucun mail en début de journée, donc
n'était pas routable non plus) — le devis a rendu lisible un état qui l'était déjà.
Arbitrage à rendre avant correction.
2026-08-08 — Devis des expositions : « est-ce que mes services répondent ? »
Troisième devis de service. Il pose la seule question qui compte pour un utilisateur, et quand la réponse est non, il dit où ça casse.
Une vraie requête, jamais un connect(). À travers l'OPNsense (anti-spoofing), toute
connexion TCP réussit — y compris vers une adresse où aucune machine n'existe. Et « lire des
données après connexion » ne vaut rien en TLS, où c'est le client qui parle en premier : le
port 443 d'un edge sain se comporte exactement comme un port mort. Le devis fait donc une
requête HTTPS complète, avec la racine de l'AC, et lit le code de retour.
Deux points de vue — depuis l'edge (edge + dorsal) et depuis le poste (DNS + frontière + edge + dorsal). C'est leur différence qui diagnostique : l'un répond et pas l'autre, ce n'est pas le service, c'est le chemin.
Un code n'est pas un verdict. Mon premier comparateur ne testait que la présence d'un code : un 502 des deux côtés passait pour conforme alors que le dorsal est mort derrière. Trouvé par le test négatif, pas par la relecture — troisième fois aujourd'hui que c'est l'épreuve, et non le raisonnement, qui tranche. Un 302 ou un 401 reste en revanche un service vivant : il redirige vers l'IdP ou exige une authentification.
État : les six expositions déclarées au plan répondent, des deux points de vue. Test négatif — une frontière qui bloque et un dorsal tombé — les deux écarts sont nommés distinctement, code de sortie 1.
2026-08-08 — Le devis des certificats trouve l'autorité expirée depuis huit heures
Deuxième application du patron devis/applicateur aux services, sur le défaut le plus coûteux qu'on connaisse : un certificat renouvelé sur disque mais toujours servi périmé depuis la mémoire du service.
Trouvé à la première exécution. Sur infra-pki-01 — l'autorité elle-même — le
certificat était expiré depuis plus de huit heures, et le renouvellement échouait toutes
les quatorze minutes :
'step ca renew' requires the '--ca-url' flag
notAfter=Aug 8 02:51:21 2026 GMT (il était 11:14 UTC)
Cause : sur l'hôte de l'AC, /etc/step est le STEPPATH du serveur, pas un amorçage
client — il n'y a donc pas de defaults.json, et l'unité de renouvellement, identique
partout, en dépendait. La leçon avait déjà été apprise, et écrite noir sur blanc dans
le commentaire de la tâche d'émission (« l'autorité ne bootstrape pas »), qui passe
--ca-url et --root explicitement. Elle n'avait jamais été reportée sur l'unité de
renouvellement.
Et le rôle ne pouvait pas se soigner. La condition de ré-émission ne regardait que la
forme — cert absent, ou SAN manquant. Un certificat expiré portant les bons SAN ne
déclenchait rien. client_pki vérifie désormais aussi la validité
(client_pki_marge_renouvellement, une heure).
Ce qu'il a fallu désapprendre pour écrire le devis. Les certificats vivent 24 h et le
minuteur les renouvelle toutes les ~14 min : une empreinte servie différente de celle sur
disque est l'état normal. Comparer les empreintes aurait donné un vérificateur qui crie
en permanence — et qu'on aurait appris à ignorer. Le signal utile est l'échéance de ce qui
est réellement servi, plus l'absence de client_pki_reload_services.
Le devis a d'abord menti, du défaut même qu'il traque. include_vars au niveau du play
prime sur les group_vars : le premier jet rapportait client_pki_reload_services: [] sur
les quatorze hôtes alors que quatre groupes le déclarent. Le correctif suivant a paru
fonctionner — set_fact accepte un dictionnaire entier en argument libre sans erreur et
n'en fait rien. Il faut réimposer clé par clé, en boucle. Les deux devis sont corrigés et
le piège est consigné dans docs/devis-services.md, avant d'écrire le prochain.
Deux latents relevés et corrigés au passage : step-ca (8443) et icinga2 (5665) servaient
une copie qu'aucun rechargement ne rafraîchissait ; les deux déclarent maintenant leur
service, en reload (SIGHUP pour step-ca, safe-reload pour icinga2), sans interruption.
Vérifié dans les deux sens : CONFORME sur les quatorze hôtes après correction ; sur un
relevé où l'on rejoue une copie périmée en mémoire, deux écarts listés et code de sortie 1.
2026-08-08 — Un devis pour l'identité : rien ne comparait le déployé au déclaré
Constat de l'exploitant après trois séries de corrections : « ça fait beaucoup de trucs incohérents qu'on débusque ensemble ». Exact, et il y a une raison mesurable.
Les 30 preuves sont statiques. scripts/prouver.py ne fait aucun appel réseau, aucun
SSH, aucun ansible. Elles établissent que le dépôt est cohérent avec lui-même. Aucune
ne demande au système déployé s'il ressemble à ce que le dépôt annonce — et c'est
exactement là que vivaient les quatre défauts de la journée.
La classe statique, elle, est presque épuisée. Recensement des motifs « crée mais ne
réconcilie jamais » : amorcage_acces (délibéré, D-67), serveur_openldap (corrigé le
matin), et un seul reste réel — rbac-oidc.yml, qui crée trois objets sans jamais les
mettre à jour. Une preuve statique de plus aurait rapporté une ligne. Le trou est ailleurs.
Le dépôt avait déjà la réponse sans l'avoir appliquée aux services. Le patron
devis/applicateur (D-23/D-24) existe pour les quatre pare-feu : make frontiere-plan lit
la frontière réelle et montre l'écart. Rien d'équivalent pour l'identité.
make identite-plan comble ça. Le playbook relève le déclaré et le réel et les dépose
en JSON ; scripts/devis_identite.py compare. La séparation n'est pas cosmétique : j'ai
écrit deux fois de suite une expression Jinja de comparaison illisible avant d'admettre que
le raisonnement n'a rien à faire là — et le dépôt a déjà cette forme pour les devis réseau.
Le déclaré n'est jamais recopié dans le devis : il charge les défauts du rôle et appelle
resoudre_politique_mdp et resoudre_annuaire. Un devis qui redéclare ce qu'il vérifie ne
vérifie rien.
Vérifié dans les deux sens, ce qui est le minimum pour un instrument : sur le système
réel, CONFORME. Sur une copie du relevé où les quatre défauts du jour sont rejoués, plus
deux régressions plausibles (SMTP disparu, compte sans adresse) — six divergences listées,
code de sortie 1. Un vérificateur qui ne sait dire que « conforme » ne vaut rien.
Couvre l'identité seule. Les autres services attendent le même traitement ; le patron est là pour être repris.
2026-08-08 — La politique de mot de passe existait des deux côtés et ne s'appliquait d'aucun
Question de l'exploitant : « l'intégration Keycloak/LDAP est incomplète, non ? » Elle l'était, et pas cosmétiquement. Quatre défauts mesurés, tous de la même famille — une valeur déclarée d'un côté, consommée de l'autre, sans que rien ne vérifie qu'elles se rejoignent.
1. Aucune règle de mot de passe ne s'appliquait sur le chemin d'un vrai utilisateur.
Compte sonde, même mot de passe abcd : l'opération étendue LDAP le refuse
(Constraint violation (19) — Password fails quality checking policy), Keycloak l'accepte
(204), et ldapwhoami avec abcd réussit ensuite. Deux causes empilées : Keycloak
écrivait userPassword directement, donc l'overlay ppolicy n'interceptait rien ; et
le realm n'avait aucune passwordPolicy. Chacun déléguait la vérification à l'autre.
2. ldap_entry ne fait que créer. L'entrée cn=default,ou=policies était figée à ce
qu'elle valait le jour de sa création : toute modification ultérieure de la déclaration
était ignorée en silence. Le dépôt annonçait pwdMustChange: TRUE, le serveur portait
FALSE (corrigé à la main après la boucle de changement de mot de passe du 2026-08-07).
Une reconstruction from-zero aurait donc ressuscité le défaut. ldap_attrs state: exact
réconcilie désormais la politique et les réglages de l'overlay — dont le DN, qui porte
un index attribué par slapd, est lu et non deviné.
3. Un compte créé dans Keycloak n'atteignait jamais l'annuaire. POST users → 201,
rien dans ou=people : syncRegistrations était absent. Ce compte aurait eu un accès web,
aucune boîte aux lettres, et serait resté invisible du modèle de groupes — Postfix et
Dovecot lisent LDAP, pas Keycloak. C'est la divergence nettoyée le matin même sur l'adresse
du sysadmin, réintroduite par une autre porte.
4. Le prénom était mappé sur cn. Dans inetOrgPerson, cn porte le nom complet :
Keycloak affichait « Administrateur systeme systeme ». Le mappeur pointe désormais sur
givenName, que amorcage_acces écrit, dérivé par le même découpage que sn.
Ce qui est ajouté. roles/resoudre_politique_mdp/ porte la déclaration, en termes
neutres, et la traduit dans les trois dialectes qui doivent l'appliquer : pwdPolicy,
passwordPolicy du realm, protection anti-force-brute. serveur_openldap et
serveur_keycloak la consomment ; aucun des deux ne la redéclare.
La fédération est durcie de six clés, dont deux portent la correction et se complètent :
usePasswordModifyExtendedOp (slapd voit passer le changement) et validatePasswordPolicy
(Keycloak valide avant d'écrire). La seconde est la porteuse — Keycloak se lie en rootDN, et
slapd n'applique pas ses contrôles de qualité au rootDN. S'en remettre à la première seule
aurait donné une correction qui paraît juste et ne tient pas ; c'est le test qui a tranché,
pas le raisonnement.
Vérification, mêmes sondes qu'au diagnostic — abcd → 400 Invalid password: minimum length 12, absent de LDAP ; mot de passe conforme → 204 puis ldapwhoami accepté (l'écriture
traversante reste intacte) ; POST users → 201 et dn: uid=setops-sonde2,ou=people,….
Sondes supprimées des deux côtés. Second passage des deux playbooks : changed=0.
2026-08-08 — « Mot de passe oublié » : Keycloak sait enfin envoyer
Le realm affichait une politique d'accès complète et aucun moyen d'écrire à qui que ce
soit : smtpServer vide, resetPasswordAllowed à false. Conséquence concrète — tout
oubli de mot de passe remontait à l'exploitant, qui n'avait alors d'autre choix que de
manipuler le mot de passe de quelqu'un d'autre. C'est précisément ce que « une identité,
une personne » (§3 d'autorisation.md) cherche à écarter.
Ce qui est ajouté. serveur_keycloak/tasks/courriel-realm.yml réconcilie la strophe
courriel du realm et le drapeau « mot de passe oublié ». L'hôte du relais est dérivé du
plan (applications.postfix.hote) : aucun nom de machine n'est écrit. Si le plan ne
déclare pas de MTA, le rôle refuse — un écran qui promet un courriel que personne
n'enverrait serait pire que pas d'écran du tout.
kcadm.sh ne sait pas écrire une map, et ne le dit pas. Sur smtpServer, les deux
formes documentées — -s smtpServer.host=… et -s 'smtpServer={"host":…}' — sortent en
succès, sans rien écrire. Le champ est resté { } après deux déploiements verts. La
tâche passe donc par l'API d'administration (uri), qui répond 204 et écrit vraiment.
Même famille que le reste de ce journal : une valeur déclarée d'un côté, jamais vérifiée
de l'autre. Ce qui l'a rattrapée, c'est d'avoir relu l'état après l'avoir posé — pas le
code de retour.
L'adresse de l'amorçage ne se dérive pas. J'avais d'abord posé
{{ amorcage_acces_uid }}@{{ domaine_interne }} comme défaut : c'est un piège. Cette
adresse désigne une personne, donc quelque chose d'extérieur au système qu'on amorce,
et une boîte interne n'est pas lisible tant qu'on n'a pas justement l'accès qu'on essaie de
récupérer. amorcage_acces_courriel redevient donc à déclarer, et le rôle refuse de créer
le compte sans elle — mais seulement à la création, pour qu'un écosystème déjà amorcé ne se
mette pas à échouer parce qu'on a durci la règle après coup.
Un reliquat de READ_ONLY mis au jour. Le compte sysadmin portait
sysadmin@chezlepro.ca dans Keycloak et rien dans LDAP. L'adresse avait été saisie
dans la console de compte quand la fédération était encore en lecture seule : Keycloak
l'avait gardée pour lui, l'annuaire ne l'a jamais reçue, et les deux côtés ont affiché des
valeurs différentes sans que rien ne le signale. Corrigé dans LDAP (source de vérité) puis
resynchronisé ; consigné au runbook (§6.6) parce que d'autres comptes créés avant la
bascule en WRITABLE peuvent porter le même écart.
Preuve de bout en bout, et pas un connect() : bannière SMTP lue depuis idm-01,
RCPT TO accepté, puis un vrai execute-actions-email déclenché — journal du MTA :
starttls=1, to=<sysadmin@chezlepro.ca>, relay=mx.chezlepro.ca[69.70.26.53]:25, status=sent (250 2.0.0 Ok). Le second déploiement rapporte changed=0 : la tâche
réconcilie, elle ne réécrit pas.
2026-08-06 — le chemin nord-sud devient dérivable
vault_openldap_admin — quatre consommateurs et deux pièges
Le secret le plus délicat de la liste : Keycloak, Dovecot, Postfix et Icinga Web 2 s'y lient tous.
Premier piège : ldappasswd ne peut pas le changer. cn=admin n'est pas une entrée de
la base mais le rootDN déclaré dans cn=config — la commande répond « No such object ».
Le mot de passe vit dans olcRootPW et se modifie par un bind EXTERNAL. L'échec était sans
dégât : l'ancien fonctionnait toujours, vérifié avant de continuer.
Second piège : Keycloak stocke le mot de passe de liaison dans sa base, et le masque. La
réconciliation ajoutée hier couvrait l'URL, les DN et le mode — pas bindCredential. Tourner
le secret aurait coupé Keycloak de l'annuaire, et plus personne n'aurait pu se connecter.
Comme on ne peut pas comparer une valeur masquée, la réconciliation passe par une empreinte
— même mécanisme que les comptes de secours.
Vérifié, consommateur par consommateur : synchronisation LDAP de Keycloak (qui prouve la
liaison), carte LDAP de Postfix, Dovecot actif. Les trois rejouent à changed=0.
Six secrets tournés depuis hier ; chacun a d'abord demandé de construire la capacité de le faire. C'est le motif de fond de ces deux jours : le dépôt savait créer, pas changer.
vault_forgejo_oidc — le secret exposé ne vaut plus rien
Il avait fui dans une sortie de diagnostic. Le faire tourner a d'abord demandé de rendre la rotation possible : ni Keycloak ni Forgejo ne réconciliaient un secret OIDC existant.
Le commentaire de clients-oidc.yml l'avouait — « la réconciliation fine n'est pas faite :
create-si-absent » — et update-oauth, côté Forgejo, ne passait pas --secret. Régénérer la
voûte aurait donc laissé les deux côtés sur l'ancienne valeur, ou pire, un seul des deux : le
SSO aurait cassé sans que rien ne l'annonce.
Les deux réconcilient désormais. Keycloak compare le secret en place (get client-secret)
à celui voulu avant d'écrire — pas de changed inutile.
Vérifié par empreinte, aux trois endroits :
voûte 2484770b53a4fd76f9b57bf1
Keycloak 2484770b53a4fd76f9b57bf1
Forgejo 2484770b53a4fd76f9b57bf1
Keycloak rejoue à changed=0.
Une non-idempotence préexistante, signalée sans être corrigée : Deployer app.ini change à
chaque passage sur Forgejo. Le rôle réécrit un fichier que Forgejo modifie lui-même — il y
persiste ses secrets générés. Ce n'est pas lié à la rotation, et le corriger demande de décider
quelles clés appartiennent au gabarit et lesquelles au service.
vault_keycloak_admin : le secret qui est la clé de son propre changement
Rotation faite, chaîne d'identité intacte (groupe, membre, rôle), rejeu à changed=0.
L'ordre n'est pas indifférent, et c'est le point à retenir. Ce compte est le moyen de se changer lui-même : régénérer la voûte d'abord l'aurait rendu inapplicable — plus rien n'aurait pu s'authentifier pour poser la nouvelle valeur.
1. s'authentifier avec la valeur ACTUELLE
2. poser la nouvelle dans Keycloak
3. vérifier qu'elle fonctionne
4. seulement alors, écrire la voûte
C'est une procédure, pas un redéploiement — et la même contrainte vaut pour
vault_openldap_admin, qui reste à faire. Consigné au runbook (§6.9), avec le rappel qu'une
vérification n'est pas un message de succès.
La rotation des comptes de secours devient possible — elle ne l'était pas
Le runbook §6.8 promettait de régénérer les comptes de secours ; le code ne savait pas le faire. Les trois rôles ne posaient le mot de passe qu'à la création :
grafana GF_SECURITY_ADMIN_PASSWORD n'agit qu'à la création du compte
forgejo admin user create `creates: .admin-created` — une seule fois
nextcloud maintenance:install seulement à l'installation
Régénérer la voûte sans cela aurait produit exactement le mensonge silencieux corrigé toute la journée : la voûte dit une chose, le service en a une autre.
Chaque rôle sait désormais changer un mot de passe existant, avec une idempotence par
empreinte du secret appliqué — on ne peut pas relire un hachage, donc on mémorise ce qu'on
a posé. Pour Nextcloud, le secret passe par l'environnement (--password-from-env) et non par
la ligne de commande, où il serait visible dans la table des processus.
grafana-cli écrivait dans une base fantôme
Et disait « Admin password changed successfully ✔ » à chaque fois.
La CLI prend paths.data par défaut à <homepath>/data ; le paquet Debian range la base dans
/var/lib/grafana. Elle créait donc /usr/share/grafana/data/grafana.db, y écrivait, et
annonçait le succès — pendant que le serveur lisait l'autre fichier.
Ce qui l'a démasqué : le champ updated du compte, resté à l'heure du déploiement initial
malgré quatre réinitialisations « réussies ». Un message de succès n'est pas une preuve ;
l'état l'est. --configOverrides=cfg:default.paths.data=… est désormais imposé.
Vérifié par authentification réelle : Grafana 200, Nextcloud 200. Pour Forgejo,
ENABLE_BASIC_AUTHENTICATION = false ferme l'API par doctrine (D-41) — la seule preuve
disponible est le retour de la commande, qui confirme le changement.
Quatre secrets régénérés : vault_grafana_admin, vault_forgejo_admin,
vault_nextcloud_admin, vault_sysadmin_amorcage.
Icinga Web 2 cesse de nommer des personnes
Le dernier porte_par: liste-uid du catalogue. serveur_icingaweb2_admins valait un uid
en dur ; roles.ini porte désormais groups = "sysadmin", et serveur_icingaweb2_admins
devient un repli de dépannage, vide par défaut.
Le mode SSO complique le montage, et il faut le dire. Les membres d'un groupOfNames
sont des DN ; en auth: external, le nom d'utilisateur vient de REMOTE_USER — une
chaîne, pas un DN. Un backend LDAP supplémentaire est donc déclaré dans
authentication.ini : jamais utilisé pour authentifier (l'externe répond en premier),
uniquement pour que groups.ini résolve le nom vers son DN.
Sans ce pont, l'habilitation par groupe est impossible en SSO — et il faudrait continuer à nommer des personnes.
Ce que je n'ai pas pu vérifier. La configuration est déployée et cohérente, mais la
résolution REMOTE_USER → DN → appartenance est interne à Icinga Web 2 : seule une connexion
réelle par le SSO la prouve. Je ne la déclare donc pas prouvée.
Administrer le realm par appartenance — le dernier porte_par: aucun
realm-admin (rôle du client realm-management) est attaché au groupe sysadmin.
Administrer le realm ne passe plus par le compte local : il suffit d'appartenir au groupe dans
l'annuaire.
Portée : ce realm seulement, jamais master. Le compte admin reste hors d'atteinte du
groupe, et c'est délibéré — un accès de secours qui dépendrait des habilitations qu'il doit
pouvoir réparer n'en serait pas un (D-40).
La console à utiliser est celle du realm — /admin/<realm>/console/ — pas la racine /admin/,
qui est celle de master. Le runbook nomme désormais les trois portes et dit ce que
chacune gouverne.
Les rôles de client sont un espace de noms distinct des rôles de realm ; la déclaration
gagne un champ roles_client. Et l'API attend l'UUID du client, pas son clientId :
interroger par le nom rendait une erreur, la vérification échouait toujours, et la tâche se
déclarait changed à chaque passage alors que le rôle était déjà posé. Corrigé — deux passages
consécutifs à changed=0.
La boucle de changement de mot de passe
Après editMode=WRITABLE, le changement partait mais rebouclait sans fin. Cause :
pwdMustChange: TRUE signifie « quand un administrateur pose un mot de passe, l'utilisateur
doit le changer ». Or Keycloak écrit en tant qu'administrateur (cn=admin) — chaque changement
relayé était donc vu comme une réinitialisation, et OpenLDAP reposait pwdReset aussitôt.
C'est incompatible par construction avec un IdP qui relaie le changement. La contrainte a
été déplacée là où l'utilisateur la voit : pwdMustChange: FALSE côté annuaire, et Keycloak
pose l'action requise UPDATE_PASSWORD tant que pwdReset est vrai — un écran qui explique,
au lieu d'un refus muet au niveau du protocole.
Je ne l'avais pas vu parce que j'avais éprouvé pwdReset au niveau LDAP, où il fonctionne
parfaitement, sans jamais parcourir le chemin complet à travers Keycloak. L'opérateur l'a dit
avant moi : « ce n'est pas du tout explicite » — c'était le symptôme de deux mécanismes qui ne
se parlent pas.
« Federated storage is not writable » — la fédération était en lecture seule
Le changement de mot de passe imposé échouait : editMode=READ_ONLY était codé en dur
dans federation-ldap.yml. Keycloak lisait l'annuaire sans jamais pouvoir y écrire — donc
pwdReset était un cul-de-sac : LDAP exige le changement, et Keycloak ne peut pas le
faire.
Trois modes, un seul tient avec la doctrine :
| Mode | Effet | Verdict |
|---|---|---|
READ_ONLY |
Keycloak n'écrit jamais | le changement de mot de passe est impossible |
UNSYNCED |
Keycloak écrit dans sa base | Dovecot et Postfix, qui se lient directement à LDAP (D-39), valideraient encore l'ancien — une identité, deux mots de passe |
WRITABLE |
Keycloak écrit à travers vers LDAP | l'annuaire reste la source unique ; Keycloak n'en est qu'un client |
UNSYNCED aurait « marché » à l'écran tout en cassant le courriel en silence. C'est le piège
qu'il fallait éviter.
Le mode devient une variable, et il est réconcilié — comme l'URL depuis hier. Un provider
créé en READ_ONLY le serait resté à vie.
Deux fautes de ma part dans le même correctif. Mon extraction du mode actuel s'ancrait sur
$ alors que la ligne finit par un guillemet : elle ne correspondait jamais. Et sans || true,
un grep sans correspondance tue le script entier sous pipefail. La tâche échouait — masquée
par no_log, pour la troisième fois aujourd'hui.
« invalid username or password » — c'était la porte, pas le mot de passe
Première tentative de connexion du sysadmin : refusée. Ni le jeton ni le compte n'étaient en
cause — vérifié dans l'ordre : ldapwhoami avec le jeton retourne le DN, le compte n'est ni
verrouillé ni en échec (pwdFailureTime absent), et Keycloak voit sysadmin activé et
fédéré.
https://auth.<domaine>/ redirige vers /admin/ — la console d'administration du realm
master, où sysadmin n'existe pas. Il vit dans le realm applicatif. Keycloak répond donc
« identifiants invalides » : exact, et parfaitement trompeur.
Le runbook disait « se connecter à Keycloak » sans donner d'URL, et l'URL évidente est la mauvaise. C'est un défaut du document, pas de la manipulation. Il nomme désormais les deux consoles et dit laquelle sert à quoi :
/realms/<realm>/account/ ton compte, tes accès sysadmin + jeton
/admin/ administrer Keycloak admin + vault_keycloak_admin
L'absence de trace d'échec côté LDAP était le vrai indice. pwdFailureTime vide signifiait
qu'aucune tentative n'atteignait l'annuaire — donc que le problème était en amont de la
validation, pas dedans. Chercher d'abord où la requête s'arrête vaut mieux que présumer ce
qui est faux.
Le sysadmin ne pouvait atteindre aucune interface web
Depuis le poste d'administration, auth.chezlepro.internal ne répondait pas — ni aucun autre
service. Le pare-feu de l'hyperviseur n'autorisait le 443 de l'edge que depuis +t17-flotte,
les hôtes du tenant. Le réseau d'administration est dans t17-admin, pas dans t17-flotte.
L'exploitant arrivait donc par un troisième chemin que rien ne déclarait : ni externe
(Internet, affaire de la frontière), ni flotte (le tenant). Il n'est ni l'un ni l'autre — et
le runbook de reprise, écrit la veille, supposait pourtant qu'on ouvre Keycloak dans un
navigateur.
admin devient un pair déclarable, au même titre que flotte et edge : les réseaux de
l'intrant nftables_admin_ssh, déjà source unique de la garde anti-lockout. serveur_nginx
le déclare pour son 443, et les deux générateurs le traduisent — l'IPSet t17-admin existait
déjà. L'edge seul reçoit ce droit : c'est le point d'entrée unique, et ouvrir les services
en direct élargirait la surface sans rien gagner.
Les six interfaces répondent maintenant depuis le poste.
Une erreur de méthode que j'ai commise en donnant les instructions. Mon premier test
utilisait /dev/tcp et concluait « atteignable ». C'était faux : la frontière répond au SYN à
la place de la cible. J'avais consigné ce piège le matin même et j'y suis retombé. Un
connect() ne prouve rien ; seule une lecture prouve.
make ca-racine — la racine de l'AC, et son empreinte
Le runbook demandait de faire confiance à l'AC interne sans dire comment. Deux cibles :
make ca-racine # écrit ./root_ca.crt, affiche sujet, validité, empreinte
make ca-empreinte # la même empreinte, lue SUR l'AC — le témoin de comparaison
L'hôte de l'AC est dérivé du groupe serveur_step_ca, jamais nommé ; une instance sans
autorité interne reçoit un refus qui l'explique.
La racine est un certificat public : elle n'a rien à faire dans la voûte, et tout à faire dans le magasin de confiance de qui administre. Mais la sortie insiste sur la comparaison d'empreinte — installer une AC, c'est lui donner le droit de signer n'importe quel nom.
step-ca publie aussi sa racine sur https://<ca>:8443/roots.pem, joignable depuis le tenant.
Ce chemin n'est pas ouvert au réseau d'administration, délibérément : la commande ci-dessus
donne déjà le résultat, et la racine de confiance n'a pas besoin d'une porte de plus.
Quatre empreintes muettes — et une VM qui en est morte
collab-01 a cessé de répondre en SSH. Le symptôme ressemblait à un problème réseau ; la
cause était un fichier YAML mal formé depuis des semaines.
Quatre meta/empreinte.yml déclaraient leurs valeurs à la racine, sans la clé
setops_empreinte: : collabora, nextcloud, web_dorsal, web_frontal. charger_empreinte_role
les lisait comme vides et rendait {0,0,0} — en silence. Les VM concernées recevaient le
minimum du socle.
collab-01 s'est donc retrouvée avec 1 cœur / 1 Go pour porter Nextcloud et Collabora.
L'installation de PHP 8.4, nginx et Redis a épuisé la mémoire, et sshd n'a plus pu forker.
Après correction : 4 cœurs / 5 632 Mo. web-dorsal-01 passe de 1024 à 2048 Mo.
Un fichier qui existe mais ne dit rien est pire qu'un fichier absent : le repli aurait donné des valeurs sensées. Une garde refuse désormais cette forme, en nommant le fichier — et elle distingue « pas d'empreinte » de « empreinte illisible », qui n'appellent pas la même réponse.
Les deux VM ont été redimensionnées à chaud (arrêt propre, config PUT, redémarrage). Elles
sont dans la couche apps — la dernière — donc rien ne dépendait d'elles.
Deux courses de premier démarrage, corrigées à la racine
Le clone rend la main avant que sa configuration existe. Sur un stockage lent, qm clone
retourne et le .conf n'est pas encore écrit ; la tâche suivante échouait sur
« Configuration file … does not exist ». Une attente active interroge l'API jusqu'à 150 s.
Au passage, j'ai failli livrer pire que le défaut : pour raccourcir une ligne trop longue, je
l'avais coupée avec >- — qui replie les retours en espaces, insérant une espace au milieu
de l'URL. Le lint passait, la requête aurait échoué. L'URL est assemblée dans une variable.
Le verrou dpkg frappait hors de common_packages. J'y avais mis lock_timeout hier, mais
chaque autre rôle installant des paquets restait exposé — serveur_nextcloud en a fait les
frais. Il est désormais posé en module_defaults sur les 30 playbooks de groupe : toute
tâche apt du play en hérite, y compris celles des rôles inclus. Une déclaration au lieu de
trente.
Une collision de noms qui rapportait ok
web-frontal-01 ne se créait pas : une VM héritée portait déjà ce nom (911401, arrêtée,
adressage 192.168.15.x de l'ancien monde). proxmox_kvm identifie par le nom, l'a trouvée,
et a rapporté ok sans rien cloner.
C'est plus grave que l'échec : un déploiement peut paraître réussi alors qu'aucune VM n'a été créée. Ça touche directement D-37 — les noms courts sont volontairement identiques d'un tenant à l'autre, et un parc hérité qui partage un nom crée exactement cette collision silencieuse. La VM héritée a été renommée.
resoudre_idp — le nom inventé était dans quatre rôles
forge-01 a échoué sur serveur_forgejo_oidc_discovery, qui pointait
https://keycloak.<domaine> — le nom que rien ne publie. J'avais corrigé exactement ça dans
serveur_oauth2_proxy quelques heures plus tôt, en croyant régler un cas isolé.
Il était en fait dans quatre rôles : forgejo, grafana, nextcloud, oauth2-proxy. Chacun
fabriquait le même nom par la même convention, et chacun aurait échoué au même endroit —
oauth2-proxy l'a fait le premier parce qu'il a été déployé le premier.
Une cinquième copie corrigée à la main aurait divergé comme les quatre autres. D'où
roles/resoudre_idp : il lit l'exposition déclarée au plan et rend resoudre_idp_hote,
resoudre_idp_base, resoudre_idp_discovery. Les trois rôles restants l'appellent ; leur
défaut ne garde plus qu'un repli nommé, jamais la valeur de travail.
Vérifié en base sur forge-01 :
OpenIDConnectAutoDiscoveryURL : https://auth.chezlepro.internal/realms/chezlepro/…
GroupClaimName : 'groups' AdminGroup : 'sysadmin'
Le câblage par groupe et l'URL correcte, sur un service déployé pour la première fois.
La leçon est la même que pour resoudre_annuaire hier, et elle mérite d'être dite deux
fois : corriger la valeur là où elle échoue ne corrige que là. Ce sont les autres copies,
silencieuses, qui coûtent la journée suivante.
Forgejo et Nextcloud câblés — avant d'être déployés
Les deux services n'existaient pas encore : les câbler maintenant vaut mieux que les corriger
après. porte_par passe de aucun à claim-groupe dans leurs meta/acces.yml.
Forgejo reçoit --group-claim-name + --admin-group sur sa source OAuth2. Et sa tâche
est passée de « créer si absent » à add-oauth ou update-oauth : le même défaut que la
fédération Keycloak — créé une fois, jamais corrigé — l'attendait sinon.
Nextcloud était déjà en upsert. Il reçoit --mapping-groups et --group-provisioning,
plus une tâche qui verse les membres du groupe d'habilitation dans le groupe interne admin :
être dans un groupe projeté ne donne aucun pouvoir en soi. L'absence du groupe au premier
déploiement n'est pas une erreur — il n'existe qu'à la première connexion d'un membre.
Le maillon qui manquait aux deux. Les groupes existaient dans le realm mais
n'apparaissaient dans aucun jeton : pas de mapper de protocole. Forgejo et Nextcloud auraient
lu un claim vide et n'auraient rien accordé — un câblage correct des deux côtés, et rien au
milieu. oidc-group-membership-mapper est désormais posé sur les trois clients, avec
full.path=false pour que le claim porte sysadmin et non /sysadmin.
Vérifié : grafana, forgejo, nextcloud portent chacun le mapper.
La chaîne d'habilitation est complète
group-ldap-mapper construit, et le rôle est attaché au groupe — pas à une personne.
LDAP cn=sysadmin,ou=groups member: uid=sysadmin
Keycloak groupe `sysadmin` projeté, membre `sysadmin`
rôle de realm `grafana-admin` attaché AU GROUPE
Grafana claim roles → oidc_role_path → Admin
Ajouter quelqu'un à cn=sysadmin dans l'annuaire lui ouvre Grafana en Admin : sans toucher
au dépôt, sans déploiement, sans nommer personne. C'est D-66 réalisé, et c'est ce qui rend
la reprise par le sysadmin effective. Rejoué : changed=0.
serveur_keycloak_role_assignments reste vide, et son commentaire dit maintenant que c'est
définitif.
Un défaut de fond découvert en chemin : la fédération n'était jamais réconciliée.
federation-ldap.yml créait le provider s'il manquait, puis ne le corrigeait plus jamais. Le
provider pointait donc encore ldaps://id-ldap-01.chezlepro.internal — le nom d'hôte erroné
corrigé le matin même dans resoudre_annuaire. La fédération était muette, et la
synchronisation des groupes échouait sur un laconique UnknownHost.
C'est le revers de D-67 appliqué au mauvais endroit : l'infrastructure se réconcilie, seules les appartenances ne le sont pas. L'URL, le DN des utilisateurs et le DN de liaison sont désormais corrigés à chaque passage.
Et j'avais masqué l'échec. La synchronisation portait || true : le mapper existait, le
realm restait vide, et rien ne disait pourquoi. Ça m'a coûté la moitié du diagnostic. Le || true est retiré, l'erreur remonte avec son message.
Les cinq meta/acces.yml — et ce qu'ils rendent visible
Chaque rôle web déclare désormais le groupe qu'il reconnaît et ce qu'il lui accorde. Un
troisième champ s'est imposé en écrivant : porte_par — le mécanisme qui transporte
réellement l'habilitation. Sans lui, les déclarations auraient décrit une chaîne inexistante,
ce qui est exactement le défaut levé sept fois hier.
L'état réel, mesuré rôle par rôle :
| Rôle | porte_par |
Ce que ça veut dire |
|---|---|---|
serveur_grafana |
role-realm |
mécanisme réel : claim roles → oidc_role_path |
serveur_forgejo |
aucun |
rien n'est câblé ; tout authentifié a le niveau par défaut |
serveur_nextcloud |
aucun |
idem — l'administration passe par le compte local |
serveur_icingaweb2 |
liste-uid |
nomme des personnes (D-66) |
serveur_keycloak |
aucun |
la projection LDAP → rôle de realm n'existe pas |
Trois services sur cinq n'ont aucun mécanisme, et deux nomment des personnes — ce que D-66 interdit, écrit une heure plus tôt.
Le maillon manquant est chez Keycloak. serveur_keycloak_role_assignments assigne un rôle
à un utilisateur, nommément ; son propre commentaire l'admettait déjà (« en prod, préférer
l'assignation via groupe d'annuaire ; ici, explicite pour la preuve »). Sans mapper
group-ldap-mapper, les groupes LDAP n'atteignent jamais les services : la chaîne s'arrête
avant le premier. Sa méta a donc une autre forme — acces_projection, car Keycloak projette
au lieu de consommer (D-65).
Un défaut concret corrigé. serveur_icingaweb2_admins valait "testmail" — un compte de
test codé en dur dans le moteur, qu'aucune instance ne surchargeait. Le seul administrateur
déclaré de la supervision était donc un utilisateur inexistant. Il suit désormais l'uid
d'amorçage ; vérifié sur mon-01 : users = "sysadmin".
Ce que la doctrine visait est maintenant lisible. Le §1 d'autorisation.md disait
qu'aucun service ne lisait ou=groups. Les métas le disent maintenant service par service,
avec le mécanisme qui manque à chacun — c'est la condition pour qu'une preuve puisse un jour
le vérifier.
ppolicy chargé — le jeton d'amorçage devient vraiment à usage unique
serveur_openldap charge désormais l'overlay ppolicy et pose une politique par défaut.
Le module était sur disque (/usr/lib/ldap/ppolicy.so) mais jamais chargé : seul
back_mdb l'était. Depuis OpenLDAP 2.5 son schéma est intégré au module — aucun
.ldif à charger, contrairement à 2.4.
La contrainte mord, mesuré sur un compte fraîchement amorcé :
$ ldapsearch -D uid=sysadmin,… -w <jeton>
Insufficient access (50)
Operations are restricted to bind/unbind/abandon/StartTLS/modify password
Le sysadmin peut se connecter et rien d'autre que changer son mot de passe. Ce que la doctrine promettait est maintenant garanti techniquement, pas seulement demandé.
pwdMustChange est ce qui donne son effet à pwdReset : sans lui, marquer une entrée
n'oblige à rien. Les deux vont ensemble, et c'est le genre de couple qu'on découvre en le
testant. La politique apporte aussi la longueur minimale (12 — pwdMinLength n'est appliqué
que si pwdCheckQuality > 0), le verrouillage après 5 échecs et l'historique.
olcPPolicyUseLockout reste à FALSE, délibérément : répondre « ce compte est verrouillé »
renseignerait un attaquant sur l'existence du compte.
Le DN de la base est lu, pas supposé. olcDatabase={1}mdb est l'usage courant mais
l'index n'est pas garanti — le rôle le cherche.
Et la détection du rôle d'amorçage s'est vérifiée d'elle-même : rejoué après le chargement, il annonce « Changement FORCÉ à la première ouverture (ppolicy actif) » là où il disait l'inverse une heure plus tôt. C'est exactement pourquoi il détecte au lieu de supposer.
Le rôle d'amorçage : amorcage_acces
Crée un compte (uid=sysadmin) et un groupe (cn=sysadmin) dans LDAP, puis se
retire. Prouvé sur idm-01 : le compte s'authentifie (ldapwhoami retourne son DN), et le
second passage ne touche à rien — changed=0, sept tâches sautées.
Il suit l'annuaire, il ne se déclare pas au plan. Le rôle écrit par ldapi:/// — socket
locale — donc il doit tourner sur l'hôte de l'annuaire. Le déclarer comme groupe obligerait
chaque instance à le poser sur le bon hôte, et les instances ne nomment pas cet hôte pareil :
idm-01 chez Chezlepro, id-ldap-01 chez Technolibre. Je m'en suis convaincu en le posant
d'abord sur infra-pki-01 par erreur. Il est donc appliqué par le playbook de
serveur_openldap.
Trois défauts trouvés en le construisant, tous par la machine :
pwdReset n'existe pas dans ce schéma — il vient de l'overlay ppolicy, non chargé
(seul back_mdb l'était). L'entrée entière était rejetée, et no_log: true masquait la
cause. J'avais supposé un mécanisme sans vérifier qu'il existait. Le rôle le détecte
désormais : overlay présent → changement forcé ; absent → il le dit en clair, et le
changement devient une obligation d'exploitation. La doctrine a été corrigée pour ne plus
promettre ce qui n'a pas lieu.
Le mot de passe aurait été stocké en clair. ldap_entry écrit userPassword
littéralement ; le jeton d'amorçage aurait été lisible par quiconque lit l'annuaire. Il est
maintenant haché par slappasswd -h {SSHA} sur la cible.
Le recensement des secrets ne voyait pas ce rôle. voute.py ne scannait que
roles/<groupe> — un rôle appliqué par un playbook sans être lui-même un groupe échappait au
recensement, ce que D-20 interdit. Il suit désormais les listes roles: des playbooks, ce qui
vaut pour tout rôle futur dans ce cas. Le gabarit signale correctement
vault_sysadmin_amorcage, généré ensuite dans les deux tenants.
Set-OPS amorce les accès, le sysadmin gouverne (D-65 → D-67)
L'authentification était résolue et gardée (P29) ; l'autorisation n'existait nulle part.
Constat mesuré : ou=groups est créé par serveur_openldap depuis le début, et aucun des
29 rôles ne le lit — pas un memberOf, pas un filtre. ou=people est vide aussi : personne
ne peut entrer autrement que par les comptes de secours en voûte.
La décision principale contredit délibérément la doctrine du dépôt (D-67). Partout ailleurs, un écart entre le déclaré et le réel est un défaut à corriger : les devis réconcilient, les applicateurs retirent ce qui n'est plus demandé. Pour les habilitations, l'écart est légitime — c'est le sysadmin qui fait son travail.
Deux régimes, et la frontière entre eux :
| Qui décide | Régime | |
|---|---|---|
| quel groupe accorde quoi dans un service | Set-OPS | réconcilié, comme le reste |
| qui appartient à quel groupe | une personne | amorcé une fois, jamais réconcilié |
Set-OPS crée un accès — celui du sysadmin — puis se retire. Idempotence par existence, pas par conformité : compte absent, on le crée ; compte présent, aucune action quel que soit son état. Il a pu être renommé, promu, déplacé. Un dépôt qui réconcilierait les appartenances effacerait le compte créé la veille pour un nouvel employé — exactement la « correction » qu'un agent zélé ferait sans y penser, d'où la nécessité de l'écrire.
D-65 — les groupes LDAP portent l'autorisation, Keycloak les projette. Argument mécanique : Dovecot et Postfix ne savent pas lire un rôle Keycloak. L'y loger rendrait la moitié courriel aveugle et imposerait au sysadmin deux modèles de permissions. Conséquence pratique : un seul endroit à administrer.
D-66 — un service nomme un groupe, jamais une personne. C'est ce qui rend la reprise possible : révoquer quelqu'un ne demande pas un déploiement.
Le §6 est un runbook de reprise, et c'est la partie utile : récupérer le mot de passe d'amorçage, où administrer quoi, comment se rouvrir si on se ferme dehors, et ce qu'il faut changer en priorité. Ce dernier point mérite d'être dit : les comptes de secours ont été générés pendant le déploiement, et l'auteur du déploiement y a eu accès. Les régénérer n'est pas une formalité — c'est ce qui transforme une livraison en transfert.
Effet secondaire du cadrage : sans registre de personnes, aucune donnée personnelle n'entre dans l'historique git.
Rien n'est construit. Le rôle d'amorçage, les meta/acces.yml et la preuve restent à
écrire.
Le courriel interne, et l'annuaire qui ne désignait personne
infra-mail-01 (Dovecot) et edge-mta-01 (Postfix + rspamd) déployées — dix-neuf
playbooks d'affilée sans un échec. Neuf VM debout.
Postfix → Dovecot:24 ouvert
lmtp_tls_security_level = verify zéro-confiance sur le LMTP
mynetworks = … 10.27.0.0/16 le supernet dérivé, à l'œuvre
virtual_mailbox_maps → ldaps://idm-01 liaison établie
C'est la seconde branche de la directive d'authentification : LDAP direct pour les protocoles qui ne parlent pas OIDC, sans passer par Keycloak.
resoudre_annuaire fixait id-ldap-01 en dur — une machine qui n'existe dans aucun plan.
Postfix ne pouvait pas se lier : Unable to bind to ldaps://id-ldap-01.chezlepro.internal:636 (Can't contact LDAP server).
L'intention du rôle était pourtant juste, et son commentaire le disait : « LE seul point où le nom d'hôte de l'annuaire est fixé, au lieu d'être répété dans chaque rôle ». Un seul point de vérité — mais écrit au lieu d'être dérivé. Une valeur unique et fausse vaut mieux qu'une valeur répétée et fausse ; elle reste fausse.
Le plan le déclare (applications.openldap.hote: idm-01) : c'est de là qu'elle vient
désormais. Septième occurrence du même motif aujourd'hui.
Ce qui n'est pas prouvé : aucun compte n'existe dans LDAP — l'approvisionnement des utilisateurs n'est pas une étape d'infrastructure. Tout ce qui précède la boîte aux lettres est vérifié ; la remise elle-même attend un compte.
Le SSO fonctionne — quatre défauts, un seul motif
obs-01 et mon-01 déployées : Loki, Prometheus, Grafana d'un côté ; Icinga, Icinga Web 2 et
oauth2-proxy de l'autre. Sept VM debout, et la supervision est réelle — 7 cibles Prometheus
up, une par hôte vivant, scrutées en TLS à travers cinq zones de sécurité du VRF.
La preuve du SSO :
GET http://127.0.0.1:4180/ → 302
https://auth.chezlepro.internal/realms/chezlepro/protocol/openid-connect/auth
?client_id=icingaweb2&redirect_uri=https://icinga.chezlepro.internal/oauth2/callback
C'est le patron générique : Keycloak devant une application sans OIDC natif, fédérant LDAP.
Il a fallu quatre corrections, et l'erreur changeait à chaque fois — signe qu'on avançait.
Le secret de cookie était en base64 standard. oauth2-proxy décode en base64 url-safe ; un
secret contenant + ou / fait échouer le décodage, il retombe sur la chaîne brute et se
plaint de sa longueur — « is 44 bytes » — sans jamais mentionner l'encodage. Régénéré en
url-safe dans les deux tenants, avec une garde qui refuse + et / en nommant la cause.
L'issuer était inventé. Le rôle fabriquait https://keycloak.<domaine> par convention — un
nom que rien ne publie. Le plan expose Keycloak sous auth.<domaine>, et c'est ce nom que
PowerDNS résout et que nginx sert.
Keycloak s'annonçait sous ce même nom inventé, à la source : issuer did not match the issuer returned by provider. Même correctif — le nom d'hôte se dérive de l'exposition.
Le pare-feu est-ouest bloquait l'edge. Le flux existait d'un seul côté : oauth2-proxy
déclarait egress 443 → edge, la matrice était satisfaite (nginx déclare bien 443) — mais avec
pair: externe, que le devis est-ouest saute volontairement, puisqu'il relève de la
frontière. Aucune règle d'hyperviseur n'était émise, et la connexion expirait. serveur_nginx
déclare désormais aussi son 443 depuis la flotte : les FQDN publiés vivent à l'edge, et un
service interne qui appelle un autre service passe par son nom publié.
Trois de ces quatre sont le motif du jour : un nom construit par convention d'un côté,
déclaré de l'autre. Le champ expose du plan a maintenant cinq consommateurs — PowerDNS,
nginx, /etc/hosts, oauth2-proxy et Keycloak — pour une seule source.
Le supernet du tenant devient un intrant dérivé
Keycloak ne démarrait pas :
FATAL: aucune entrée dans pg_hba.conf pour l'hôte « 10.27.17.11 »,
utilisateur « keycloak », base « keycloak », chiffrement SSL
PostgreSQL n'autorisait que 10.11.0.0/16 — l'ancien monde — pour un tenant en
10.27.0.0/16. La valeur était figée dans group_vars, sous un commentaire
« AJUSTER au sous-réseau réel de déploiement » que personne n'avait suivi. Un commentaire
qui demande une action est une action qui n'aura pas lieu.
Postfix portait la même valeur périmée, et aurait échoué de la même façon plus tard, sur
le courriel. Technolibre aussi : 10.12.0.0/16 pour un tenant en 10.21.0.0/16. Quatre
fichiers, une seule faute, répétée parce que recopiée.
instancier émet désormais setops_supernet, dérivé du seed comme le VMID et l'adresse.
Les quatre fichiers le consomment, et la valeur suit le tenant sans être saisie :
Chezlepro 10.27.0.0/16
Technolibre 10.21.0.0/16
La chaîne d'identité est debout
keycloak active issuer = http://keycloak.chezlepro.internal:8080/realms/…
slapd active namingContexts: dc=chezlepro,dc=internal
idm-01 : dix playbooks, aucun échec, fédération LDAP configurée. Et les quatre premiers se
sont rejoués à zéro changement — tout ce qui a été corrigé aujourd'hui converge.
Quatre VM sur quatorze sont entièrement déployées : l'autorité de certification, le DNS autoritatif, l'identité (annuaire + SSO) et les bases (PostgreSQL + Redis).
Trois défauts que seul un vrai déploiement pouvait montrer
idm-01 — première VM d'une autre zone de sécurité (t17iden) — a prouvé le routage
inter-zone dans le VRF : 10.27.19.21 joint 10.27.17.11, à travers deux passerelles
anycast. Puis elle a levé trois défauts, tous invisibles jusqu'à ce qu'on déploie pour de vrai.
Le handler de client_pki rechargeait un service pas encore installé. Conséquence directe
de l'ordre rétabli : client_pki s'exécute avant les services — ils ont besoin du
certificat — et son handler tentait systemctl reload slapd. Un consommateur absent n'est pas
une erreur : il prendra le certificat déjà posé à son installation. Le silence est resserré sur
ce cas précis ; un vrai échec de rechargement reste fatal, sinon un service servirait un
certificat périmé sans que personne ne l'apprenne.
resoudre_base ne trouvait aucune base de portée application. Le registre accepte deux
portées : groupe désigne un groupe opérationnel, application une application du plan.
Keycloak déclare consommateur: keycloak — valide, le validateur l'accepte — mais le rôle
cherchait serveur_keycloak. Le lien entre les deux était déclaré (applications.yml
nomme le groupe de chaque application) : on le suit désormais, plutôt que de retirer un
préfixe à la main. Une convention de nommage se contredit un jour ; une déclaration se corrige.
Keycloak attend une base qui n'existe pas encore. data-sql-01 n'est pas déployée : le
service démarre, se connecte, échoue. Ce n'est pas un défaut — make deployer HOTE=x ne connaît
que l'ordre intra-hôte. L'ordre inter-hôtes existe (couches-deploiement.yml place
serveur_postgresql avant les applications) et c'est make site qui l'exploite.
Deux attentes, sans lesquelles make myDay ne peut pas reconstruire
Enchaîner creer-vm puis deployer échouait presque toujours sur une machine neuve, pour deux
raisons distinctes :
- SSH n'est pas levé quand Proxmox rend la main. Le symptôme trompe : à travers la frontière le TCP s'établit (SYN proxy) et l'échec se lit « timed out during banner exchange ».
- Le verrou dpkg est tenu par les mises à jour automatiques de Debian, par vagues, pendant plusieurs minutes après le premier démarrage.
_attendre-hote attend les deux, et creer-vm rend désormais une VM prête plutôt que
seulement démarrée. ATTENTE_HOTE (600 s) borne l'attente : dépasser reste un échec, pour
qu'une panne ne devienne pas une attente infinie.
Deux couches, pas une. Le premier essai vérifiait le verrou avec fuser — un instantané.
Il était libre au test, repris juste après. Chaque tâche apt de common_packages porte donc
lock_timeout: 300 : le Makefile attend la fin des vagues, le rôle survit à une vague qui
repart entre deux tâches.
Et un défaut dans l'attente elle-même : unattended-upgrades.service est un démon
(Type=simple), toujours active. L'inclure dans la condition la rendait impossible à
satisfaire — elle échouait au délai, systématiquement. Seules les unités apt-daily* sont des
one-shot ; c'est le verrou qui dit si dpkg est occupé.
make deployer ignorait l'ordre des couches
client_metrique échouait sur les deux premières VM : il exige le certificat TLS du
node_exporter, que seule l'AC peut émettre. Cause : afficher_playbooks_hote() triait les
groupes alphabétiquement après le socle. client_journal, client_metrique,
client_unbound passaient donc avant serveur_step_ca — sur l'hôte de l'AC lui-même.
docs/couches-deploiement.yml existe précisément pour définir cet ordre, et sa dernière
couche dit en toutes lettres : « intégrations déployées en dernier, quand leurs cibles sont
debout ». make site le lit ; make deployer ne l'avait jamais lu. Deux chemins pour la
même question, un seul registre consulté.
Le tri se fait désormais par couche — socle d'abord, alphabétique seulement à l'intérieur d'une couche — depuis le même registre que l'orchestrateur.
infra-pki-01 socle → serveur_step_ca → client_pki → intégrations
infra-dns-01 socle → client_pki → serveur_powerdns → intégrations
L'autorité n'est plus une exception, elle est un cas à part
Deux politiques universelles se contredisaient. client_pki exemptait l'AC — « elle EST la
source de la confiance » — et client_metrique refuse toute exemption — « un collecteur
muet sur son propre état est un angle mort ». Les deux avaient raison séparément, et l'AC
restait la seule machine impossible à mesurer.
L'exemption confondait ne pas s'enrôler et ne pas avoir de certificat. L'AC n'a pas à aller chercher sa racine par le réseau, chez elle, en vérifiant une empreinte qu'elle vient de produire — mais ses services ont besoin de certificats comme tous les autres. Elle est précisément la machine qui peut se les signer, localement, sans réseau.
client_pki distingue donc les deux chemins : bootstrap pour les autres, émission locale
sur l'AC. L'exemption disparaît, et la doctrine reste intacte.
Un effet de bord instructif. step ca bootstrap écrit aussi le defaults.json qui porte
l'URL de l'AC : en sautant le bootstrap, on perdait l'information sans le voir —
flag '--ca-url' is required. --ca-url et --root sont désormais explicites pour tous
les hôtes. Dépendre d'un fichier écrit par une étape qu'on saute volontairement, c'était
reconstruire le même piège.
Deux services souverains debout
step-ca active, :8443 `step ca health` → ok
powerdns active, 10.27.19.11:53 (plus 0.0.0.0 — la restriction d'écoute a pris)
dig @10.27.19.11 infra-pki-01.chezlepro.internal → 10.27.19.21
infra-dns-01 est la première VM tenant entièrement déployée : huit playbooks, aucun
échec — socle, durcissement, PKI cliente, PowerDNS, sauvegarde, journaux, métriques, courriel.
La zone souveraine résout, et l'AC a émis son premier certificat à un tiers.
L'ICMP n'a pas de port — et la seconde barrière n'avait jamais démarré
nftables.service refusait de démarrer sur la première VM déployée :
/etc/nftables.conf:23 icmp dport frag-needed accept
^^^^^ syntax error, unexpected string
Le générateur émettait {protocole} dport {port} pour tout flux. L'ICMP n'a pas de
port : il a un type et un code. Les deux flux PMTUD de D-30 produisaient donc un jeu que
nft rejette — et un jeu rejeté ne se charge pas du tout, si bien que l'hôte perd sa
barrière au lieu d'en gagner une.
Le défaut touchait les quatorze hôtes. La seconde barrière de D-31 — les nftables d'hôte, qui doublent le filtrage de l'hyperviseur — n'avait jamais pu démarrer nulle part. Personne ne s'en était aperçu parce qu'aucune VM tenant n'avait encore été déployée.
_selecteur_nft() traduit désormais : tcp dport 22 reste tel quel,
icmp frag-needed devient icmp type destination-unreachable icmp code frag-needed. Un
code ICMP inconnu est refusé à la génération, avec le nom du rôle fautif.
La garde tourne aussi à la vérification, pas seulement à la génération. Un rôle peut
déclarer un flux que personne ne porte encore : le devis passerait, et la panne arriverait
le jour où un hôte prend ce rôle. nft -c en local demanderait des privilèges netlink ;
construire le sélecteur ne coûte rien et attrape exactement la même faute.
Mesuré après correction : nftables=active sur les deux VM, 16 et 17 règles chargées, dont
la garde anti-lockout ip saddr 10.0.0.0/24 tcp dport 22 accept.
client_unbound devient universel — et l'amorçage DNS trouve sa place
Une VM ne peut pas s'installer sans résoudre des noms : apt en dépend. Or le DNS
autoritatif du tenant (PowerDNS) répond uniquement pour la zone souveraine et refuse
le reste — il ne récurse pour personne. Il manquait donc un résolveur récursif, et
client_unbound est exactement l'outil écrit pour ça : zone interne déléguée à
l'autoritatif, récursion depuis la racine, aucune dépendance au résolveur d'un fournisseur.
Il rejoint client_journal et client_metrique parmi les intégrations universelles :
13 hôtes sur 14, déclarés une fois dans meta/integration.yml, et 8 déclarations
redondantes retirées du plan.
L'exemption, et son revers. infra-dns-01 est exempté : PowerDNS occupe déjà son port
53, y ajouter Unbound produirait un conflit de liaison. Au passage,
serveur_powerdns_listen_addresses passe de 0.0.0.0 à l'adresse de l'hôte — lier toutes
les interfaces occupait aussi 127.0.0.1:53, là où un résolveur local voudrait s'installer.
Mais l'appartenance au groupe est aussi ce qui ouvre le port 53 à la frontière. En
exemptant la machine, je lui retirais le droit de résoudre : le serveur qui devait servir de
DNS au tenant était le seul à ne pas pouvoir s'installer. serveur_powerdns déclare donc son
propre flux sortant — il ne récurse pour personne, mais il doit résoudre pour lui-même.
Le résolveur d'amorçage, et pourquoi cloud-init ne suffisait pas
Nouvel intrant dns_amorcage, dérivé jusqu'à make creer-vm. Cloud-init l'écrit bien —
mesuré : dns-nameservers 9.9.9.9 149.112.112.112 dans 50-cloud-init. Et il reste sans
effet : dns-nameservers d'ifupdown exige resolvconf, absent du gabarit doré, qui
transporte en outre un /etc/resolv.conf figé de l'ancien monde. Installer resolvconf
demanderait apt, qui demande la résolution : la boucle se referme.
serveur_debian pose donc le résolveur en pre_tasks, avant common_packages — donc
avant le premier apt. Une garde tient le passage de relais : si 127.0.0.1 est déjà là,
client_unbound a basculé et le fichier n'est pas touché. Sans elle, chaque déploiement
aurait défait la bascule, et deux rôles se seraient disputé le même fichier sans fin.
Un défaut créé puis corrigé en chemin. La première version de _intrants_communs() lisait
instance/ en dur : un test sur inventaire synthétique serait allé chercher les intrants de
la production. Le chemin dérive maintenant de l'inventaire reçu, et un test le prouve dans les
deux sens — avec inventaire, et sans.
La première VM tenant, et les quatre défauts qu'elle a révélés
infra-pki-01 recréée pour éprouver la chaîne complète. Le CA est le bon premier service :
il est le seul rôle exempté de client_pki — il ne s'enrôle pas auprès de lui-même — donc
sans dépendance amont.
Tout ce qui dérive du seed est exact :
VMID 117402101 pool Chezlepro-17
carte bridge=t17serv, firewall=1, aucune étiquette VLAN
adresse 10.27.19.21/24, gw 10.27.19.1
calcul 1 cœur / 1024 Mo
depuis le VRF : ping 0.08 ms, port 22 ouvert
Mais y arriver a demandé quatre corrections, et chacune serait passée inaperçue.
Le pare-feu est-ouest aurait enfermé Ansible. t<idx>-srv-debian n'autorisait SSH que
depuis +t<idx>-flotte — les hôtes du tenant. admin_de(nom) était collecté dans le devis
puis jamais utilisé. La première VM passée en policy_in=DROP se serait fermée derrière
l'outil qui venait de la configurer. Un IPSet t<idx>-admin dédié porte désormais l'intrant
nftables_admin_ssh, et une règle s'y source. Pas d'ajout à flotte : ce mot-clé sert aussi
LDAP, SQL et les métriques, qu'il aurait ouverts au réseau d'administration.
L'applicateur ne convergeait pas. Proxmox range 192.168.255.2/32 sous la forme
192.168.255.2. Comparés littéralement, l'écart ne se referme jamais — chaque passage croit
devoir corriger. _norm() ramène les deux à la même forme.
cloner-vm refusait toute VM de tenant. Son garde exigeait VLAN=, alors qu'en SDN
l'étiquette est portée par le VNet et le VLAN est volontairement vide. Il accepte désormais un
VLAN vide si un pont est fourni — sans quoi la VM ne serait branchée nulle part.
Le clonage ignore cores et memory. L'API Proxmox ne les accepte pas au moment du
clone : la VM héritait du gabarit — 2 cœurs / 2048 Mo contre 1 / 1024 au plan. Le plan était
contredit sans un mot. Une tâche les repose après le clone, et la mesure le confirme.
Troisième verrou posé : proxmox_clone_parefeu_interface: true. Sans firewall=1 sur la
carte, les groupes de sécurité affectés à la VM ne s'appliquent jamais — le filtrage est-ouest
serait posé et sans effet (D-64).
Le DROP se pose par VM, pas au datacenter (D-64)
Le devis enseignait un geste dangereux : « pare-feu activé, politique d'entrée DROP » au
niveau du datacenter. Or policy_in y est la politique par défaut de toute VM dont le
pare-feu s'active. Sur ce cluster, cela vise 37 machines héritées qui n'ont aucune règle.
policy_in existe aussi par VM. Le devis et l'applicateur le posent désormais là :
datacenter enable=1, policy_in laissé au défaut ACCEPT
VM tenant enable=1 + policy_in=DROP + carte firewall=1
VM héritée rien — politique inchangée, pare-feu éteint
Même isolation est-ouest, sans le moment où tout bascule. Et l'applicateur n'a plus besoin de refuser une partie de son devis : la partie dangereuse a disparu.
Trois verrous, pas un. Une VM n'est filtrée que si le datacenter est actif, que son
propre enable vaut 1 — défaut 0, c'est le verrou du milieu — et que sa carte porte
firewall=1. C'est ce verrou du milieu que j'avais manqué en annonçant que huit VM en
production tomberaient.
enable=1 au datacenter : mesuré, pas supposé
Basculé avec vérification immédiate. Après :
pve-firewall enabled/running
chaînes iptables 12, toutes des chaînes-cadres PVEFW-*
chaînes par VM aucune
règles visant roxanne 0
15 VM en marche 15
hyperviseurs, frontière joignables
sortie tenant 2/2, 13 ms
enable=1 pose le cadre et rien d'autre. Ce que le schéma laissait prévoir est maintenant
constaté sur la machine — la distinction compte, et c'est la seule raison d'avoir tenté le
geste plutôt que de l'écrire.
Un applicateur pour le pare-feu est-ouest — et un refus assumé
scripts/appliquer_proxmox_fw.py réconcilie les trois couches du devis : 26 IPSets,
36 groupes de sécurité, et les affectations aux VM. Créé, mis à jour, retiré — même contrat
que les deux autres.
Ce qu'il ne fera jamais : activer le pare-feu du datacenter. Ce réglage
(enable=1 + policy_in=DROP) vaut pour toutes les VM du cluster, y compris les 37
machines héritées qui n'ont aucune règle. Le basculer couperait le parc d'un coup. L'écart est
signalé à chaque exécution, en toutes lettres ; la décision reste humaine.
C'est la première fois qu'un applicateur de ce dépôt refuse par conception de faire une partie de son devis. Le refus vaut mieux qu'une option qu'on finirait par cocher sans y penser.
Les 28 VM du devis n'existent pas encore. Elles sont listées comme différées, pas comme erreurs : leur affectation se posera au prochain passage, une fois clonées.
Les objets posés sont inertes, précisément parce que le prérequis n'est pas rempli. C'est ce qui rend l'application sûre aujourd'hui : la politique est en place et vérifiable, sans rien filtrer tant qu'on ne l'a pas décidé.
scripts/proxmox_api.py extrait ce que les deux applicateurs Proxmox partagent — surtout
la recomposition du jeton utilisateur@royaume!nom, dont la voûte ne porte que le nom. La
dupliquer, c'était préparer un 401 muet le jour où l'un des deux morceaux changerait.
Une fausse alerte en passant : les noms tronqués à 18 caractères semblaient collisionner entre
IPSets et groupes. Ce sont deux espaces de noms distincts chez Proxmox, et les règles
référencent un IPSet par un +. Aucune collision — et le devis portait déjà sa garde.
Un applicateur pour le SDN, et pour la sortie des VRF
scripts/appliquer_sdn.py — deuxième applicateur du dépôt, même contrat que celui de la
frontière : ce qui manque est créé, ce que le devis ne demande plus est retiré.
Deux cibles, parce qu'elles n'ont pas la même prise. Les objets de cluster (zone, VNets,
sous-réseaux) passent par l'API Proxmox ; la sortie du VRF est un fichier sur chaque nœud de
sortie, qu'aucune API n'expose — donc SSH. make sdn-plan lit, make sdn-appliquer CONFIRMER=true agit.
Périmètre strict. Seules les zones nommées par le devis et celles de la liste explicite des anciens nommages sont touchées. Une zone inconnue est signalée et laissée intacte : ce dépôt n'est pas seul au monde sur ce cluster.
Deux garde-fous sur le retrait. Un VNet encore branché à une VM est refusé, avec le nom des machines — on ne débranche personne par inadvertance. Et l'ordre suit les dépendances : sous-réseaux, puis VNets, puis zones à la suppression ; l'inverse à la création. Proxmox refuse tout autre ordre, et l'avait déjà appris à ses dépens.
Une source unique pour la strophe. devis_sdn.strophe_frr() sert à la fois au devis qui
l'affiche et à l'applicateur qui la compare au fichier distant. Deux rendus séparés auraient
fini par diverger — c'est le mode de panne que ce dépôt passe ses journées à fermer.
Un défaut trouvé par la première exécution. L'applicateur lisait frr.conf.local sans
sudo ; la lecture échouait, un || true masquait l'échec, et un fichier présent était
déclaré absent — donc réécrit sans raison. Il distingue désormais « absent », « illisible » et
« différent », trois états qui appellent trois réponses.
Le rejeu ne trouve plus rien à faire, les six VRF portent leur défaut, et la sortie tenant répond toujours.
Trois nœuds de sortie, et le primaire enfin émis
t17 passe à asgard,gandalf,vishnu, primaire asgard — t11 l'était déjà. La strophe FRR
est posée sur les trois nœuds, et les six VRF (deux zones × trois nœuds) portent leur défaut.
Un défaut du devis découvert au passage. proxmox_sdn.sortie_primaire était déclaré et
utilisé par devis_opnsense pour dériver le prochain saut des routes de la frontière,
mais devis_sdn ne l'émettait jamais. Une zone créée depuis ce devis n'aurait pas eu de
primaire, et les deux devis se seraient contredits : la frontière aurait pointé un nœud que le
SDN n'avait pas désigné. Le devis l'émet désormais, et refuse un primaire absent de la liste
des nœuds de sortie.
Une mesure qui accusait à tort. Depuis gandalf et vishnu, la sortie semblait morte :
100 % de perte là où asgard passait. La capture a tranché — pendant que gandalf pingait,
asgard recevait les quatre réponses :
IP 10.0.4.1 > 10.27.19.1: ICMP echo reply, seq 1..4 (vu sur asgard)
La source du test, 10.27.19.1, est la passerelle anycast : elle existe à l'identique sur
les trois nœuds. La frontière route 10.27.0.0/16 par sa route statique unique vers
10.0.4.41, et asgard consomme les réponses. Une VM tenant a une adresse unique, et le
relais EVPN la lui rend — mécanisme déjà observé (10.27.19.21 via 10.0.5.41).
Ce retour par le primaire n'est pas un défaut : c'est le sens de « primaire », et la conséquence d'une route statique unique côté frontière. À retenir pour les mesures futures : une adresse anycast ne peut pas servir de source de test — elle ne désigne pas le nœud d'où l'on part.
Les tenants sortent — et le chemin est entièrement dérivé
Bout en bout, mesuré depuis vrf_t17 puis vrf_t11 sur asgard :
tenant -> 10.0.4.1 (frontière) 3/3 0.14 ms
tenant -> 69.70.26.49 (passerelle FAI) 3/3 0.31 ms
tenant -> 9.9.9.9 (Internet) 2/2 11 ms (Chezlepro)
2/2 17 ms (Technolibre)
Il a fallu lever deux obstacles, et aucun des deux n'était celui qu'on croyait.
Proxmox n'installe aucun défaut dans le VRF du tenant (D-62). Déclarer des nœuds de
sortie ne suffit pas : default-originate annonce une route aux autres nœuds, il n'en pose
pas chez lui. show ip route vrf vrf_tNN 0.0.0.0/0 était vide sur les deux zones — et
t11 était configuré depuis plus longtemps, donc ce n'était pas un oubli récent.
La sortie vient d'une strophe dans /etc/frr/frr.conf.local, que Proxmox fusionne à
chaque régénération (EvpnPlugin.pm, read_local_frr_config). Vérifié : la ligne se retrouve
dans le frr.conf généré, survit à pvesh set /cluster/sdn et à un
systemctl restart frr.
Le choix de construction est le cœur de l'affaire. nexthop-vrf default emprunte une
adresse — celle de la frontière, connectée sur vlan40 — au lieu d'importer la table
principale. Conséquence voulue et vérifiée : la route par défaut des hyperviseurs ne
gouverne pas la sortie des tenants, et peut rester où elle est. import vrf default, le
geste « simple », aurait fait sortir les tenants par 192.168.11.254 en contournant la
frontière, tout en leur donnant 10.0.5.0/24 (transport VXLAN), la gestion et les VLAN
hérités.
Le NAT sortant ne couvrait pas les supernets tenants (D-63). Le mode « automatique » d'OPNsense ne traduit que les réseaux directement attachés ; un supernet joint par route statique en sort silencieusement. Le diagnostic est venu d'un ping vers la passerelle du FAI — un seul saut, donc aucune ambiguïté sur l'origine de la panne — puis de la table d'états :
état 10.27.19.1 -> 69.70.26.49 icmp 0:0 nat_addr : absent
Le filtre laissait passer (un état n'existe que si une règle a autorisé) ; c'est la
traduction qui manquait. devis_opnsense émet désormais une règle de NAT par tenant
(section 2bis), le réconciliateur les applique et les retire par
/api/firewall/source_nat/*, et P24 refuse tout supernet routé mais non traduit — la
garde qui aurait nommé la panne du premier coup.
devis_sdn §4 émet la strophe FRR : le nom du VRF vient de l'index, l'adresse de la
frontière vient de passerelle_sortie dans l'underlay. Rien n'est saisi.
Le réconciliateur sait enfin retirer
scripts/appliquer_opnsense.py remplace les scripts jetables qui appliquaient la frontière
depuis un bac à sable. Il travaille dans les deux sens : ce que le devis demande et qui
manque est créé ; ce que le devis ne demande plus est retiré.
Sans ce second sens, un devis qui change laisse derrière lui des règles mortes. Elles
n'ouvrent rien — mais elles décrivent une politique qui n'est plus la nôtre, et une bordure
dont la lecture ment est pire qu'une bordure vide. C'est exactement ce que D-61 venait de
produire : deux règles SSH sur wan que plus aucun devis ne réclame.
Périmètre strict. Seuls les objets marqués setops: (règles) ou préfixés SETOPS_
(alias) sont touchés. Ce qu'un humain a posé à la main dans l'interface n'existe pas pour ce
script — il ne peut donc pas le supprimer.
Ordre imposé par les dépendances, et il porte une propriété : les alias d'abord (une règle référençant un alias absent est refusée), puis les créations, puis seulement les retraits. À aucun instant la politique n'est plus permissive qu'avant, et si un retrait échoue on reste en surcouverture — jamais avec un trou.
Garde de la règle 4. Sans CONFIRMER=true, aucune écriture : le script affiche le plan
et s'arrête. make frontiere-plan pour lire, make frontiere-appliquer CONFIRMER=true pour
agir. Le devis doit en outre passer sa propre garde P24 avant qu'une seule requête ne parte.
Un alias encore référencé par une règle survivante n'est jamais retiré — la suppression échouerait et laisserait le boîtier à moitié réconcilié.
La frontière filtre pour de vrai
La règle any → any d'opt1 est retirée. Les 26 règles posées la veille n'étaient jusque-là
qu'une intention derrière un laissez-passer ; elles sont maintenant la politique.
Prouvé dans les deux sens, pas seulement constaté :
frontière → asgard sonde de passerelle Online, 0 %, 0.2 ms
asgard → 10.0.4.1 ping depuis vlan40 2 transmis, 0 reçu, 100 % perte
Le lien L2 est sain — c'est le pare-feu qui jette. Et la sonde tient : le trafic émis par le
pare-feu lui-même passe par let out anything from firewall host itself, sa réponse revient
par l'état, et les règles pass in on opt1 ne le concernent pas. J'avais annoncé un risque
là-dessus ; il n'existait pas.
Conséquence ferme, désormais mesurée. Les 14 règles d'opt1 n'autorisent que les supernets
tenants en source. Rien n'autorise 10.0.4.41/.43/.47. Poser la route par défaut des
hyperviseurs sur vlan40 les couperait donc immédiatement : cette tâche dépend maintenant de
l'inventaire d'exploitation de l'hébergeur (D-46/48), qui n'avait pas d'échéance.
L'interface d'une règle se dérive de l'attachement, pas du sens du flux (D-61)
Le passage de l'administration au VLAN 10 a révélé une hypothèse devenue fausse. Le devis
rangeait tout flux entrant sur le WAN, en supposant que l'administration revenait par
l'adresse publique. Vrai pour 192.168.255.0/24 ; faux pour 10.0.0.0/24, qui est
directement attaché sur lan (igb0, « GESTION »).
Les deux règles SSH étaient donc mortes deux fois : mauvaise interface, et
Block private networks les aurait filtrées de toute façon. Elles ne fonctionnaient que grâce
au Default allow LAN to any hérité. Pire, le devis en tirait un conseil nuisible —
« décocher Block private networks sur WAN » — qui aurait affaibli l'interface publique pour
admettre un réseau qui n'y arrive jamais.
reseaux_locaux_frontiere() dérive de l'underlay les sous-réseaux où la frontière porte
elle-même une adresse, hors lien de transit. Chaque CIDR d'administration est rangé de ce
côté-là ou du WAN, avec un alias par interface : Technolibre a les deux, Chezlepro n'a que
la gestion. L'avertissement RFC1918 ne compte plus que les sources réellement côté WAN.
Sans underlay, tout retombe sur le WAN — le comportement d'avant, inchangé.
La garde tient la décision. P24 confronte chaque règle d'administration à l'attachement de sa source : une règle sur la gestion dont la source n'est attachée nulle part, ou une règle sur le WAN dont la source est locale, font échouer le devis. Vérifié en rejouant l'ancien comportement : la garde le refuse.
Nouvel intrant opnsense_if_gestion (lan ici), au catalogue du GUI. 27 règles au lieu de 26
— Technolibre en gagne une, ayant des sources des deux côtés.
Le réseau d'administration est le VLAN 10, partout
nftables_admin_ssh déclarait encore 192.168.255.0/24 chez Chezlepro — l'ancien monde.
Cet intrant a trois consommateurs : les alias SETOPS_ADMIN_* de la frontière, le
devis du commutateur, et le jeu nftables de chaque VM. Il est donc devenu la source unique
du « d'où administre-t-on ».
- Chezlepro :
10.0.0.0/24. Le VLAN 10 est son réseau d'administration (D-54). - Technolibre : ses deux réseaux plus
10.0.0.0/24. Un tenant doit accepter son propre admin et celui de qui l'héberge — c'est de la VLAN 10 de l'hébergeur qu'Ansible se connecte. Sans le second, aucun déploiement ne joindrait ses VM.
Propagé : alias mis à jour sur la frontière, 14 aperçus nftables régénérés.
Ce que ça évitait. flux-genere/infra-pki-01.nft était figé avec l'ancienne valeur, et
il prime sur le gabarit plat quand il existe. Un make deployer aurait posé un jeu
n'autorisant SSH que depuis 192.168.255.0/24, sur un hôte joint depuis 10.0.0.x — la
machine se serait fermée derrière l'outil qui venait de la configurer. Les treize autres
n'avaient pas d'aperçu et seraient tombées sur le gabarit plat : SSH ouvert à tous, pas de
blocage mais aucune restriction non plus. Les quatorze portent maintenant la bonne source.
make flux était cassé par un port non numérique
Le tri du registre comparait les ports directement, donc int contre str dès que deux
types se croisaient dans un même sens pour un même rôle. Le défaut était latent :
serveur_nginx a derive depuis longtemps, mais son voisin est dans l'autre sens et le
tuple tranche sur le rang avant d'atteindre le port. Les deux flux ICMP frag-needed de
serveur_debian, eux, sont dans les deux sens — ils ont rendu la comparaison inévitable.
_cle_port() rend la clé homogène : les numériques d'abord, les symboliques ensuite par
ordre alphabétique. Le registre se régénère.
L'EVPN tourne
Les commutateurs configurés, le trunk vérifié : 10.0.5.41 joint .43 et .47. Le SDN
appliqué a fait basculer les tunnels — ils sortaient de 192.168.11.41, la carte de
gestion, et sont maintenant sur vlan11. C'est ce que l'objection du 4 août visait, et ce
n'est vrai que depuis cette application.
bgpd et bfdd étaient à no sur les trois nœuds : FRR tournait avec zebra seul, donc
aucune session EVPN. Activés un nœud à la fois — vishnu, gandalf, asgard — avec
vérification du quorum et de la route par défaut après chacun. Maillage complet : six
sessions établies, 16 préfixes échangés.
Sauvegarde /etc/frr/daemons.avant-bgpd posée sur chaque nœud avant modification.
Une alarme retirée
J'avais annoncé les VRF « inversés » — vrf_t11 portant les sous-réseaux de Chezlepro. Le
noyau tranche : 10.21.16.0/24 is directly connected, t11fron dans vrf_t11. Chaque VRF
porte bien ses propres sous-réseaux, par les passerelles anycast de ses VNets. Les
ip route … null0 sont ceux des autres zones — et avec deux tenants, « les autres » et
« inversés » se ressemblaient exactement.
Une brèche réelle, à filtrer avant la première VM
Une fois les nœuds de sortie actifs, une VM tenant atteint tous les réseaux directement connectés sur son propre hyperviseur — gestion Proxmox, stockage iSCSI, Ceph, et le transport VXLAN lui-même. Mesuré avant la suppression de la VM d'essai.
Ce n'est pas un défaut de configuration : c'est le prix du routage inter-VRF. Une route
connectée l'emporte sur la route par défaut, donc déplacer celle-ci (D-57) n'y suffira
pas. Il faut une règle « supernet tenant → réseaux de l'hyperviseur : DROP », que
make devis-proxmox-fw sait déjà dériver.
Déclencheur : avant la première VM tenant. À ce stade — plateforme en construction, zéro locataire — c'est un chantier, pas un incident.
La frontière : identifiant vérifié, nœud de sortie dérivé
La clé d'API fonctionne (OPNsense 26.1.2_5). L'API des règles donne elle-même la liste des identifiants qu'elle accepte :
lan → « GESTION » opt1 → « TENANTS » wan → « WAN »
Ni le périphérique (vlan040), ni le libellé. L'intrant opnsense_if_transit: opt1
était juste — la question ouverte depuis deux jours est tranchée par la mesure.
Le prochain saut des routes tenants ne s'écrit plus <NOEUD-DE-SORTIE-EVPN> : il dérive.
Deux déclarations doivent concorder, et c'est voulu — l'hébergeur nomme le nœud
(proxmox_sdn.sortie_primaire, propriété du cluster), l'underlay dit son adresse sur le
lien de frontière. Nommer un nœud absent du lien rend le devis muet plutôt que faux.
route add 10.21.0.0/16 via 10.0.4.41
route add 10.27.0.0/16 via 10.0.4.41
Le devis explique pourquoi cette adresse-là, et pourquoi un seul saut : une route statique n'en porte qu'un, et deux nœuds actifs en sortie avec une seule route en entrée donneraient un chemin asymétrique — la réponse reviendrait par une interface où l'état n'a pas été créé.
30 preuves OK.
2026-08-04 (suite 4) — le numéro est l'adresse
Les deux commutateurs deviennent bifrost-3 et bifrost-4, avec leurs adresses
10.0.0.3 et 10.0.0.4. Tout l'ensemble de bordure porte désormais un seul nom, et
N est son dernier octet :
bifrost-1 frontiere 10.0.0.1 · 10.0.4.1 ← passerelle
bifrost-2 frontiere 10.0.0.2 · 10.0.4.2
bifrost-3 switch 10.0.0.3 ← racine du spanning-tree
bifrost-4 switch 10.0.0.4
Plus de table de correspondance : bifrost-4, c'est .4.
D-12 est renversée. Elle disait « bifrost aux frontières, sleipnir à la fabric » —
le nom portait le type de la machine. Or c'est le champ role qui le fait, et lui
seul pilote le devis : aucune logique ne dépendait du nom, seulement des données et un
commentaire. Ce qui survit de D-12, c'est l'idée qu'un nom doit se retenir — elle passe
maintenant par le numéro.
Le devis a suivi seul, jusqu'aux marqueurs de ports (<PORT-VERS-BIFROST-4>).
Inconvénient assumé : bifrost-3 ne dit plus « commutateur ». Il faut lire role. En
pratique le devis s'en charge — sa partie B s'intitule « SWITCHES D'ACCÈS (L2 pur) :
bifrost-4 ».
30 preuves OK.
2026-08-04 (suite 3) — le VNet d'une VM se dérive, et un hyperviseur a plusieurs pattes
Le pont n'était pas seulement non portable : il était faux
proxmox_clone_pont faisait naître les VM sur vmbr1 avec une étiquette VLAN —
l'ancien monde. En SDN, une VM appartient à son VNet. C'est ce qu'il a fallu corriger
à la main sur infra-pki-01, et les treize suivantes auraient suivi.
Le VNet est dérivable : index + zone de sécurité → t17serv, comme le VMID et l'adresse
le sont déjà. deriver_nomenclature() expose désormais la zone, instancier émet
proxmox_pont et une étiquette vide — le VNet porte déjà le tag, en poser un second
donnerait un double étiquetage.
SETOPS_PONT='t11appl'
SETOPS_VLAN=''
Trois pièges en chemin. Un doublon dans le Makefile passait PONT_PROXMOX deux fois
dans la même cible : la seconde, vide, aurait écrasé la valeur dérivée. Un repli naïf
sur proxmox_vlan aurait fait revenir l'étiquette en SDN — le repli ne s'applique que si
la clé est absente, jamais si elle est présente et vide : c'est la différence entre
« on ne sait pas » et « on a décidé qu'il n'y en a pas ». Et le test unitaire est tombé,
à raison ; il couvre maintenant cette distinction.
La vocation du dépôt réseau (D-55)
Il porte le contrat entre l'Alliance et ses hébergeurs : si chacun présente la même interface, un tenant se déplace sans rien changer chez lui. Il abstrait le matériel en encapsulant chaque tenant dans sa zone EVPN — le VRF borne ce qu'il a le droit de connaître.
Mesuré : un tenant est à deux valeurs de la portabilité complète (noeud,
stockage). Aucun ne nomme un commutateur, un VLAN, une adresse d'underlay ni une zone.
Un hyperviseur a plusieurs pattes (D-57)
Les hyperviseurs reçoivent 10.0.0.41/.43/.47 sur vmbr0 — interface sysadmin,
sans route par défaut. On ne l'atteint que depuis le même domaine de diffusion : un
accès distant doit être ouvert explicitement à la frontière, il ne peut pas exister par
accident.
Leur route par défaut passe sur vlan40, vers l'OPNsense. Ça tranche la question
laissée ouverte depuis ce matin — option A, mais sur une interface dédiée, ce qui lève
l'objection qui la bloquait : le trafic tenant ne touche plus la carte d'administration.
Le modèle ne savait pas exprimer deux interfaces sur un même hôte : ajouter le VLAN 10 l'a
mis sur le trunk de bond3, remettant la gestion dans le domaine qu'on venait d'en
sortir. D'où le champ via, dont le devis dérive un port par interface et son
type :
<PORT-VERS-PROXMOX-BOND3> trunk 11,40
<PORT-VERS-PROXMOX-VMBR0> access 10
Un seul VLAN sur une interface = port d'accès ; plusieurs = trunk. Dérivé, pas déclaré.
Régression créée puis corrigée au passage : le modèle public, qui ne déclare aucun hyperviseur, n'émettait plus rien pour ce port. Un devis muet ferait croire qu'il n'y a rien à configurer. Il émet désormais tout l'underlay, en disant que c'est un repli.
Côté cluster
vmbr3 retiré des trois nœuds, vlan11 et vlan40 créées sur bond3 aux bonnes
adresses. Les pairs du contrôleur EVPN pointaient encore sur 10.0.0.x — des adresses
qui n'existaient plus. Corrigés vers 10.0.5.x, dérivés d'underlay.yml plutôt que
retapés : une liste saisie à la main diverge au premier changement, ce qui venait
précisément d'arriver. Confronté à l'API : les trois pairs correspondent à une vlan11
réelle.
Pas encore câblé, donc pas encore appliqué. infra-pki-01 n'a rien senti.
30 preuves OK.
2026-08-04 (suite 2) — l'invariant du dernier octet retrouve sa portée
Il valait partout ; il ne vaut que là où il a un sens : l'adressage dérivé des
tenants, où passerelle_de(index, zone) produit le même .1 dans les treize
sous-réseaux. C'est une propriété de la dérivation, pas une loi universelle.
Dans l'underlay, il produisait deux effets pervers :
- un seuil arbitraire — les sous-réseaux plus étroits qu'un
/24étaient exemptés, donc élargir un/29changeait la validité du fichier sans que rien d'autre bouge ; - une couture entre propriétaires — l'octet attendu venait de la nomenclature d'un tenant, appliquée à la fabric de l'hébergeur. La validité de l'underlay aurait dépendu du tenant actif si l'un d'eux réservait autre chose.
Ce qui reste est plus fort, et suffit : une passerelle doit être l'adresse d'un hôte déclaré sur ce réseau (D-52). Elle attrape les passerelles fantômes, ce que le comptage d'octets ne faisait pas.
À noter, parce que l'ordre était mauvais. Le ré-adressage de l'OPNsense en
.1a été demandé au nom de cette règle, deux messages avant qu'elle soit recadrée. Il n'est pas perdu —.1est la position conventionnelle d'une passerelle, et la frontière la porte désormais partout — mais la portée de la règle aurait dû être questionnée avant de faire changer une adresse sur un boîtier en service.
Deux décisions consignées, non construites
D-53 — le réseau et l'underlay de l'hébergeur méritent leur propre dépôt.
underlay.yml décrit une infrastructure ; le dépôt de tenant décrit une
organisation. Un tenant peut déménager, une fabric non. Les mêler oblige, à chaque
commit, à trancher ce qu'on touche. Le symlink désignant l'hébergeur pointerait vers ce
dépôt-là — la distinction deviendrait visible dans les chemins.
D-54 — 10.0.0.0/24 est réservé à l'IPAM, la gestion des équipements et l'OOB ;
accès sysadmin. Aucun hyperviseur, aucune VM, aucun trafic tenant — ni encapsulé, ni
décapsulé. C'est la raison d'être des VLAN 11 et 40. Écrit dans underlay.yml à côté du
réseau lui-même, pas seulement dans la documentation.
30 preuves OK.
2026-08-04 (suite) — plus aucun commutateur ne route
Question posée à froid : « je ne vois plus de valeur ajoutée au point de routage
sleipnir-01 ». Vérification faite, aucun de ses trois SVI n'avait de consommateur.
| SVI | Membres du VLAN | Qui a besoin de routage |
|---|---|---|
Vlan11 |
les trois VTEP, tous en 10.0.5.0/24 |
personne — même sous-réseau |
Vlan40 |
frontières et nœuds de sortie, tous en 10.0.4.0/24 |
personne — adjacents |
Vlan10 |
les commutateurs | eux-mêmes, pour leur sortie |
Et l'OPNsense a déjà une patte sur le VLAN 10 : les commutateurs peuvent l'utiliser directement.
Deux décisions séparées avaient vidé ce rôle sans qu'on regarde leur effet cumulé. Le passage à l'EVPN a retiré les VLAN tenants du fil ; la fusion du lien de sortie dans le VLAN 40 a rendu les nœuds de sortie adjacents à la frontière. Chacune était justifiée seule.
sleipnir-01 disparaît
Pas seulement son rôle L3 : la machine. En étoile, le centre est sur tous les
chemins — un point de panne unique pour le plan de données entier. Ça vidait aussi de son
sens l'ajout d'une seconde carte à bond3 : deux liens qui aboutissent au même
commutateur protègent d'un câble, pas d'un équipement.
Deux commutateurs L2 reliés entre eux, avec les bond3 répartis, donnent la redondance
qu'une étoile ne peut pas donner. D-51, et D-05 est renversée.
Ce que le devis perd
Trois SVI, quatre routes statiques, et surtout la section 5 — celle qui portait « à appliquer en dernier ; ces routes coupent l'accès d'administration au switch lui-même ». La manœuvre la plus risquée du devis n'existe plus. Le devis frontière annonce désormais « prérequis réciproques : AUCUN ».
passerelle change de sens (D-50)
Elle signifiait « l'adresse du SVI du commutateur » — une hypothèse déguisée en donnée.
Elle signifie maintenant « la passerelle de ce sous-réseau, où qu'elle vive », et le
devis dérive s'il doit émettre une interface routée : uniquement si le porteur
déclaré a le rôle switch.
Le même moteur sert donc les deux postures. Le modèle public démontre celle où le commutateur route ; Chezlepro celle où la frontière route.
Deux gardes remplacées, pas affaiblies
passerelle_sortie exige aussi passerelle et routeur.ip == passerelle encodaient
l'ancienne hypothèse et refusaient la seule configuration correcte. À leur place, une
règle plus forte : une passerelle doit être l'adresse d'un hôte déclaré sur ce
réseau. Elle attrape en plus les passerelles fantômes — une adresse inventée, un octet
de trop. Éprouvée par trois sabotages, les trois attrapés.
Elle a immédiatement trouvé une sous-déclaration dans le modèle public : il annonçait
un SVI de transit à 10.0.4.6 sans dire qui le porte.
Trois trous trouvés en chemin
Tous de la même famille — une liste figée finit toujours par mentir :
- le port vers la frontière était figé sur le transit, muet dès que les
bifrostont eu une seconde patte ; - le trunk vers Proxmox excluait le transit « parce qu'aucun hyperviseur n'y est » ;
- les switches d'accès sautaient le VLAN de transit à la déclaration.
Et un quatrième, créé par la suppression du SVI : le commutateur de tête n'avait plus d'adresse de gestion. Tant qu'il portait le SVI, son adresse était la passerelle ; sans SVI, elle n'était plus émise nulle part — visible seulement en commentaire.
Adressage résultant
VLAN 10 bifrost-1 .1 (passerelle) bifrost-2 .2 sleipnir-02 .3 sleipnir-03 .4
VLAN 11 asgard .41 gandalf .43 vishnu .47 aucune passerelle
VLAN 40 bifrost-1 .1 (sortie) bifrost-2 .2 asgard .41 gandalf .43
La frontière porte .1 partout : l'invariant du dernier octet (D-04) est enfin vrai pour
le seul routeur. opnsense_api_url passe de .254 à .1 — le boîtier doit suivre,
mais rien dans Set-OPS n'appelle son API aujourd'hui, donc aucune automatisation ne casse
en attendant.
D-03 est renversée : le /29 élargi en /24 fait tomber l'exemption d'invariant, et
le .1 revient à la passerelle. Le plan survit, décalé d'un cran.
Question ouverte
L'octet attendu vient de plan/nomenclature.yml d'un tenant, appliqué à l'underlay de
l'hébergeur. Deux propriétaires, une seule valeur : la validité de la fabric
dépendrait du tenant actif si l'un d'eux réservait autre chose. Non corrigé.
30 preuves OK.
2026-08-04 — séparation des plans, et où vont les services de l'hébergeur
Deux VLAN pour séparer ce qui était mêlé
Le VTEP vivait sur le VLAN 10, donc le transport tenant partageait son domaine de
diffusion avec l'administration des équipements (SVI du commutateur, OPNsense).
Plus grave : un nœud de sortie décapsule le trafic tenant et le remet dans sa table
principale — dont la route par défaut sort par vmbr0, l'interface de gestion des
nœuds. Le trafic des VM aurait emprunté le lien physique de l'interface web, de SSH
et du cluster.
Ça défaisait ce que l'EVPN devait obtenir. underlay.yml déclare donc :
vlan 11 underlay-vxlan 10.0.5.0/24 SVI 10.0.5.1 transport VXLAN
vlan 41 sortie-tenant 10.0.6.0/24 aucun SVI trafic décapsulé
sortie-tenant n'a volontairement pas de passerelle : le commutateur le transporte
sans le router, donc le trafic tenant en clair ne traverse aucun SVI de gestion. Le
devis n'émet d'ailleurs pas d'interface Vlan41 — le modèle a exprimé l'intention sans
qu'on ait à la commenter.
Support prévu : bond3 une fois doublé — enp7s0 est libre sur les trois nœuds, et le
bond est déjà en active-backup, le mode qui convient sans MLAG. Aujourd'hui bond3
n'a qu'une carte : tout le trafic tenant, intra-zone compris, repose sur enp8s0.
Un défaut que ce changement a créé, et corrigé
Le port du commutateur vers la frontière était figé sur le seul VLAN de transit. Les
bifrost ayant désormais une patte sur le 41, ce port serait resté muet : le devis
aurait eu l'air juste et le trafic ne serait jamais arrivé. Il dérive maintenant des
rattachements déclarés des hôtes role: frontiere → allowed vlan 40,41.
Vérification de la construction parallèle
Le cluster actuel ne correspond pas à ce qui est planifié, et c'est voulu : on construit
à côté. Encore faut-il qu'il n'y ait pas de collision. Mesuré sur les 38 VM héritées :
étiquettes 7, 8, 9, 10, 12, 13, 14, 15, 1001, 1003 — aucune ne croise les VNI projetés
(1111‑1116, 1171‑1176) ni les VLAN d'underlay.
Deux points relevés : le VLAN 10 porte 4 VM héritées (il n'est donc pas purement de la
gestion), et infra-pki-01 est encore branchée à l'ancienne — vmbr3 + étiquette
1174 — pas sur le VNet t17serv. À rebrancher à la bascule.
Où vont les services de l'hébergeur (D-46 à D-48)
Constat : aucun équipement de l'hébergeur n'est dans un inventaire Ansible, et rien ne sauvegarde leurs configurations. Ni les hyperviseurs, ni les commutateurs, ni la frontière.
Un hébergeur porte trois catégories : son tenant (son courriel, sa forge — un client comme les autres), ses opérations (supervision de la fabric, journaux, sauvegarde des configs, DNS d'underlay), et le plan de contrôle (déjà dehors).
Les opérations vivent dans le dépôt de l'hébergeur, et leurs VM se rattachent à un pont VLAN, jamais un VNet : un service qui observe la fabric ne peut pas dépendre d'elle. Et la doctrine « hors flotte » ne vaut que pour les commutateurs et la frontière — les hyperviseurs sont des Debian joignables en SSH.
Décision consignée, rien n'est construit. docs/hebergeur-exploitation.md.
Question laissée ouverte
L'index 0 réservé au tenant propre de chaque hébergeur — local par construction, donc
jamais à coordonner. Deux obstacles mesurés : il produit les VNI 1001–1006, que le
parc hérité utilise déjà (1001 TechnoLibre historique, 1003 KBR) ; et P21
déclencherait une fausse collision entre deux dépôts d'hébergeurs. En attente.
2026-08-03 (suite 17) — le plan de données existe (make devis-sdn)
Ajouter un tenant n'ajoute pas qu'un plan : cela implique 1 zone EVPN + 6 VNets + 6 sous-réseaux sur le cluster. Aucun générateur ne les produisait — c'était la dernière lacune dans un dépôt où tout dérive du seed.
make devis-sdn les émet, tenant par tenant. 26 objets pour les deux tenants,
tous dérivés : le VNI est 1000 + index×10 + zone, le sous-réseau et la passerelle
viennent des mêmes fonctions que l'inventaire.
Le nommage, en deux temps
Première version : VRF0017 / v1174, alignés sur ce que le cluster portait déjà.
C'était le réflexe inverse du bon — cette convention venait d'une création à la main,
ne disait pas de quel tenant il s'agissait, et chez174 demandait d'ouvrir la table
des catégories pour être lu.
Forme retenue : t<index> pour la zone, t<index><zone abrégée> pour le
VNet — t17, t17serv, t17obse. C'est le préfixe que le pare-feu Proxmox
utilisait déjà (t17-cli-metrique, t17-flotte) : un seul schéma se lit dans tout
le dépôt. L'abréviation vient des 4 premières lettres du libellé, accents retirés,
donc dérivée.
Pas de tiret entre l'index et la zone, contrairement aux IPSets : t245-serv ferait
9 caractères. Sans séparateur, t245serv en fait 8 — la forme reste uniforme
jusqu'au dernier index de la fédération.
La contrainte qui a tout cadré
Zones et VNets sont limités à 8 caractères par Proxmox — l'identifiant sert de
base aux noms de bridge, veth et tap. Le tableau de sdn-evpn.md annonçait
chez17-services-infra, soit 21 : il aurait été refusé à l'application. P30
refuse désormais tout dépassement, sur les deux objets, et toute collision de nom, de
VNI ou de sous-réseau entre tenants.
Éprouvé aux bornes (t1serv 6, t17serv 7, t245serv 8) et par sabotage : deux
libellés partageant leurs 4 premières lettres produisent le même VNet, et la garde
l'attrape.
Vérification la plus forte disponible
Avant renommage, la dérivation reproduisait à l'identique les deux zones déjà créées à la main — nom, VNI de VRF, MTU, contrôleur. La dérivation retombait sur ce qu'un humain avait posé.
voute.py saisir : le pendant de la génération
On génère un secret dont le dépôt est la source ; on saisit celui dont un tiers est la source. Inventer une clé d'API OPNsense donnerait une valeur syntaxiquement correcte, refusée à la première requête — et P18 au vert sur une voûte inutilisable.
Saisie sans écho, double confirmation, rien sur la ligne de commande. Éprouvée sur une
voûte jetable : écrit, préserve l'existant, refuse d'écraser sans --remplacer.
Ce qui reste ouvert
Aucune zone ne déclare de nœud de sortie, et le devis émet un marqueur plutôt qu'une valeur plausible. Deux points à trancher avant :
- le nœud de sortie route selon sa propre table ; la passerelle par défaut des
trois hyperviseurs est
192.168.11.254, pas la frontière ; - l'entrée n'est pas redondante : elle dépend d'une route statique d'OPNsense vers un nœud. Deux nœuds de sortie ne donnent aucune redondance entrante.
D-43, D-44, D-45, AFF-112. 30 preuves OK.
2026-08-03 (suite 16) — la directive d'authentification est gardée (P29)
Une règle qu'aucune garde ne vérifie finit par ne plus être vraie : c'est exactement ce
qui était arrivé aux 28 lignes d'intégration recopiées. Chaque rôle serveur_* porte
désormais un meta/authentification.yml, et P29 le confronte à son code.
web-sso 5 grafana, forgejo, nextcloud, icingaweb2, oauth2-proxy
socle-identite 2 keycloak, openldap — ils SONT la chaîne d'identité
ldap-direct 2 dovecot, postfix
interne-sans-auth 2 prometheus, loki — lacunes nommées
sans-auth-humaine 12
Elle refuse l'oubli et le mensonge
Éprouvée par sabotage, sept cas : déclaration supprimée, portée inventée, secours retiré,
posture de formulaire retirée, raison retirée, ldap-direct mensonger, réglage retiré des
defaults. Les sept sont attrapés.
Les deux derniers passaient dans ma première version.
Le mensonge passait à cause d'un de mes propres commentaires. Je cherchais le mot
« ldap » dans le rôle — or serveur_grafana/defaults/main.yml contient la phrase
« désactiver quelqu'un dans LDAP ». De la prose suffisait à valider une déclaration fausse.
La preuve exige maintenant un indice nommé : une variable du namespace du rôle
(<rôle>_oidc, <rôle>_ldap) ou une URI ldap://.
Le réglage retiré passait parce que le gabarit citait encore la variable alors que plus
rien ne lui donnait de valeur. La preuve lit désormais defaults/main.yml en YAML et
exige que la clé y soit définie, pas mentionnée.
Une valeur que la preuve a forcé à inventer
Elle a d'abord échoué sur oauth2-proxy : je l'avais déclaré « formulaire local fermé »
alors qu'il n'a aucun compte local — c'est une passerelle. D'où formulaire_local: aucun, qui distingue « il n'y en a jamais eu » de « il y en a un, il est fermé ». Écrire
« fermé » aurait laissé croire qu'une porte avait été close.
Correction : Prometheus et Loki ne sont pas exposés
Contrairement à ce que la note de la veille affirmait, ils n'ont aucun expose au plan.
Seuls six groupes sont exposés : collabora, forgejo, grafana, keycloak, nextcloud,
oauth2-proxy. Le risque est intra-tenant, pas frontalier — plus petit qu'annoncé, réel
quand même.
Ces deux lacunes sont comptées, pas masquées : interne-sans-auth ne fait pas échouer
la preuve, mais figure dans chaque rapport. La refuser bloquerait le harnais sur une
décision déjà prise ; la taire la ferait oublier.
AFF-111, décision D-42. 29 preuves OK, 0 échec, 0 sautée.
2026-08-03 (suite 15) — la porte de secours cesse d'être annoncée
Client OIDC Nextcloud ajouté aux deux tenants — quatre clients chacun désormais, sur
le chemin que l'app user_oidc impose (…/apps/user_oidc/code). Le secret existait des
deux côtés depuis hier ; le client qui devait le porter manquait.
La directive
Trois règles, indissociables parce que chacune crée le problème que la suivante résout :
toute authentification web passe par Keycloak ; LDAP est la source unique des
comptes ; chaque service garde un accès de secours par sudo sur l'hôte.
La chaîne service → Keycloak → LDAP est en série. Le secours n'est donc pas une entorse
au SSO : c'est ce qui rend les deux premières règles tenables. Portée : le web seulement —
IMAP et SMTP se lient à LDAP directement, SSH est en clé seule.
La posture : <rôle>_connexion_locale: false
Le compte local existe — il ne peut pas dépendre de Keycloak, sinon il tomberait avec lui — mais son formulaire n'est plus proposé au repos. Un formulaire ouvert en permanence contourne la politique de mot de passe, le MFA, et surtout la révocation centrale : désactiver quelqu'un dans LDAP laisserait son compte local valide, sans que rien ne le signale.
Fermer ne coûte rien depuis qu'on a tranché que sudo suffit : sudo est le mécanisme
de réouverture.
Ce que chaque service sait vraiment faire
Vérifié auprès de l'amont, puis par rendu réel des gabarits dans les deux postures.
| Service | Réglage | Effet réel |
|---|---|---|
| Grafana | GF_AUTH_DISABLE_LOGIN_FORM |
ferme le formulaire |
| Forgejo ≥ 10 | ENABLE_INTERNAL_SIGNIN + ENABLE_BASIC_AUTHENTICATION |
ferme la connexion interne et l'API en Basic |
| Nextcloud | hide_login_form |
masque seulement |
Nextcloud est une exception, écrite comme telle. …/login?direct=1 atteint encore le
formulaire, et l'amont le documente comme voulu — c'est ainsi qu'un administrateur entre.
La protection est de ne plus l'annoncer, pas d'interdire. Le présenter comme équivalent
donnerait un faux confort.
Forgejo tombe juste. ENABLE_INTERNAL_SIGNIN n'existe que depuis la v10 (ticket amont
7476, clos : « This option was added to Forgejo v10 ») et le rôle épingle 10.0.0. Sur une
version antérieure il serait ignoré sans erreur : un assert refuse la fermeture sous
10.0.0 plutôt que de laisser le rôle croire qu'il a fermé la porte.
Le défaut que le rendu a attrapé
Ma première version testait {% if not serveur_forgejo_connexion_locale %} sans | bool.
En rendu réel, aucune des deux postures n'émettait quoi que ce soit : une valeur
transmise en chaîne (-e, ou un group_vars non typé) est vraie au sens Jinja, donc le
bloc ne sortait jamais — la connexion locale serait restée ouverte en silence. C'est
exactement le genre de panne que lire le gabarit ne révèle pas.
Décisions D-38 à D-41, docs/authentification.md.
Ce qui reste
Prometheus et Loki exposent une interface sans SSO ; oauth2-proxy est déjà éprouvé devant
Icinga Web 2. Et aucune preuve ne garde cette directive — même risque que les
intégrations universelles avant leur inversion.
2026-08-03 (suite 14) — les deux secrets Nextcloud, et un qui ne se génère pas
vault_nextcloud_admin et vault_nextcloud_oidc étaient exigés par le plan et absents
de la voûte réelle de Technolibre. Générés (32 octets urlsafe), en mémoire, avec
relecture et aller-retour de chiffrement vérifiés avant écriture ; jamais affichés.
L'opération est idempotente : une clé déjà renseignée n'est pas touchée, et une clé
présente mais vide compte comme absente — c'est le cas du gabarit recopié.
Générer était légitime ici parce qu'Ansible configure les deux côtés depuis la même
variable : le client Keycloak déclare secret: "{{ vault_nextcloud_oidc }}" et le rôle
Nextcloud lit la même clé. La valeur n'a pas à préexister ailleurs.
Harnais complet, voûte lisible : 28 preuves OK, 0 échec, 0 sautée.
Le contre-exemple, trouvé chez Chezlepro
La même vérification y signale vault_opnsense_api_key et vault_opnsense_api_secret
absents. Il ne faut surtout pas les générer. OPNsense est hors flotte : ses
identifiants d'API sont émis par le boîtier (System > Access > Users > API keys), et le
secret n'est affiché qu'à la création. Une valeur inventée serait syntaxiquement
correcte et refusée à la première requête.
La distinction vaut d'être retenue : on génère un secret dont le dépôt est la source, jamais un secret dont un tiers est la source. Le boîtier n'étant pas encore installé, la question ne se pose pas avant sa mise en service.
Une lacune connexe, non corrigée
Aucun des deux tenants ne déclare de client OIDC Nextcloud dans
group_vars/serveur_keycloak.yml — seulement grafana, forgejo et icingaweb2. Le secret
existe donc désormais des deux côtés, mais le client qui doit le porter n'est pas
déclaré. À ajouter avant tout déploiement de collab-01, sur le patron des trois autres.
2026-08-03 (suite 13) — le cluster répond, et il contredit trois hypothèses
Reconnaissance en lecture seule de l'API Proxmox, avec le jeton de la voûte. Trois valeurs que j'avais devinées étaient fausses, et deux défauts bloquants sont apparus.
Ce que le cluster a corrigé
Stockages : truenas-dbsql manquait à ma liste. Et le catalogue ne doit offrir que
ceux qui portent images — PBS, cephFS, local et truenas (iSCSI brut,
content=none) n'accueillent pas de disque de VM.
Ponts : vmbr0 à vmbr3, vérifiés présents sur les trois nœuds. J'avais omis
vmbr0 et je n'avais pas contrôlé l'uniformité — un pont partiel est un piège, la VM ne
démarre que sur certains nœuds.
Un troisième homonyme : web-frontal-01 (vmid 911401) existe déjà hors Set-OPS, sans
pool. Les deux tenants en planifient un chacun.
Les pools sont créés
Chezlepro-17 et Technolibre-11, dérivés comme le reste. Les pools Env.Tenant
antérieurs (Prod.Chezlepro, Lab.KBR…) sont l'ancien monde : on n'y touche pas, et
on n'y verse pas la flotte générée — les mélanger effacerait la frontière que Set-OPS
établit.
Diff constaté sur le cluster : 2 pools ajoutés, 0 retiré, 1 VM sur 38 déplacée —
infra-pki-01, qui n'appartenait à aucun pool.
Défaut bloquant : make creer-vm aurait échoué en 401
proxmoxer recompose utilisateur!nom à partir d'api_user et d'api_token_id. La
voûte stocke la forme complète, que les playbooks passaient telle quelle — d'où
ansible@pve!ansible@pve!nom et un 401 muet, alors que le même jeton fonctionne en
curl. Le diagnostic aurait coûté cher.
Mesuré des deux côtés avec un module en lecture seule : forme complète = 401, forme
courte = OK, 3 nœuds. Les playbooks normalisent désormais (split('!') | last), ce qui
accepte les deux écritures.
Le reliquat proxmox.vault.yml est supprimé
Toléré « en compatibilité », il restait le seul porteur du jeton chez Technolibre. Et
comme *.vault.yml est gitignoré, ce jeton ne voyageait avec aucun dépôt : une voûte
unique (D-19) qui ne l'était pas.
Migration faite en mémoire — aucune valeur en clair sur disque ni affichée — avec
relecture et aller-retour de chiffrement vérifiés avant écriture. Puis suppression du
fichier et retrait des listes de chargement des deux playbooks. Validé par un appel API
réel ne chargeant que all/vault.yml.
Et la garde qui manquait
voute.py verifier ne comparait que le gabarit. C'est pourquoi il annonçait
« complet » pendant qu'un secret vivait ailleurs : le gabarit dit ce qu'il faudrait, pas
ce qui est.
Il contrôle maintenant aussi la voûte réelle, quand ANSIBLE_VAULT_PASSWORD_FILE la
rend déchiffrable — noms de clés seulement, jamais de valeur. Sans mot de passe, la
vérification se saute : le harnais reste utilisable sans accès aux secrets.
Dès son premier passage, elle a trouvé un second trou : la voûte réelle de Technolibre
n'a ni vault_nextcloud_admin ni vault_nextcloud_oidc, que le plan exige depuis
l'arrivée de Nextcloud. Le déploiement aurait cassé sur une variable indéfinie. Non
corrigé : générer ces deux secrets est une décision, et le secret OIDC doit
correspondre à ce que Keycloak connaîtra.
2026-08-03 (suite 12) — un pool Proxmox par tenant
Onze des quatorze serveurs portent le même nom court chez Chezlepro et chez
Technolibre : infra-pki-01, backup-01, obs-01…
Vérifié un par un, ce n'est pas un problème technique. Tout le reste dérive du seed
et diverge : 10.27.19.21 contre 10.21.19.21, VMID 117402101 contre 111402101,
VLAN 1174 contre 1114, et deux domaines internes distincts. Et rien n'est indexé sur le
nom court — toutes les opérations Proxmox portent un vmid (le name: n'est qu'une
étiquette), les certificats un FQDN, et client_backup_repo vise
backup-01.{{ domaine_interne }}, donc le serveur du tenant.
Le coût est humain, et il est réel : la console Proxmox affiche le nom. Deux
infra-pki-01 y sont indiscernables à l'œil, et c'est ainsi qu'on éteint la mauvaise
machine. Le VMID porte pourtant le tenant — encore faut-il connaître le codage.
Ce qui a été fait
Un pool par tenant, dérivé : dossier d'instance + index → Chezlepro-17,
Technolibre-11. L'index étant déjà unique par P21, le nom l'est aussi — aucun
registre de plus.
make devis-proxmox-pools produit le rattrapage de la flotte existante (création du
pool, puis affectation des VM actives). Non destructif, à relire avant d'appliquer.
Les VM créées ensuite entrent d'elles-mêmes : make creer-vm dérive le pool par la
même fonction et le passe à la création. Le playbook crée le pool au préalable — deux
raisons : proxmox_kvm échoue sur un pool inconnu, et l'API ne sait pas changer le
pool d'une VM existante. C'est aussi pourquoi le rattrapage passe par les membres.
P28 garde deux collisions : deux tenants ne peuvent pas revendiquer le même nom de pool, ni le même VMID — une machine appartenant à deux tenants serait pire qu'une homonymie. Décision D-37, affirmation AFF-110.
Ce que je n'ai pas fait
Renommer les VM par tenant. Ça casserait ce que ces homonymes prouvent : même fonction, même nom, partout — c'est ce qui rend un modèle réutilisable.
Le devis ne lit pas le cluster : il dit l'état cible, pas l'écart. Les commandes sont idempotentes, donc rejouables sans risque, mais il ne saura pas dire ce qui est déjà en place.
2026-08-03 (suite 11) — le cluster appartient à l'hébergeur
En ouvrant le panneau « Intrants de base », on trouvait côte à côte et sans distinction des valeurs du tenant (son domaine, son realm, son modèle) et des valeurs de l'hébergeur (son cluster, sa frontière, sa fabric). Deux propriétaires, deux dépôts, deux cycles de vie — et rien à l'écran ne le disait.
Trois sections sur sept étaient déjà chez l'hébergeur (Frontière, Fabric). La section Proxmox, elle, ne l'était pas — alors qu'un cluster est du matériel possédé par l'hébergeur au même titre que ses commutateurs.
La recopie avait déjà divergé
Même cluster, deux inventaires contradictoires :
Chezlepro stockages [TrueNAS, CephHDD, CephNVMe] ponts [vmbr3]
Technolibre stockages [local-lvm, TrueNAS] ponts [vmbr1, vmbr2]
Rien ne « cassait » : ces listes ne peuplent que des menus déroulants. Mais un opérateur
plaçant une VM Technolibre ne se voyait jamais proposer CephNVMe, et ça n'était la
décision de personne. Le fichier de l'hébergeur prend l'union des deux — aucune n'était
complète, et choisir l'une aurait été arbitraire. À confirmer contre le cluster réel.
Le partage retenu
Hébergeur (<dépôt hébergeur>/proxmox-hebergeur.yml, à côté d'underlay.yml) :
proxmox_api_host, _user, _port, _validate_certs, proxmox_noeuds, _stockages,
_ponts.
Tenant (group_vars/proxmox.yml) : son golden template — chaque tenant a le sien —
et ses défauts de placement (nœud, stockage, pont). Ce sont des choix faits à
l'intérieur de ce que l'hébergeur offre.
Le chemin se dérive du symlink underlay.yml, qui désigne déjà l'hébergeur : rien de
nouveau n'est déclaré (D-17 tenue). Sans underlay monté, tout retombe dans le fichier du
tenant — un dépôt autonome fonctionne exactement comme avant.
Ce que ça a demandé de moins que prévu
Les playbooks chargent ces fichiers par chemin explicite (include_vars), pas par
appariement de groupe Ansible — il n'existe d'ailleurs aucun groupe proxmox dans
l'inventaire. Une tâche stat + include_vars de plus a suffi ; aucun symlink dans
group_vars, aucune génération.
Vérifié en exécution réelle : depuis Technolibre (tenant actif), la dérivation résout vers
OPS-Chezlepro/proxmox-hebergeur.yml et charge asgard + les quatre stockages.
Le panneau nomme désormais le propriétaire
Chaque section porte une pastille tenant (bleu) ou hébergeur (ambre), avec en
infobulle ce que ça implique : éditer une section « hébergeur » vaut pour tous ses
tenants. Le schéma d'intrants porte un champ proprietaire — c'est la donnée qui manquait,
pas l'affichage.
P27 garde la séparation : aucune clé de l'hébergeur ne peut réapparaître dans un
group_vars de tenant. Sans elle, le premier make config lancé d'un autre poste
recommençait la recopie. Décisions D-35 et D-36, affirmation AFF-109.
Une chose à trancher
modeleChezlepro reste déclaré comme golden template de Technolibre. Ta décision — un
modèle par tenant — le rend incorrect, mais le corriger suppose qu'un modeleTechnolibre
existe réellement sur le cluster. Laissé tel quel, signalé ici.
2026-08-03 (suite 10) — les intégrations universelles cessent d'être recopiées
Le plan portait 57 lignes d'intégration écrites à la main. Le décompte est sans appel :
client_metrique 14/14, client_journal 14/14, client_pki 13/14 — mais client_backup
7/14, client_smtp 8/14, client_unbound 1/14.
28 de ces 57 lignes disaient oui à quelque chose de vrai pour tout le monde. Elles
n'existaient donc que pour être oubliées une vingt-neuvième fois — et elles l'avaient été :
dans Chezlepro, backup-01 et infra-pki-01 n'étaient ni supervisés, ni journalisés, ni
certifiés. Rien ne l'aurait signalé, puisqu'une machine non supervisée ne proteste pas.
Ce qui change
Le rôle déclare sa politique, une fois, dans roles/<role>/meta/integration.yml :
integration:
universelle: true
raison: "Tout hôte est mesuré. Une machine hors supervision tombe sans que personne ne l'apprenne."
sauf_role: serveur_step_ca # l'AC ne s'enrôle pas auprès d'elle-même
Le plan ne porte plus que les intégrations qui sont un vrai choix — sauvegarde, courriel,
résolveur. Et il refuse désormais une recopie : deux sources finiraient par diverger, et
surtout, l'absence de client_metrique en face d'un serveur se lirait « non supervisé » alors
qu'il l'est.
L'exemption suit le service, pas le nom d'hôte
sauf_role: serveur_step_ca retire client_pki à l'hôte qui rend le service. Déplacez
step-ca sur une autre machine et l'exemption suit toute seule. Un nom d'hôte en dur, lui,
aurait laissé la nouvelle AC s'enrôler auprès d'elle-même et l'ancienne sans certificat.
Une seule fonction de résolution
inventory_rules.integrations_de() est lue par les trois consommateurs — inventaire,
voûte et panneau. La voûte en particulier : sans elle, les secrets des intégrations
universelles auraient cessé d'être exigés et P18 serait passé au vert sur une voûte
incomplète.
Vérification
Sur Technolibre, make instancier donne diff vide : la politique reproduit exactement ce
que les 41 lignes retirées produisaient. Sur Chezlepro, elle produit précisément les trois
groupes manquants sur backup-01 et les deux sur infra-pki-01 — et pas client_pki sur
ce dernier, l'exemption ayant joué. Le trou se referme, rien d'autre ne bouge.
P26 garde les deux côtés : aucun hôte sans intégration universelle, aucune recopie dans le plan. Décisions D-33 et D-34.
Ce que le panneau montre
Dans la fiche d'un serveur : les universelles en ✓ non décochables, les exemptions barrées, chacune avec sa raison en infobulle. Sans cet affichage, un plan devenu silencieux se serait lu comme une flotte non supervisée — l'inverse exact de la vérité.
Et une vue Intégrations : la matrice
L'autre axe manquait, et c'est celui qui aurait servi. La fiche montre les intégrations d'un serveur ; savoir qui n'a pas de sauvegarde demandait d'ouvrir les quatorze. Le trou de Chezlepro n'a d'ailleurs pas été trouvé par le panneau — il est sorti du devis de pare-feu Proxmox, qui énumère les rôles par hôte. L'information était à l'écran, répartie sur quatorze clics, donc invisible.
La matrice serveurs × intégrations : colonnes ✓ vertes pour la politique, — barré pour les
exemptions, cases à cocher pour les facultatives — éditables sur place, en-têtes et colonne
de noms figées, clic sur un nom pour ouvrir sa fiche.
La ligne couverture affiche n/N sans juger : 7/14 sur client_backup peut être
exactement juste. Elle rend le motif visible ; décider s'il s'agit de choix ou d'oublis reste
au lecteur.
Sur Technolibre, elle affiche 41 ✓ et une exemption — soit très exactement les 41 lignes
retirées du plan et le client_pki de l'AC.
2026-08-03 (suite 9) — le SSH inter-nœud était perdu
En éclatant les règles par rôle source, un défaut de ma première version est apparu : je
sautais le flux entier dès qu'un de ses pairs valait externe.
Or le SSH du socle est déclaré pair: [flotte, externe]. La moitié externe relève bien de
la frontière — mais la moitié flotte, le SSH entre hôtes, celui d'Ansible, était perdue.
Sous une politique DROP, plus aucun hôte n'aurait été joignable en SSH depuis l'intérieur.
Même piège pour le SMTP interne de Postfix, déclaré [externe, client_smtp].
externe est désormais sauté pair par pair, jamais le flux entier. 36 groupes, 56 règles.
Ajouté — ce qui n'a aucune règle entrante, et pourquoi
Onze rôles portés n'ont aucune règle entrante : sous DROP, ils sont injoignables. C'est
voulu dans les onze cas — la boucle locale pour Prometheus, Redis, rspamd, Icinga et Unbound,
la frontière seule pour nginx, aucun service pour serveur_durci et les clients.
Le devis les nomme avec leur motif au lieu de laisser un lecteur le vérifier lui-même. Et
un douzième motif existe, marqué /!\ : « flux entrants déclarés mais aucune source résolue
ici » — celui-là serait un vrai trou.
2026-08-03 (suite 8) — le pare-feu Proxmox, troisième lecture du même registre
make devis-proxmox-fw (preuve P25). Le filtrage est-ouest intra-tenant est désormais
dérivé pour l'hyperviseur : 34 groupes de sécurité, 40 règles, 2 tenants — depuis les
57 flux intra-tenant que le registre connaissait déjà.
Décidé : la défense est en profondeur, pas en remplacement. L'hyperviseur filtre, puis
l'hôte destinataire filtre à nouveau. Une VM compromise doit franchir les deux. Le coût de
maintenance est nul : les deux barrières lisent le registre par les mêmes fonctions
(_resoudre_sources, _pairs, _hotes_du_groupe) — la duplication est dans l'application,
jamais dans la décision.
Un IPSet par rôle porte les membres, les groupes de sécurité y renvoient : ajouter un hôte à un rôle met à jour toutes les règles qui l'autorisent, en un seul endroit.
La garde qui manquait à ma première version
Proxmox limite un nom de groupe à 18 caractères. Ma première version tronquait sans vérifier : deux rôles tronqués au même nom auraient fusionné leurs règles, donnant à une VM les autorisations d'un rôle qu'elle ne porte pas — silencieusement.
Le préfixe porte maintenant l'index (t17-) plutôt que l'étiquette (chez17-), ce qui
rend la troncature bien plus rare, et une garde échoue sur toute collision plutôt que
d'émettre un devis pareil. Exercée.
Unifié — un seul schéma de nommage dans le devis
Les IPSets portaient l'étiquette longue (chez17_serveur_postgresql), les groupes l'index
court (t17-srv-postgresql) : deux conventions à lire dans un même document. Tout porte
désormais le préfixe t<index>- et la même forme abrégée de rôle.
La troncature reste propre à chaque objet : Proxmox est large sur les IPSets, étroit (18 caractères) sur les groupes. Un nom peut donc être entier d'un côté et abrégé de l'autre — chacun respecte sa contrainte, et le préfixe reste commun.
Vérifié : aucune collision d'IPSet, et tout renvoi +X d'une règle pointe vers un IPSet qui
existe — 58 IPSets, 34 groupes, aucun orphelin.
Corrigé — des listes d'adresses en dur, et 48 IPSets inutilisés
Six règles par tenant portaient quatorze adresses en dur : les mots-clés flotte et
edge n'avaient pas droit à un IPSet, seuls les rôles en avaient. flotte en reçoit un
désormais, et edge renvoie à celui de nginx. 36 des 40 règles se lisent maintenant
-source +t17-….
Et le devis listait 28 à 30 IPSets par tenant dont la moitié n'était référencée nulle part : un opérateur en aurait créé 58 pour n'en utiliser qu'une douzaine. Seuls les IPSets réellement référencés sont émis — 6 par tenant. Un devis crée ce qu'il liste.
Puis : une règle par rôle source
Les quatre règles restées en liste explicite sont éclatées — un flux dont le pair nomme quatre rôles donne quatre règles, chacune renvoyant à l'IPSet de son rôle. Plus une seule adresse en dur : 52 règles, toutes par IPSet.
Le gain n'est pas cosmétique : une règle porte désormais qui elle autorise. -source +t17-srv-keycloak se lit ; -source 10.27.16.21,10.27.17.11,10.27.19.31,10.27.20.21 demande
de retrouver à qui appartient chaque adresse.
La raison, elle, appartient au flux et non à chacune de ses règles : elle est écrite une fois au-dessus du paquet qu'elle explique, au lieu d'être répétée quatre fois.
Corrigé — l'affectation variait selon l'état du tenant
Elle partait de hotes_actifs, avec un repli sur « tous » quand il n'y en avait aucun. Deux
tenants donnaient donc deux comportements : Technolibre listait ses 14 VM (zéro actif → repli),
Chezlepro une seule (un actif). Un opérateur aurait lu qu'une seule VM avait besoin de
règles.
Les IPSets et les groupes incluaient déjà les hôtes planifiés, délibérément — un pare-feu se prépare avant que la VM existe. L'affectation suit désormais la même règle : 14 de chaque côté, toutes avec leur VMID.
Conséquence à retenir
Puisque tout ce qui entre dans un tenant passe par la frontière, le contrôleur Ansible aussi. L'OPNsense devient un prérequis de déploiement, pas une étape parmi d'autres : sans lui, plus rien ne se déploie.
2026-08-03 (suite 7) — le modèle déclarait un MTU que le matériel n'a pas
En préparant le déplacement des adresses de VTEP, la lecture des interfaces a montré deux choses que le modèle ignorait.
underlay.yml annonçait 9000, vmbr3 est à 1500
La garde P23 validait donc une déclaration fausse : elle exigeait ≥ 1550 et passait parce que le fichier disait 9000. Une garde qui valide une déclaration plutôt qu'une réalité donne un faux confort — c'est pire qu'une garde absente, qui au moins n'endort personne.
Le seuil ne peut pas non plus être fixe : 1550 aurait rejeté à tort un transport à 1500
portant un overlay à 1450, qui tient exactement. Il dérive désormais d'un
mtu_overlay déclaré (1450 par défaut) : transport ≥ overlay + 50.
Le fichier dit maintenant la vérité — 1500 — et la garde passe pour la bonne raison. Exercé : un overlay porté à 1500 sur ce transport est refusé.
vmbr3 n'est pas VLAN-aware
Pas de bridge_vlan_aware, contrairement à vmbr2. L'adresse du VTEP y est non
étiquetée : elle vit dans le VLAN natif du port de commutateur. Déplacer le VTEP vers
l'underlay n'est donc pas un changement d'adresse — il faut soit un VLAN natif 10, soit une
interface étiquetée dédiée (bond3.10).
C'est la raison pour laquelle le déplacement n'a pas été effectué : le geste demandé suppose une décision de câblage qui n'est pas prise.
2026-08-03 (suite 6) — les hyperviseurs entrent au modèle, à leur adresse cible
Redresser les pairs EVPN vers l'underlay suppose d'abord que les hyperviseurs existent dans le modèle. Ils n'y étaient pas.
La reconnaissance a montré la cause
vmbr3 — la nouvelle interface 2,5G — porte 10.27.19.{41,43,47} sur les trois nœuds :
l'adresse des VTEP est prise dans le supernet de Chezlepro. Le transport du cluster dérive
donc de l'index d'un tenant, et une VM de sa zone Services-infra partage son sous-réseau avec
les trois VTEP.
Le modèle refuse d'exprimer cet état : déclarer 10.27.19.0/24 en underlay ferait échouer
P23. La garde écrite deux jours plus tôt détecte la faute avant qu'on ne la documente.
underlay.yml déclare donc asgard, gandalf et vishnu à leur adresse cible
10.0.0.{41,43,47} — dernier octet conservé, comme sur vmbr0.
Ajouté — un role sur les hôtes de l'underlay
switch (défaut), hyperviseur, frontiere. Le réseau ne suffit pas à le déduire : un
hyperviseur partage le réseau de management avec les commutateurs, et recevait une
configuration de commutateur en partie B du devis dès qu'on le déclarait.
Corrigé — une « source unique » qui n'en était pas une
switches_acces() avait été introduite comme la décision du « qui est un switch d'accès »,
utilisée pour les rayons de l'étoile. Mais partie_acces() avait gardé sa copie locale du
filtre et ne l'appelait jamais. Les deux ont divergé au premier hôte non-commutateur déclaré.
Écrire « source unique » dans un commentaire ne la crée pas.
Deuxième blocage signalé, non corrigé
vishnu : son vmbr3 n'a aucun port physique. Le pont existe, porte une adresse, ne mène
nulle part. Un pair VXLAN pointé sur lui ne fonctionnera jamais — c'est du câblage, pas de la
configuration.
2026-08-03 (suite 5) — l'ICMP entre au registre, parce que l'overlay descend à 1450
Décision : l'overlay EVPN plafonne à 1450. Elle a une conséquence qui ne se voit pas — sous 1500, tout ce qui traverse la frontière dépend de la découverte de MTU de chemin, donc de l'ICMP « fragmentation nécessaire ».
Or le registre des flux ne connaissait que TCP et UDP. Ce message ne pouvait pas être
déclaré, et la bordure en block in log all l'aurait jeté. Symptôme : la connexion s'établit,
les petites requêtes passent, les grosses réponses restent suspendues — la panne la plus
coûteuse à diagnostiquer, et celle qu'on impute d'abord à l'application.
protocole: icmp est admis ; pour lui, le champ port porte le type (frag-needed). Le
socle déclare les deux sens : entrant pour qu'un distant puisse nous demander de réduire
nos paquets, sortant pour que nos hôtes signalent l'overlay aux correspondants.
Vérifié : les nftables d'hôte sont inchangés — le pair externe reste sauté, ces flux
relèvent de la bordure. Le devis frontière passe à 26 règles.
Reconnaissance de l'existant (lecture seule)
L'EVPN est déjà à moitié construit sur le cluster : Proxmox 8.4.19, contrôleur EVPN0017
(ASN 65000), zones VRF0011 et VRF0017 — un VRF par tenant, VNI égal à l'index, conforme à
D-08. Mais aucun VNet et aucun nœud de sortie : le plan de contrôle existe, le plan de
données non.
Signalé, non corrigé : les pairs BGP sont 10.27.19.41/.43/.47, dans le sous-réseau
Services-infra de Chezlepro. Le transport du cluster dérive donc de l'index d'un tenant — et
une VM de cette zone partage son sous-réseau avec les trois VTEP, ce qui perce l'isolation à
l'endroit même que l'EVPN devait fermer.
2026-08-03 (suite 4) — un index des décisions d'architecture
docs/decisions-architecture.md. Les décisions étaient écrites là où elles s'appliquent, et
leur histoire dans ce journal — mais « pourquoi le /29 et pas le /30 ? » demandait de
relire vingt entrées. Le registre ne répète rien : il dit quelles décisions existent,
pourquoi, où lire le détail, et ce qui les garde.
28 décisions en quatre familles — le réseau, qui possède quoi, les secrets, la méthode. Chaque ligne porte sa raison en une phrase et sa preuve quand il y en a une. Une décision peut n'être gardée par aucune preuve : elle reste une décision, et le registre le montre plutôt que de laisser croire à une couverture complète.
La section qu'on omet d'habitude : les décisions renversées
Trois y figurent — l'isolation par ACL de commutateur, le routage inter-zone sur les
commutateurs, l'underlay gitignoré à la racine du moteur. Les garder évite de refaire le
chemin, et explique pourquoi le code porte encore des branches qui semblent inutiles :
acl_inter_tenant: true et routage_tenants: switch restent les défauts, parce qu'une autre
fabric peut en être capable.
Aucun de ces renversements ne vient d'un changement d'avis : les trois viennent d'un fait découvert après la décision — une commande absente de l'aide du matériel, une capacité manquante, une dizaine de modifications irrécupérables. C'est l'argument le plus fort pour éprouver avant de figer.
Les 30 renvois internes du registre ont été vérifiés : aucun document ni aucune section citée n'est introuvable.
Corrigé le jour même — des dates déduites plutôt que vérifiées
Les trois dates de la section « décisions renversées » avaient été estimées. L'historique
git les corrige : les ACL et le routage sur commutateur remontent au 2026-07-07
(make devis-reseau), pas au 29 juillet ; l'underlay gitignoré au 2026-07-24, pas au 31.
Un registre qui invente une date perd la confiance qu'on lui accorde sur le reste. Chaque renversement cite désormais le commit qui l'a opéré, donc vérifiable en une commande.
Ajouté aussi : qui décide. Toutes ces décisions sont celles de l'opérateur du dépôt, plusieurs prises sur recommandation — l'assistance propose et argumente, elle ne tranche pas. La distinction compte : une décision se renverse par celui qui l'a prise.
2026-08-03 (suite 3) — les preuves réseau sont rattachées à de vraies affirmations
Trois preuves — P21, P23, P24 — renvoyaient à AFF-001, qui affirme que « Set-OPS
est un moteur Ansible générique … à partir d'un plan ». Aucun rapport avec la fédération,
l'underlay ni la frontière. Trois autres — P17, P19, P20 — n'avaient aucune
référence.
Une preuve accrochée à la mauvaise affirmation ne prouve rien. Elle passe au vert et n'atteste de rien de ce qu'on croit.
Ajouté — §10 du registre : architecture réseau et fédération
Six affirmations (AFF-101 à AFF-106) : dérivation intégrale depuis le seed, absence de
collision d'index, underlay disjoint de la plage tenant, garde anti-lockout de la frontière,
validité des modèles underlay compris, couverture du plan par le panneau — cette dernière en
🟡, avec ses exceptions nommées plutôt que tues.
Et une affirmation volontairement absente : la justesse des devis. Leur syntaxe dépend d'un matériel que le dépôt ne possède pas ; six familles ont été confrontées à un commutateur réel, deux étaient fausses, mais c'est une vérification datée et non une preuve rejouable. Le dépôt n'affirme pas que ses devis s'appliquent ; il affirme qu'ils dérivent.
Corrigé — quatre preuves sans référence, et une erreur de la table
P03, P06, P12, P13 portent désormais les références que la table de couverture leur
attribuait déjà : la correspondance existait en double, dans le document et dans le code,
et seul le document la tenait.
La table attribuait par ailleurs AFF-030 (« inventaire complet ») à P15, qui valide le
modèle socle. C'est P16 qui exécute ansible-inventory --list.
Vérifié : 35 affirmations référencées, aucune référence orpheline, une seule preuve sans
référence — P16, dont la référence existe mais sous une autre forme syntaxique.
2026-08-03 (suite 2) — le panneau présente les deux devis
La vue Réseau n'affichait que le devis des commutateurs : le devis frontière était totalement absent de l'interface, alors qu'il porte les règles de la bordure et ses avertissements — dont celui sur « Block private networks », invisible dans les règles elles-mêmes.
Ajouté : /api/devis-opnsense et son bloc d'affichage, avec bouton de copie. Vérifié par
HTTP que les deux points d'API servent exactement ce que le CLI produit — 181 et 125 lignes,
identiques au caractère près.
L'import est paresseux et gardé : ce module lit l'underlay et les inventaires de tous les tenants, et une erreur y aurait sinon empêché l'affichage du reste de la vue.
Corrigé — le texte d'aide était périmé sur trois points
Il annonçait les VLAN tenants « uniques sur le trunk » — faux en SDN, où aucun ne circule ;
renvoyait le dialecte à SETOPS_DIALECTE alors que c'est un intrant de la section
Fabric ; et disait que « la route par défaut vers OPNsense reste à adapter » alors que la
section 5 l'émet depuis hier.
Il dit maintenant ce qui reste réellement à nommer à la main : les ports physiques et, en SDN, le nœud de sortie EVPN. Rien d'autre — adresses, VLAN et routes se dérivent.
2026-08-03 (suite) — le devis frontière rattrape la bascule SDN
Trois affirmations du devis frontière étaient devenues fausses, dont une qui cassait le routage.
Corrigé — le prochain saut des routes tenants
Elles pointaient le SVI du commutateur (10.0.4.6). En EVPN, il ne route plus les tenants :
une route pointée là arriverait sur un équipement sans chemin vers le tenant. Configuration
qui s'applique sans erreur et ne fonctionne pas — la signature qu'on traque depuis deux jours.
Le devis émet désormais <NOEUD-DE-SORTIE-EVPN> et dit pourquoi, à figer après le spike.
underlay.passerelle_sortie garde son sens : c'est l'adresse du pare-feu sur le lien de
transit, donc la sortie de l'underlay. Deux choses distinctes qu'il ne faut pas confondre.
Corrigé — la description du lien affichait le marqueur du prochain saut
Régression de la correction ci-dessus : le champ décrivant le câblage du lien de transit
réutilisait la variable du prochain saut. Les deux étaient identiques jusqu'à la bascule
SDN ; elles ont divergé, et la section 1 annonçait switch <NOEUD-DE-SORTIE-EVPN>. Sur ce
lien, le commutateur est bien à 10.0.4.6 — le nœud de sortie n'y figure pas. Le champ décrit
désormais le câblage, indépendamment du routage.
Corrigé — une contradiction entre les deux devis, antérieure au SDN
La section 0 du devis frontière demandait de router les réseaux d'administration vers
10.0.4.6 — le SVI du commutateur lui-même. make devis-reseau émet 10.0.4.1, l'adresse
du pare-feu. Les deux devis se contredisaient, alors que l'un affirmait que l'autre « émet déjà
ces routes ». Elles coïncident maintenant, vérifié ligne à ligne.
Corrigé — deux commentaires qui affirmaient l'inverse de la décision
L'en-tête (« les passerelles de zone restent sur les switches L3 ») et la description des routes (« routage inter-zone sur les switches L3 ») se dérivent maintenant du mode de routage.
2026-08-03 — le SDN prend le routage tenant, le devis switch se vide
Trois décisions, et le devis les applique déjà : une zone EVPN par tenant ; le routage et le filtrage entre les zones d'un même tenant à ce niveau ; l'inter-tenant obligatoirement par l'OPNsense.
underlay.routage_tenants — switch (défaut) ou sdn
En sdn, le devis switch cesse d'émettre VLAN tenants, SVI de zone et ACL, et les retire des
trunks. Chez Chezlepro, les trunks ne portent plus que deux VLAN d'underlay au lieu de
quinze : aucun VLAN de tenant ne circule sur le fil, seulement du VXLAN que le commutateur
transporte sans le lire.
Les sections 1 à 3 sont remplacées par la raison, dont celle-ci : les passerelles .1 n'ont
pas changé d'adresse, elles ont changé de porteur — passerelle anycast du VNet, présente
sur chaque hyperviseur.
Le MTU devient une garde, pas un conseil
VXLAN ajoute 50 octets. make underlay refuse un réseau de transport sous 1550 dès que
routage_tenants: sdn, en disant pourquoi : sous ce seuil, le ping passe et les transferts
échouent — la panne la plus coûteuse à diagnostiquer. Les deux réseaux de la fabric principale
passent à 9000.
Corrigé — la partie B déclarait encore les VLAN tenants
Le filtre routage_tenants n'avait été posé que sur la partie A et les trunks : la partie B a
sa propre boucle de déclaration, et sortait toujours les douze VLAN tenants. Aucun trunk ne
les portait, aucun SVI ne les utilisait — mais leur présence suggérait que les switches
d'accès devaient les connaître, ce qui contredit la décision.
Vérifié dans les deux sens : zéro VLAN tenant en mode sdn, les vingt-quatre déclarations
(douze par partie) de retour en mode switch.
Le partage des responsabilités, écrit
Une table dans docs/sdn-evpn.md dit qui route et qui filtre pour chaque nature de trafic.
Deux conséquences y sont nommées : l'inter-tenant ne peut plus être oublié — il doit sortir
du VRF, donc traverser une bordure en block par défaut ; et le commutateur ne voit plus
rien du trafic tenant, donc y chercher la trace d'un problème applicatif est une perte de
temps.
Point ouvert ajouté : le registre des flux n'a aucun mot-clé pour un flux inter-tenant. Défaut sûr aujourd'hui, mais il rend impossible de déclarer une exception légitime.
2026-08-02 (suite 20) — décision : le routage passe aux hyperviseurs (SDN EVPN)
docs/sdn-evpn.md. Les commutateurs ne savent pas lier une ACL à une interface de routage ;
plutôt que d'assumer indéfiniment la perte d'isolation réseau que cela entraîne, le routage
inter-zone passe à Proxmox SDN, zones EVPN.
Une zone EVPN est un VRF — c'est celui qu'on regrettait de ne pas avoir dans le matériel, obtenu en logiciel. Et il referme le trou signalé quelques heures plus tôt : un tenant n'a plus de route vers l'underlay, celui-ci n'étant pas dans sa table de routage. Le plan de gestion redevient protégé par construction, pas par une règle qu'on pourrait oublier.
La projection du modèle ne demande aucun changement de dérivation — vérifiée sur les deux tenants fédérés :
| Objet SDN | Vient de |
|---|---|
| zone (VRF) | le tenant |
| VNet | la zone de sécurité |
| tag (VNI) | vlan_de(index, zone) |
| subnet + gateway | sous_reseau_de(...) + passerelle_de(...) |
Le .1 ne change pas d'adresse, il change de porteur : du SVI d'un commutateur vers la
passerelle anycast du VNet, présente sur chaque hyperviseur. L'invariant du dernier octet
survit tel quel.
Le devis switch maigrira d'autant : plus de VLAN tenants, plus de SVI de zone, plus d'ACL — en EVPN aucun VLAN de tenant ne circule sur le fil. La fabric redevient un transport IP.
Rien n'est éprouvé et rien n'est généré. Le document fixe la cible, la projection et une séquence de spike en cinq points — dont la vérification du MTU, premier mur de VXLAN, et surtout la tentative d'accès à l'underlay qui doit échouer, puisque c'est le gain principal. Le principe du dépôt s'applique : éprouver l'outil avant d'écrire le rôle.
2026-08-02 (suite 19) — pas d'ACL sur cette fabric : on route, et c'est tout
Les interfaces VLAN du Binardat n'offrent aucun access-group : impossible de lier une ACL à
un SVI. Plutôt que d'émettre des règles qui ne seraient jamais liées — elles auraient l'air
d'isoler sans jamais filtrer —, la capacité devient déclarée :
underlay.acl_inter_tenant: false.
Ce n'est pas lié au dialecte de CLI mais au matériel : un autre commutateur parlant la
même CLI pourrait savoir lier des ACL. Par défaut la valeur reste true, donc rien ne change
pour une fabric qui en est capable.
À false, la section 3 du devis ne contient plus de règles mais la raison — et surtout ce
qu'on perd :
Une VM émettant vers l'underlay voit son paquet routé localement par le commutateur — mgmt des switches, mgmt Proxmox, OOB/IPMI. Les nftables des VM n'y peuvent rien (politique
outputpermissive), et l'IPMI n'est pas un hôte géré.
L'isolation inter-tenant repose désormais entièrement sur les nftables d'hôte, en policy drop. C'est défendable — c'est déjà là que vit le zéro-confiance est-ouest — mais le plan de
gestion de la fabric perd sa seule protection réseau.
Des VRF auraient donné cette isolation sans ACL, par séparation des tables de routage. Ce matériel n'en a pas : c'est le critère à retenir au prochain renouvellement. Parade structurelle disponible d'ici là : sortir le management de la fabric routée des tenants, comme l'est déjà le stockage.
2026-08-02 (suite 18) — la liaison des ACL n'existe pas sur une interface VLAN
ip ? sur une interface VLAN du Binardat n'offre aucun access-group, et la liste
complète des commandes de ce mode n'en contient pas davantage. La ligne
ip access-group <NOM> in que le devis pose sur les douze SVI n'existe donc pas sur cette
plateforme.
C'est la ligne qui rend l'isolation effective. Sans elle, les ACL de la section 3 sont
parfaitement définies et jamais liées : show access-lists afficherait « used 0 time(s) », et
rien d'autre ne signalerait que l'isolation inter-tenant ne filtre rien. Même signature que le
défaut du trunk — une configuration qui a l'air juste et n'agit pas.
Le firewall disable aperçu dans un show running-config prend rétrospectivement du sens :
le filtrage semble conditionné globalement.
Aucune forme de remplacement n'est devinée. Le devis porte un avertissement à cet endroit,
en dialecte binardat uniquement — après trois syntaxes supposées dont deux fausses, marquer
l'incertitude vaut mieux qu'un quatrième pari. Reste à trancher sur une interface physique
(ip ?, access-group ?) et sur le rôle de firewall enable.
2026-08-02 (suite 17) — spanning-tree vérifié : les six familles sont closes
spanning-tree ? en mode configuration tranche la dernière inconnue, en faveur de la forme
émise : mode et priority s'acceptent au niveau global. La priorité n'a pas besoin
d'être portée par une instance, même en MSTP — la réserve inverse, notée la veille, était
infondée et le devis ne la porte plus. Une mise en garde fausse est aussi nuisible qu'une
syntaxe fausse.
Ajouté : spanning-tree seul, qui garantit l'état actif. Sans lui, mode et priority
sur un boîtier où le protocole aurait été désactivé configureraient un arbre qui ne tourne
pas. Sans effet s'il est déjà actif — même logique déclarative que pour les trunks.
Vérifié contre le matériel : VLAN, SVI, trunks, routes, définition des ACL, spanning-tree
global. Deux ont révélé un défaut réel plutôt que de confirmer l'existant — les routes
(notation CIDR) et surtout les trunks, dont la forme add ne retranchait rien.
Rectification (même jour). L'entrée ci-dessus a d'abord annoncé « les six familles sont closes ». C'était faux : l'aide consultée était celle du mode configuration globale, et trois lignes du devis vivent ailleurs —
spanning-tree portfast trunketip access-group … inau niveau interface,ip default-gatewayen global mais absent de cette aide. Elles restent non vérifiées, et la plus douteuse estportfast trunk:trunkest un mot-clé Cisco.
2026-08-02 (suite 16) — la syntaxe des ACL vérifiée sur le matériel
show access-lists confirme la dernière famille de syntaxe restée ouverte : une ACL générée
s'applique telle quelle sur le Binardat, ses règles dans l'ordre émis —
ip access-list extended <NOM>, masques normaux, any.
Détail de lecture consigné : le boîtier affiche any-destination là où l'on saisit any.
Comparer un show access-lists au devis ferait apparaître une différence qui n'en est pas une
— c'est le genre de faux positif qui fait perdre une heure.
Bilan des dialectes. Cinq familles sur six sont désormais vérifiées contre le matériel :
VLAN, SVI, trunks, routes, ACL. Ne reste que la forme d'entrée des commandes de
spanning-tree, que show ne révèle pas.
2026-08-02 (suite 15) — underlay.stp.mode aligné sur le matériel
mode: mstp, parce que c'est le mode d'usine du commutateur (show spanning-tree :
IEEE 802.1s, Force Version 3). Sur une étoile sans lien redondant, l'instance 0 de MSTP se
comporte comme un RSTP : changer de mode aurait donné le même résultat, au risque près de
toucher à un protocole qui fonctionne déjà.
Le devis énonce désormais sa propre incertitude là où elle est : en MSTP la priorité se
règle souvent par instance, alors que la forme émise est globale. Le générateur le dit
plutôt que de laisser croire à une syntaxe vérifiée — show spanning-tree donne l'état du
protocole, jamais la forme d'entrée des commandes.
Corrigé au passage : le commentaire de la section 6 disait « RSTP » en dur alors que le mode est déclaré. Il le dérive.
2026-08-02 (suite 14) — le spanning-tree du matériel : MSTP, actif, priorité par défaut
show spanning-tree sur le commutateur Binardat corrige une déduction fausse et en apporte
deux faits.
Correction. J'avais déduit de son absence du show running-config que le spanning-tree
était probablement désactivé. Il est actif : il n'y figurait pas parce qu'il est aux
valeurs d'usine. Une absence dans une configuration ne veut pas dire une absence de fonction.
La plateforme est en MSTP (IEEE 802.1s, Force Version 3), alors que underlay.stp.mode
déclare rstp. Sur une étoile sans lien redondant les deux se comportent identiquement — la
question est de savoir si l'on aligne la déclaration sur le matériel ou le matériel sur la
déclaration.
La priorité de pont est déjà 32768, donc la ligne émise pour les switches d'accès est un non-opérant : elle écrit ce qui est déjà vrai.
Reste non vérifiée la forme d'entrée des commandes de spanning-tree : show en donne
l'état, pas la syntaxe. En MSTP la priorité se règle en général par instance, ce que la
forme actuellement émise ne fait pas.
2026-08-02 (suite 13) — le trunk ne restreignait rien
switchport trunk allowed vlan ? sur le matériel réel confirme la syntaxe et révèle un
défaut : add ajoute à la liste courante, la forme sans mot-clé la définit.
Le devis émettait add. Or un port trunk neuf autorise tous les VLAN — dans une
configuration réelle, les ports n'avaient aucune ligne allowed vlan, ce qui signifie
exactement cela. Y ajouter la liste des VLAN voulus n'en retranchait aucun : le trunk
continuait de tout transporter, et le devis donnait l'illusion de restreindre.
C'est le pire genre de défaut — une configuration qui a l'air juste, s'applique sans erreur, et ne fait pas ce qu'elle annonce.
La forme sans mot-clé est retenue, et elle est aussi atomique : none puis add aurait
coupé le trunk entre les deux commandes, ce qui suffit à perdre la session si on l'applique
sur le port de gestion.
2026-08-02 (suite 12) — la syntaxe des routes, vérifiée sur le matériel
Un show running-config du commutateur Binardat tranche la question restée ouverte : la
plateforme écrit ses routes en notation CIDR — ip route 0.0.0.0/0 192.168.10.254 — et
non en masque séparé comme Cisco. Le générateur produisait du Cisco quel que soit le dialecte.
route_statique() suit désormais le dialecte, au même titre que les masques d'ACL. Vérifié
dans les deux formes.
Restent non vérifiés faute d'apparaître dans la configuration réelle : la syntaxe des ACL,
celle de switchport trunk allowed vlan add, et le spanning-tree — totalement absent du
show running-config, ce qui suggère qu'il est désactivé par défaut sur cette plateforme.
2026-08-02 (suite 11) — le responsable prend sa section, et la symétrie est dite
Corrigé — un renvoi ambigu
« Il ne dégèle qu'en cas de retour arrière (§6) » figurait dans l'étape 6. Deux « 6 » ne désignant pas la même chose dans une seule phrase, alors que tout le document distingue soigneusement sections et étapes. La section est désormais nommée plutôt que numérotée.
Déplacé — le responsable désigné devient le §3
Il vivait dans « Le modèle : le transfert de nom de domaine », alors que ce n'est pas un emprunt aux registraires : c'est une décision de modèle, valable migration ou pas. Le §2 ne traite plus que de ce qui est emprunté et de là où l'analogie casse.
Ajouté — ce qui suit le tenant, en regard de ce qui reste
Le §8 énumérait ce que la migration ne déplace pas — fabric, frontière, index — sans dire ce qu'elle déplace. Or le responsable désigné, lui, suit le tenant : c'est exactement l'inverse, et le dire renforce la ligne de partage.
Si quelque chose appartenant à l'organisation ne peut pas partir, elle n'est pas vraiment souveraine ; si quelque chose appartenant à l'hébergeur devait partir, c'est que la frontière entre les deux est mal tracée.
Cette symétrie est le test le plus simple d'une migration bien conçue.
Sections renumérotées en conséquence (9 au lieu de 8) ; les six renvois internes vérifiés un par un.
2026-08-02 (suite 10) — le responsable désigné, et la réversibilité nuancée
Décidé — chaque tenant a un responsable désigné
Un domaine a un titulaire ; un tenant a un responsable désigné — la personne qui engage l'organisation, et dont la signature seule vaut mandat de migration.
Ce n'est pas une formalité. Sans responsable nommé d'avance, la question « qui peut décider de déménager cette organisation ? » se pose au pire moment : quand les deux hébergeurs ont un intérêt dans la réponse. Un employé de bonne foi ne peut pas mandater le déménagement de son employeur.
Corrigé — la table des états laissait croire que revenir est facile jusqu'au bout
Elle annonçait une réversibilité « gratuite » entre préparé et libéré, alors que le §6
établit qu'elle change de nature à la bascule. Les deux ne se contredisent pas — on peut
effectivement revenir jusqu'à libéré — mais un lecteur pressé s'arrêtant au tableau en
retirait une fausse impression.
Ce qui reste gratuit est l'adressage, pas le retour : tout dérive d'un seul chiffre, dans les deux sens.
Ajouté aux points à trancher — trois questions de gouvernance
Où le responsable désigné est déclaré, et surtout comment on en change : c'est un acte au moins aussi sensible que la migration, puisqu'il décide qui pourra la mandater ensuite.
Le recouvrement de la clé du responsable — elle se perd, se compromet, ou la personne quitte l'organisation. Sans procédure, un tenant devient inmigrable : captif non par contrat mais par accident, exactement ce que la recette existe pour empêcher.
Deux écueils symétriques y sont consignés. Trop lourde, la procédure n'aboutit jamais et le tenant reste bloqué. Trop légère, elle devient le chemin de moindre résistance pour contourner la signature — inutile de forger un mandat si l'on peut se faire attribuer la clé. Le recouvrement doit être au moins aussi difficile que ce qu'il protège.
Piste retenue, la plus transposable des registraires : un contact de secours nommé en même temps que le responsable, tant que personne n'est en situation d'urgence.
La durée de rétention : convenue avec qui, consignée où, attestée par qui. Sur une séparation d'hébergeur, un flou ici finit en litige.
2026-08-02 (suite 9) — le retour arrière de la migration
La recette affirmait la réversibilité sans jamais décrire le retour. Or elle change de nature à la bascule, et le geste évident — repointer le DNS — devient faux à cet instant.
Trois régimes, désormais écrits :
- avant le gel : sans conséquence, le sortant n'a jamais cessé de servir. C'est ce que la règle d'ordre achète — tout ce qui peut échouer sans coût échoue là ;
- pendant le gel : dégeler, l'interruption se limite à la durée du gel ;
- après la bascule : ce n'est plus un retour mais une migration inverse. Les utilisateurs ont écrit chez l'entrant — courriels, fichiers, commits — et ces données n'existent nulle part ailleurs. Repointer le DNS les perdrait, et silencieusement.
Point rendu explicite : le sortant reste gelé après la bascule, jusqu'à confirmation. Le dégeler « au cas où » créerait deux copies vivantes du même tenant et plus aucune vérité. En contrepartie il n'a pas divergé, donc le delta d'un retour reste à sens unique.
Deux points de non-retour à ne pas confondre : la bascule fait perdre le retour gratuit (il reste la migration inverse) ; la purge fait tout perdre.
D'où une exigence ajoutée aux points à trancher : les critères de confirmation de l'étape 7 se fixent par écrit avant la première bascule. Décider après coup ce qui compte comme « ça marche » revient à se donner raison.
Corrigés au passage : deux renvois d'étape faux (le rattrapage est à l'étape 5, non 4 ; le chemin de vérification hors DNS public est un prérequis de l'étape 3 elle-même).
2026-08-02 (suite 8) — recette de migration d'un tenant entre hébergeurs
docs/migration-tenant.md : la séquence, les états et les gardes. Écrite avant tout code,
délibérément — figer un enchaînement qu'on n'a jamais joué serait prématuré.
Le modèle est le transfert de nom de domaine, qui résout depuis trente ans les mêmes problèmes : le mandat appartient au client (ni l'hébergeur sortant ni l'entrant ne peut déplacer un tenant seul), verrou par défaut, deux actes délibérés et traçables.
Là où l'analogie casse, elle est remplacée plutôt qu'étirée : il n'y a pas de registre central pour arbitrer, donc le mandat est signé par le tenant et vérifié contre une clé publique de son plan — un secret partagé ne prouverait rien à l'entrant, il pourrait venir du sortant.
L'ordre est commandé par une règle unique : le receveur doit être prouvé prêt avant que
quoi que ce soit ne gèle. L'entrant se construit et se prouve pendant que le sortant sert
normalement ; l'interruption se réduit au rattrapage du delta plus la propagation DNS. Ce
n'est pas un conseil mais une garde de transition — l'état gelé est inaccessible tant
que préparé n'est pas prouvé.
Deux pièges consignés parce qu'ils ne vont pas de soi : le TTL s'abaisse à l'étape 1, pas à la bascule, sinon tout le bénéfice de l'ordre est perdu ; et l'entrant doit être vérifiable sans être public, sinon le tenant sert des deux côtés et l'identité se dédouble.
Enfin, la libération est une révocation, pas une transmission : re-clétage de la voûte et rotation des secrets chez l'entrant. Transmettre le mot de passe laisserait à l'ancien hébergeur un accès à vie aux secrets d'un client parti — rien ne rattrape ça après coup.
2026-08-02 (suite 7) — la frontière appartient à l'hébergeur, pas au tenant actif
Un hébergeur sert plusieurs tenants et n'a qu'une frontière. Or ses intrants
(group_vars/opnsense.yml) étaient lus chez le tenant actif : basculer sur un invité —
Technolibre, qui n'a pas de frontière à lui — faisait perdre au devis l'URL de gestion,
l'adresse publique et les deux interfaces. Il repartait en marqueurs, comme si le boîtier
n'existait pas. Vérifié en simulant la bascule : AUCUN intrant lu.
Même famille que le défaut de l'underlay corrigé plus tôt, et c'est la distinction hébergeur/tenant qui le fait apparaître.
Le devis et le panneau lisent désormais la frontière chez l'hébergeur. Celui-ci n'est pas
déclaré pour autant : le symlink underlay.yml le désigne déjà, et une seconde
déclaration ouvrirait la porte à deux valeurs contradictoires. Repli sur l'instance active
quand aucun underlay n'est monté — un site sans fabric déclarée continue de fonctionner.
Vérifié : devis identique avec l'hébergeur actif, et intrants conservés avec un invité
actif. docs/frontiere-opnsense.md gagne un §2 qui pose qui possède quoi.
2026-08-02 (suite 6) — l'underlay devient modélisable
Un modèle décrivait jusqu'ici un tenant : ses services, ses zones, ses bases. Or tous les hébergeurs n'ont pas le même matériel, et l'infrastructure physique mérite le même traitement.
Ajouté — exemples/modeles/socle/underlay.yml
Le modèle public gagne un underlay volontairement minimal : un seul commutateur, pas de fabric de stockage séparée. C'est le point de départ honnête d'un petit hébergeur ; les montages plus riches (étoile à trois commutateurs, paire en MLAG, stockage jumbo dédié) sont d'autres modèles, conformément à la doctrine — un générique public, les étoffés en privé.
Le modèle contient désormais deux moitiés qui ne vont pas au même endroit : plan/ et
inventories/ chez le tenant, underlay.yml chez l'hébergeur. Chez un hébergeur qui est son
propre tenant, les deux atterrissent au même dépôt — c'est le cas particulier, pas la règle.
Étendu — modeles.py verifier valide l'underlay (preuve P17)
La validation est facultative (un modèle sans underlay reste valide) et porte sur la cohérence interne seulement : VLAN sous la plage tenant, sous-réseaux disjoints, passerelle dans son réseau, routeur déclaré, ports non dupliqués, dernier octet partagé.
Elle n'est pas confrontée aux tenants fédérés réels : un modèle est un gabarit, pas un
site déployé. Il a fallu pour cela rendre paramétrables deux hypothèses du validateur, qui
lisait la nomenclature de l'instance active et globait les dépôts frères — sur un modèle,
les deux auraient été faux. charger_depuis() et plan_nomenclature= ; comportement par
défaut inchangé.
Cinq cas de rejet exercés sur un modèle fautif : VLAN empiétant sur la plage tenant, passerelle au mauvais dernier octet (lue dans la nomenclature du modèle), routeur inconnu, sortie hors du lien, port déclaré deux fois.
2026-08-02 (suite 5) — l'underlay rejoint le dépôt de l'hébergeur
underlay.yml vivait gitignoré à la racine du moteur : consommé par deux générateurs,
validé par la preuve P23, et versionné nulle part. La dizaine de modifications de la journée
— transit, renumérotage du /29, séparation des fabrics, spanning-tree, dialecte, ports —
n'était récupérable d'aucune façon, et un clone frais repartait du gabarit.
Il appartient à l'hébergeur : ce sont ses switches, ses câbles, ses VLAN. Pas au moteur, qui est générique, ni à un tenant, qui n'en possède pas. Chezlepro est ici hébergeur et tenant, d'où la confusion : un tenant qui s'hébergerait sur son propre matériel aurait son propre underlay, dans son dépôt.
Le moteur le monte par symlink, comme il monte le plan par instance/ :
Set-OPS-public/underlay.yml -> ../OPS-Chezlepro/underlay.yml
Ce lien ne suit pas make instance-utiliser. Basculer l'instance active sur un autre
tenant ne change pas la fabric : elle reste celle de l'hébergeur. Deux symlinks, deux durées
de vie — c'est la conséquence directe de la distinction hébergeur/tenant.
Vérifié : les deux devis sortent identiques octet pour octet avant et après, P23 reste
verte, 24 preuves. Et un symlink brisé — le cas d'un clone du moteur sans le dépôt de
l'hébergeur — dégrade proprement : exists() suit le lien, l'underlay est vu comme absent,
et les devis omettent leurs sections au lieu d'échouer. Cas exercé.
2026-08-02 (suite 4) — deux devis qui se contredisaient, et une case à cocher
Corrigé — le devis frontière certifiait des routes inexistantes
Sa section 0 annonçait trois routes de retour « DÉJÀ ÉMISES par make devis-reseau ». Le
devis switch n'en émettait qu'une : devis_reseau lisait nftables_admin_ssh de la
seule instance active, alors que la frontière était passée multi-tenant la veille. Les deux
réseaux d'administration de Technolibre n'étaient routés nulle part.
Pire qu'un silence : une affirmation fausse désamorce la vérification.
admin_tous_tenants() vit désormais dans devis_reseau et devis_opnsense l'importe
au lieu d'en refaire une copie. Les routes de retour et les règles lisent les mêmes tenants,
par construction. Vérifié : les deux listes sont identiques.
Ajouté — l'avertissement « Block private networks »
Le SSH d'administration a une source RFC1918 arrivant sur une interface WAN. OPNsense active par défaut ce filtre d'interface, qui s'applique avant les règles : coché, il jette le paquet sans qu'aucune règle ne soit consultée. La config paraît juste, le SSH ne passe pas.
Le devis le signale dès qu'une source RFC1918 entre par le WAN — un réglage d'interface est invisible dans les règles, il fallait donc l'écrire à part.
Le prédicat est exactement RFC1918, périmètre de cette case ; ipaddress.is_private
aurait été trop large (plages de documentation, CGNAT), et l'avertissement se serait déclenché
à tort. Les trois cas exercés : RFC1918 → averti ; 8.8.8.8/32 → muet ; 203.0.113.7/32
(documentation) → muet.
2026-08-02 (suite 3) — chaque règle porte son interface, et l'octet est gardé
Ajouté — l'interface d'arrivée, dérivée du sens du flux
Dans OPNsense une règle est toujours in sur l'interface d'arrivée : posée ailleurs, elle
ne s'applique jamais et le trafic est bloqué sans que la configuration paraisse anormale. Les
règles n'en portaient aucune, alors que le champ est obligatoire dans l'API.
L'attribution se dérive : un flux ingress/externe arrive par le WAN, un flux
egress/externe par le lien de transit. Le SSH d'administration suit la première ligne
— le VPN est hébergé sur le pfSense voisin et revient par l'adresse publique de la frontière.
C'était la dernière inconnue, et le montage parallèle décrit le 2026-08-02 la lève.
Le rendu abandonne pass out pour pass in on <interface>, qui est l'idiome réel d'OPNsense
et ce que le futur client d'API devra envoyer.
Ajouté — opnsense_wan_ip, la face publique
L'adresse publique de la frontière (69.70.26.62, reprise du pfSense) est un intrant de la
section Frontière et s'affiche en section 1 du devis.
Ajouté — invariant du dernier octet (preuve P23)
Convention d'exploitation : un point de routage porte le même dernier octet sur tous les
sous-réseaux où il participe — on retient une adresse, pas treize. sleipnir-01 est .1
partout. Le chiffre n'est pas codé en dur : il vient de reservations.passerelle dans la
nomenclature, et make underlay refuse une passerelle qui s'en écarte.
Exemption assumée : les liens plus étroits qu'un /24. Sur le /29 de transit, l'adressage
est dicté par les participants — les deux frontières prennent .1 et .2, le switch .6.
Vérifié que l'invariant était déjà respecté sur les 13 sous-réseaux routés avant d'écrire
la garde.
2026-08-02 (suite 2) — la frontière porte les règles de TOUS les tenants
Le devis était multi-tenant pour ses routes et mono-tenant pour ses règles : il routait
10.21.0.0/16 et 10.27.0.0/16, mais ne filtrait que l'instance active. Technolibre aurait
été routé jusqu'à la bordure puis bloqué dans les deux sens, SSH d'administration compris,
sans qu'aucune ligne ne dise pourquoi. Chemin présent, politique absente — le mode de panne
du 2026-07-29, transposé.
La résolution est désormais paramétrée par tenant : inventaire_de() lit le hosts.yml de
chaque instance fédérée, cibles_par_role() prend l'inventaire en argument, et les alias
d'hôtes sont préfixés (SETOPS_CHEZ17_SERVEUR_NGINX). 11 règles par tenant, 22 au total.
Cloisonnement du plan de gestion
Première version de ce correctif : SETOPS_ADMIN devenait l'union des réseaux
d'administration. Le plan de gestion de Technolibre aurait alors pu entrer en SSH chez
Chezlepro — la bordure rouvrait ce que les ACL de switch ferment. Corrigé avant livraison :
un alias par tenant, SETOPS_ADMIN_<TENANT>, n'ouvrant que son propre supernet.
L'union est conservée pour les routes de retour côté switch et la garde P24 : router n'est pas autoriser, et le switch doit savoir revenir vers tous les plans de gestion.
Deux omissions annoncées au lieu d'être tues
Un tenant sans inventaire généré : aucune règle, et le devis le dit. Un tenant dont
nftables_admin_ssh est vide : la règle SSH est omise plutôt qu'ouverte à any, ce qui
exposerait le SSH à Internet. Cas dégradé exercé.
2026-08-02 (suite) — la sortie générale est déclarée, pas subie
Le devis frontière se terminait par block out log all avec une seule règle sortante
(le relais SMTP). Appliqué tel quel, il coupait la flotte d'Internet : plus de apt, plus
de NTP, plus de récursion DNS. Rien ne le signalait — c'était la ligne la plus lourde de
conséquences du devis, posée à la suite des autres.
La sortie est désormais déclarée dans le registre, donc dérivée comme tout le reste :
serveur_debian(le socle, porté par les 14 hôtes) —443/tcpet80/tcppour les dépôts apt,123/udppour l'horloge. Une dérive d'horloge fait échouer la validation des certificats step-ca et le SSO, des semaines après la cause.client_unbound—53/udpet53/tcp:client_unbound_transitairesest vide, donc Unbound interroge lui-même la racine. C'est le choix souverain ; il a un coût réseau qu'il faut déclarer. Le TCP n'est pas optionnel — c'est le repli obligatoire dès qu'une réponse DNSSEC dépasse la taille UDP.
Le devis passe de 6 à 11 règles, et sa section 5 énonce le default-deny sortant, le nombre de règles qui l'accompagnent, et où déclarer un besoin oublié — jamais à la main dans le pare-feu, la règle serait perdue à la génération suivante.
Vérifié : les nftables d'hôte sont inchangés, octet pour octet. Le pair externe reste
sauté par resoudre_flux.py — ces flux relèvent de la bordure, et d'elle seule.
Non déclaré volontairement : le rôle chrony existe mais n'est référencé par aucun groupe,
aucun playbook ni le graphe de dépendances. Lui écrire un flux aurait créé une règle morte.
2026-08-02 — les interfaces se nomment par leur identifiant
opt1, igb1 et TENANTS désignent le même port dans OPNsense : l'identifiant interne, le
périphérique FreeBSD et le libellé affiché. L'API REST ne parle que du premier, et c'est
lui que veulent opnsense_if_wan et opnsense_if_transit. Le libellé de ces deux intrants ne
le disait pas — la question s'est posée en pratique.
Les libellés le disent maintenant explicitement, et docs/frontiere-opnsense.md §6 explique
les trois couches ainsi que le motif du choix : opt1 est le plus stable des trois, il
survit à un changement de carte réseau comme à un renommage.
La note « opnsense_prochain_saut dérive de l'underlay » est repliée dans l'en-tête que le
panneau régénère : une sauvegarde l'effaçait, puisque le fichier est réécrit depuis le YAML
analysé. Vérifié qu'une sauvegarde préserve valeurs et références de voûte.
Corrigé — la doc portait encore l'ancien plan du /29
Après le renumérotage (bifrost-1/-2 en .1/.2, SVI en .6), deux passages de
docs/frontiere-opnsense.md annonçaient toujours 10.0.4.2 comme prochain saut. La doc
contredisait le devis généré ; les ip route des deux coïncident désormais.
2026-08-01 (suite 14) — l'empreinte du root CA n'est pas un secret de voûte
client_pki dérive l'empreinte à chaud depuis l'autorité (step certificate fingerprint en delegate_to sur serveur_step_ca) — précisément parce qu'un from-zero
régénère l'AC avec une empreinte neuve. Une empreinte figée en voûte serait périmée dès la
première reconstruction, et une empreinte périmée fait échouer le bootstrap de chaque hôte.
Or defaults/main.yml portait encore client_pki_ca_fingerprint: "{{ vault_step_ca_fingerprint | default('') }}". Ce défaut était mort : la tâche suivante
écrase le fait sans condition. La valeur de la voûte n'avait aucun effet, quel qu'en soit le
contenu.
Le recensement de voute.py s'y laissait prendre — il cherche la chaîne vault_* dans les
fichiers, sans pouvoir savoir qu'un défaut n'est jamais lu. La « source unique » avait donc
hérité de l'erreur, et le panneau réclamait un secret impossible à fournir avant que l'AC
n'existe.
Le défaut mort est retiré, et la clé disparaît d'elle-même du recensement (25 → 24 exigés),
du gabarit et du panneau. client_pki_ca_fingerprint_override reste le moyen documenté
d'épingler une empreinte (AC externe, migration).
Corrigé — une troisième copie manuelle de la liste des secrets
docs/intrants-communs.md §H énumérait les secrets à la main, avec les deux mêmes erreurs.
Elle renvoie désormais à scripts/voute.py lister et à la preuve P18 plutôt que d'entretenir
une copie de plus.
2026-08-01 (suite 13) — le rappel des secrets dérive de voute.py
SECRETS_ATTENDUS était une liste écrite à la main dans inventory_gui.py, en parallèle du
recensement que scripts/voute.py fait déjà depuis le plan, les rôles des groupes actifs et
les group_vars. Deux sources pour la même vérité, et la manuelle avait divergé :
| Clé | Panneau | Gabarit | Référencée |
|---|---|---|---|
vault_ldap_sssd |
annoncée | absente | nulle part — aucun rôle sssd n'existe |
vault_step_ca_fingerprint |
omise | présente | roles/client_pki/defaults/main.yml:24 |
Un opérateur qui suivait le panneau créait donc un secret que rien ne consomme, et oubliait
celui dont client_pki a besoin pour vérifier l'empreinte de l'AC racine.
Le rappel dérive désormais de voute.secrets_exiges() — la même source que la preuve
P18 — augmentée de SECRETS_HORS_MOTIF pour les jetons Proxmox, qui ne portent pas le
préfixe vault_. Vérifié : 27 noms, écart nul avec le gabarit. Sur un dépôt public nu,
la liste est vide plutôt qu'en erreur.
2026-08-01 (suite 12) — la fabric se règle depuis la console
Tout le modèle d'underlay bâti aujourd'hui — routeur, spanning-tree, fabrics, transit — s'éditait à la main dans un YAML, pendant que la doctrine dit qu'un sysadmin doit exploiter l'outil sans IA. La frontière avait eu sa section dans le panneau ; l'underlay, non.
Une section Fabric couvre désormais les valeurs plates : le switch routeur, le dialecte
de CLI, le mode et la topologie de spanning-tree. Les listes de tables (reseaux, hotes,
et donc les ports) restent hors de portée du panneau — elles demandent une vue dédiée, comme
celle des serveurs.
Le dialecte devient un intrant déclaré
Il ne vivait que dans SETOPS_DIALECTE, une variable d'environnement. C'est une propriété du
matériel, donc de la fabric : elle se déclare dans underlay.yml. Précédence désormais
explicite : drapeau --dialecte > variable d'environnement > intrant déclaré > cisco.
make underlay refuse un dialecte inconnu.
Écriture chirurgicale, pas de safe_dump
underlay.yml porte 23 lignes de commentaires qui expliquent des décisions d'architecture —
pourquoi un seul routeur, pourquoi le transit vit dans l'underlay, pourquoi les rayons ne
sont pas des ports de bord. Un safe_dump les aurait toutes effacées, comme c'est arrivé aux
commentaires de plan/applications.yml. Le panneau remplace donc la ligne existante en
respectant son indentation. Vérifié : trois valeurs modifiées, 73 lignes et 23 commentaires
avant comme après.
Une clef absente du fichier n'est pas créée : le panneau refuse explicitement plutôt que de l'inventer à un endroit arbitraire.
2026-08-01 (suite 11) — les ports physiques entrent dans le modèle
Les noms de ports n'étaient modélisés nulle part : <PORT-VERS-PROXMOX> et consorts
étaient des marqueurs littéraux dans le générateur. L'opérateur les remplaçait à la main dans
la sortie, et recommençait à chaque régénération. C'était le seul endroit du devis où le
travail était perdu à répétition.
Ils se déclarent désormais par équipement dans underlay.yml, sous quatre clefs qui
correspondent aux quatre natures de lien :
ports:
hyperviseurs: [Te1/0/1, Te1/0/2, Te1/0/3] # ports terminaux (portfast)
frontiere: [Gi1/0/23] # vers le pare-feu (portfast)
rayons: { sleipnir-02: Te1/0/47, … } # côté ROUTEUR
montante: Te1/0/48 # côté SWITCH D'ACCÈS
Le devis émet alors les vrais ports, y compris plusieurs vers les hyperviseurs — il n'en supposait qu'un seul jusqu'ici, alors que le cluster en compte trois. Non déclarés, les marqueurs reviennent : la dégradation est par équipement, un switch renseigné et un autre non cohabitent sans problème.
La partie B devient par switch : adresse de gestion, montante et ports terminaux différant d'une machine à l'autre, un bloc commun n'avait plus de sens.
make underlay refuse un port déclaré deux fois sur un même équipement, un rayon vers un
switch inconnu, des rayons sur autre chose que le routeur, une montante sur le routeur
lui-même. Les quatre cas exercés.
2026-08-01 (suite 10) — une interface, un bloc
La partie A déclarait ses ports de bord deux fois : l'interface en section 4, son portfast
en section 6. La partie B, elle, posait tout dans le même bloc. Deux conventions pour la même
chose dans un seul document — un opérateur qui applique la partie A section par section
configurait la même interface à deux endroits.
Le portfast est désormais posé avec son interface (sections 4 et 4b). La section 6 se
réduit à ce qui est global — mode, priorité du pont racine — plus les deux avertissements :
que les rayons de la section 4c n'en sont volontairement pas, et pourquoi BPDU guard n'est
pas émis.
Vérifié : aucune interface n'est déclarée deux fois dans une même partie, trois portfast
avec stp, zéro sans lui, zéro sur un rayon.
2026-08-01 (suite 9) — rayons de l'étoile séparés des ports terminaux
L'ajout du spanning-tree venait de rendre dangereuse une imprécision qui, jusque-là, n'était
qu'un titre approximatif. La section 4 s'appelait « Trunk vers Proxmox + inter-switch »
et n'émettait qu'un port, que la section 6 déclarait en bord de réseau. Réutiliser ce
placeholder pour les rayons vers les switches d'accès revenait à mettre portfast sur les
liens qui portent les BPDU — c'est-à-dire à désactiver la protection anti-boucle exactement
là où elle sert.
Les rayons sont désormais dérivés et émis à part (section 4c côté routeur, B3a côté
accès), un par switch d'accès, avec l'avertissement qu'ils ne sont pas des ports de bord.
La section 4 ne désigne plus que les hyperviseurs, et la partie B distingue sa montante
(B3a) de son trunk terminal (B3b).
underlay.switches_acces() devient la source unique du « qui est un switch d'accès » —
utilisée pour leur devis et pour les rayons côté routeur : les deux ne peuvent pas
diverger.
Vérifié : trois commandes portfast émises avec stp déclaré, aucune sans lui, et
aucune sur un rayon.
2026-08-01 (suite 8) — spanning-tree dérivé de la topologie déclarée
Ajouté — underlay.stp et les sections 6 / B4
Le devis ne disait rien du spanning-tree. Sur une fabric convergée à trois switches, une boucle par brassage accidentel n'est pas discrète : c'est une tempête de diffusion.
La topologie se déclare (mode: rstp, topologie: etoile) et le devis en tire la
configuration. Le routeur est désigné pont racine — il est le centre de l'étoile, tous
les chemins passent déjà par lui, donc l'arbre logique suit le câblage physique au lieu de
sortir d'une élection arbitraire. Les switches d'accès reçoivent une priorité volontairement
haute : ils ne doivent jamais devenir racine.
Les ports terminaux (hyperviseurs, frontière) sont déclarés en bord de réseau. BPDU guard n'est délibérément pas émis : un pont Linux dont le STP serait activé enverrait des BPDU et ferait tomber le port côté hyperviseur. Le devis dit pourquoi, et à quelle condition l'ajouter.
En étoile, aucun lien n'est redondant : RSTP est un filet, pas une nécessité — le devis le dit plutôt que de laisser croire à une protection indispensable.
make underlay valide mode et topologie, et refuse un stp déclaré sans routeur :
sans lui, aucun pont racine ne peut être désigné. Sans stp, la section signale l'absence
de protection au lieu de disparaître.
Réserve consignée : la forme binardat des lignes de spanning-tree n'a pas été confrontée au
matériel, comme les ip route.
2026-08-01 (suite 7) — l'underlay connaît ses fabrics physiques
Le stockage jumbo (iSCSI, Ceph) est porté par un réseau indépendant de deux switches
10G, sans câble commun avec la fabric convergée des sleipnir. Le modèle l'ignorait :
le devis déclarait les VLAN 20/30/31 sur les switches convergés et les mettait dans leurs
trunks. C'était faux.
Chaque réseau de l'underlay porte désormais une fabric (principal par défaut). Le devis
ne configure que celle du routeur, et énonce ce qu'il ne couvre pas plutôt que de le
taire :
! HORS PERIMETRE — fabric 'stockage' : stockage-iscsi (VLAN 20), ceph-public (VLAN 30), …
! Portee par des switches distincts, sans cable commun avec celle-ci :
! ni VLAN a declarer ici, ni trunk, ni spanning-tree partage.
Conséquence directe : la question du spanning-tree ne se pose que sur la fabric principale — trois switches — et pas sur le stockage, dont les deux switches forment un domaine séparé.
Le roster d'hôtes de la section 0 est filtré de la même façon : un équipement d'une autre fabric n'apparaît pas, même en commentaire. Un devis est une configuration qu'on applique, pas un inventaire. Vérifié en déclarant un hôte de la fabric stockage — il reste absent, et la sortie du jour est inchangée.
Les deny de l'ACL couvrent en revanche toutes les fabrics, y compris celles hors
périmètre : la règle porte sur l'adresse de destination, pas sur le câblage. Si un chemin
s'ouvre un jour vers le stockage, il est déjà fermé.
2026-08-01 (suite 6) — nommage : bifrost aux frontières, sleipnir à la fabric
bifrost-1 et bifrost-2 sont réservés aux deux frontières OPNsense — Bifröst est le pont
vers l'extérieur. Les switches internes deviennent sleipnir-01…03 : le cheval qui traverse
les mondes, pas le pont qui en sort. La division du nom suit celle de l'architecture.
Les deux boîtiers sont déclarés comme hôtes du lien de transit (10.0.4.1, 10.0.4.2) :
hors flotte Ansible, la déclaration documente le lien et réserve les noms. Le /29 choisi
plus tôt les loge tous les deux, comme prévu.
Le plan du /29 est réorganisé en conséquence : frontières en bas (.1, .2), SVI du
switch en haut (.6), et .3 laissée libre pour une future IP virtuelle CARP si les deux
OPNsense passent en haute disponibilité. Ce jour-là, passerelle_sortie pointera sur la VIP
plutôt que sur un boîtier nommé — un seul endroit à changer.
Conséquence à traiter : la partie B du devis switch aurait listé les deux pare-feux parmi les « switches d'accès ». Elle ne retient désormais que les hôtes du réseau de management — un équipement déclaré ailleurs ne reçoit aucune ligne de configuration de switch.
Le devis frontière nomme le boîtier quand il est déclaré : frontiere 10.0.4.1 (bifrost-1).
2026-08-01 (suite 5) — deux incohérences du devis switch
Corrigé — le routeur avait deux adresses de gestion contradictoires
bifrost-01 portait le SVI Vlan10 → 10.0.0.1 et était déclaré dans underlay.hotes
à 10.0.0.2. Une interface VLAN n'a qu'une adresse primaire : les deux ne pouvaient pas
être vraies. L'entrée datait d'avant la désignation du routeur, quand 10.0.0.1 était une
passerelle abstraite.
Le routeur est désormais déclaré à l'adresse du SVI qu'il porte, et make underlay refuse
la divergence : ip 10.0.0.2 != passerelle 10.0.0.1 — le routeur porte ce SVI, les deux doivent coïncider. Le roster des trois switches reste complet.
Corrigé — VLAN de transit déclaré sur les switches d'accès
La partie B créait vlan 40 alors que le trunk B3 ne le transporte pas — le transit ne relie
que le routeur à la frontière. Un VLAN qui n'aurait jamais vu de trame. Il est exclu de la
partie B, comme il l'est déjà des trunks généraux.
2026-08-01 (suite 4) — un seul switch route, les autres en L2 pur
Décidé — underlay.routeur
Sans MLAG, le routage est porté par un unique switch (bifrost-01 chez Chezlepro) ; les
autres restent en L2 pur. Le devis émettait jusqu'ici un jeu unique de SVI sans dire à quel
switch il s'adressait : appliqué sur les trois, il aurait créé autant de conflits d'adresses
qu'il y a de zones.
Ajouté — le devis se scinde en deux parties
- Partie A — switch routeur : VLANs, SVI, ACL d'isolation, trunks, routes. Va sur lui seul, et l'en-tête le nomme.
- Partie B — switches d'accès (L2 pur) : mêmes VLANs pour commuter les trames étiquetées,
une adresse de gestion par switch (tirée de
underlay.hotes) avecip default-gatewayvers le routeur, et les trunks. Aucun SVI de zone, aucune ACL, aucune route.
Deux gardes : make underlay refuse un routeur qui ne nomme aucun hôte déclaré ; et la
partie B avertit qu'un seul de ses blocs de gestion va sur chaque machine — les coller tous
écraserait l'adresse. Sans routeur désigné, l'en-tête signale explicitement le risque de
duplication au lieu de laisser croire que le devis est applicable partout.
Reste ouvert et consigné : la syntaxe des ip route n'est pas dialecte-consciente,
contrairement aux ACL.
2026-08-01 (suite 3) — les tenants n'atteignent plus l'underlay
Corrigé — ACL de switch : deny vers la fabric physique
L'ACL d'isolation bloquait l'autre tenant, puis se terminait par permit ip <tenant> any.
Ce any autorisait 10.27.x → 10.0.0.0/24 : le management des switches, celui de Proxmox
et l'OOB/IPMI, plus les réseaux iSCSI et Ceph. Une VM compromise atteignait la console
physique des hyperviseurs.
Le commentaire du générateur disait « Reste → passerelle OPNsense », mais c'est faux pour l'underlay : ce trafic est routé localement par le switch et ne passe jamais par la frontière, donc il n'est jamais filtré par elle.
devis_reseau.py émet désormais un deny par sous-réseau underlay avant le permit final,
dérivé de underlay.yml — dialecte respecté (masque normal ou wildcard). Aucun flux du
registre ne vise l'underlay : le blocage ne casse rien de déclaré.
Deux limites consignées dans docs/frontiere-opnsense.md §6 : le registre des flux n'a pas
de mot-clé underlay, donc un besoin légitime (superviser l'hyperviseur) ne pourrait pas
être déclaré ; et devis_reseau.py émet un jeu unique de SVI pour trois switches sans MLAG,
ce qui reste une décision d'architecture ouverte.
2026-08-01 (suite 2) — le port vers la frontière, et l'ordre d'application
Ajouté — section 4b : le port du switch vers la frontière
Le devis étiquetait le VLAN de transit sur le trunk <PORT-VERS-PROXMOX> et n'émettait
aucune interface vers le pare-feu. Deux erreurs en une : les hyperviseurs n'ont pas
d'interface sur le transit, et le pare-feu n'a rien à faire des VLAN tenants — il route vers
eux, il ne les étiquette pas. Le VLAN de transit sort donc du trunk Proxmox et prend son
propre port, dérivé du transit déclaré.
Ajouté — avertissement d'ordre en tête de la section 5
Les routes de la section 5 déplacent la sortie du switch, y compris celle de ses propres réponses. Tant que l'adresse de la frontière ne répond pas, elles coupent l'accès d'administration au switch lui-même — le mécanisme qui a rendu une VM muette le 2026-07-29, appliqué cette fois à l'équipement depuis lequel on travaille.
Le devis crachait ces lignes à la suite des autres comme si elles étaient équivalentes. Il énonce désormais les préalables (boîtier câblé, adressé, joignable depuis le switch, session console ouverte) — inacceptable autrement pour un outil censé être exploitable sans IA.
2026-08-01 (suite) — le prochain saut dérive du transit
opnsense_prochain_saut n'est plus un intrant du panneau : il dérive du réseau de
transit de l'underlay (la passerelle du réseau portant passerelle_sortie).
Un seul bloc de six lignes alimente désormais les deux devis : 10.0.4.1 devient le SVI
côté switch et le prochain saut des routes tenants côté frontière ; 10.0.4.2 devient
la route par défaut du switch. Le saisir en doublon dans le panneau rouvrait la possibilité
de deux valeurs contradictoires pour un seul lien — précisément le mode de panne qu'on venait
de fermer pour les réseaux d'administration.
Le devis frontière gagne au passage un bloc transit (JSON compris) et cesse de réclamer en
section 0 des routes que make devis-reseau émet maintenant : il dit lesquelles sont déjà
émises, ou signale l'absence de transit déclaré. Sans underlay, le marqueur
<PROCHAIN-SAUT-SWITCH> revient — le repli reste explicite.
Reste un seul intrant à figer au câblage : opnsense_if_transit, le nom de l'interface qui
porte le VLAN 40 sur le boîtier — la seule valeur que rien ne peut deviner.
2026-08-01 — le lien de transit et les deux routes (la boucle est fermée)
Ajouté — réseau de transit dans l'underlay (clé passerelle_sortie)
Le lien entre le routeur est-ouest (switches L3) et la frontière nord/sud manquait dans
tous les fichiers : le devis switch ne contenait pas une seule ip route.
Il vit dans l'underlay et non dans un tenant, pour une raison qui tranche : la frontière
route vers tous les supernets tenants par le même prochain saut. Le lien est donc partagé
et ne peut dériver d'aucun index. Un réseau underlay portant passerelle_sortie (l'adresse
du pare-feu sur le lien) le déclare — chez Chezlepro : VLAN 40, 10.0.4.0/29, SVI 10.0.4.1,
frontière 10.0.4.2. Un /29 et non un /30 parce que deux pare-feux cohabitent pendant la
transition.
underlay.py valide la nouvelle clé (sortie sur le lien, distincte du SVI, SVI obligatoire,
un seul transit) — preuve P23, cas de rejet exercés un par un.
Ajouté — devis-reseau émet les routes (section 5)
Deux routes dérivées, et il en faut impérativement deux :
- l'aller :
ip route 0.0.0.0 0.0.0.0 <sortie>— sans elle, aucun hôte n'a de sortie ; - le retour : une route par réseau d'administration — sans elle, la réponse d'une VM revient au pare-feu par une autre interface que celle où l'état a été créé, et se fait jeter en silence. C'est le piège qui a coûté la passe de déploiement du 2026-07-29.
Les réseaux d'administration viennent de l'intrant nftables_admin_ssh : même source
unique que la garde anti-lockout des nftables et l'alias SETOPS_ADMIN de la frontière —
les trois pare-feux et les routes ne peuvent pas diverger. Sans transit déclaré, la section
s'affiche en clair comme manquante plutôt que de disparaître silencieusement.
Corrigé — le panneau refusait d'enregistrer les intrants de la frontière
group_vars/opnsense.yml porte à la fois des paramètres anodins et deux références de
voûte ({{ vault_opnsense_api_key }}). La fusion « préserve les clés non gérées » les
relisait, et le garde-fou, qui ne regardait que les noms, les prenait pour des secrets
soumis. Il regarde désormais la valeur : une référence de voûte est un pointeur et passe ;
toute valeur réelle sur ces clés fait toujours échouer l'écriture. Ajout d'un refus explicite
à l'entrée pour une requête forgée, au lieu d'un abandon silencieux.
Retiré — reliquat proxmox.vault.yml
La voûte est unique (group_vars/all/vault.yml) ; l'ancien fichier séparé n'était plus
chargé automatiquement (aucun groupe proxmox dans l'inventaire) et entretenait la confusion.
Supprimé de l'instance, avec son gabarit.
Au passage : supprimer_vm_debian.yml ne chargeait que ce reliquat pour ses secrets. Le
supprimer tel quel aurait cassé make detruire, l'outil même du rebuild from-zero. Sa liste
est alignée sur celle du playbook de clonage (all/vault.yml en dernier, il l'emporte), et la
résolution du jeton depuis la voûte unique est vérifiée en exécution réelle.
2026-07-29 (suite) — la frontière OPNsense, dérivée du registre des flux
Ajouté — make devis-opnsense (+ preuve P24)
La bordure nord/sud devient un artefact dérivé, comme le devis switch. Rien de saisi à la main : ni port, ni adresse, ni nom d'hôte n'apparaît dans le générateur.
Le constat qui rend la chose évidente : resoudre_flux.py saute volontairement les flux
pair: externe (scripts/resoudre_flux.py:184) parce qu'ils ne concernent pas le pare-feu
d'hôte. Plusieurs raison du registre disent déjà « filtré à l'OPNsense ». La politique de
la frontière était donc déjà écrite — il ne restait qu'à la dériver.
scripts/devis_opnsense.py— agrège les fluxexterne, résout les destinations depuis l'inventaire (hôtes actifs et planifiés : la frontière se prépare avant les VM), les supernets depuis les nomenclatures fédérées, et l'accès d'administration depuis l'intrantnftables_admin_ssh. Sort un devis relisible ou--json(destiné à l'API OPNsense).- Garde anti-lockout (P24) —
--verifierrefuse un devis dontnftables_admin_sshest vide : la règle SSH entrante n'aurait aucune source et leblock infinal fermerait l'accès d'administration. Même intrant que les nftables d'hôte : source unique, donc pas de divergence possible entre la bordure et les hôtes. - Section 0 du devis : les routes de retour à poser sur les switches. C'est le piège qui a coûté la passe de déploiement du jour — la passerelle de zone répond au ping, mais aucun hôte derrière elle n'est joignable, parce que la réponse revient au pare-feu par une autre interface que celle où l'état a été créé.
docs/frontiere-opnsense.md— les décisions d'architecture (frontière nord/sud, les SVI restent sur les switches L3), le partage des rôles entre les trois pare-feux, le chemin d'application par l'API, et ce qui reste ouvert (câblage, second VPN, sortie générale).
Corrigé — intrant nftables_admin_ssh vide sur l'instance Chezlepro
Il valait [] alors que group_vars/hotes_actifs.yml active nftables_baseline_enabled.
Autrement dit : la flotte se serait mise en policy drop sans aucune règle autorisant le
contrôleur, qui arrive par le VPN hors du sous-réseau de la flotte. Renseigné à
192.168.255.0/24. Le sous-réseau du second VPN (OPNsense) devra y être ajouté.
Ajouté — la frontière devient réglable depuis la console (section Frontière)
Les valeurs non sensibles du pare-feu de bordure sont de vrais intrants, pas un fichier YAML tenu à la main : un opérateur règle la frontière depuis le panneau « Intrants de base », sans IA et sans éditeur.
INTRANTS_SCHEMA— nouvelle section Frontière :opnsense_api_url,opnsense_api_verifier_certs,opnsense_if_wan,opnsense_if_transit,opnsense_prochain_saut. Cible d'écrituregroup_vars/opnsense.yml, en fusion (comme Proxmox) pour préserver les références de voûte du fichier. Le panneau rend ses sections génériquement : aucune modification d'interface n'a été nécessaire.- Garde-fou —
opnsense_api_key/opnsense_api_secretajoutés àINTRANTS_CLES_INTERDITES: le GUI refuse de les écrire, donc impossible de coller un secret d'API dans le panneau par mégarde. Ils n'apparaissent qu'en lecture seule, par leur nom, dansSECRETS_ATTENDUS. devis_opnsense.pylit désormais ces intrants et n'affiche ses marqueurs (<IF-TRANSIT>,<PROCHAIN-SAUT-SWITCH>) qu'en repli : le devis se complète de lui-même dès que la console est renseignée.
Ajouté — les identifiants d'API de la frontière, dans la voûte
Même partage que Proxmox : l'anodin en clair, le secret dans la voûte unique de l'instance.
group_vars/opnsense.yml(instance, en clair) — URL de gestion, interfaces et prochain saut à figer au câblage, plus les références par nom{{ vault_opnsense_api_key }}et{{ vault_opnsense_api_secret }}. Aucune valeur de secret n'y figure.- Gabarit de voûte — les deux clés ajoutées à
vault.yml.example.scripts/voute.pyles a recensées tout seul depuis lesgroup_vars(il ne lit jamais la voûte, il ne compare que des noms) : le gabarit passe de 23 à 25 secrets, et la preuve P18 reste verte.
Validation : make verifier vert — 24 preuves CONFORME, 0 échec, 0 sauté.
2026-07-29 — dette documentaire soldée (un README par rôle) + carte revue
Ajouté — les 12 README de rôles manquants
Tous les rôles ont désormais un README. Les 12 restants sont écrits, au format maison (intention → rôle → variables → notes/limites → prérequis), et documentent surtout ce qui ne se lit pas dans les tâches :
- Socle et résolution —
serveur_debian(rôle-catégorie sans tâches : il ne porte que le flux SSH du plan de gestion, sans quoi les nftables couperaient l'accès Ansible),hosts_statiques(le plancher/etc/hostsqui rend l'ordre de reconstruction possible). - Rôles utilitaires —
resoudre_baseetresoudre_annuaire: entrées/sorties (facts), et pourquoi le FQDN plutôt que le nom court (fédération +verify-full). - Courriel —
serveur_dovecot(les trois réglages Dovecot 2.4 qui conditionnent la remise ; le local-part seul comme chemin commun LMTP/IMAP),serveur_postfix(liensmailstore/milter, recopie de/etc/hostsdans le chroot),serveur_rspamd(clé DKIM idempotente ; le domaine signé doit être public en prod). - Sauvegardes —
client_backup(jobs déclaratifs, chiffrement côté client, le dépôt neuf est vide tant qu'une première sauvegarde n'a pas tourné) etserveur_backup(hors-nœud ≠ hors-site). - SSO et supervision —
serveur_oauth2_proxy(le patron réutilisable Keycloak-devant- n'importe-quoi ; pourquoiallow_unverified_emailest nécessaire avec un annuaire LDAP) etserveur_icingaweb2(modesldapvsexternal, et l'écoute à restreindre en SSO). client_unbound— le garde-fou de bascule du résolveur (applyetconfirm, validation avant de toucher/etc/resolv.conf).
Corrigé — docs/carte-set-ops.md ne décrivait plus l'état du code
exposeest consommé au déploiement (la carte l'annonçait encore comme « Phase 3 à venir ») : vhosts nginx dérivés viaexpositions_des_applications+expositions.conf.j2, alias/etc/hosts, SANs d'edge dérivés parinstancier.py.- Trois mécanismes transverses ajoutés au catalogue : résolution d'annuaire
(
resoudre_annuaire), plancher de résolution (hosts_statiques), etresoudre_basenommé dans la ligne des bindings app→base. - Cinq entrées d'index ajoutées : réseau/pare-feu, ordre de déploiement, preuve/recette, exploitation courante, wiki — des pans entiers du corpus n'étaient pas indexés.
- Reste ouvert, explicitement :
meta/liens.ymlsur le seulserveur_postfix, etrequiertnon consommé au déploiement (indice applicatif ; l'ordre, lui, vient des couches+graphe).
Validation : make verifier vert (ansible-lint 514 fichiers, tests, syntax-check,
23 preuves CONFORME).
2026-07-28 — figures annotées dans le wiki (console d'exploitation)
Ajouté — les 8 vues de la console illustrées, dans le wiki
La série des figures annotées (une par vue du GUI, en SVG auto-contenu : capture + repères
intégrés en base64) est désormais intégrée à l'unité wiki
Le GUI (console d'exploitation), en fin de section ②.
Vues éditables (Serveurs, Applications, Bases × 2, Domaines) puis dérivées (Flux, Couches, Réseau).
- Symlink
wiki/img → ../docs/img— foyer unique des figures dansdocs/img/; les pages wiki y réfèrent en relatif (img/*.svg) sans duplication dans l'arbre source. make wiki-publierembarque désormais lesdocs/img/*-annote.svg(déréférencés) dans le wiki Forgejo publié — c'étaient jusqu'ici les seules.mdqui voyageaient, donc aucune image.
2026-07-24 — underlay (fabric physique, cluster-global)
Ajouté — l'underlay comme concept de premier plan
Le modèle dérive l'adressage par tenant (VLAN 1000+index×10+zone), mais la fabric
physique qui porte la flotte — mgmt des switches, mgmt Proxmox/OOB, iSCSI, Ceph — n'appartient
à aucun tenant et ne dérive d'aucun index. Elle vit dans le sous-sol du modèle. Jusqu'ici
elle n'était pas codifiée. Elle l'est.
scripts/underlay.py+make underlay— charge/affiche/valideunderlay.yml: réseaux (nom, VLAN, sous-réseau, passerelle optionnelle → SVI, MTU/jumbo) et hôtes fixes documentés (les switches). La validation refuse toute collision avec la plage tenant : VLAN < 1000, sous-réseaux hors des supernets10.(10+index).0.0/16.underlay.yml(racine du moteur, gitignore comme le vault ; gabarit publicunderlay.yml.example), surchargeable parSETOPS_UNDERLAY. Absent → tout reste inchangé.make devis-reseauémet désormais une section 0. Underlay (VLANs, SVI, hints jumbo, IP des switches en commentaire) et ajoute les VLAN underlay au trunk Proxmox, avant les tenants. Respecte le dialecte (cisco/binardat).- Preuve P23 —
underlay.py --verifier: la fabric n'empiète pas sur la plage tenant. Sautée (⚪) siunderlay.ymlest absent (dépôt public), comme P16 sans vault.
Le plafond tenant (245) est inchangé : l'underlay occupe 10.0.0.0/16 .. 10.10.0.0/16, laissé
libre par la dérivation (index ≥ 1 → 10.11+).
2026-07-23 (suite 7)
Ajouté — dialecte de CLI du commutateur (devis-reseau)
Constat de l'opérateur : son switch est un Binardat, dont la CLI diffère de Cisco sur deux points que le devis généré ignorait — et deux pièges qui font passer un VLAN mais fuir un tenant :
- Masque d'ACL — Cisco veut un masque inversé (wildcard
0.0.255.255), Binardat un masque normal (255.255.0.0). Le SVI (ip address … 255.255.255.0) était déjà normal, donc valide sur les deux. remark— Binardat n'a pas la commande de commentaire d'ACL de Cisco. Ces lignes faisaient rejeter le bloc.
scripts/devis_reseau.py gagne un dialecte (cisco par défaut, binardat) :
masque_acl() choisit wildcard ou masque normal ; remarque() omet les remark en Binardat.
Réglable par --dialecte, par SETOPS_DIALECTE, ou make devis-reseau DIALECTE=binardat. Le
GUI (lecture seule) suit l'env. Le code public reste générique (défaut cisco).
2026-07-23 (suite 6)
Ajouté — plan de recette (le pendant manuel de make prouver)
Constat de l'opérateur : les 78 exercices « ④ À toi de jouer » du wiki forment, ensemble, un plan de tests d'acceptation. Formalisé, sans dupliquer :
scripts/plan_recette.py+make plan-recette— génèredocs/audit/plan-de-recette.mddepuis les exercices du wiki : une grille auto-contenue (le geste inline) par unité, colonnes Ce qu'on éprouve · Le geste · Type (observe/casse-répare) · Preuve auto. La dernière colonne est extraite du texte (lePxxque l'exercice mentionne) : elle montre quels gestes manuels sont aussi gardés par la machine. Étant générée, la grille ne peut pas dériver du wiki.- Preuve P22 —
plan_recette.py --verifieréchoue si le fichier committé n'est plus à jour (le wiki a changé sans régénérer). Le plan de recette devient un artefact auto-gardé. - Honnêteté de couverture assumée dans le document : un « — » = manuel seul (aucune preuve machine ne le double) ; le plan ne prétend pas à l'exhaustivité au-delà des exercices du wiki.
C'est le pendant humain de make prouver : le harnais prouve le moteur (P01–P21), la recette
valide l'exploitation — et sert de checklist au protocole-operateur-independant.md (« exploitable
sans IA »).
Validé : 78 gestes sur 19 unités, 5 doublés d'une preuve Pxx ; P22 testée (détecte une dérive) ;
make verifier → CONFORME 22/22 (contre une instance cohérente).
2026-07-23 (suite 5)
Ajouté — wiki : l'axe « méthode » (KB enrichie)
Le wiki enseignait les fondamentaux services (identité, PKI, courriel…) mais pas la méthode de Set-OPS. Cinq pages ajoutées, au moule à 4 temps (concept → Set-OPS → transférable → à toi de jouer), avec exercices concrets :
- Le plan & l'adressage dérivé — un seed (
index), tout en découle (DRY, source unique). - Multi-instance & fédération — un moteur, N écosystèmes ; découverte par convention.
- La preuve — « ne jamais affirmer plus que ce qu'on prouve » ; le registre,
make prouver, P01–P21. - Le GUI (console d'exploitation) — éditer la source, dry-run avant apply, l'invalide
impossible à saisir (le
<select>sans hôte fantôme) ; la flotte et la bascule. - Glossaire — 24 concepts en une phrase (était « à venir »).
Raccordées dans Home.md (deux unités-pilotes : services et méthode) et _Sidebar.md
(section « Flotte & preuve »). Fidèle à la doctrine : le wiki pointe vers docs/, ne recopie
pas. Publié via make wiki-publier. Wiki : 855 → 1573 lignes, 22 pages, aucun lien mort.
2026-07-23 (suite 4)
Ajouté — créer un MODÈLE (make model-creer, dépôt privé)
Symétrique de instance-creer, mais produit un modèle réutilisable dans le dépôt PRIVÉ
(Set-OPS-Modeles/, jamais exemples/modeles/). scripts/model_creer.py, deux modes :
MODE=base BASE=<modele> NOM=<x>— copie un modèle déjà générique (copier + éditer).MODE=instance SOURCE=OPS-<x> NOM=<y>— promeut une instance éprouvée en modèle : généralise l'identité (domaine → exemple.internal, organisation →Exemple, realm →exemple), fixeindex → 1,setops_production → false,nftables_admin_ssh → [], vide la clé publique de sauvegarde, génériqueproxmox.yml(host/nœud/stockage/VMID vidés, golden template →modele-debian13).
Sûreté : aucun secret ne sort (vault.yml/proxmox.vault.yml jamais copiés ; refus si
l'un subsiste ; le .example est conservé). Copie ciblée (plan/ + inventories/ seulement,
symlinks résolus) — robuste aux dépôts imbriqués et boucles de symlinks. Le modèle produit
doit valider (modeles.py verifier), sinon il est annulé. make model-creer.
Validé : les deux modes testés en isolement (destination tmp, dépôt privé jamais touché) —
promotion du labo (cohérent) réussie + validée, aucun chezlepro résiduel, aucun secret,
proxmox.yml génériqué ; copie base (socle) OK ; refus d'une instance INVALIDE (le garde-fou
a détecté un hôte fantôme dans une instance en cours d'édition). node --check, make verifier
rc=0 CONFORME 21/21.
2026-07-23 (suite 3)
Ajouté — créer une instance depuis un modèle (CLI + GUI)
Le moteur est indépendant des instances (le symlink instance/ est gitignoré, aucun
artefact d'instance n'est committé). Créer une instance existait seulement à la main
(cp -r + ln -s, QUICKSTART). C'est désormais une capacité de premier ordre.
scripts/instance_creer.py— copie un modèle (exemples/modeles/*+SETOPS_MODELES) vers un dépôt frère../<nom>, y fixe l'index(le seed), et refuse : un nom déjà existant (rien n'est écrasé), un modèle inconnu, un index en collision avec une instance fédérée (le garde-fou vérifie AVANT toute copie). Le.gitethosts.genere.ymldu modèle ne sont pas copiés.make instance-creer NOM=OPS-X MODELE=socle [INDEX=N]etmake 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/instancesrenvoie aussimodelesetindex_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, sansvault_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 symlinkinstance/(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_INVENTAIREforce encore un inventaire fixe (CI). POST /api/instance-utiliser+basculer_instance(nom)— repointe le symlink avec les garde-fous demake instance-utiliser, plus une validation stricte :nomdoit être une instance découverte (dossier frère), ce qui interdit toute traversée de chemin (testé :../etcrefusé).- 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 avecplan/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--verifiersort 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 sansSETOPS_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_nomenclaturene lit plus aucun adressage stocké ; le modecompactest supprimé (tout est ip-miroir dérivé).devis_reseauimporte ces helpers (plus de duplication) ; découverte des tenants surindexprésent (le filtrevmid_schemadisparaît).- Les 10 nomenclatures (3 instances + 7 modèles) passent au format maigre :
index,cidr_hote,reservations, libellés de zones etfonctionsseulement. Supprimés :supernet,vmid_schema, et par zonesous_reseau/passerelle/vlan.presence-web, encore encompact, gagneindex: 1. - GUI :
indexdevient 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 dec.vlanstocké).
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 (lesexemples/modeles/*du dépôt, plus les chemins deSETOPS_MODELES, ex. le dépôt privé). A immédiatement trouvé 6 modèles invalides sur 7 : cinq portaientautorite: interne(valeur périmée jamais propagée depuis la correction desocleen phase 5),presence-webmanquait son domaine interne. Corrigés. - P18 — gabarit de voûte complet (
scripts/voute.py). Confrontevault.yml.exampleaux secrets réellement exigés par le plan (champsecretdes bases + référencesvault_*des rôles actifs et des group_vars). Ne déchiffre jamais la vraie voûte : compare des noms.--strictsignale 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) avecCHAMPS_ECRITS_PAR_GUI, nouveau tableau deinventory_gui.pydé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_applicationsaccepte désormais le registre des serveurs et refuse une application posée sur un hôte non déclaré — la faute exacte qu'integralportait. Câblé dansscripts/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-webgagne son domaine interne pour ses expositionssite.*/app.*. - Champ
websocketdans le GUI (inspecteur d'application) — Collabora en a besoin.
Validé : make verifier → rc=0, CONFORME 19/16→19 (P01–P19), ansible-lint 0 échec,
les 7 modèles valident (SETOPS_MODELES posé), gabarit de voûte complet (23/23), couverture
GUI 27/27 champs (nomenclature tolérée), DIFF VIDE conservé, JS du GUI valide.
2026-07-22
Ajouté
- Champ « Liens (bindings) » dans le GUI (
scripts/inventory_gui.py). L'inspecteur d'application porte une section dédiée : une ligne par lien,rôle → cible, les deux en listes déroulantes, avec bouton d'ajout et de retrait. Les rôles proposés sont exactement ceux que le rôle porteur déclare accepter (roles/<groupe>/meta/liens.yml), et les cibles sont les autres applications du plan. Chaque ligne affiche les variables que le lien injectera. Changer le groupe d'une application vide les rôles de lien devenus inacceptés. Comble un manque relevé à l'audit du GUI : les bindings ne pouvaient se déclarer qu'en éditantplan/applications.ymlà la main, ce qui rendait la configuration du courriel (Postfix → Dovecot, Postfix → rspamd) inaccessible sans éditeur. liens_acceptes(groupe)etcatalogue_liens()dansscripts/inventory_rules.py— source unique de « quels liens un rôle accepte », partagée par le validateur, le GUI etinstancier.py. Nouvelle cléliens_acceptesdans la charge utile de/api/inventaire.
Modifié
valider_applicationsvalide désormais lesliens: 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 sansmeta/liens.ymlreste toléré au validateur —instancier.pytranche avec le même message. Bénéficie aussi àmake inventaire-verifieret à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 litmeta/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.auroradé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 partemplates/custom/header.tmplsur 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.mddocumente 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érencerheader.tmplsur le serveur. La procédure ne duplique aucun fichier : elle pointe vers ceux deroles/serveur_forgejo/files/custom/. Comprend la détection du répertoirecustom, un garde-fou pour ne pas écraser unheader.tmplexistant (ajout de ligne, pas remplacement), la vérification parcurl, 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.mdl'é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
.auroraporté 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 publicsocle. 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 dedocs/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 verifierinclut désormais les preuves (make prouver).make verifierse termine parpython3 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éedocs/audit/preuve-<date>.md.make verifieréchoue donc si une preuve échoue.make prouverseul (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 avecmake 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.mdréduit à un pointeur mince : autorité unique d'AGENTS.md+ les cinq règles absolues (agent unique ;hosts.ymlgé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.mdprescrivaitPasswordAuthentication yespendant la construction ; le code applique en réalitéPasswordAuthentication no+AuthenticationMethods publickeydès le départ (prouvé, cf.docs/audit/affirmations.mdAFF-037/050). La doctrine SSH périmée deCLAUDE.mdest 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.mdproposaitcp -r exemples/modeles/presence-web …— modèle inexistant dans le dépôt public (seulsoclel'est). Étape 2 réécrite sursocle; les modèles assemblés renvoyés au dépôt privéSet-OPS-modeles, cohérent avecexemples/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é enproduction/(le modèlesoclen'a qu'un inventaireproduction/;make configrésoutlab > principal > production). Piège corrigé : le socle livrait ses intrants enproduction/group_vars/all.yml(forme fichier) ; y ajouterall/vault.yml(forme dossier) fait ignorer silencieusementall.ymlpar 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 --hostchargedomaine_internedepuis la nouvelle disposition. - Commandes
makepérimées (AFF-080/081/082).make help→make;make syntax-template→make syntaxe-modele(dansdocs/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 decloner_vm_debian.yml) — lot séparé à prévoir.
- Modèle absent (AFF-020/021).
- Conformité Phase 3 — lot B « doc (prose) ».
- Prérequis Vault rappelé (AFF-026).
QUICKSTART.md: note quemake inventaire-verifier/make verifierchargent l'inventaire complet et exigentANSIBLE_VAULT_PASSWORD_FILE, sinon « no vault secrets found ». - Sous-dossiers
playbooks/(AFF-039).AGENTS.md: précisé que seulsgroupes/,maintenance/,modeles_vm/,proxmox/sont peuplés ; les autres sont prospectifs. SOLUTION.mdremis 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 dansall/vault.yml, maiscloner_vm_debian.ymlne le lit que depuisproxmox.vault.yml/env. Couplé à AFF-097 dans un futur lot « voûte Proxmox ».
- Prérequis Vault rappelé (AFF-026).
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 dansinstance/inventories/<env>/group_vars/all/vault.yml, maisplaybooks/proxmox/cloner_vm_debian.yml(lancé-i localhost,) ne le chargeait que depuisproxmox.vault.yml→make creer-vméchouait l'assertproxmox_api_token_secretpour qui suivait la voûte unifiée. Corrigé :all/vault.ymlajouté aux sources de secrets du playbook (autoritaire) ; la détection de voûte chiffrée duMakefile(cloner-vm) cherche d'abordall/vault.ymlpuisproxmox.vault.yml.proxmox.vault.ymlreste accepté en compatibilité. Validé :--syntax-checkOK,ansible-lint0 échec, test fonctionnel (token chargé depuisall/vault.yml, assert vert). - Docs Proxmox/template alignées (AFF-097).
lab/codé en dur →production/+ voûte unifiée dansplaybooks/proxmox/README.md,docs/procedure-template-debian13-proxmox.md,docs/vm-lifecycle.md,docs/modeles_vm/debian13-proxmox.md. Plus aucune référenceinventories/lab/group_varsdans les fichiers suivis.
- Le clonage lit la voûte unifiée (AFF-098, option a).
- Conformité Phase 3 — lot C «
make verifiervert » (AFF-006).ansible-lintpasse de 33 échecs à 0 (profilmin→production), doncmake lintrc=0. Trois causes :site.ymlgénéré lint-propre :scripts/orchestrer.pyémet unname:avant chaqueimport_playbook(30×name[play]) ;site.ymlrégénéré. Orchestration inchangée.risky-shell-pipe:set -o pipefail+executable: /bin/bashsur les deux tâches shell à pipe deplaybooks/valider.yml.name[template]: Jinja déplacé en fin denamedanssupprimer_vm_debian.yml. Validé :make lintrc=0 ; toutes les étapes demake verifiervertes (lint, test, site-verifier, flux-verifier, syntaxe) — seuleinventaire-verifierrequiert 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
socleinvalide (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.pyetscripts/inventory_gui.pyretombaient surprincipal/quand aucunhosts.ymln'existe encore ; or le socle est enproduction/→ la 1ʳᵉ génération écrivait dansprincipal/, à côté desgroup_varsrestés enproduction/. Corrigé : le repli vise le répertoire d'inventaire déjà présent (commeconfig_proxmox.py). Instances existantes (avechosts.yml) inchangées (non-régression vérifiée surprincipal). - 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) — écritproduction/hosts.yml, diff vide ensuite, 4 validateurs verts. Nouvelle preuve récurrente P15 dansmake prouver(« modèle public socle valide ») :make prouver= 15 OK, 0 échec, 1 sautée.
- Modèle
Ajouté
- Harnais de preuve
make prouver(Phase 4). Nouveauscripts/prouver.py— un orchestrateur mince qui rejoue les preuves automatisables du registre en appelant l'outillage existant (les mêmes scripts quemake 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. Produitdocs/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 preuveP15(inventaire Ansible complet) est sautée proprement sans mot de passe Vault (prérequis AFF-026). Documenté dansREADME.md(une phrase) etdocs/audit/README.md(mode d'emploi complet + comment ajouter une preuve).affirmations.md: section « Couverture parmake 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 duMakefile, 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-checkde tous les playbooks, test unitaire. Dix écarts majeurs classés par risque pour un opérateur suivant la doc à la lettre — dont :QUICKSTARTrenvoie à un modèle absent (presence-web),make verifieréchoue (ansible-lint : 33 failures), contradiction SSHCLAUDE.md↔ code/AGENTS.md, chemin de voûte faux, commandesmakepérimées dansdocs/.
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 redonneindex×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. Nouveauscripts/devis_reseau.pyqui découvre les instances fédérées (../*/plan/nomenclature.yml, schémaip-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), viaGET /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 lesfatal:/UNREACHABLE!, privilégie la vraie cause (stderrplutôt que le générique « non-zero return code »), gèreno_log(« sortie masquée — secret ») et les injoignables. Éprouvé sur les 4 échecs réels du from-zero du jour.node --checkOK. - 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, validationvalider_domaines(rejette une autorité inconnue). Éprouvé : round-trip backend OK,node --checkOK, smoke serveur OK. (Leautorite: internepérimé des instances Chezlepro/Technolibre a été corrigé enauto-hebergeau passage.) - GUI : persistance des journaux d'exécution. Chaque action lancée depuis l'interface
(créer-vm / vérifier / déployer) écrit désormais, en plus du streaming live, un journal
horodaté
<instance>/logs/<hôte>-<action>-<date>.log(gitignoré). Bonus : si le navigateur se déconnecte, l'exécution continue et le journal est capturé jusqu'au bout (au lieu d'être interrompue) — utile pour un déploiement qu'on ne veut pas voir avorter à la fermeture d'un onglet. - GUI : vues « Flux » et « Couches » (lecture seule). Deux nouveaux onglets rendent visibles
dans l'interface deux registres jusque-là en ligne de commande seulement : Flux = la matrice
d'audit réseau (rôle · sens · port · pair · chiffrement coloré · raison, depuis les
meta/flux.yml), et Couches = l'ordre de reconstruction (socle → pki → services → apps → agents, avec les groupes triés topologiquement par couche). Alimentées parresoudre_flux/orchestrervia l'API. Éprouvé :node --checkOK, 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 devault_step_ca_fingerprintdes secrets attendus (empreinte du root CA désormais dérivée dynamiquement, plus un secret).node --checkOK.
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_overridepermet 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_fluxau lieu deflush ruleset(préserve les tables Docker : DNAT/forward des conteneurs) et (b) autorisedocker0+ct established,relateddans la chaîneforward. Sans ça,forward policy dropcoupait 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é, secretsno_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*-verifierstatiques).playbooks/valider.yml, lecture seule : cibles Prometheus toutes UP (API/targets) + vhosts HTTPS exposés répondent (dérivés desserver_nameréels de l'edge, filtrés surdomaine_interne— générique) + courriel bout-en-bout (envoi via le MTA → LDAP → LMTP → Maildir, remise vérifiée pardoveadmsur le comptetestmail, message de test nettoyé) + restauration de sauvegarde (pour chaque nœudclient_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 sansclient_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 dropavec 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/14active+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-01seul (test dead-man switchsystemd-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 parnftables_baseline_enabled: true(group_varshotes_actifs; le golden template n'y est pas → reste sans pare-feu, voulu). - Le déploiement dépose
instance/flux-genere/<hôte>.nftdans/etc/nftables.conf+ serviceenabled(survit reboot ET futursmake myDay).
- Intrant
Corrigé
- Détection du coffre Vault :
production/codé en dur → inventaire réel.deployer,deployer-toutetverifier-deploiementcherchaient le coffre chiffré dansinventories/production/group_vars, alors qu'une instance enprincipal/(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 legroup_varsde l'inventaire résolu ($(dir $(INVENTAIRE_PRODUCTION))group_vars). Vérifié : le coffre deprincipal/est bien détecté.
Modifié
nftables_baselinebranché sur les flux résolus (reconstruction, phase 0). Le rôle déploie désormais le ruleset résolu généré parmake flux(instance/flux-genere/<hôte>.nft— règles par source,ip saddr= moindre privilège) quand il est présent ; sinon repli sur le gabarit plat. Toujoursnftables_baseline_enabled: falsepar défaut → aucune activation (l'activation reste un geste dédié, testé par nœud). Nouveau varnftables_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— bouclecreer-vmsur tous les hôtes actifs du plan (clone Proxmox). Nouvelle sous-commandeinventory_host.py lister-actifs.make reconstruire CONFIRMER=true— enchaîne flotte-creer → attente SSH de la flotte (_attendre-flotte,ATTENTE_MAXré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 sontpresent/resized(grow-only), le déploiement Ansible converge. Re-lançable sans risque, qu'il reste des VM ou non.make myDayrepointé surreconstruire(le vrai « bouton rouge » ; n'était qu'un alias dedeployer-tout). Distinction assumée :deployer-tout= converger la config d'une flotte existante (2b, avecMODE_CHECK=1) ;reconstruire/myDay= créer les VM manquantes puis déployer (2a+2b). GardesCONFIRMER=truesur les trois. Non testé contre Proxmox/lab (validé : énumération des 14 hôtes actifs, refus sansCONFIRMER, enchaînementmake -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 parmake wiki-publier WIKI_REMOTE=…<dépôt>.wiki.git: clone superficiel du wiki, synchronisation des pages (wiki/*.mdsaufREADME.md; suppressions propagées), commit + push seulement s'il y a du changement. Refuse sansWIKI_REMOTE. Éprouvé de bout en bout contre un dépôt bare local (17 pages publiées = 17 source, diff vide, README exclu,_Sidebarinclus, 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 socleserveur_debianpour 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) : valeursssh(transport SSH, restic/backup + SSH de gestion) ettls-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 ciblemake wiki-publier. -
Résolveur de flux (reconstruction, phase 0 — §Séquence 2).
scripts/resoudre_flux.pyagrège lesmeta/flux.yml, résout lespair, et produit deux artefacts, hors-ligne, sans activation :docs/registre-flux.md(généré) — la matrice d'audit source→destination (rôle, sens, port, chiffrement, raison) + synthèse chiffrement. Artefact du label de certification.- aperçus nftables par hôte (
instance/flux-genere/<hôte>.nft, gitignorés) — règles résolues avec IP réelles,ip saddr= moindre privilège (ex. LMTP 24 sur le mail n'accepte que l'IP du nœud Postfix),policy drop. Aperçus inspectables, NON activés (l'activation reste un geste dédié testé par nœud, cf. flux-conception §Activation prudente). - Cibles
make flux(registre + aperçus) etmake flux-verifier(schéma + matrice, branché dansmake verifier). Validé : 29 rôles / 63 flux cohérents, 14 aperçus générés. Reste : branchernftables_baseline(modèle plat aujourd'hui) sur ces aperçus, et le test lab.
-
Orchestrateur ordonné (reconstruction, phase 2).
playbooks/site.ymln'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 viadependances-groupes.yml(ex. dovecot avant postfix, icingaweb2 après icinga, nextcloud en dernier). Génèresite.ymlcomme 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é dansmake verifier),make deployer-tout CONFIRMER=true(déploiement orchestré de la flotte, limité àhotes_actifs; gardeCONFIRMERcar action impactante ;MODE_CHECK=1pour l'essai idempotent à blanc). Validé :verifierOK (30 groupes, aucun cycle/arête arrière),--syntax-checkdusite.ymlgénéré OK, refusdeployer-toutsansCONFIRMER(rc=2).
Modifié
- Audit exhaustif du codé-en-dur (reconstruction, phase 1b). Balayage complet
tasks + templates + defaultsde 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'intrantorganisation: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→ slugchezlepro, identique à l'URI de redirection Keycloak de l'instance (pas de casse). Conservés intentionnellement : realmdefault('chezlepro')(décisionidentite_realmacté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-checkOK (playbook forgejo via inventaireprincipal). - Audit du graphe de dépendances (reconstruction, phase 1a).
docs/dependances-groupes.ymlgagne 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 viaresoudre_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éconciliationmeta/liens.yml: le seul lien structurel (serveur_postfixmailstore → Dovecot) coïncide avec le graphe. Conclusion d'archi : la règle « TLS vérifié ⇒client_pkiaux 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.ymlne capture que le fin ordonnancement intra-couche. Validé : YAML conforme, aucun cycle, tri-topo réussi (19 nœuds), chargeurcharger_dependancesaccepte (12 groupes,est_groupe_operationnelOK), tous les groupes ont un rôle.
2026-07-05
Modifié
- VLAN dérivé du tenant (réseau convergé).
deriver_nomenclature(schémaip-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émacompact(lab, sandbox) reste inchangé. Champsvlan: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 scrapescheme: https+tls_config), logs (Lokihttp_tls_config+ cert-sync owned loki ; Alloy pushhttps+tls_config), courriel (LMTPedge-mta→infra-mail:24enlmtp_tls_security_level=verify+lmtp_tls_CAfile;client_smtpen STARTTLS vérifié). Chacun prouvé de bout en bout (200 HTTPS, cibles UP, livraisonstatus=sent, HTTP rejeté). Motif cert-sync.pathindustrialisé. Reste : edge→backends + DNS (DoT). - VMID 9 chiffres mnémotechnique (schéma
ip-miroir, opt-in).vmid_schema: ip-miroirdans la nomenclature → VMIDI·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éfautcompactrétro-compatible (instances déployées inchangées). - Instance partenaire Technolibre. Écosystème complet (12 VM,
etat: planifie) dans10.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_baserenvoie le FQDN (→ keycloak/forgejo/icinga) ;client_journal_loki_urldérivé du groupeserveur_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éfautchezlepro; 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.
integralrégénéré depuis le cas prouvé (6 zones, fonctions éprouvéesdata-sql/id-ldap/id-sso/sup, ip-miroir, nouveaux intrants) ;socle/identite/observabilite/forgeréalignés sur le même moule (prouvés : dérivation +valider_serveurs).presence-webmarqué 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 (
hostssldans 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_pkisur data-sql-01 (cert), etroot_ca.crten 0644 (cert public, requis par les clients TLS non-root). cert-sync PG (motif.path, owned postgres) + reload de l'instancepostgresql@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_dirsous/etc/postgresql/faisait choisirtlscomme « 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_dirdé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 sonExecStartPostrechargeait 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 nginxsur 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), mapperrolessur les clients choisis (_role_mapper_clients, rôles de realm → claimrolesdans 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é (idempotencechanged=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é dansserveur_forgejo(serveur_forgejo_branding,_app_name,_theme), déployé dans{{ data }}/custom/. Prouvé sur forge-01 : accueil rend (200, « Forge Chezlepro »),alliance.cssservi (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 desite-alliance-boreale) : ciel nocturne aurore (#05060f/#0a0d24+ dégradés), carte glassmorphism, logo étoile aurore (lefavicon.svgdu 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 viakcadm ... -s loginTheme(varserveur_keycloak_login_theme, idempotent), Keycloak rechargé (flush_handlersavant la config realm). Prouvé : la page de login chargealliance.css(HTTP 200) + lelogo.svg(200),loginTheme=alliance-borealeactif surchezlepro. 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 ; respecteprefers-reduced-motion. Prouvé :constellation.jsréférencé + servi (200). Console de compte thémée aussi (thèmeaccount,parent=keycloak.v3) : overlay CSS surchargeant les variables PatternFly 5 (fond aurore, cartes en verre, accent aurore) + la même constellation animée. Varserveur_keycloak_account_themeviakcadm -s accountTheme. Prouvé : console charge (HTTP 200,keycloak.v3intact),account.cssservi (200). - Soumission courriel
:587interne (authentifiée) — la boucle souveraine est bouclée. Postfix (edge-mta) sert la soumission:587(blocmaster.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) :testmails'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 sectioninet_listener; ajouter un servicemaster.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œudbackup-01) = cible SFTP/SSH (utilisateurrestic, clé autorisée, dépôts sous/srv/restic/<nœud>).client_backup(intégration par nœud) = restic + jobs déclaratifs (client_backup_jobs:{nom, commande?, chemins}), clé SSH + mot de passe restic en voûte, script + timer systemd (quotidien) + rétentionforget --prune. Prouvé de bout en bout sur le Tier 0 (infra-pki-01→/etc/step-ca, l'ancre de confiance) : sauvegarde hors-nœud versbackup-01, puis restauration byte-identique des clés CA (root_ca_key,intermediate_ca_key,ca.json). Piège corrigé : le plancher/etc/hostsd'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(slapcatLDIF — 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é contienttestmail. Les 5 dépôts restic (pki, sql, ldap, mail, forge) sont hors-nœud surbackup-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-échafaudagesdebugsans rôle (nextcloud, collabora, client_supervision, web_frontal, web_dorsal) ; l'intention reste documentée dansdocs/catalogue-services.md. Binding annuaire : nouveau rôle utilitaire partagéresoudre_annuaire(commeresoudre_base) qui dérive la connexion OpenLDAP dudomaine_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 viaset_fact.
- 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
- Binding annuaire complété —
icingaweb2+client_ldapmigrés versresoudre_annuaire. Fin des 2 loose ends :serveur_icingaweb2(connexion LDAP dormante en mode SSO) résout viaresoudre_annuaire(redéploiementchanged=0, SSO intact) ;client_ldap(SSSD, dormant) ne pointe plus sur unidm-01pé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 authexternal. Rôle paramétrable (client, secret voûte, redirect, upstream, cookie voûte) — réutilisable pour toute app OIDC-less. Éprouvé sursup-01devant Icinga Web 2 : client Keycloakicingaweb2, oauth2-proxy:4180(exposé par l'edge) → upstream nginx local:8080→ icingaweb2backend = external(REMOTE_USER depuisX-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 pasemail_verified; IdP interne de confiance) ; en reverse-proxy oauth2-proxy passeX-Forwarded-*(pasX-Auth-Request-*) ; handler nginx enrestart(pasreload) 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 + moduleicingadb), pas d'assistant de setup. Base IcingaDB viaresoudre_base(registre). Auth LDAP direct vers OpenLDAP (LDAPS,client_pkisursup-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ôteicinga). Déploiementfailed=0(le rôle est bon ; les frictions étaient dans le simulateur de login curl : contrôle de cookie_checkCookie, champsuid/submit_login, valeur CSRF avantname). - Module BPM (Business Process) éprouvé + codifié — pile Icinga complète.
serveur_icingaweb2installe + activeicingaweb2-module-businessprocess(serveur_icingaweb2_modules), crée le répertoire des processus (éditable via l'UI, groupeicingaweb2, setgid) et sème des processus métier en IaC (serveur_icingaweb2_bpm_processes, nom → contenu.conf). Format des feuilleshost;service(éprouvé via les fixtures du module). Prouvé : un processus « Supervision Chezlepro » (agrège load/procs/swap/ping4/ssh du hosticingaen logique ET) rend un état dans l'UI (testmailconnecté), 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é sursup-01(🔧→⭐), baseicingadbPostgreSQL 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 DRYresoudre_basesur 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é surforge-01(🔧→⭐), adossé à PostgreSQL (baseforgejoauto-provisionnée depuis le registre), exposé par l'edge (vhost + cert SAN + A PowerDNS + alias plancher, tout auto-dérivé deexpose). Source OAuth2 vers Keycloak (forgejo admin auth add-oauth, idempotent, realmchezlepro), client OIDCforgejoenregistré viaserveur_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éeserveur_sendmail→serveur_postfix(le vrai MTA) ;app.inidoit appartenir au usergit(Forgejo persiste des secrets générés) ; ordre admin/migrations (flush_handlers+wait_foravantadmin user create) ;HTTP_ADDR127.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é dansserveur_keycloak,serveur_forgejoetserveur_icinga(charger le registre, filtrer par consommateur, déréférencer le secret vialookup('vars', ...), résoudre hôte/port) est extrait dansroles/resoudre_base(factsresoudre_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 + forgejofailed=0, idempotent,testmailtoken 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_powerdnsgénère désormais, dans la zone interne, un enregistrement A pour chaque FQDN d'exposition (champexposedes applications) vers l'edge qui le sert (domaines.edge) — viaexpositions_des_applications(même source que les vhosts nginx et les SANs du cert edge). Déclarerexposeproduit maintenant vhost + SAN de cert + enregistrement DNS, tout dérivé. Prouvé :dig @infra-dns-01 grafana.lab.chezlepro.internaletkeycloak.…→192.168.15.21(edge). Optionserveur_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ïvementclient_dnsvers l'autoritatif. hosts_statiques— alias d'exposition dans le plancher/etc/hosts(résolution client, sûre). Le plancher pose désormais, sur chaque nœud,<IP edge> <FQDN exposé>pour chaqueexpose(dérivé dedomaines.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 (statdelegate_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/hostsd'obs-01 régénéré aveckeycloak/grafana→ edge (ligne manuelle éliminée),getentOK, et le flux SSO Grafana fonctionne via la résolution du plancher (login: testmail). Boucle Phase 3 fermée : déclarerexpose→ vhost + SAN cert + A PowerDNS + alias plancher, tout dérivé. Bugs corrigés en chemin :serveur_loki(groupelokimanquant),statsur cible→contrôle,becomeinutile 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 viaclient_unbound_transitaires). Alternative dynamique au plancher/etc/hostsstatique, sans casser Internet. Bascule de/etc/resolv.confproté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,aptOK. 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 sesliens: [{vers, role}]dansplan/applications.yml; chaque rôle décrit les liens qu'il accepte dansmeta/liens.yml(setops_liens.accepte, commemeta/empreinte.yml).instancierré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 instancierdonne 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/porteedansbases-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 danscatalogue-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é depuisarchitecture-set-ops.md.serveur_postgresqletserveur_keycloaképrouvés sur VM réelles (🔧→⭐). PostgreSQL déployé (data-sql-01), écoute réseau + pg_hba VLAN, et provisionne la basekeycloakdepuis 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éescatalogue-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, viakcadm(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 parenvironment+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_expositionsexistait déjà (litexposedes applications +edgededomaines.yml, dériveamont = http://<IP hôte>:<port>, génère le vhost avecX-Forwarded-*). Le bac à sable déclarekeycloak.expose: [keycloak.lab.chezlepro.internal]+ le domaine internelab.chezlepro.internal(edgeserveur_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/..."(lesX-Forwardedpassent, 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 auxclient_pki_sansde l'edge (client_pki demande + renouvelle déjà le cert d'hôte), puis pointerserveur_nginx_certificatsur le cert client_pki (/etc/step/certs/<edge>.crt). - Cert de l'edge : snakeoil → step_ca (HTTPS valide). Appliqué le raffinement ci-dessus :
client_pkiajouté à l'edge, sesclient_pki_sansincluent le FQDN d'exposition (keycloak.lab.chezlepro.internal), etserveur_nginx_certificat/_clepointent sur le cert client_pki. Prouvé : HTTPSHTTP 200avecssl_verify_result=0(chaîne validée contre la racine step_ca, nom correct), émetteurSet-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 viaGF_AUTH_GENERIC_OAUTH_*(client confidentielgrafana, realmchezlepro, secretvault_grafana_oidc).serveur_loki+serveur_prometheus+serveur_grafanadéployés surobs-01(🔧→⭐). Bug de rôle corrigé :serveur_lokicréait le répertoire engroup: lokialors que le paquet crée l'utilisateur ennogroupsans groupeloki→ 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/userrenvoielogin: 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 viakcadmà la main (à codifier — rôle grafana ou liste de clients côté keycloak) ; (2) la résolutionkeycloak.…internal → edgesur obs-01 est un/etc/hostsmanuel (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éclarativeserveur_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) ; lesecretréférence la même variable de voûte que l'app. Prouvé : clientgrafanasupprimé → rôle → recréé →testmailse 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 parinstancier.pyen variables Ansible ; chaque rôle décrit les liens qu'il accepte dansmeta/liens.yml(commemeta/empreinte.yml) ; FQDN cible dérivé de la nomenclature (jamais codé en dur). Domaines publics traités comme lienexposition(écrit sur l'edge). Réconcilie l'existant (basesconsommateur,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.
LICENSEremplacé par le texte officiel intégral de l'AGPLv3 (verbatim, non modifié). Attribution + modèle libre + services/certification documentés dans leREADME(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 IaCstalwart config applyannoncé 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ôleserveur_stalwartest retiré (git en garde la trace) ;docs/courriel-conception.mdmis à 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é viasmtpd_milters(optionserveur_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/mailroot 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'attributmail), 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. Configmain.cf+ carteldap-mailboxes.cf, validée parpostfix check. Secret de bind :vault_openldap_admin. Nécessiteserveur_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 (versserveur_openldap, filtremail), stockage Maildir (user systèmevmail), 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 pardoveconfau déploiement. Sockets d'intégration Postfix conditionnels (rendus si l'utilisateurpostfixest co-localisé). Prouvé :doveadm auth test— bon mot de passe accepté, mauvais refusé, sur cert step_ca. Secret de bind :vault_openldap_admin. serveur_openldapdurci 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 paropenldap(/etc/ldap/tls) par un script + une unitépathsystemd qui re-synchronise et recharge slapd à chaque renouvellement ;olcTLS*configuré danscn=config,SLAPD_SERVICESexposeldaps://. Dégrade proprement (slapd en clair local) siclient_pkin'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.
- TLS (LDAPS + STARTTLS) : le certificat d'hôte step_ca (déposé par
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é/actifdé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
creerréussi) ou qu'on déploie passe automatiquementactifdans 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.
- Sonde de vie : le GUI teste la joignabilité SSH de chaque hôte en arrière-plan
(
Corrigé
-
nginx ne validait pas sur Debian 13 (
server_tokensen double). Debian 13 livreserver_tokens off;actif dans/etc/nginx/nginx.conf(avant : commenté). Le drop-inconf.d/99-setops.confdu rôle le redéclarait →nginx -téchouait (« directive is duplicate ») et le déploiement plantait au handler de validation. Le rôleserveur_nginxneutralise 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échiffrergroup_vars/all/vault.ymlet échoue sans mot de passe (exit 4) — alors que l'opération est structurelle, sans secret.instancierutilise désormais automatiquement le fichier conventionnel~/.config/setops-vault-pass(siANSIBLE_VAULT_PASSWORD_FILEn'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 selonvault.exemple.ymlne 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 sesvault_*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--checkn'installe pas réellement → le service (ou le fichier de zone/conf) n'existe pas encore → fauxfatal, qui bloquait le déploiement (le dry-run doit réussir pour débloquer « Déployer »). Ajout dewhen: not ansible_check_modesur ces tâches et handlers des 13 rôlesserveur_*(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 appliquersans--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 →
🖥 Pousserclone 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 →
Pousserdéploie l'hôte porteur (make deployer HOTE=<hôte>). - Base →
Pousserdéploie l'hôte du serveur de BD (crée la base). Nouveaux modescreer/pousserdansexecuter_flux+ routes/api/creeret/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 soclehosts_statiques(dansserveur_debian) : génère/etc/hostssur 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_dnsest rendu tolérant (inerte si aucun DNS interne) et sa dépendance àserveur_powerdnspasse molle. - Adressage fédéré : index d'instance. Le VMID n'est plus codé
9CSNNen dur : il prend le préfixe d'unindexdéclaré en tête deplan/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-hoc172.19.x). Code mort retiré (deriveServeurJS). Voirdocs/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 symlinkinstancevers un autre dépôt d'instance (prod ↔ bac à sable) ;make instance-couranteaffiche 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.ymlde 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 durinventories/lab/inventories/production: il vise un inventaire par instance, détecté de façon rétro-compatible (principal>production>lab) et surchargeable parSETOPS_INVENTAIRE. Les instances existantes (découpage lab/production) continuent de fonctionner à l'identique ; les nouvelles peuvent adopterinventories/principal/. Deuxième pierre de la séparation par instance (la 1re étant le drapeausetops_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 drapeausetops_productiondansgroup_vars/all/ne bloque plus rien : il marque la PRODUCTION pour exiger une confirmation renforcée au déploiement (bannière/badge rouge « PROD », bouton Déployer en rouge, re-saisie du nom d'hôte) ; un bac à sable (false) affiche « bac à sable » et déploie sans cette étape. Rétro-compatible (à défaut de drapeau, ancien repère « inventaire production »). Le GUI affiche toujours quel type d'instance est monté.
Modifié
- Voûte de secrets unique par environnement. Fini les voûtes éparpillées : tous
les secrets de l'instance (token Proxmox + 17
vault_*pour PKI, LDAP/SSO, bases, forge, observabilité) vivent dans un seul fichier chiffré,inventories/<env>/group_vars/all/vault.yml. Gabarit committéexemples/vault.exemple.yml.make config(config_proxmox.py) écrit/édite désormais cette voûte (semée depuis le gabarit si absente)..gitignoredurci (**/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/laben dur (config Proxmox + voûte) — vestige du modèle env qui cassait les instancesprincipal. 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éclaraitlaunch+=bindque le paquetpdns-backend-bindpose déjà. Le rôle ne déclare pluslaunch(seulementbind-config).
- Clonage/Makefile codaient
chezlepro_timezonen'était appliqué nulle part. Cet intrant de base global était défini mais aucun rôle ne s'en servait. Le rôlechrony(appliqué à tout hôte viaserveur_debian) règle désormais le fuseau horaire à partir dechezlepro_timezone(chrony_timezonepar 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#messageet#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-5changent de vue,j/kparcourent les hôtes,v/dvérifient/déploient l'hôte sélectionné,/cible le filtre,Ctrl+Ssauvegarde 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
beforeunloadsi 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 (cataloguesproxmox_noeuds/proxmox_stockages, vide = défaut global) et Intégrations une rangée de cases à cocher des rôlesclient_*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 dansgroup_vars/proxmox.yml; l'API exposeintegrations_disponibles(scan deroles/client_*). Le typeliste(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, rendudessinerArbre, 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/inventairerenvoie 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, étatmodifie). - 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 depuismake helpetQUICKSTART.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 dedomaine_interne(clé de voûte) demande une confirmation explicite ; undomaine_internevide est refusé côté serveur. La nomenclature reste en lecture seule (modifiable dans le plan). Voirdocs/intrants-communs.mdetdocs/intrants-base-gui-conception.md. - Source unique d'identité partagée.
domaine_interneetchezlepro_timezonevivent désormais dansinventories/partage/intrants-identite.yml, référencé par symlink depuis chaque environnement (group_vars/all/10-intrants.yml) — fin de la duplication lab/production. - Info-bulles d'aide sur les champs du GUI. Survoler la description d'un champ à saisir affiche une bulle avec des instructions sommaires.
2026-06-28
Ajouté
- Document de présentation de l'écosystème Chezlepro (
docs/ecosysteme-chezlepro.md). Description vulgarisée à destination client/partenaire : les piliers de l'écosystème souverain, le modèle reproductible (plan → génération → clonage → conformité), et un inventaire des mesures de renforcement réellement en place (SSH durci, fail2ban, sysctl, nftables, AppArmor, auditd, mises à jour automatiques, gestion des secrets, garde-fous destructifs, sécurité par l'architecture). Honnête sur le statut « défini/validé vs déployé ».
Ajouté
- Dimensionnement dérivé des ressources VM. Les cœurs/RAM/disque d'une VM sont
désormais estimés depuis les logiciels hébergés + le socle SE, au lieu d'hériter
des specs du golden template (CPU/RAM identiques pour toutes les VM auparavant).
Chaque rôle déclare son empreinte (
roles/<rôle>/meta/empreinte.yml) ; le générateur somme par hôte (marge + arrondis) et écritproxmox_coeurs/proxmox_memoire/proxmox_disque_taille; le clonage passecores/memoryà Proxmox (omitsi absent → aucune régression). Override par hôte possible dans le plan (serveurs.yml). Voirdocs/dimensionnement-ressources.md.
Corrigé
make instancier-appliquer FORCE=1n'honorait pas--force. La recette Makefile lançaitinstancier.py appliquersans relayerFORCE; 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 parmake, 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.