1.1 MiB
CHANGELOG — Set-OPS
2026-10-01 (59) — Chezlepro reconstruite par Set-OPS en 41 minutes, sans un arrêt
Lancée par l'exploitant (make reconstruire-locataire TENANT=OPS-Chezlepro CONFIRMER=true ARMER=oui) : 8 étapes sur 8, aucun arrêt, 41 min, contre 60 la veille et 43 visées.
| Étape | Durée |
|---|---|
| sauvegarder + raser | 1 min |
| créer les VM | 5 min |
| inséminer + armer | 2 min |
monter (déploiement, état remis, valider) |
31 min |
pare-feu (sonde connectivite) |
2 min (20 la veille) |
| bilan | < 1 min |
Les 7 jeux d'état restaurés depuis l'instantané pris avant de raser ; Icinga à 0 critique ; l'archive Nextcloud prise au dépôt de binaires du site, plus sur Internet (58). Les deux corrections du jour se lisent dans ce chiffre : la sonde à la minute (−18 min), le dépôt du site rouvert (−20 min sur Technolibre, 63 min une heure plus tôt).
2026-10-01 (58) — Le dépôt de binaires du site rendait 403 : quatre rôles se disputaient un répertoire
Le symptôme (reconstruction de Technolibre lancée par l'exploitant, 63 min au lieu de
~43) : deployer-tout restait 20 min sur « Télécharger le tarball Nextcloud dans le cache du
contrôleur » — 281 Mo depuis download.nextcloud.com à 130–230 Ko/s. Le runner, né à vide,
devait les prendre au dépôt de binaires du SITE ; l'étape qui l'y cherche (failed_when: false, « le dépôt est une commodité ») avait rendu « ok » sur un 403.
La cause : le dépôt existe et est complet depuis le 2026-09-12 (Forgejo, Keycloak,
Nextcloud 34.0.2, oauth2-proxy), LocalDirs est en place — mais apt-cacher-ng, qui ne tourne
pas en root, ne traversait plus /var/lib/setops (0750). QUATRE rôles tiennent ce
répertoire : common_packages, client_journal, serveur_ops_site en 0750, et
serveur_artefacts en 0755. Le dernier déployé gagnait ; mes déploiements au site du matin
l'avaient refermé (06:16). Le piège était documenté dans serveur_artefacts — pas chez les
trois autres.
Correction : 0755 partout (le répertoire ne tient que des états de sondes, déjà lisibles) ;
appliqué à site-cache-01 : les quatre archives répondent 200, à la taille exacte, depuis une
VM de locataire.
La garde, test_repertoires_partages.py : un répertoire dont plusieurs rôles imposent le
mode n'en a qu'un — chemins Jinja RÉSOLUS depuis les défauts (celui de serveur_artefacts
s'écrit {{ … | dirname }}, une lecture littérale ne l'aurait pas vu). À sa première
exécution, elle a trouvé deux autres désaccords :
/etc/setops: 0700 (client_backup,serveur_backup) contre 0755 (client_sante,serveur_icinga). Il tient des secrets, tous ses lecteurs tournent en root : 0700./srv/resticsursite-backup-01:serveur_backupy est appliqué deux fois — en dépendance deserveur_backup_site(racine/srv/restic/site) et par son propre groupe (défaut/srv/restic, 0700 au compterestic). Joué seul, ce second passage refermait le parent des dépôts de tous les locataires (sshdStrictModes, mesuré le 2026-09-01). Dormant — un déploiement complet du site rejoueserveur_backup_siteaprès. L'inventaire du site donne désormais au groupe la racine de la dépendance, LUE dans sonmeta/main.yml. Le test admet ce cas : une dépendance qui surcharge le chemin n'est pas un désaccord.
2026-10-01 (57) — Aucune exception : la soumission n'est ouverte que là où elle existe
J'avais présenté site-mon-01:465/587 (« sans service ») comme une exception tolérable.
L'exploitant l'a refusée : « je ne comprends pas pourquoi on tolérerait cette exception » —
et a demandé si c'était le relais qui avait une lacune. Il n'en a pas : le relais du site fait
sortir le courriel des MACHINES par le 25 ; la soumission (465/587) est la porte des PERSONNES,
authentifiée par l'annuaire — que le site n'a pas, par conception. Le défaut était dans le
registre : ces deux flux, ajoutés le 2026-09-29, avaient été déclarés pour toute instance de
Postfix, sans suivre l'interrupteur qui active le service (serveur_postfix_submission_actif,
faux par défaut, levé par les seuls locataires). Ils le suivent désormais (seulement_si).
Site : site-mon-01 perd ses deux règles ; les 9 machines, sonde lancée directement après le
déploiement (simulé d'abord) : tous les flux déclarés ouverts, aucun « sans service »,
aucune entrée hors des règles. Locataires et frontière : inchangés (edge-mta-01 garde ses
465/587, la frontière ses redirections publiques).
Les trois niveaux de pare-feu n'ouvrent plus aucun port où rien n'écoute — sans exception.
2026-10-01 (56) — Le registre des flux sait dire « seulement si » ; la sonde au site
Le registre ne savait pas exprimer une condition. La sonde connectivite montrait, chaque
minute, des ports ouverts vers rien : la vigie (8080) et la console (8090) en SSO, liées à
127.0.0.1 derrière la passerelle — le code le savait (« la règle devient simplement sans
objet »). seulement_si: {variable, egal} ou {variable, non_vide: true}, évalué PAR
ÉCOSYSTÈME (variables d'hôte, group_vars, défauts du rôle) dans les trois générateurs —
nftables, Proxmox, frontière. Une expression Jinja indéterminable garde le statu quo ; une
variable absente partout compte comme vide pour non_vide.
| Flux | Condition | Effet |
|---|---|---|
| vigie 8080 | serveur_icingaweb2_nginx_bind == "" |
retiré chez les locataires (SSO) ; gardé au site |
| console 8090 | serveur_ops_gui_auth == locale |
idem |
| forge 3000 | serveur_forgejo_tls == false |
retiré au site (TLS propre sur 443) |
| AXFR 5300 | dns_public_site non vide |
retiré au site (pas d'instance publique) ; gardé chez les locataires |
Appliqué : locataires — nftables, et Proxmox (8 règles, 2 groupes de sécurité vides retirés),
connectivite 13/13 au vert des deux côtés, l'edge à 27/27 ; site — nftables, ses deux
« coupures » (site-dnspub-01 → site-dns-01:5300, site-edge-01 → site-forge-01:3000)
disparues avec leurs règles. Frontière, sur accord de l'exploitant : 8 règles
d'administration vers 8080/8090 et 2 alias orphelins retirés ; plan ensuite à 0 création,
0 retrait. Après coup : connectivite 13/13 au vert chez les deux locataires, et la vigie
comme la console répondent toujours par l'edge (302 vers la connexion SSO).
Un défaut latent trouvé au passage : la console du SITE tourne en locale depuis le
2026-09-15, mais son plan ne le disait pas — et le défaut du rôle est oidc. Le prochain
déploiement du site l'aurait liée à 127.0.0.1 derrière une passerelle inexistante. Le plan
le déclare désormais (serveur_ops_gui_auth: locale).
La sonde au site (simulée d'abord, --check --diff) : la simulation a montré que le chemin
de la liste pointait hors du dépôt (inventaire dynamique : même surcharge que le ruleset), que
la tâche aurait changé les droits de /etc/setops (la liste, qui n'a rien de secret, va dans
/usr/local/lib/setops/), et que l'activation du minuteur échouait en simulation. Corrigés,
puis déployés : les 9 machines rapportent ; reste une information — site-mon-01:465/587, le
relais du site n'offre pas la soumission.
2026-09-30 (55) — Première reconstruction sans un seul arrêt ; le pare-feu jugé par une sonde à la minute
Chezlepro, quatrième reconstruction, lancée par l'exploitant (reconstruire-locataire,
détachée) : 8 étapes sur 8, aucun arrêt, 60 min, les 7 jeux d'état restaurés. Première
reconstruction de bout en bout sans aucune intervention. Sur ces 60 min, 20 pour l'étape
parefeu — « vraiment trop de temps », dit l'exploitant, qui propose des sondes de
connectivité rapportant à Icinga chaque minute.
La sonde connectivite (déclarée par serveur_durci, déposée par nftables_baseline) :
chaque VM teste chaque minute les flux SORTANTS que le registre lui promet. La liste est
écrite par make flux à côté de chaque .nft, depuis les MÊMES règles résolues
(flux-genere/<hôte>.connectivite.json). La cause est mesurée, pas supposée (2026-09-30,
depuis web-frontal-01) : rejet Proxmox = EHOSTUNREACH → COUPÉ ; drop nftables =
délai → COUPÉ ; port autorisé où rien n'écoute = ECONNREFUSED → information (pas une
coupure). Elle signale aussi les connexions entrantes établies que les règles ne couvrent
pas. Contrôle négatif : une liste imposée (flux bloqué, port fermé, flux ouvert) → COUPÉ,
code 2, chaque cause nommée.
Le porteur à la minute : client_sante joue sondes-minute/ chaque minute (ttl 3 min),
hors du répertoire au quart d'heure — sinon le ttl de 90 min masquerait le silence. Même
script, mode minute. Icinga accepte une fraicheur: par sonde ; P64 reconnaît
sondes-minute/.
L'étape parefeu (eprouver_parefeu.py --flotte --sondes, utilisée par
reconstruire-locataire et parefeu-*-flotte) : refus si un flux est DÉJÀ coupé, activation
des 13 VM d'un coup, attente des rapports postérieurs, seul ce qui change compte. Mesuré :
Technolibre 135 s, Chezlepro 94 s (au lieu de ~20 min), 0 critique apparu. La matrice VM
par VM reste pour le diagnostic (--hote).
Deux défauts de la sonde vus au premier déploiement, corrigés : une machine qui se parle
par sa propre adresse (Loki sur obs-01) se déclarait « hors des règles » ; deux flux
déclarés sans service (infra-edge-01 → mon-01:8080, → ops-01:8090 : interfaces liées en
local derrière la passerelle SSO) étaient un avertissement permanent — ce sont désormais des
informations nommées. Reste : retirer ces deux déclarations obsolètes du registre ;
générer et déployer la sonde au SITE (ses listes ne sont pas encore générées — un
serveur_icinga du site redéployé l'attendrait en rouge).
2026-09-30 (54) — Chezlepro au bout ; Technolibre arrêtée par un verrou dpkg
Chezlepro (lancée par l'exploitant, reprise DEPUIS=parefeu après (53)) : pare-feu 13/13
sans flux perdu, les 7 jeux d'état restaurés depuis les instantanés de 15:59. Silence de
douze minutes pendant parefeu : la sortie du Python enfant restait dans son tampon (tuyau) —
les sous-commandes écrivent désormais sans tampon.
Technolibre (lancée par l'exploitant) : arrêt à monter, mon-01 en échec dans
common_packages — apt-get dist-upgrade refusé, verrou dpkg tenu par
unattended-upgrades, né avec la VM. La tâche portait pourtant lock_timeout: 300 : il ne
protège que la vérification du MODULE ; apt-get, appelé ensuite, n'attend pas — APT 3.0
ne donne d'attente qu'à la commande apt (Binary::apt::DPkg::Lock::Timeout "120"). Le
verrou a été repris entre les deux. Correction : DPkg::Lock::Timeout posé pour tout
appel d'APT (/etc/apt/apt.conf.d/10setops-verrou), en toute première tâche du rôle.
Rien à voir avec la restauration : un tirage au sort du premier démarrage, que la troisième
reconstruction de Chezlepro n'avait pas tiré.
Reprise DEPUIS=monter, second arrêt : collab-01 en échec — mais la tâche avait été
tuée SUR L'AC (Killed, rc=137). client_pki : Dériver l'empreinte du root CA était déléguée
à infra-pki-01 pour chacun des 13 hôtes, en parallèle : treize modules Python sur une
machine de 765 Mo et 1 vCPU. Le noyau (OOM) a tué le module de collab-01, et Alloy au
passage. L'empreinte est la même pour toute la flotte : run_once. Éprouvé depuis le runner
de Technolibre : 1 délégation au lieu de 13, client_pki sans échec, AC saine.
2026-09-30 (53) — Troisième reconstruction (Chezlepro, lancée par l'exploitant) : arrêtée au pare-feu
L'exploitant a lancé lui-même make reconstruire-locataire TENANT=OPS-Chezlepro CONFIRMER=true. sauvegarder → raser → creer → inseminer → armer → monter : sans
intervention. Arrêt à parefeu (code 4), deux fois : Icinga portait des critiques
sauvegarde puis restauration sur les sept détenteurs d'état.
Le défaut était dans (49). Le premier rapport à un Icinga neuf se déclenchait sur le
changement de icinga-ca.crt — or client_sante dépose le MÊME fichier, plus tôt dans le
déploiement : sur une flotte neuve, il était déjà là, « ok », et rien ne partait. L'épreuve
de (49) avait joué client_backup seul, fichier retiré : elle ne pouvait pas le voir.
sauvegarde est passé au vert à 17:01 par son minuteur de 4 h ; restauration aurait
attendu le dimanche. Correction : client_backup retient lui-même l'empreinte de l'Icinga
qui l'a entendu, dans un marqueur à lui, écrit APRÈS le rapport. Éprouvé sur le vrai cas
(les nœuds neufs de Chezlepro) : premier rapport sur les 7, Icinga à 0 critique ; second
passage sans aucun gestionnaire.
Et la procédure du pare-feu s'arrêtait sur des critiques qu'elle n'avait pas causés : elle
relève désormais les critiques AVANT l'activation, et seul ce qui apparaît après compte.
collab-01, activé deux fois pendant ces arrêts, n'a perdu aucun flux (16/16).
Reprise : make reconstruire-locataire TENANT=OPS-Chezlepro CONFIRMER=true DEPUIS=parefeu.
2026-09-30 (52) — make reconstruire-locataire : une commande, du site à la recette
Les reconstructions du jour passaient par cinq endroits — poste, runner du site, poste,
runner du locataire, poste — avec des commandes SSH tapées à la main. La séquence vit
désormais dans scripts/reconstruire_locataire.py, lancé depuis le POSTE (seul à tenir à la
fois la voûte du locataire et l'accès au runner du site) :
sauvegarder → raser → creer → inseminer (runner du site) → armer (poste ; pause,
sauf ARMER=oui) → monter (make monter-flotte sur le runner du locataire, suivi toutes
les 30 s) → parefeu (--flotte) → bilan (ce que chaque jeu d'état est devenu).
TENANT= désigne l'écosystème en toutes lettres ; le nom qu'exige raser en est DÉRIVÉ
(raser.nom_court, une seule dérivation), pas redemandé. La première version exigeait aussi
INSTANCE= — relevé par l'exploitant : « pourquoi cette cible a besoin de se faire dire
"chezlepro" deux fois ? ». Le garde-fou de raser n'a de sens que pour raser seul, qui rase
l'écosystème du lien instance/ ; ici, rien d'implicite n'est à confirmer. raser n'a pas
d'autre condition que sa confirmation. Arrêt à la première étape en échec, avec la commande de reprise
(DEPUIS=<étape>) ; journal complet dans <locataire>/logs/.
Le runner du site se met à jour avant sa première étape (quelle que soit la reprise) :
il tire le moteur, le plan du locataire et SON dépôt de site — déduit du lien underlay.yml,
pas nommé —, en avance rapide seulement, et refuse devant une modification locale (éprouvé :
une ligne ajoutée au README du site → REFUS, rc=1 ; fichier remis). Avant, seul raser
tirait, deux dépôts sur trois, et une reprise DEPUIS=creer ne tirait rien.
Éprouvé sur Technolibre de armer à bilan (les étapes non destructives) : 26 min,
0 échec — armement, flotte montée et recettée, pare-feu 13/13 sans flux perdu, bilan.
Un défaut de forme corrigé : chaque relevé de suivi recopiait tout le journal distant ; seul
le nouveau y va désormais. Reste l'épreuve entière, destruction comprise.
2026-09-30 (51) — Ce qui était fait à la main dans les reconstructions entre dans le code
Le bilan de l'exploitant : aucune des deux reconstructions du jour n'est allée au bout seule. Au-delà des défauts corrigés, trois gestes vivaient hors du dépôt :
- La séquence du runner du locataire était un script écrit dans
/tmpdu runner. Elle devientmake monter-flotte CONFIRMER=true:flux, AC et DNS (_amorcer-socle),deployer-tout,valider— étapes horodatées, arrêt à la première en échec.reconstruires'appuie désormais dessus (et gagnevalider, qu'il n'avait pas). - La réactivation du pare-feu Proxmox était une boucle tapée à la main.
eprouver_parefeu.py --flotteporte la procédure pour toutes les VM, une à une, le runner en dernier, arrêt au premier refus — la règle qui la rend sûre vit avec elle. Ciblesparefeu-verifier-flotte(mesure) etparefeu-activer-flotte(depuis le poste : lui seul tient à la fois les accès du locataire et le runner du site). - La sauvegarde fraîche avant de raser reste une ÉTAPE du runbook
flotte-refaire, pas une condition deraser: décision de l'exploitant, « une confirmation suffit ».
--flotte a trouvé un défaut dès sa première passe (lecture seule, Technolibre) : il
s'est arrêté sur infra-edge-01, un flux 10.37.0.17 → 443 « hors des règles ». C'était le
navigateur de l'exploitant, depuis la zone d'administration du site — légitime, et
effectivement admis par Proxmox. La matrice ne retenait des IPSet que les membres qui sont
des MACHINES ; les RÉSEAUX (t23-admin : 10.37.0.0/24…) étaient ignorés, et l'observateur
ne connaissait l'administration que sur le port 22. Les réseaux de chaque règle (exclusions
! comprises) comptent désormais sur tous les ports. Seconde passe : 13/13, runner en
dernier, aucun flux perdu. Ce faux refus aurait bloqué une reconstruction le jour où
l'exploitant a un onglet ouvert sur ses services — c'est-à-dire presque toujours.
2026-09-30 (50) — Le web frontal n'est plus un détenteur d'état
Le frontal RELAIE : ses vhosts, son WAF et sa page 404 se redéploient depuis le dépôt. Le
catalogue de sauvegarde lui gardait /srv/web, venu de l'époque où il servait du statique
— ses instantanés étaient vides, et valider le disait « À CONFIRMER » à chaque passage.
Retiré du catalogue et de la liste en clair : Icinga, le témoin de dépôt du site et P36 le
suivent du même geste (P36 : 82 OK, 0 échec). Le frontal ne restaure plus rien.
Ce que ça a révélé : client_backup, devant un nœud qui n'a plus rien à sauvegarder,
retirait la sauvegarde mais pas les VÉRIFICATIONS du dépôt et de la restauration — elles
auraient rapporté, toutes les quatre heures, à des services qu'Icinga ne déclare plus. Elles
partent désormais avec elle. L'intégration client_backup quitte ensuite le plan des
frontaux de Chezlepro et de Technolibre.
2026-09-30 (49) — Un Icinga neuf entend les nœuds tout de suite (signal corrigé en (53))
Le défaut (45, point 4) : un Icinga neuf marque sauvegarde et restauration
CRITIQUES tant qu'aucun rapport n'est arrivé — voulu : le silence doit alerter. Mais les
minuteurs ne rapportent que toutes les 4 h (dépôt) et le DIMANCHE (restauration) : après
chaque reconstruction, Icinga restait rouge jusqu'à une semaine, et on déclenchait les
contrôles à la main (Technolibre, puis Chezlepro, ce 2026-09-30).
Le signal retenu : le compte de rapport d'un nœud ne sait que DÉPOSER, il ne peut pas demander à Icinga s'il l'a déjà entendu. Mais chaque nœud recopie l'AC d'Icinga : un Icinga refait a une AC neuve, un nœud refait n'en a pas encore de copie. Dans les deux cas, cette copie change — et elle seule notifie « Premier rapport a Icinga » : déposer, vérifier le dépôt, vérifier la restauration, dans cet ordre. Une retouche de gabarit ne le déclenche pas : elle ne dit rien de ce qu'Icinga a reçu.
Éprouvé sur web-frontal-01 de Technolibre, copie de l'AC retirée (Icinga « neuf ») :
les trois unités tournent (04:39:37, :40, :42), rc=0 — donc acceptées par Icinga, le script
échouant sinon. Second passage : aucun gestionnaire, changed=0. Garde statique ajoutée à
test_restauration.py.
2026-09-30 (48) — Chezlepro rasée et reconstruite par les runners : l'état est revenu, à l'identique
La preuve visée : que (47) remette réellement l'état, sur une vraie reconstruction.
Déroulé : make sauvegarder-maintenant (8 instantanés à 03:06) ; empreintes témoins
relevées ; depuis le runner du site raser (13/13), flotte-creer, inseminer ; armement
depuis le poste (voûte, clé de voûte, identité SSH du runner reprise de la voûte) ; sur le
runner de Chezlepro : _amorcer-socle, deployer-tout, valider — 0 échec.
Chaque jeu a choisi l'instantané de 03:06, le dernier avant la naissance des machines (03:12), et l'a remis :
| Témoin | Avant | Après |
|---|---|---|
| racine de l'AC | F0:32:89:A0:BB:98:79:AC |
identique — la flotte s'est enrôlée auprès d'elle |
sysadmin |
empreinte 4406f2fa…, changé le 2026-09-16, pas de changement forcé |
identique |
| annuaire | 12 entrées | 12 |
| Nextcloud | 217 fichiers, instanceid ocytcfy7ss5a |
identique |
| clé DKIM | 760208b7… |
identique — l'enregistrement publié reste valable |
| Keycloak | 100 tables, 2 utilisateurs | identique |
| Nextcloud (base) | 139 tables | 139 |
| boîtes | root |
root (+ testmail, écrit par valider) |
Un défaut trouvé, dans le code d'hier soir : la mesure « l'annuaire est-il vierge ? »
filtrait par grep -v — sur un annuaire VRAIMENT vierge, plus aucune ligne, grep sort en
1, pipefail fait échouer la tâche. Technolibre, déjà peuplée, ne pouvait pas l'exercer.
Comptage par awk, puis reprise à deployer-tout : l'AC et les bases, déjà remises, ont été
reconnues « actées » et n'ont pas été rejouées.
Après : les 8 dépôts passent la garde ; contrôles de dépôt et de restauration rapportés
à Icinga ; recette — fédération LDAP authentifiée, passerelle vers Keycloak, vigie,
Nextcloud, Dovecot ; essai.chezlepro.ca sur .61 → 200, page absente → la 404 du PSPBT.
Pare-feu Proxmox réactivé VM par VM : 13/13, flux identiques avant et après, Icinga à 0
critique.
Ce que ça prouve, et ce que ça ne prouve pas. Une reconstruction complète d'un écosystème, conduite par les runners, rend l'état qu'on lui a confié — la limite qu'énonçait honnêtement (46). Le SITE, lui, n'a toujours jamais été reconstruit ; et chaque reconstruction a encore trouvé un défaut (ici un seul, dans le code neuf).
2026-09-30 (47) — La reconstruction remet l'état : chaque rôle propriétaire restaure le sien
Le défaut (46) : une reconstruction repartait d'un état neuf. Les instantanés se restauraient pour PROUVER qu'ils s'ouvrent, jamais pour être remis en service.
Le principe retenu : pas d'étape à part dans la chaîne — chaque rôle qui POSSÈDE un
état le remet au moment où il le créerait neuf, et l'ordre vient des couches. La chaîne
des runners (_amorcer-socle → deployer-tout) et make reconstruire n'ont pas changé.
| Rôle | Point de remise |
|---|---|
serveur_step_ca |
avant step ca init ; refus si la voûte n'ouvre plus les clés restaurées |
serveur_postgresql |
bases juste créées, chacune rejouée en UNE transaction |
serveur_openldap |
fin de rôle (le LDIF exige ppolicy), puis comptes de service réalignés sur la voûte |
serveur_nextcloud |
fin de rôle : base + fichiers + config ensemble, occ upgrade |
serveur_rspamd |
avant la génération DKIM — sinon chaque reconstruction changeait la clé publiée |
serveur_dovecot, serveur_forgejo, serveur_web_* |
racine encore vide |
Nextcloud se remet en fin de rôle, pas avant l'installation : le code de user_oidc et
richdocuments n'est pas sauvegardé ; restaurée avant, la base les dirait installés et
occ app:install refuserait. serveur_postgresql exclut donc sa base (et celle
d'Icinga, dont l'historique est lié à un environnement non sauvegardé).
Quelle incarnation : le dernier instantané pris AVANT la naissance de la machine — la
date de sa clé d'hôte SSH (machine-id, lui, vient du gabarit : tous les nœuds portent le
2026-09-01). Un état en place n'est jamais écrasé d'office ; ce qui est remplacé est mis de
côté (/var/backups/setops-avant-restauration/) ; chaque jeu laisse un marqueur.
La sauvegarde refuse de déposer tant qu'un état d'avant attend (code 3). Sans cette
garde, restic forget --keep-daily 7 aurait gardé l'instantané de l'état NEUF et chassé
celui d'avant dès qu'ils tombaient le même jour — ce matin, ils étaient à cheval sur minuit
par hasard.
Nouveaux gestes : make sauvegarder-maintenant (juste avant raser, inscrit au
runbook flotte-refaire), make restauration-etat, make restauration-renoncer. Outil de
nœud utilisable sans Ansible : setops-restaurer (avec des répétitions qui ne touchent à
rien : annuaire --essai, base --vers, fichiers --vers).
Garde : scripts/tests/test_restauration.py — chaque détenteur d'état du catalogue doit
avoir son point de remise (une liste qui suit une autre prend du retard), et l'outil, rendu
depuis son gabarit avec des doublures de restic/psql, doit choisir l'incarnation d'avant,
refuser d'écraser, bloquer puis libérer la sauvegarde, et couper la section d'une base avant
le DROP DATABASE postgres;. Le test a trouvé un défaut avant tout déploiement : une
apostrophe dans ${c:-…}, que bash lit comme un guillemet ouvrant.
Déployé sur Technolibre (runner, deployer-tout) : 13 hôtes, 0 échec. Chaque jeu vivant
reconnu en_place (rien écrasé) ; les deux serveurs web, vides, ont pris le chemin
a_restaurer depuis leur instantané d'avant. make sauvegarder-maintenant : les 8 dépôts
passent la garde. Répétitions sur les vraies données, sans rien toucher : annuaire à blanc
(12 entrées rejouables depuis 71fb5481), courriel remis dans un répertoire jetable.
La répétition a trouvé un défaut : psql tourne en postgres, qui ne lit pas le
répertoire jetable de root (0700) — en reconstruction, la base serait restée vide et le
déploiement aurait échoué. La section passe désormais par l'entrée standard ; le test
l'exige.
Pas encore éprouvé par une reconstruction réelle : l'épreuve est la prochaine.
2026-09-30 (46) — Technolibre : l'état d'avant la reconstruction remis en place, sans reconstruire
Le constat : après (45), le mot de passe de sysadmin était revenu à celui de l'amorçage.
La reconstruction n'avait rien restauré — aucune étape de Set-OPS ne réinjecte les
instantanés. « Restauration vérifiée » (make valider) veut dire : restaurée dans un
répertoire temporaire et lue, pas remise en service. Chezlepro est dans le même cas (racine d'AC
et annuaire nés le 2026-09-13).
Perdu, mesuré contre l'instantané de 23:50 (restic-tech) : sysadmin (changé le
2026-09-16), racine et intermédiaire de l'AC (nouvelle empreinte), bases Keycloak et Nextcloud,
fichiers Nextcloud (197 → 108), une boîte aux lettres.
Remis en place, sur les machines existantes (l'état neuf mis de côté avant chaque geste,
dans /root/avant-restauration-2026-09-30 de chaque nœud) :
- annuaire (
idm-01) :slapadd -uà blanc, puis base remplacée ; empreinte du mot de passe = celle d'avant, sans changement forcé.amorcage_accesne touche qu'un compte absent : il ne le réécrasera pas ; - Keycloak (
data-sql-01) : sectionkeycloakdupg_dumpallrejouée dans une base vide, Keycloak arrêté ; sondeidentiteverte (fédération LDAP authentifiée) ; - Nextcloud (
collab-01+data-sql-01) : base,data/etconfig/ensemble (les secrets d'instance voyagent avec les données), cache Redis purgé,occ upgrade(richdocuments) ; - courriel (
infra-mail-01) :/var/vmail.
Le piège du runbook s'est présenté : la section nextcloud se terminait par
DROP DATABASE postgres;. La découpe coupe désormais aussi sur DROP DATABASE, et la garde
« hors-périmètre » l'avait vu.
Non remis : l'AC (aucune confiance extérieure ne s'y rattache — le poste ne fait confiance
à aucune des deux racines ; la remettre imposerait de réenrôler les 13 machines) ; icingadb
(historique de supervision) ; forgejo (base orpheline, plus de forge au plan).
Contrôle : make valider depuis le runner de Technolibre, 0 échec ; sondes identité,
passerelle, vigie, collaboration, boîtes, tableaux vertes.
Reste à coder : l'étape de réinjection dans la reconstruction elle-même, dans l'ordre AC → confiance de la flotte → annuaire → bases → fichiers et courriel. Chezlepro n'est pas reconstruite tant qu'elle n'existe pas.
2026-09-30 (45) — Technolibre reconstruit depuis le runner du site, puis par son propre runner
La preuve visée : la limite 2 du jalon de reconstruction autonome — un locataire refait PAR LES RUNNERS, pas depuis le poste. Le runner du site rase, recrée les VM et amorce le runner du locataire (sans aucun de ses secrets) ; le poste l'arme (sa voûte et sa clé, le seul geste humain) ; le runner du locataire déploie et valide sa flotte.
Déroulé : sauvegarde fraîche des 8 machines qui portent un état (vérifiée) ; raser (13 VM,
toutes 123…) et flotte-creer depuis le runner du site ; inseminer (socle + moteur) ;
armement depuis le poste ; sur le runner de Technolibre : amorçage du socle (AC, DNS),
deployer-tout, make valider — 0 échec, valider vert, restaurations vérifiées. Puis le
pare-feu Proxmox VM par VM (procédure éprouvée : 13/13, flux identiques avant et après, Icinga
à 0 critique), et la recette.
Quatre défauts trouvés — c'est le rôle de l'épreuve — tous corrigés dans le code :
- L'identité SSH du runner changeait à chaque reconstruction : il fabriquait sa paire à sa naissance, la flotte naissait avec la clé DÉCLARÉE — refus sur les 13 machines. Chez Chezlepro, la clé déclarée était déjà celle d'un runner disparu. Désormais la clé privée vit dans la voûte du locataire, l'armement la remet, et refuse si sa clé publique n'est pas déclarée au plan (garde éprouvée en défaut). Pour cette passe, les 12 VM encore vierges ont été recréées avec la bonne clé.
make fluxsur le runner d'un locataire amputait les pare-feux : sansunderlay.ymlni plan du site, les paires qui en dépendent ne se résolvaient à rien — ni transfert de zone depuis le DNS public du site, ni collecte de la fabric, ni insémination. Le générateur s'abstient désormais (les règles committées font foi). Vu sur le runner avant tout dommage durable ; les fichiers committés ont été remis pendant le déploiement.- Keycloak : un client absent faisait échouer les mappers de groupes et de rôles — sous
set -euo pipefail,grepsans correspondance sortait avant la branche « client absent ». Jamais vu tant qu'un clientforgejosubsistait d'une époque où le locataire avait une forge. - (conception — corrigé en (49)) Un Icinga neuf marque aussitôt
sauvegardeetrestaurationcritiques (« jamais rapporté ») ; la restauration n'étant vérifiée que le dimanche, elle resterait rouge jusque-là après chaque reconstruction. Ici : sauvegarde, vérification du dépôt et contrôle de restauration déclenchés à la main sur la flotte reconstruite.
Et une garde qui a fait son travail : le runner a refusé de déployer un génome en retard d'un commit sur la forge du site — publié pendant qu'il travaillait.
Recette : Proxmox 0 écart ; frontière mesurée conforme ; depuis l'Internet sur .60 : 404
du frontal, SMTP (bannière de son edge-mta-01), 465/587/993 en TLS 1.3 ; zone
technolibre.ca republiée et reprise par le DNS public du site (nouveau numéro de série).
2026-09-29 (44) — La page du PSPBT devient la page 404 du frontal de Chezlepro
Précision de l'exploitant sur (43) : c'est le frontal qui doit servir cette page, comme page 404 par défaut — pas le dorsal comme page d'un site.
- Le rôle
serveur_web_frontalaccepte une page 404 du locataire (serveur_web_frontal_page_404, un chemin dans le dépôt du locataire) : le serveur par défaut la sert, statut 404, à tout nom qu'il ne publie pas ; sans page déclarée, il ferme toujours sans réponse (444). La page estinternal(pas de lecture directe de/404.html: 404 aussi), avec sa propre CSP (styles et scripts en ligne, rien d'extérieur). Le nom inconnu n'obtient toujours aucun service. - Chezlepro, puis Technolibre à la demande de l'exploitant :
contenus/frontal-404.html(le fichiersite web.htmlde l'exploitant), déclaré dans leurs variables du frontal. Technolibre éprouvé de même sur69.70.26.60(racine, chemin quelconque, nom inconnu → 404). essai.chezlepro.carevient à sa page de test, CSP par défaut (la CSP propre au site, posée en (43), est retirée).
Éprouvé depuis l'Internet : http://69.70.26.61/, un chemin quelconque, /404.html et un nom
inconnu → 404 avec la page du PSPBT ; essai.chezlepro.ca → 200, la page de test.
2026-09-29 (43) — essai.chezlepro.ca sert la page du « Parti de la Sainte-Paix et du Bon Temps »
À la demande de l'exploitant : le fichier site web.html (le seul du dossier parti politique du bonheur national) devient public/index.html du dépôt essais/essai-web sur la forge du
site (mise à jour par l'API, depuis le runner du site), puis le dorsal de Chezlepro le tire.
Une CSP propre à ce site. La page porte ses styles et ses scripts dans le HTML (<style>,
<script>, onclick) : la CSP par défaut du dorsal ('self') les aurait bloqués — page sans
mise en forme, boutons muets. Le site essai déclare donc style-src et script-src avec
'unsafe-inline', pour lui seul ; rien d'extérieur n'est autorisé. Vérifié depuis l'Internet :
200, l'en-tête CSP servi est celui du site.
2026-09-29 (42) — Le WAF des frontaux passe en blocage
Avant de basculer, le journal d'audit relu : chez Chezlepro, la seule transaction signalée
était l'injection de test ; chez Technolibre, les requêtes du prototype (dont ?q=bonjour
marqué 920350 parce qu'il visait une adresse IP brute). Aucune requête légitime signalée — sur
un trafic encore mince, ce qui est dit dans la déclaration.
Par locataire (group_vars/serveur_web_frontal.yml : serveur_web_frontal_waf_mode: "On"),
pas dans le défaut du rôle : un nouveau locataire commence en détection et observe.
Éprouvé depuis l'Internet, sur essai.chezlepro.ca : page et recherche normales → 200 ;
injection SQL → 403 ; script injecté (XSS) → 403 ; remontée de répertoires → 400 (nginx la
refuse avant même le WAF). La sonde frontal dit désormais le mode en clair (« blocage »,
« détection seule »).
Un faux positif se corrige par une exclusion ciblée du CRS
(REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf), pas en revenant en détection.
2026-09-29 (41) — Le chemin public éprouvé de bout en bout : Internet → .61 → WAF → dorsal → forge du site
Le contenu vient de la forge du site. Organisation essais (distincte du génome), dépôt
public essai-web et sa page public/index.html, créés par l'API de la forge depuis le runner
du site ; lecture anonyme vérifiée.
Le dorsal fait confiance à la forge du site, et à elle seule. Comme le runner : la racine
de l'AC du site n'entre pas dans le magasin système, git config http.<url>.sslCAInfo la
limite à l'adresse de la forge. L'adresse et la racine sont LUES chez le runner du locataire
(serveur_ops_forge_amont, serveur_ops_forge_amont_ac) — pas de seconde copie. Premier
passage : le répertoire de la racine manquait (créé désormais). Posé aussi chez Technolibre.
Déclaré au plan de Chezlepro : application essai_web (dorsal, port 80,
expose: essai.chezlepro.ca) et le site essai du dorsal ; inventaire régénéré (make instancier, écart relu : serveur_web_dorsal_hostname et sans_exposition du frontal — une
variable que seul l'edge lit ; le certificat interne du frontal passe par client_pki, filtré).
Éprouvé depuis l'Internet (le wifi du poste sort par un autre accès) :
- le DNS public du site répond
essai.chezlepro.ca → 69.70.26.61— la zone du locataire, notifiée et transférée ; - la page est servie :
.61→ frontal → dorsal ; - une injection SQL est journalisée par le WAF (942100, 949110) et passe (200) : mode détection, comme prévu ;
- un nom inconnu sur
.61est refusé.
Sondes : sites-servis « 1 servi », frontal « 1 exposition publique servie, WAF CRS en
DetectionOnly ».
Reste : Let's Encrypt (le 443 attend son certificat) ; passer le WAF en blocage après lecture de son journal.
Plancher de Chezlepro (fait ensuite, après simulation) : une seule ligne ajoutée sur les 13
machines, essai.chezlepro.ca → 10.17.21.31 (le frontal) ; toutes la résolvent, plancher
reste conforme (il ne compte que le domaine interne). Depuis une machine de la flotte, le 80
du frontal ne répond pas, et c'est voulu : il n'est ouvert qu'à l'Internet et à l'administration.
2026-09-29 (40) — Le domaine public désigne le frontal ; les noms publics pointent vers l'adresse du locataire ; le dorsal refuse les inconnus
Point 3. domaines.yml des deux locataires : le domaine public (chezlepro.ca,
technolibre.ca) désigne serveur_web_frontal — l'edge garde les noms internes. Aucun nom
public n'est encore exposé : rien de visible ne change avant le premier site.
Les noms exposés d'une zone publique pointent vers l'adresse DU LOCATAIRE. Ils
pointaient vers ip_publique_site (.62), l'adresse du site — donc vers son DNS public, pas
vers le frontal du locataire. serveur_powerdns_ip_expositions = ip_publique (repli
ip_publique_site) ; les serveurs de noms (dns1) restent à l'adresse du site.
Le DNS public, confirmé tel que l'exploitant le décrit : chaque locataire tient sa zone
sur son PowerDNS (instance publique, signée) ; le DNS public du site (site-dnspub-01, joint
par .62:53) la reçoit par NOTIFY + transfert TSIG ; le registraire désignera ce serveur.
Éprouvé à l'instant : le retrait de git.chezlepro.ca (entrée 35) déployé chez Chezlepro, le
site a pris le nouveau numéro de série et ne sert plus ce nom.
client_pki ne demande à l'AC interne que le domaine interne. step-ca n'a aucune
politique de noms : il aurait signé les noms publics du frontal, qui relèvent de Let's Encrypt.
Filtre sur le champ domaine de l'exposition. Un premier essai par expression régulière
vidait la liste de l'edge (l'échappement de \. ne passe pas de la même façon dans un bloc
YAML replié) : vu au rendu, avant tout déploiement. Vérifié ensuite : l'edge garde ses six
noms, chaque service du site le sien, le frontal le seul sien.
Le dorsal refuse les noms inconnus (444, comme l'edge et le frontal) ; le site Debian par
défaut est retiré. Posé chez les deux locataires : sondes sites-servis et apps-servies
saines (elles interrogent par nom et par port d'application, pas par le défaut).
2026-09-29 (39) — Le web frontal devient reverse proxy + WAF ; le dorsal porte les sites
L'architecture, précisée par l'exploitant : le web dorsal est le serveur des sites ; le web frontal est le reverse proxy et le WAF devant les services web publics du locataire. L'edge reste la porte des services internes, pour l'administration.
Le frontal (serveur_web_frontal, réécrit) :
- ses vhosts dérivent des expositions dont le domaine public le désigne
(
domaines.yml,edge: serveur_web_frontal) — la même dérivation que l'edge, filtrée sur son groupe ; il ne sert plus rien lui-même ; - WAF : ModSecurity v3 (
libnginx-mod-http-modsecurity) + OWASP CRS 3.3.7 (modsecurity-crs), paquets Debian. Éprouvé avant d'écrire le rôle, sur le vrai frontal : une injection SQL journalisée (942100, 949110) en détection, refusée (403) en blocage, une requête saine servie dans les deux cas. Un premier essai « prouvait » l'inverse :return 200s'exécute avant la phase où ModSecurity inspecte — l'essai, pas le WAF, était faux. L'inclusion livrée par Debian ne charge que le moteur : le rôle compose moteur, mode, CRS et exclusions.DetectionOnlyd'abord ;Onaprès lecture du journal ; - serveur par défaut qui refuse tout nom inconnu (444) ; 80 seulement en attendant
Let's Encrypt ; sonde
frontal: nginx et configuration, module WAF chargé et CRS présent, chaque exposition publique servie à travers lui.
Le dorsal (serveur_web_dorsal) reprend les sites statiques (clone, vhost, CSP) et la
sonde sites-servis ; il est relayé par l'edge (noms internes) ET le frontal (noms
publics). Flux : frontal → dorsal 80 ; le frontal ne reçoit plus rien de l'edge.
Posé chez les deux locataires : rôles, pare-feux des deux machines, Proxmox (2 IPSets,
4 groupes), Icinga. frontal et sites-servis au vert (aucune exposition publique ni site
déclarés encore) ; frontal → dorsal : 200 ; frontière : rien à changer. Au premier
déploiement, nginx -t a refusé la configuration AVANT tout rechargement (server_tokens
déjà actif dans le nginx.conf de Debian 13) — nginx a continué de servir l'ancienne.
Reste : le domaine public de chaque locataire désigne encore l'edge (domaines.yml) ;
le dorsal sert la page d'accueil de Debian à un nom inconnu (un serveur par défaut qui
refuse, comme à l'edge et au frontal) ; Let's Encrypt.
2026-09-29 (38) — Frontière appliquée : chaque locataire entre et sort par sa propre adresse
Appliqué (plan relu avant) : 47 créés, 2 retirés ; convergé, 0 écart, 336 règles ; pool et WAN d'accord (4/4).
Prouvé par la table d'états de la frontière, pas seulement par un connect().
- Sortie :
10.17.20.11(Chezlepro) sort en69.70.26.61,10.23.20.11(Technolibre) en69.70.26.60. - Entrée, depuis l'Internet (le wifi du poste sort par un autre accès, vu comme
69.70.26.50) :.61:25 → 10.17.16.21:25et.60:25 → 10.23.16.21:25, chacun avec la bannière de SONedge-mta-01; 465, 587 et 993 en TLS 1.3 sur les deux adresses. - Le 80 n'aboutit pas : rien n'écoute sur le web frontal, qui n'a encore aucun site déclaré
(
serveur_web_frontal_sites: []). La chaîne (redirection, filtre, Proxmox, hôte) est posée ; à revérifier au premier site publié, avec son TLS.
Une erreur de lecture, de ma part. Un premier test concluait « 25 muet » : il lisait la bannière trop tôt, dans une construction shell qui avalait la réponse. Repris avec une vraie connexion chronométrée : établie en 0,00 s, bannière immédiate. Le fournisseur de l'accès wifi n'y était pour rien (un MX tiers répondait, ce que j'avais vérifié).
2026-09-29 (37) — Les services publics complets : web frontal et courriel chiffré ; externe ouvre vraiment chez l'hôte
Ce qui manquait au plan (36), relevé par l'exploitant : le web public vers le web frontal, et les ports du courriel chiffré.
- Web frontal (
serveur_web_frontal) : 80 et 443externe— la porte publique du locataire. Le 443 attend son TLS (Let's Encrypt, plus tard) : la redirection est prête. - Postfix : la soumission 587 (STARTTLS obligatoire) devient publique ; le 465 (TLS
direct,
submissions, RFC 8314) est ajouté au rôle, mêmes restrictions (authentifié ou refusé). Déployé chez les deux locataires : 465 et 587 répondent en TLS 1.3 depuis la flotte. Dovecot : IMAPS 993 seulement (pas de POP3, donc pas de 995).
Un défaut plus ancien, trouvé en lisant les pare-feux générés. Un flux externe combiné
à une source nommée — le 25 de Postfix [externe, client_smtp], la soumission
[flotte, externe] — rendait une règle nftables limitée à CETTE source : l'Internet, que la
frontière redirige et que Proxmox laisse passer, aurait été refusé par l'hôte lui-même.
resoudre_flux.py ouvre désormais ces ports à tous chez les locataires (sauf le SSH, dont
l'externe est celui de la gestion). Au site, rien ne s'élargit : un premier essai y
retirait l'ICMP de découverte de MTU et le 25 du relais ; il a été remplacé avant toute pose.
Posé : Postfix (465), pare-feu des machines (edge-mta-01, web-frontal-01 des deux
locataires ; 465 limité à la flotte sur site-mon-01), pare-feu Proxmox (4 groupes, convergé).
Au passage, la copie de /etc/hosts dans le chroot de Postfix était restée à l'ancien plancher :
un plancher rejoué ne la rafraîchit pas — seul un passage du rôle Postfix le fait.
Frontière — en attente de l'accord de l'exploitant : 47 à créer, 2 à retirer. NAT sortant
.61 / .60 ; par locataire, six redirections depuis son adresse (80, 443 → web frontal ;
25, 465, 587 → edge-mta-01 ; 993 → infra-mail-01), leurs règles WAN, et les chemins
gestion/VPN vers 465, 587, 80, 443 ; le 465 du relais du site pour les zones du site.
2026-09-29 (36) — Le NAT des locataires par leur adresse publique (devis ; application en attente)
La règle : les services publics d'un locataire passent par SON adresse publique, et sont redirigés par la frontière ; sa sortie est traduite avec cette même adresse.
Ce que faisait la frontière. Sortie : les deux locataires et le site sortaient tous par
l'adresse du WAN (wanip, .62) — vus de l'Internet, une seule machine. Entrée : seule la
redirection du site existait (DNS public 53) ; un flux externe de locataire (Postfix 25,
Dovecot 993) produisait une règle de filtrage vers une adresse privée, sans redirection
devant — une publication qui avait l'air faite et ne recevait rien.
Le devis (devis_opnsense.py) :
- NAT sortant de chaque locataire vers l'adresse que le pool du site lui attribue ; le site
garde
wanip; sans attribution, repli surwanip(et une note). - Tout flux entrant
externed'un locataire — hors SSH de gestion, hors ICMP — devient une redirection DEPUIS son adresse publique vers la machine unique qui porte le rôle (plusieurs machines : aucune redirection, une note). La règle WAN existante voit l'adresse traduite. - Gardes : pas deux redirections sur la même adresse/protocole/port public ; l'adresse d'une redirection de locataire est celle que le pool lui attribue.
appliquer_opnsense.py: l'adresse publique entre dans l'identité d'une redirection (les deux locataires publient le 25) — saufwanip, pour que celle du site ne soit pas recréée ; le plan l'affiche.
Plan (non appliqué) : NAT Chezlepro → .61, Technolibre → .60 (2 remplacés) ;
redirections .61/.60 : 25 → edge-mta-01, 993 → infra-mail-01. Le pare-feu Proxmox reçoit
déjà ces deux ports depuis +t<i>-internet (entrée 33).
2026-09-29 (35) — Le pool d'adresses publiques du site ; chaque locataire reçoit la sienne
La règle : l'hébergeur détient un pool d'adresses publiques, et c'est le site qui en attribue une à chaque locataire — celle de son web frontal et de son courriel.
L'état du boîtier, relu avant d'écrire. Le WAN de la frontière porte 69.70.26.62
(le site) et quatre alias IP /32 posés par l'exploitant : .58, .59, .60, .61.
L'outillage ne gère pas les adresses virtuelles : un frontiere-appliquer ne les retire pas.
Le pool, déclaré au site (SITE-Chezlepro/opnsense.yml, opnsense_ips_publiques) :
.61 → OPS-Chezlepro, .60 → OPS-Technolibre, .59 et .58 libres.
- Le contrat du site (
site_intrants.py) rendip_publique: l'adresse attribuée au locataire monté. Les deux locataires la déclarent (10-intrants.yml) ;make site-intrants-verifierest conforme chez les deux, et dit l'écart si l'un recopie une autre adresse (éprouvé :.59déclarée chez Chezlepro → NON CONFORME, les deux valeurs données). - Le pool est validé au même endroit : adresse invalide ou privée, adresse du site
(
opnsense_wan_ip), locataire que le site n'héberge pas, locataire servi deux fois — refusés (test_pool_ips_publiques.py). make frontiere-plancompare, en lecture seule, le pool aux alias du WAN : une adresse attribuée absente du WAN, ou une adresse du WAN hors du pool, est signalée. Aujourd'hui : 4 au pool, 4 sur le WAN.git.chezlepro.ca → 69.70.26.59retiré de la zone publique de Chezlepro : une copie de l'ancienne production, et.59n'y sert plus.
ip_publique_site reste l'adresse du SITE (serveurs de noms publics). La suite : le web
frontal de chaque locataire, joint par son adresse (redirection à la frontière, TLS Let's
Encrypt).
2026-09-29 (34) — La frontière appliquée : l'edge hors de l'Internet, l'administration traduite chez les tenants
Un premier plan refusé. Retirer externe de l'edge faisait aussi disparaître, à la
frontière, les chemins gestion → edge et VPN → edge des locataires : chez un locataire, le
devis ne rendait le chemin du poste qu'au travers des flux externe (option poste). Les
flux admin, appliqués par Proxmox et nftables, n'y étaient pas traduits — ils l'étaient au
site. Le devis les traduit désormais chez les locataires aussi, par les mêmes portes que le
SSH de gestion (gestion, WAN d'administration s'il est déclaré, VPN) ; un port non
numérique (derive) est sauté avec une note plutôt que rendu en règle invalide.
Appliqué (plan relu avant) : 4 règles retirées — « WAN, n'importe qui → edge 80/443 »
des deux locataires ; 18 ajouts — gestion/VPN → edge du site en 80, et gestion/VPN → Grafana
3000, Icinga Web 8080, console 8090 des locataires (déclarés admin par leurs rôles, déjà
permis par Proxmox et nftables ; Icinga Web et la console n'écoutent qu'en local chez les
locataires). Convergé : 0 écart, 293 règles. Depuis le poste : edges des deux locataires et du
site en 443 (302) et 80 (301), Dovecot 993 en TLS 1.3. La route du poste vers le site
(10.37.0.0/16 via 10.37.0.1), ajoutée par l'exploitant, ferme la régression notée en (33).
La mesure de la frontière accusait l'edge à tort. frontiere-mesurer rendait
infra-edge-01:80 « rien n'est livré : l'hôte refuse ». C'était le serveur par défaut de
nginx, qui ferme sans rien dire un nom inconnu (return 444) — la sonde envoie Host: sonde. Mesuré : l'edge ferme en 0,00 s (FIN, lecture vide) ; le contrôle et un port bloqué
n'aboutissent pas (délai). sonde_tcp.py compte désormais une fermeture propre après la
requête comme une réponse de la destination ; le silence reste AMBIGU, et le contrôle garde
l'instrument (test_sonde_tcp.py : parle, lit-puis-ferme, muet). Mesure conforme chez les
deux locataires. Au passage : la mesure écrit toujours dans instance/ — mesurer un autre
locataire que celui monté écrase le relevé du premier.
Architecture précisée par l'exploitant, pour la suite : l'edge expose les services internes à l'administration ; les services publics d'un locataire le seront par son web frontal, avec des certificats Let's Encrypt ; le site attribue à chaque locataire une IP publique de son pool.
2026-09-29 (33) — L'edge fermé à l'Internet ; le pare-feu Proxmox reçoit ce que la frontière laisse entrer
La règle, mise au point par l'exploitant : les URL *.internal des edges ne s'ouvrent
qu'à la zone d'administration de leur écosystème ; ce qui sera publié sur l'Internet le sera
par le web frontal, pas par l'edge.
L'edge déclarait pourtant 80/443 externe. Conséquences, dérivées du même registre :
la frontière portait « WAN, n'importe qui → edge 80/443 » chez les deux locataires, et le
pare-feu de la machine (nftables) de chaque edge — site compris — acceptait 80 et 443 de
toute source. Seul le pare-feu Proxmox, qui sautait tout externe, fermait la porte.
Désormais serveur_nginx ne déclare plus d'externe : 443 depuis flotte et admin, 80
(redirection) depuis admin. Vérifié avant : le poste joint les edges par le réseau de
gestion (10.37.0.17, règle admin), et les journaux d'accès des trois edges ne montrent
aucun client hors flotte et administration.
Le pare-feu Proxmox et les flux externe. Il les sautait tous (« la frontière s'en
charge ») ; depuis que les VM rejettent par défaut, un service publié aurait été refusé par
sa propre VM — et le poste ne joignait pas Dovecot 993, que la frontière lui ouvre. Même
partage que la frontière : un flux entrant externe est reçu depuis +t<i>-internet
(0.0.0.0/1, 128.0.0.0/1, les trois blocs RFC 1918 exclus en nomatch — jamais une source
vide, et un IPSet refuse /0), et depuis +t<i>-admin quand le poste en est client
(poste, vrai par défaut ; faux pour le 25 de Postfix). Le SSH externe reste réservé à
l'administration. Rendu : Postfix 25 (Internet), Dovecot 993 (Internet, admin), ICMP
« fragmentation nécessaire » (découverte de MTU). appliquer_proxmox_fw pose et relit les
exclusions. test_proxmox_fw_externe.py garde les invariants : edge jamais ouvert à
l'Internet, tout externe reçu, aucune source vide, exclusions en nomatch.
Le web frontal ne déclare encore que le 80 depuis l'edge : le rendre public (son propre TLS, sa redirection à la frontière) est l'étape suivante, pas celle-ci.
Posé. Pare-feu Proxmox (depuis le runner du site) : 2 IPSets internet, 8 groupes mis à
jour, convergé (0 écart). Premier passage : Proxmox a refusé frag-needed — nom nftables ;
l'API attend fragmentation-needed (ICMP_PROXMOX dans le devis). Les autres règles des
groupes srv-debian étaient restées en place (SSH 13/13 chez chaque locataire, ping de
supervision). Nftables des trois edges, après simulation : les deux « toute source »
retirées, 80 réservé à l'administration. Depuis le poste : Dovecot 993 répond (TLS 1.3) chez
les deux locataires — il était rejeté par la VM — ; vigie/auth en 443 (302) et 80 (301
vers HTTPS) par leurs noms.
Une régression, de ma part : le poste ne joint plus l'edge du SITE. Il a une route par
son lien de gestion (10.37.0.17) vers 10.17.0.0/16 et 10.23.0.0/16, mais aucune vers les
zones du site (10.37.x) : il y va par le wifi (192.168.13.254) et arrive à l'edge avec une
adresse hors administration. Seule la règle « toute source » le laissait passer ; je ne l'ai
vu qu'après l'avoir retirée (le contrôle d'avant, sur les journaux d'accès, ne montrait que
des adresses internes — la traduction en route cachait le chemin). Remède sur le poste,
comme pour les tenants : 10.37.0.0/16 via 10.37.0.1 sur la connexion filaire.
2026-09-29 (32) — L'edge expose aux personnes ; entre machines du site, on va au service
La règle, énoncée par l'exploitant : l'edge sert à exposer des interfaces web ; les
connexions entre les VM du site ne passent pas par lui. Les anciens planchers du site la
respectaient (forge, observatoire, vigie → les services eux-mêmes) ; c'est la
dérivation qui avait tort, en envoyant à l'edge tout nom d'un domaine qui en a un. Ma
recommandation de (31) — rejouer ces planchers par l'edge — tombe.
Déclarée par domaine, pas imposée partout. Chez les locataires, les machines passent
encore par l'edge pour auth.<domaine> : Keycloak n'écoute qu'en clair derrière lui, et le
canal serveur-à-serveur de l'OIDC (Grafana, oauth2-proxy) en dépend. Nouveau champ de
domaines.yml : resolution_interne: service | edge (absent = edge). Le site déclare
service.
expositions_des_applicationsrendresout_vers; une exposition qui se sert elle-même (sans port, ou sans edge) mène toujours au service.- Le plancher (
hosts.j2) et la zone (zone.db.j2, A et empreinte_plancher) suivent la même règle ;test_empreinte_plancher.pycouvre un nom résolu vers son service. client_pkiajoute au certificat du service le nom qui mène à lui : sans cela, une reconstruction desite-forge-01aurait rendu un certificat sansforge.genese.internal, que le runner du site appelle en direct.- Validation (
valider_domaines) et schéma : valeur horsedge/servicerefusée. Pas dedefautau schéma : le GUI l'aurait écrit à chaque sauvegarde (test_rendu_guil'a vu).
Posé au site, après simulation. Zone : console, forge, observatoire, vigie →
leurs services (10.37.31.11, .33.11, .36.11, .36.11). Planchers des 9 machines : console
et site-dnspub-01 ajoutés ; site-dnspub-01 ramené aux services. Cache d'Unbound vidé.
Le runner du site joint la forge par son nom, en direct (ls-remote : tête publiée) ; les
deux locataires ouvrent leur session SFTP de sauvegarde. Sonde plancher : 35 machines sur
35 conformes (13 + 13 + 9). Chez les locataires, rien ne change (simulation : 0 enregistrement,
13 planchers sur 13 intacts). Les runners des locataires joignaient déjà la forge du site en
direct, par son adresse.
À savoir. Au prochain passage de client_pki au site, site-ops-01 recevra
console.genese.internal dans son certificat (le nom mène désormais à lui) : réémission
normale.
2026-09-29 (31) — Sonde plancher ; elle a trouvé une régression que j'avais causée au site, corrigée à la source
La sonde plancher (rôle client_sante, sur toute machine qui rapporte). Le plancher
/etc/hosts et la zone PowerDNS suivent le même plan, chacun quand son rôle est rejoué
(voir (30)). La zone publie désormais _plancher.<domaine> TXT "n=… sha256=…" : le nombre et
l'empreinte des paires « nom adresse » que le plancher DOIT porter — flotte active ET
planifiée, expositions du domaine. La sonde calcule la même chose sur /etc/hosts, en
interrogeant l'autoritatif directement, sans transfert de zone (AXFR reste fermé). En cas
d'écart elle nomme ce qu'elle peut : noms publiés absents (nombre), adresses différentes,
noms inconnus de l'autoritatif. Manquant ou réadressé : critique ; en trop seulement :
avertissement. seulement_si: hosts_statiques_actif. test_empreinte_plancher.py rend les
deux gabarits sur un même inventaire et exige la même empreinte.
Chez les deux locataires : 26 machines sur 26 conformes (19 noms chacun). Mises en défaut
sur mon-01 (Technolibre), sur des copies du plancher : vide, sans console → critique,
« 1 nom publié absent » ; forge-01 en trop → avertissement nommé ; mon-01 réadressé →
critique, les deux adresses données.
Ce qu'elle a trouvé au site — et ce que j'y avais cassé. En (30), j'ai redéployé la zone
du site sans simulation préalable, en attribuant la réécriture au seul site-dnspub-01. La
zone faisait en réalité pointer sauvegarde, pki et dns.genese.internal vers l'edge du
site (10.37.37.11), qui ne les sert pas. Les locataires sauvegardent vers
sauvegarde.genese.internal, résolu par le DNS du site : de 05 h 23 à 11 h 51, ce nom menait
à l'edge, où aucun SFTP n'attend. Aucune sauvegarde n'est tombée dans la fenêtre (elles
tournent vers 02 h 30), mais celles de la nuit auraient toutes échoué.
La cause est dans la dérivation. expositions_des_applications attribuait tout FQDN
d'un domaine à l'edge de ce domaine. Or un edge ne publie que ce qui a un port : nginx n'écrit
un vhost que pour une exposition qui en porte un. Depuis que genese.internal a un edge
(14 septembre), les trois expositions sans port lui étaient attribuées ; les anciens
planchers du site, antérieurs, les envoyaient encore aux bonnes machines, et la zone chargée
avant (30) aussi. Corrigé : une exposition sans port se sert elle-même
(test_expositions_sans_port.py) ; la preuve de prouver.py qui refuse qu'une exposition
retombe sur son propre groupe dans un écosystème à edge excepte désormais celles sans port.
Zone du site redéployée après simulation (trois A, vers 10.37.35.11, .32.11, .34.11), cache
d'Unbound vidé pour genese.internal. Depuis les deux locataires, sauvegarde rend
10.37.35.11 et une session SFTP s'ouvre dans le bon dépôt. site-dnspub-01, dont le plancher
avait été généré avec la dérivation fautive, rejoué seul : conforme.
Correction de (30) : la zone du site n'y avait pas seulement reçu site-dnspub-01 —
elle avait aussi envoyé ces trois noms vers l'edge.
Ce qui reste, et qui est une décision. Les 8 autres machines du site ont un plancher
antérieur à l'edge : forge, observatoire et vigie y mènent directement aux services,
et console, site-dnspub-01 y manquent (le DNS du site les résout). La sonde les montre
critiques. Les rejouer ferait passer le runner du site par l'edge pour joindre la forge en
HTTPS : la lecture y a été éprouvée (ls-remote identique), mais l'edge limite une requête à
16 Mo et Set-OPS-public en pèse 171 — un envoi de rattrapage n'y passerait pas. Et
client_pki retiendra, à son prochain passage au site, pki.genese.internal sur
site-pki-01 plutôt que sur l'edge — le nom qu'il sert vraiment.
2026-09-29 (30) — console ne se résolvait pas : un plancher périmé et une zone jamais rechargée
Constat. Depuis l'edge de chaque locataire, console.<domaine> ne se résolvait pas,
alors que l'edge la sert. Les autres expositions se résolvaient.
Première cause : le plancher /etc/hosts n'avait pas été rejoué. Il dérive du plan
(inventaire + expose), mais n'est posé que par le socle (serveur_debian). La console a
été ajoutée au plan après le dernier passage du socle ; seul ops-01, redéployé depuis,
la portait. Rejoué seul (hosts_statiques, sans common_packages et sa mise à jour
complète) sur les 26 machines des deux locataires, après simulation : console ajoutée,
forge-01 et forge retirées (l'ancienne forge des locataires, sortie du plan).
Seconde cause, plus grave : PowerDNS servait une zone antérieure à son fichier. Le
fichier de zone, réécrit le 16 septembre, portait console ; PowerDNS servait la version
chargée à son démarrage (13 septembre chez Chezlepro, 14 chez Technolibre). Le rechargement
ne passait que par un handler, et un handler en attente est abandonné quand une tâche échoue
plus loin dans le même passage ; aux passages suivants, le fichier ne change plus et rien ne
le redemande. La sonde zones comptait les zones servies, pas leur fraîcheur : verte.
- Rôle
serveur_powerdns: à chaque passage, chaque zone (interne et publique) dont le fichier est plus récent que son chargement (bind-domain-status) est rechargée (bind-reload-now). Ce qui est à jour n'est pas touché. - Sonde
zones: critique si une zone servie est plus ancienne que son fichier au-delà deserveur_powerdns_sonde_tolerance(900 s), en nommant les zones. Mise en défaut par ce paramètre : critique. - Posé sur les trois (Chezlepro, Technolibre, site) : deux zones rechargées chez chaque
locataire ; au site, la zone et une zone inverse réécrites (le DNS public
site-dnspub-01n'y était pas encore).zonesau vert partout, « chacune conforme à son fichier ».consolese résout depuis les deux edges, par le plancher et par PowerDNS.
Une erreur de ma part, rattrapée par la garde de syntaxe. ${#tableau[@]} dans une sonde
ouvre un commentaire Jinja. test_sondes_syntaxe.py l'a vu ; mais j'avais enchaîné le
déploiement de Technolibre sans attendre le test, et la pose de la sonde y a échoué (la
réconciliation, elle, était passée). Corrigé et redéployé ; la configuration publique
réécrite pendant ce passage raté ne différait que par des commentaires.
Ce qui n'est pas traité ici. Les machines des locataires interrogent le résolveur du
site, qui répond NXDOMAIN pour leurs noms internes : leur résolution repose entièrement sur
le plancher. C'est l'état voulu tant que le basculement vers leur résolveur
(client_resolveur_apply) n'est pas confirmé. Et rien ne signale encore qu'un plancher est
en retard sur le plan : il suit le plan au déploiement, pas en continu.
2026-09-29 (29) — passerelle fait le trajet d'un visiteur jusqu'à la page de connexion ; fin de la revue des sondes
Cinquième et dernière des sondes « répond » de la revue (25).
Avant. check_http sur /ping : vert tant que le processus oauth2-proxy vit, même
quand plus personne ne peut entrer — client supprimé ou renommé dans Keycloak, URL de
retour retirée du client, IdP injoignable depuis la machine de la passerelle.
Maintenant. Après /ping, la sonde fait le trajet d'un visiteur : /oauth2/start doit
renvoyer vers l'émetteur configuré (lu dans la configuration déployée), pour CE client ; puis
l'IdP, joint depuis cette machine avec le même magasin de confiance que la passerelle, doit
servir sa page de connexion (200). C'est aussi le chemin de l'échange du code au retour.
Un refus de Keycloak est nommé (Invalid parameter: redirect_uri, Client not found…) ;
un IdP injoignable rend le code d'erreur de curl.
Ce qu'elle ne teste pas : le secret du client. Le vérifier demande un échange de jeton raté, que Keycloak écrit dans le journal de sécurité du realm — toutes les 15 minutes, par passerelle, ce serait noyer le journal où l'on cherche les vraies tentatives. Le cas sain n'y écrit rien.
Éprouvé. Au vert sur les quatre passerelles (vigie et console, chez les deux
locataires), 35 ms ; le site n'en a pas. Mises en défaut chez Chezlepro : URL de retour non
autorisée (serveur_oauth2_proxy_sonde_retour) → 400 nommé ; configuration illisible ; port
fermé ; magasin de confiance inutilisable → « n'atteint pas l'IdP (curl 77) ». Toutes
critiques. À savoir en lisant le journal du realm chezlepro : ces épreuves y ont laissé
cinq LOGIN_ERROR (redirect_uri vers exemple.invalid), les 28 et 29 septembre.
Au passage. Le port de la sonde était écrit en dur (4180) : il dérive de l'écoute. Un
échec de /ping sortait sur deux lignes : une seule. Et l'archive oauth2-proxy repartait du
contrôleur à chaque déploiement, pour la même raison que Keycloak en (26) — le repère était
dans /tmp ; c'est désormais le binaire installé. Plus aucun rôle ne prend /tmp pour
repère. Limite qui demeure, dans les deux rôles : un changement de VERSION ne réinstalle pas
(l'extraction s'arrête sur creates) ; à traiter le jour d'une montée de version.
Bilan de la revue. Les cinq sondes qui disaient « répond » disent désormais « sert » :
filtrage (GTUBE analysé), edge (chaque exposition servie), identite (Keycloak
authentifié auprès de l'annuaire), vigie (base, annuaire ou comptes, Redis), passerelle
(trajet jusqu'à la connexion).
2026-09-28 (28) — vigie vérifie ce que la vigie lit, pas seulement qu'elle répond
Quatrième des cinq sondes « répond » de la revue (25).
Avant. check_http sur la boucle locale : un 302 vers le formulaire de connexion
suffisait. Une vigie dont la base est injoignable (mot de passe tourné d'un seul côté,
pg_hba, certificat), dont l'annuaire refuse la liaison, ou dont le Redis est tombé, sert
très bien ce formulaire — puis une erreur, des tableaux vides, ou un compte connecté sans
aucun droit parce que ses groupes ne se lisent plus.
Maintenant. Après check_http, la sonde lit la configuration DÉPLOYÉE de la vigie
(resources.ini, authentication.ini, groups.ini, modules/icingadb/) et éprouve chaque
ressource qu'elle utilise, avec SES identifiants et le même pilote PHP : la base du moteur
(compte des hôtes, zéro = critique), l'annuaire (liaison + lecture de la racine) ou la base
des comptes, et Redis (PING). Un tenant (annuaire) et le site (base locale) passent par
le même code. Les secrets restent dans les fichiers (0640), rien n'est affiché.
Éprouvé. Au vert sur les trois : Chezlepro et Technolibre (13 hôtes, soit ceux du plan ;
annuaire ; Redis), site (23 hôtes, base des comptes et groupes, Redis) ; 51 ms. Mises en
défaut sur Technolibre, sur une copie de la configuration : table absente
(serveur_icingaweb2_sonde_table), Redis sur un port fermé, liaison à l'annuaire refusée,
mot de passe de la base faux — les quatre critiques, chacune nommant sa cause. Au site,
codes HTTP attendus changés : critique, « ne sert plus ses pages ».
Reste de la revue : passerelle (oauth2-proxy atteint-il Keycloak ?).
2026-09-28 (27) — La sonde d'Icinga Web 2 s'appelle vigie, plus console
Le vocabulaire des plans dit vigie pour Icinga Web 2 (vigie.<domaine>) et console
pour la console d'exploitation Set-OPS (serveur_ops, console.<domaine>, sonde
console-ops). La sonde d'Icinga Web 2 s'appelait pourtant console : dans Icinga,
mon-01!console parlait de la vigie, et les entrées (25) et (26) ci-dessous en ont hérité
(« console : Icinga Web lit-il sa base ? »). Renommée vigie : déclaration, gabarit
(sonde-vigie.sh.j2), tâche, et le catalogue des services. console-ops garde son nom :
reprendre tout de suite le nom libéré aurait donné deux sens à « console » dans l'historique.
Posé sur les trois supervisions (Chezlepro, Technolibre, site) dans l'ordre : serveur_icinga
(service et filtre du compte de dépôt, dérivés des déclarations), serveur_icingaweb2 (la
sonde), client_sante (qui a retiré l'orphelin console.sh sur les trois). vigie est au
vert partout ; le service console n'existe plus. Son historique Icinga repart à zéro sous
le nouveau nom.
2026-09-28 (26) — identite vérifie que Keycloak atteint l'annuaire ; l'archive Keycloak cesse de voyager pour rien
Troisième des cinq sondes « répond » de la revue (25).
identite (Keycloak). S'arrêtait à la découverte OIDC du realm. Or les comptes vivent dans
LDAP : un lien rompu vers l'annuaire (certificat, mot de passe de liaison tourné d'un seul côté,
slapd tombé) laisse la découverte parfaite et refuse chaque connexion. La sonde demande
désormais à Keycloak de s'authentifier auprès de l'annuaire avec SA PROPRE configuration
(testLDAPConnection, testAuthentication, componentId de la fédération enregistrée, secret
masqué : Keycloak substitue le secret stocké — la sonde ne manipule jamais le secret LDAP). Le
jeton admin est pris avec les identifiants de /etc/keycloak/keycloak.env (0640, lus en root,
jamais affichés ni passés en argument). Éprouvé avant d'écrire : 204 sur la vraie fédération,
400 AuthenticationFailure avec un faux bindDn. Chez les deux locataires : fédération
openldap authentifiée, reçue par Icinga (0,12 s). Mise en défaut par paramètre
(serveur_keycloak_sonde_federation) : critique, « la fédération n'existe pas » ; fichier
d'identifiants illisible : avertissement, « fédération non vérifiée ». Sans fédération
(serveur_keycloak_ldap_federation: false), la sonde s'en tient à la découverte.
Au passage : l'archive Keycloak repartait à chaque déploiement. « Déjà posée ? » regardait
/tmp/keycloak-<version>.tar.gz, que le système vide : l'archive repartait du contrôleur à
chaque passage (changed sur les deux locataires). Le repère est désormais le marqueur
.kc-built-<version> : cette version est en place, rien à apporter. Redéployé : changed=0
sur les deux.
Reste de la revue : console (Icinga Web lit-il sa base ?), passerelle (oauth2-proxy
atteint-il Keycloak ?).
2026-09-28 (25) — Deux sondes qui disaient « répond » apprennent à dire « sert » ; l'edge refuse les noms inconnus
Revue de toutes les sondes, à la recherche de celles qui resteraient vertes pendant que le
service ne rend plus rien — le cas de tableaux. La plupart vont déjà au fond (vraie requête
SQL, recherche LDAP, résolution réelle, cibles collectées…). Cinq ne le font pas :
filtrage, edge, identite, console, passerelle. Les deux premières sont faites.
filtrage (rspamd). S'arrêtait à /ping. Soumet désormais GTUBE, le message de test
que tout filtre doit rejeter : rien n'est envoyé ni livré, rspamc lit le verdict. Chez les
deux locataires : rejeté, 15/15. Mise en défaut par paramètre : critique, « n'analyse plus ».
edge (nginx). Vérifiait la configuration et les ports. Interroge désormais chaque
exposition EN LOCAL (son nom, son SNI, vers 127.0.0.1) : un 5xx ou un silence la fait passer
critique, en la nommant. Même filtre que les vhosts (hôte, adresse, port) : au site, dns,
pki et sauvegarde n'ont pas de vhost et ne sont pas attendus.
Ce que l'épreuve de edge a révélé : un nom inconnu recevait Keycloak. Sans serveur par
défaut sur 443, nginx confiait tout nom inconnu au premier vhost — absente.chezlepro.internal
obtenait un 302 vers /admin/. Une porte qui ne devrait pas exister, et une sonde aveugle :
une exposition dont le vhost aurait disparu restait « servie »… par Keycloak. Et le site par
défaut de Debian restait actif sur 80 (serveur_nginx_desactiver_defaut: false, sans raison
écrite). Désormais 000-defaut.conf : 444 sur 80, ssl_reject_handshake sur 443 ; le site
Debian est retiré. Posé sur les trois edges : expositions toujours servies (6, 6, 4), noms
inconnus et IP nue refusés, et la sonde voit une exposition fictive (critique).
Une erreur de ma part, en direct, et sa garde. sonde-edge.sh.j2 redéfinit la syntaxe des
commentaires Jinja ({=# … #=}) ; j'y ai écrit un commentaire ordinaire, sorti tel quel dans le
script : la sonde a rendu une erreur de syntaxe sur les trois edges (les edges servaient). Corrigé
aussitôt. test_sondes_syntaxe.py (dans make test) rend chaque gabarit de sonde — en
respectant l'en-tête #jinja2: — et exige que bash -n l'accepte : 44 gabarits valides, et
l'erreur recréée est refusée.
Relevé en passant, non réglé : console.chezlepro.internal ne se résout pas DEPUIS l'edge
(ni dans son /etc/hosts, ni au DNS) ; le poste le résout par le sien. L'edge le sert bien.
2026-09-28 (24) — La sonde « tableaux » vérifie aussi les sources de données
Elle était verte pendant que les tableaux des locataires n'affichaient rien : elle
demandait si Grafana répond et si sa base tient — pas s'il peut montrer quelque chose.
Elle interroge désormais la santé de CHAQUE source de données (/api/datasources/uid/…/health)
et passe CRITIQUE en nommant celle qui est en défaut. Le mot de passe d'administration est
lu là où il est, dans le drop-in systemd de Grafana (0600 root), pas recopié ; illisible, la
sonde le dit (avertissement « sources non vérifiées ») au lieu de se taire.
Éprouvée sur obs-01 de Technolibre : saine ; puis une source Loki temporaire recréant le
défaut du jour (http://localhost:3100 face à un Loki en HTTPS) → CRITIQUE, source nommée ;
source retirée → saine. Déployée sur les trois Grafana : tableaux au vert, « sources
saines (Loki, Prometheus) ».
2026-09-28 (23) — Grafana des locataires : aucune donnée, parce que l'URL de Loki ne suivait pas son TLS
Signalé par l'exploitant : une erreur de plugin et aucun panneau rempli sur les Grafana
d'obs-01, chez les deux locataires.
Cause. Les locataires activent serveur_loki_tls_actif : Loki sert en HTTPS. La source
Loki de Grafana était écrite en dur, http://localhost:3100 : Loki coupait la connexion
(« connection reset by peer »). Les tableaux construisent leur variable host depuis Loki
— 21 appels en échec sur label/host/values —, elle restait vide, et AUCUN panneau
n'affichait rien, ceux de Prometheus compris. Le même défaut que la sonde de Loki, corrigé
pour elle seule le 2026-09-10 : un paramètre qui ne suit pas l'interrupteur dont il dépend.
Correctif. serveur_grafana_loki_url se DÉRIVE de serveur_loki_tls_actif de l'hôte
Loki : en TLS, https://<fqdn>:3100 — le nom qui est dans le certificat, localhost n'y est
pas ; l'autorité interne est dans le magasin de confiance du système, Grafana vérifie. Sans
TLS (le site), rien ne change. Vérifié par l'API de Grafana chez les deux locataires : Loki
et Prometheus sains, 13 hôtes rendus pour host, plus aucune erreur Loki au journal.
2026-09-28 (22) — Pairs WireGuard de Technolibre appliqués ; un renommage n'est plus une création
Appliqué (vpn_admin.py appliquer --portee OPS-Technolibre, à la demande de l'exploitant) :
technolibre-mathieu-portable devient technolibre-responsable-portable. fabric au vert :
les quatre plans de la fabric sont conformes.
Ce que l'application a révélé. Le « nouveau » pair portait la MÊME clé publique que l'ancien : le même appareil, renommé. Le script créait avant de retirer ; OPNsense a refusé la création (« Public keys should be unique ») — mais le retrait, lui, était passé. La configuration n'avait plus AUCUN pair pour ce tunnel ; seul le fait que l'échec empêche le rechargement a gardé l'ancien en service. Rejoué aussitôt : le nouveau pair est créé, relu en service.
Corrigé : rapprocher reconnaît un renommage à la clé (à créer ET à retirer, même clé)
et le fait en MODIFICATION sur place (set_client) — l'accès n'est jamais coupé, la
frontière ne voit jamais deux fois la même clé. Un vrai remplacement (autre clé) reste une
création suivie d'un retrait. Éprouvé sur un état simulé, dans les deux cas.
2026-09-28 (21) — La sonde « genome-a-jour » : le filet sous make publier
make publier tient la forge du site à jour dans le geste courant. Reste le jour où l'on
publie autrement — un git push fait à la main, un runner injoignable au moment de publier.
La sonde genome-a-jour (runner du site) compare la tête de chaque dépôt de la forge du site
à celle d'eregion, où chaque commit du poste arrive en premier — comme REPÈRE seulement :
eregion reste hors du chemin du génome.
Seulement ce qu'eregion laisse lire sans identifiant (aujourd'hui : le moteur, celui qui avait quatre commits de retard le 2026-08-26). Les dépôts privés sont NOMMÉS comme non comparés : donner au site un jeton sur une forge héritée l'y lierait.
Un écart neuf n'est pas un retard : make publier pousse eregion une minute avant la
forge du site. La sonde retient depuis quand chaque écart dure et ne parle qu'au-delà d'une
heure. Éprouvée en vrai : commit poussé sur eregion SEULEMENT → « écart récent, toléré » ;
écart vieilli de deux heures → avertissement « FORGE DU SITE EN RETARD… make publier » ;
make publier → à jour, écart oublié. Reçue par l'Icinga du site.
2026-09-28 (20) — make publier : eregion ET la forge du site, en un geste
Le défaut. La forge du site fait autorité (D-81), mais les commits naissent sur le poste
et n'y arrivent que par make genome-pousser — un geste à part, que rien ne force ni ne
signale. Un git push vers eregion la laisse en retard. Le 2026-08-26 : quatre commits,
dont le correctif du pare-feu Proxmox. Aujourd'hui même, make genome-etat la trouvait en
retard d'un commit.
make publier (scripts/publier.py), pour chaque dépôt que le runner du site porte —
la liste de genome-pousser (serveur_ops_depots), lue et non recopiée : refuse si
eregion porte des commits absents du poste (une fusion acceptée serait écrasée sur la forge
du site), signale ce qui n'est pas committé, pousse sur eregion ; puis genome-pousser,
puis genome-etat, et dit si la forge du site porte bien la tête du poste. Un refus arrête
tout AVANT la forge du site : mieux vaut la laisser en retard que la nourrir d'un dépôt
incomplet. Ce commit est le premier publié ainsi.
2026-09-28 (19) — Chaque semaine, chaque sauvegarde est rouverte pour de vrai
Le trou. sauvegarde disait qu'un instantané existe, récent et non vide ; rien ne disait
qu'il se rouvre. Aujourd'hui, c'est à la main qu'on a restauré les dumps PostgreSQL et LDAP
— et découvert que deux locataires sur deux ne déposaient rien hors de leur flotte.
Le contrôle (client_backup, minuteur du dimanche 04:00 ± 1 h), joué par le nœud lui-même,
seul détenteur de la clé : restic check --read-data-subset=10% (l'intégrité, en relisant
des données, pas seulement l'index), puis restauration RÉELLE du dernier instantané avec
--verify (contenu recomparé), puis comptage des fichiers face à l'instantané. Répertoire
temporaire détruit quoi qu'il arrive ; nice et E/S au repos. Rapporté au service
restauration (fraîcheur 8 jours, liée au ttl par test_fraicheur_icinga.py).
Le piège évité : le compte d'API des nœuds n'écrit que sur des services NOMMÉS ; sans
l'ajout de restauration au filtre, chaque rapport aurait reçu un 404 « No objects found »
sur un objet présent — le défaut du 2026-09-02.
Premier passage, déclenché au déploiement : 19 nœuds, 3 à 6 s chacun. Tout se restaure,
contenu recomparé — Nextcloud (≈ 140 Mo), la base, le courriel, l'annuaire, la PKI chez
chaque locataire, la forge du génome (1723 fichiers) au site. Avertissement sur les
instantanés VIDES (courriel et web sans données encore), comme sauvegarde le dit déjà.
2026-09-28 (18) — La sonde « fabric » : la fabric dit-elle encore ce que le plan dit ?
Le trou. Le pare-feu Proxmox, le SDN, la frontière et les accès WireGuard ont chacun un
plan de lecture (*-plan) ; personne ne les jouait sans raison. Aujourd'hui même : 26 VM
sans pare-feu, sans signal.
Sur le runner du site (serveur_ops_site) : un minuteur horaire joue les quatre plans
(4 s au total), additionne ce qui reste à créer, retirer, mettre à jour ou corriger, et
consigne le constat dans /var/lib/setops/conformite-fabric.json. La sonde fabric le lit
— une sonde n'a que quelques secondes : écart → avertissement (avec le premier détail),
plan illisible → inconnu, constat de plus de 3 h → CRITIQUE (un minuteur mort ne doit pas
laisser la dernière bonne nouvelle au vert). Éprouvée sur quatre constats fabriqués, puis
en vrai : site-ops-01!fabric en avertissement sur un écart RÉEL — les pairs WireGuard de
Technolibre, en attente de décision.
Ce qu'elle a attrapé avant même d'exister. En mesurant la durée des plans,
frontiere-plan voulait 4 créations : des alias et des règles « SSH depuis le tunnel du
locataire, sur le WAN ». Cause : le tunnel ajouté à admin_de plus tôt dans la journée ; la
frontière range chaque réseau d'administration par interface et l'avait classé WAN — une
règle qui ne correspondrait jamais. admin_de redevient l'intrant (la frontière) ;
admin_avec_tunnel sert l'est-ouest (Proxmox, épreuve). Les deux plans : 0 écart.
2026-09-28 (17) — La sonde « disque » : espace et inodes, sur les 35 machines
Le trou. Seuls le cache apt et les dépôts de sauvegarde avaient une sonde d'espace. Rien ne regardait le disque de la base, des boîtes, de Nextcloud, ni de Loki qui grossit chaque jour ; Prometheus collecte l'occupation, mais aucune règle n'en fait une alerte. Un disque plein aurait arrêté la base ou le courriel sans prévenir.
La sonde. Pour chaque système de fichiers réel (pas tmpfs, overlay…), l'espace ET
les inodes — une file de petits fichiers épuise les inodes avec la moitié de l'espace
libre, et l'erreur parle alors d'un disque « plein » qu'un df dit à moitié vide. Seuils
80 / 90 % (client_sante_disque_avert / _crit) ; relevé du jour : au plus 22 % d'espace
et 7 % d'inodes. Une ligne, le pire système de fichiers, les métriques de tous.
Où elle vit, et pourquoi. Déclarée ET posée par client_sante, dont le groupe est
exactement « tout hôte qui rapporte ». Pas par common_packages, qui porte correctifs :
il fait un upgrade: full à chaque passage, et poser un script aurait mis à jour toute la
flotte. Une première version la déclarait dans serveur_debian : P64 l'a refusée — il
suit le playbook du groupe déclarant pour trouver le dépôt, et client_sante n'y est pas.
La garde avait raison ; la déclaration a suivi le rôle qui dépose.
Déployé (Icinga d'abord, pour que les premiers rapports aient un destinataire) :
35 services disque, tous alimentés et OK — 13, 13 et 9. make verifier : CONFORME.
2026-09-28 (16) — La procédure d'activation du pare-feu Proxmox entre au dépôt
scripts/eprouver_parefeu.py, par make proxmox-fw-eprouver TENANT= HOTE= (aucune écriture)
et make proxmox-fw-activer-vm TENANT= HOTE= CONFIRMER=true. Ce qui a servi à filtrer les 26
VM, sous une forme qui resservira : une VM reconstruite perd ses options de pare-feu.
Pour UNE VM : matrice tirée du devis (chaque membre de chaque source), connexions entrantes établies confrontées aux règles, matrice avant, activation par le runner du site (seul à joindre l'API du cluster), matrice après, sondes relancées, critiques d'Icinga. Refus d'activer si un flux réel échappe aux règles ou si un flux échoue déjà.
Plus d'exclusions à la main : un échec vers un port que rien n'écoute hors de
127.0.0.1 est classé « sans objet » (nginx lié en local derrière la passerelle SSO,
frontal sans site) — c'est ce que la procédure manuelle tranchait au cas par cas.
Le VMID se dérive du devis, et du bloc du BON locataire : au premier essai, le script
visait ops-01 de Chezlepro pour une demande sur Technolibre — les noms d'hôtes se
répètent d'un locataire à l'autre.
Éprouvé : --plan sur ops-01 (8090 reconnu sans objet), --activer de bout en bout sur
web-frontal-01 (runner : « Rien à faire », après = avant, Icinga : 0 critique).
Limite écrite : un flux UDP est testé en TCP sur le même port.
2026-09-28 (15) — Les 26 VM des locataires filtrées par Proxmox ; proxmox-fw-plan : « Rien à faire »
Chezlepro, 13/13, dans le même ordre que Technolibre et par la même procédure, VM par
VM : matrice complète tirée du devis, flux entrants établis confrontés aux règles, matrice
avant, activation, matrice après, sondes relancées sur les 13 nœuds, Icinga. Toutes :
avant = après, 0 critique. Au-delà : DNS UDP réel à travers infra-dns-01 ; les 13
rapports sante reçus après l'activation de mon-01 ; et de bout en bout,
courriel-plan (Postfix → LDAP → Dovecot → IMAP) et identite-plan : CONFORME.
Une régression introduite, puis fermée. nftables admet le SSH depuis l'intrant
nftables_admin_ssh PLUS le tunnel du locataire (10.x.29.0/24) ; le devis Proxmox ne lisait
que l'intrant. Filtré, Technolibre refusait donc le SSH venu de son propre tunnel (de 13:27
à ~15:40, SSH seulement, aucun service). Une seule dérivation désormais,
inventory_rules.tunnel_admin_de, pour les deux ; IPSets admin à trois membres ;
make flux identique octet pour octet.
Deux devis cassés depuis le 2026-09-13, trouvés en vérifiant. courriel-plan et
identite-plan appelaient resoudre_annuaire sans nommer de compte de service : ils
échouaient sur une assertion, avant toute connexion. Ils empruntent maintenant le compte du
service qu'ils vérifient (postfix, keycloak).
Reste à faire : une règle Proxmox pour les flux venus d'Internet (externe) avant la
première exposition d'un locataire.
2026-09-28 (14) — Technolibre entièrement filtré par Proxmox, VM par VM
Les quatre dernières, une à la fois : infra-dns-01, obs-01, ops-01, mon-01. Pour
chacune : matrice complète tirée du devis, flux entrants établis observés et confrontés aux
règles, matrice avant, activation, matrice après, sondes relancées sur les 13 nœuds, Icinga.
Toutes : avant = après, 0 critique. Technolibre : 13/13 VM filtrées.
Ce que la procédure a arrêté, à raison, et pourquoi c'était sans défaut :
obs-01se parle à lui-même par sa propre adresse (Prometheus, Alloy→Loki) : ce trafic passe parlo, jamais par le pont de Proxmox — l'analyse l'écarte désormais ;infra-edge-01 → ops-01:8090et→ mon-01:8080échouaient DÉJÀ avant : en modeoidc, nginx n'écoute qu'en local (le défaut le documente — une passerelle SSO qu'on ne peut pas contourner) ; c'estoauth2-proxy(4180) qui est publié, et la matrice le teste. La règle du port nginx reste pour le modelocale, sans objet ici.
Vérifié au-delà de la matrice : résolution DNS réelle en UDP à travers infra-dns-01
(noms internes et externes, depuis deux nœuds) ; pour mon-01, les 13 rapports sante
reçus APRÈS l'activation — le flux passif vers 5665 traverse le filtre.
2026-09-28 (13) — Technolibre, deuxième lot : l'edge, l'identité, la base, la PKI
Activées : infra-edge-01, idm-01, data-sql-01, infra-pki-01.
Deux vérifications, parce qu'une seule ne suffit pas. La matrice tirée du devis ne teste
que ce qui est DÉCLARÉ ; un flux réellement utilisé mais non déclaré la passerait, puis
serait coupé. Donc, en plus : relever sur chaque VM les connexions entrantes ÉTABLIES (trois
relevés), et vérifier que chaque couple source→port est couvert par une règle. 11 flux
observés, 0 hors des règles (SSH d'administration et forme IPv4-mappée d'obs-01 comprises).
Matrice étendue à TOUS les membres de chaque source : 100/100 avant, 100/100 après.
Sondes de santé relancées sur les 13 nœuds : 133 services revérifiés, aucun critique.
2026-09-28 (12) — Technolibre, premier lot : quatre VM de plus filtrées, preuve comprise
Activées : web-dorsal-01, collab-01, edge-mta-01, infra-mail-01. Méthode : une
matrice tirée du DEVIS lui-même — pour chaque règle des groupes de ces VM, un hôte réel de
la source autorisée → VM:port —, jouée AVANT (19/19) puis APRÈS (19/19, aucun changement).
Icinga : aucun critique.
Le filtrage mord, et c'est Proxmox qui mord. Un contrôle négatif doit distinguer les
deux couches : nftables, dans la VM, accepte TOUT l'ICMP ; Proxmox ne l'accepte que de
srv-icinga. Depuis data-sql-01 : pas de réponse des VM filtrées (collab-01,
web-frontal-01), réponse des non filtrées (idm-01, infra-pki-01).
Un trou à fermer AVANT d'exposer un locataire. Le devis saute les flux dont la seule
source est externe (« la frontière s'en charge »). Mais un flux redirigé par la frontière
arrive sur la VM avec sa source Internet : sous REJECT, sans règle, il serait refusé.
Sans effet aujourd'hui — la frontière ne redirige vers aucune VM de locataire (relu : seul
le DNS public du site) —, bloquant le jour d'une exposition.
2026-09-28 (11) — Première VM filtrée par Proxmox, et le ping de supervision qu'aucune règle ne portait
Activée : web-frontal-01 de Technolibre (--vm 123603101), pare-feu enable=1,
policy_in=REJECT, groupes cli-metrique, srv-debian, srv-web-frontal. Vérifié flux par
flux : SSH du poste, rapports de santé et de sauvegarde acceptés, node_exporter joint par
obs-01, SSH depuis la flotte (autorisé par srv-debian, déclaré), port 80 ouvert à l'edge
seul (rien n'y écoute encore : aucun site). Icinga : tout vert — sauf un critique NOUVEAU.
ping4 : 100 % de pertes. Le ping de supervision EST déclaré (serveur_debian,
echo-request depuis serveur_icinga), mais _ports() ne garde que les nombres : le type
ICMP était rangé parmi les « ports dérivés » et le flux sauté. Aucune règle, donc REJECT.
_cibles() rend désormais un flux ICMP en règle proto: icmp + icmp_type, que
l'applicateur transmet (icmp-type) et compare. Appliqué : srv-debian des deux locataires
accepte echo-request depuis srv-icinga ; ping4 revenu OK (0,20 ms).
Vérifié avant d'aller plus loin : le « sans source » serveur_icinga:5665 que recense le
devis est le flux de serveur_backup, qu'aucun locataire ne porte — les rapports passifs,
eux, ont leurs règles (cli-backup, srv-debian). Et le recensement ne déclare plus sauté
un flux ICMP qu'il rend.
2026-09-28 (10) — Le pare-feu est-ouest de Proxmox ne filtrait aucune VM des locataires
Mesuré. Pare-feu du datacenter actif, firewall=1 sur les cartes des 26 VM de Chezlepro
et Technolibre — mais AUCUNE n'avait son propre pare-feu activé ni un seul groupe affecté.
Proxmox ne filtrait rien entre elles ; seul nftables, dans chaque VM, tenait l'est-ouest.
Le plan de proxmox-fw-appliquer le rattrapait d'un bloc : 64 changements, dont
l'activation en REJECT des 26 VM à la fois — autant de pannes possibles si une règle
manquait.
Par étapes. appliquer_proxmox_fw.py prend --objets-seulement (IPSets et groupes, aucune
VM) et --vm <vmid> (répétable) ; le plan affiché est exactement ce qui sera appliqué.
Posés aujourd'hui, les OBJETS seuls, inertes tant qu'aucune VM ne les porte : IPSets admin
passés aux sources d'administration actuelles (10.37.0.0/24, VPN 10.37.29.0/24),
membres retirés (ancien forge-01, x.18.21), objets des rôles disparus (backup,
forgejo, artefacts) retirés, srv-debian, srv-ops, srv-powerdns, srv-ops-tenant
créés, douze groupes remis au devis. Relu : objets conformes, restent les 26 affectations.
2026-09-28 (9) — P02 : l'écriture d'une table s'arrêtait au premier commentaire
make verifier : CONFORME, 83 OK, 0 échec — P02 échouait depuis le 2026-09-16.
La cause. _fusion_table (écriture ligne à ligne des registres du plan, par le GUI)
délimitait une entrée par les lignes plus indentées qui la suivent, et s'arrêtait à la
première ligne de commentaire. Le 2026-09-16, chezlepro.ca a reçu des commentaires À
L'INTÉRIEUR de son entrée (signature DNSSEC, relevé de la zone) : l'entrée était coupée en
deux, et les lignes suivantes testées comme entrées de la table. - {nom: "@", type: A, …} y passait, clef - {nom, YAML = une liste → 'list' object has no attribute 'get'.
Le GUI ne pouvait plus écrire domaines.yml.
Le correctif. L'étendue d'une entrée traverse commentaires et lignes vides tant que la
prochaine ligne significative est encore plus indentée ; un commentaire suivi de l'entrée
SUIVANTE reste avec elle. Et seules les lignes à l'indentation des enfants directs peuvent
être des entrées. Réécrire le vrai domaines.yml sans changement le rend octet pour octet.
Le test aussi avait une prémisse périmée. Retirer une entité devait garder TOUS les commentaires du fichier — juste tant qu'aucune entrée n'en portait à l'intérieur. Un commentaire intérieur part désormais avec son entrée (laissé, il expliquerait la voisine) ; ceux qui l'entourent restent, et c'est ce que le test exige maintenant.
2026-09-28 (8) — L'amont du cache des locataires suit le site, au lieu d'une adresse morte
serveur_artefacts_amont valait http://10.0.33.21:3142 chez Chezlepro ET Technolibre :
l'adresse du cache du site avant qu'il prenne son index (10.37.3x). Inerte aujourd'hui —
aucun hôte de ces écosystèmes ne porte serveur_artefacts, et apt passe par
artefacts_amorcage (mesuré sur les nœuds : 10.37.33.21:3142) —, mais un piège le jour où
un locataire reprendrait son propre cache : son amont serait né mort. La preuve d'amorçage
ne regardait que les intrants, pas ce fichier. La valeur est désormais DÉRIVÉE :
"http://{{ artefacts_amorcage }}", la seule adresse que cette preuve garde alignée.
2026-09-28 (7) — Rotation des clés WireGuard de l'exploitant, sans qu'aucune privée ne s'affiche
Pourquoi. Les deux clés privées de daniel-portable (tunnel du site, tunnel de
Chezlepro) ont fui dans l'historique d'une session d'assistance. On les change.
Ce que vpn_admin.py ne savait pas faire. pair-nouveau AFFICHE la privée pour qu'on
la colle ; lancé par un assistant ou dans un terminal journalisé, cet affichage la dépose
dans un historique — exactement la fuite qu'on répare. Trois ajouts :
cle-appareil --vers <fichier>: la privée va droit dans un fichier en 0600 (refus d'écraser), seule la publique s'affiche. Sert à la création comme à la rotation ;config --cle <fichier> --vers <fichier>: la configuration COMPLÈTE, écrite en 0600 ;plan|appliquer --portee site --portee OPS-X: sans filtre,appliquerréconcilie TOUS les tunnels — la rotation aurait emporté l'écart en attente de Technolibre (un pair retiré que personne n'a encore décidé de retirer).
Et service : ? après un rechargement réussi : OPNsense répond result, pas status.
Fait. Deux paires tirées dans ~/wireguard-rotation/ (hors dépôt), cle_publique
remplacée au plan du site et de Chezlepro — nom et adresse inchangés —, appliqué sur ces
deux seuls tunnels. Relu EN SERVICE sur la frontière : les deux nouvelles clés actives,
les deux anciennes absentes. Technolibre intact.
2026-09-28 (6) — Les voûtes sortent du poste, à côté des clés qui les ouvrent
Ce qui manquait. La clé USB portait les CLÉS des voûtes, pas les voûtes. Or elles sont
gitignorées : sur aucune forge, seulement sur le poste et sur le runner de chaque
écosystème. Poste et runner perdus ensemble, les clés restaurées n'ouvraient rien, et
chaque secret était à régénérer. La runbook cles affirmait pourtant « les voûtes sortent
chiffrées ».
scripts/exporter_voutes.py (make voutes-recenser, make voutes-exporter VERS=…).
Une archive à part, setops-voutes-<date>-<poste>.tar : restaurer_cles.py range chaque
clé d'après son seul nom, et les voûtes s'appellent presque toutes vault.yml — elles s'y
seraient écrasées. Chaque voûte garde donc son chemin relatif aux dépôts ; restauration :
tar -xf … -C <dossier des dépôts>. Pas de seconde couche : elles sont déjà chiffrées par
une clé qui n'est pas dans cette archive, et le script REFUSE tout fichier dont l'en-tête
n'est pas $ANSIBLE_VAULT. Il refuse aussi une destination dans l'infrastructure et
l'écrasement d'une archive existante, puis relit empreinte par empreinte.
Fait et éprouvé : six voûtes (Chezlepro, Chezlepro-lab ×2, Technolibre, les deux
sites) sur la clé USB, 80 Ko ; extraites dans un répertoire temporaire, chacune rouverte
avec les clés (33, 25, 2, 37, 20 et 14 entrées), puis détruites. Le mode d'emploi de la
clé, la runbook cles et sortir-les-cles-du-poste.md le disent désormais. À refaire
après toute écriture dans une voûte.
2026-09-28 (5) — L'exportateur PostgreSQL de Chezlepro, et où vivent vraiment les voûtes
Le dernier critique de Chezlepro. obs-01!collecte : Prometheus scrutait
data-sql-01:9187, où rien n'écoutait. serveur_postgresql ne pose l'exportateur que si
vault_pg_exportateur existe ; Technolibre l'avait, pas Chezlepro. Ajouté à sa voûte
selon la procédure (copie, lecture par un tube et comptage : 32 clés, chiffrement vers un
fichier neuf relu : 33 clés, les 32 d'origine identiques, puis remplacement). Rôle
redéployé : exportateur actif, pg_up 1. Chezlepro n'a plus aucun critique.
La promesse alignée sur Prometheus (même jour). vault.yml.example promettait
« VIDE = … Prometheus ne dérive aucune cible » ; le gabarit dérive pourtant la cible de
tout hôte de serveur_postgresql, secret ou pas. Le comportement est gardé — une base sans
métriques doit se voir — et la phrase dit désormais ce qui arrive : sans le secret, la
collecte d'obs-01 rougit, et renseigner le secret l'éteint. Corrigé dans les quatre
exemplaires : exemples/vault.exemple.yml, docs/metriques-conception.md, et les
vault.yml.example de Chezlepro et de Technolibre.
Où vivent les voûtes. sortir-les-cles-du-poste.md et exporter_cles.py disaient les
voûtes chiffrées répliquées sur les forges. Elles sont gitignorées : chacune vit sur le
poste et sur le runner de son écosystème (copie chiffrée par serveur_ops_tenant). Le
runner de Chezlepro portait donc la voûte d'avant ce changement ; redéposée, empreintes
identiques. Les deux phrases sont corrigées.
2026-09-28 (4) — Une sonde posée sous condition n'est attendue que là où elle est posée
Ce que la fraîcheur a fait voir. Chez les deux locataires, obs-01!journaux-frontiere
est passé au rouge et y serait resté pour toujours. serveur_loki ne pose cette sonde que
si une étiquette de frontière est déclarée — au SITE, jamais chez un locataire, dont la
frontière appartient à l'hébergeur — et la RETIRE sinon. Icinga, lui, l'attendait sur tout
hôte du groupe. Trois rôles posent ainsi leur sonde sous when: (journaux-frontiere,
audit, console-ops) ; les deux autres n'étaient pas rouges par chance, leur condition
étant vraie partout.
Le correctif. La déclaration porte la même condition que le dépôt :
seulement_si: <variable> dans meta/supervision.yml, lue par setops-sondes.conf.j2
dans l'inventaire de l'hôte, sinon dans les défauts du rôle qui la définit (defini_par)
— la valeur même que ce rôle voit. test_sondes_conditionnelles.py (dans make test)
exige qu'un when: qui pose une sonde ait son seulement_si, sur la même variable, avec
un défaut littéral ; vérifié en retirant volontairement une condition.
Simulé puis appliqué : chez chaque locataire, un seul changement — journaux-frontiere
retiré d'obs-01. Technolibre n'a plus aucun critique ; Chezlepro n'en garde qu'un, son
exportateur PostgreSQL, faute du secret vault_pg_exportateur.
Corrigé le même jour : les journaux de la frontière SONT couverts. Cette entrée
affirmait le contraire, tirée d'un --diff où journaux-frontiere n'apparaissait pas —
un diff ne montre que ce qui CHANGE, pas ce qui existe. Mesuré ensuite : la frontière
envoie en TCP vers site-mon-01:1514 (Alloy), qui porte bien serveur_loki ; la sonde
site-mon-01!journaux-frontiere est verte, 748 lignes reçues en 10 min.
2026-09-28 (3) — Un service qui n'a jamais rien reçu passe au rouge
Le trou. Tous les services passifs (sauvegardes, santé, sondes de rôle) étaient
déclarés enable_active_checks = false, avec une commande qui exécutait /bin/true. Le
ttl de chaque envoi couvrait le silence qui SUIT un rapport ; un service qui n'en a
jamais reçu restait « en attente », gris, pas rouge. C'est ce qui a caché dix nœuds de
Chezlepro sans sauvegarde pendant seize jours.
Le correctif. Un gabarit, setops-rapport-attendu (dans setops-sauvegardes.conf.j2),
que les trois familles importent : service ACTIF, commande dummy en CRITIQUE,
check_interval = le seuil — patron documenté d'Icinga 2 pour la fraîcheur. Chaque rapport
repousse l'échéance ; seul le silence la laisse arriver, y compris le silence initial.
Seuils = les ttl que les nœuds envoient (6 h, 45 min, 90 min), dans
serveur_icinga_fraicheur_*. Une liste qui en suit une autre : test_fraicheur_icinga.py
(dans make test) les compare et refuse tout service passif sans échéance — vérifié en
cassant volontairement une valeur.
Prouvé en vrai sur site-mon-01 : rapport envoyé avec un ttl de 60 s → CRITIQUE à
t+60 s (« AUCUN RAPPORT… ») → vrai rapport de santé → OK. Déployé sur les trois Icinga
(site, Chezlepro, Technolibre) : 120 + 138 + 138 services, tous avec échéance.
Ce que ça a aussitôt montré. Chez les deux tenants, dix services n'avaient JAMAIS reçu
de rapport, et rougissent maintenant : obs-01!journaux-frontiere, et neuf sondes de rôle
(console, passerelle, édition, cache, filtrage, sites/apps servis) — leur client_sante
n'a pas été redéployé depuis que ces sondes existent. Leur Icinga surveillait aussi
encore forge-01, retiré du plan : la configuration est réalignée, mais obs-01!collecte
reste rouge tant que Prometheus scrute l'ancienne adresse (10.x.21.11:9100).
2026-09-28 (2) — Deux locataires sur deux n'avaient pas de sauvegarde hors de leur flotte
Question de l'exploitant : les sauvegardes des tenants passent-elles ? Non. Mesuré
côté dépôt (site-backup-01, ce que l'hébergeur voit sans la clé) puis côté nœuds.
Technolibre — refusé chaque nuit, restic-tech vide. Ses huit nœuds à état se
présentaient comme restic, le compte du SITE : Permission denied (publickey), plusieurs
fois par jour. Chezlepro déclare deux lignes (client_backup_cible ET
client_backup_utilisateur_distant) ; Technolibre n'avait recopié que la première, et le
rôle retombait sur son défaut. Ajouté restic-tech (OPS-Technolibre ef8c905), déployé,
sauvegarde lancée : huit premiers instantanés, 79 Mo ; le dump PostgreSQL restauré (4,4 Mo,
435 tables) se lit.
Chezlepro — dix nœuds sur onze sans client_backup du tout. Seul infra-pki-01
déposait (9 instantanés depuis le 2026-09-13). Les autres n'avaient ni script, ni minuteur,
ni secret : ils ne frappaient même pas à la porte. Les dates de naissance le situent au
redéploiement du 2026-09-12 au soir — client_backup y a abouti sur infra-pki-01
(21:22) et infra-dns-01 (21:28), les deux nœuds amorcés en premier, jamais sur les
autres, alors que client_sante, plus haut dans site.yml, les a tous atteints (21:59).
Aucun journal n'a été gardé : échec ou interruption, on ne sait pas. Le rôle, relancé sans
aucune modification, a réussi partout. Sept premiers instantanés ; dump PostgreSQL (6 bases,
435 tables) et annuaire (12 entrées) restaurés et lus. /var/vmail, /srv/web et
/srv/webapp sont vides sur le disque aussi : rien de manqué.
Pourquoi personne ne l'a vu. Un nœud sans client_backup n'a pas non plus sa
vérification : il ne rapporte RIEN. Un service passif qui n'a jamais reçu de résultat reste
« en attente » dans Icinga — il ne passe jamais au rouge. Le ttl n'alerte que sur un
silence qui SUIT un rapport. C'est le trou à fermer : une fraîcheur qui vaut aussi pour
le premier rapport.
2026-09-28 (1) — La sauvegarde de la forge du génome passait ; sa vérification parlait dans le vide
Question de l'exploitant : la sauvegarde de site-forge-01 passe-t-elle vraiment ? Oui,
et c'est maintenant mesuré jusqu'à la restauration : setops-sauvegarde réussit chaque nuit
(dernier instantané 2026-09-28 02:43, 9 conservés, ~45 Mio), et set-ops-public.git
restauré depuis latest passe git fsck, avec pour tête 859ac07 — exactement ce que la
forge portait à 02:43, avant les poussées de la journée.
Ce que la mesure a trouvé en chemin. La vérification que chaque nœud fait de SON dépôt
(setops-verification-depot, celle qui détient la clé) échouait toutes les quatre heures
depuis le 2026-09-20 sur site-forge-01, site-mon-01 et site-pki-01 : 48 fois
« ECHEC du rapport Icinga : 404 No objects found ». Le nœud rapportait à
<nœud>!sauvegarde ; setops-sauvegardes.conf.j2 ne créait ce service que dans un
écosystème SANS dépôt. Le site en a un (site-backup-01) : seuls existaient les services
du témoin du dépôt, site-backup-01!sauvegarde: <nœud> — au vert, et donc crus complets.
Le gabarit choisissait l'un OU l'autre témoin ; il crée désormais les deux quand un dépôt
existe. Déployé sur site-mon-01, vérification relancée sur les trois nœuds : six
services au vert, et les deux témoins disent la même chose (1723 fichiers, 40 Mo pour la
forge). Chez les tenants, sans dépôt, rien ne change.
2026-09-27 (1) — Patient 0 effacé, l'index 29 libéré
Ses machines n'existaient plus depuis le 2026-09-06 (D-83), mais son plan restait sur
disque en federe: true : la fédération lui réservait l'index 29, et quatre machines du
site ouvraient SSH, apt, DNS et HTTPS à 10.29.0.0/16 — un périmètre vide.
Ce qui est fait :
SITE-Chezlepro/underlay.yml:OPS-Patient0: 29retiré detenants.SITE-Chezlepro/plan/serveurs.yml: le runner du site ne clone plusops-patient0.make flux:10.29.0.0/16sort des règles desite-backup-01,site-cache-01,site-dns-01,site-dnspub-01etsite-forge-01— et rien d'autre ne change.- Le dépôt local
OPS-Patient0/est supprimé, puis, le 2026-09-28, ses deux copies distantes :genome/ops-patient0sur la forge du site (API, 404 relu) etChezlepro/OPS-Patient0sureregion(interface web — le jeton du poste n'a paswrite:repository; 404 relu). La clé de sa voûte est détruite (shred). - Les commentaires et documents vivants qui le nommaient gardent leur leçon sans le nommer (moteur, modèles, tenants, sites). Le CHANGELOG, les rapports de preuve datés et la chronique restent tels quels : ce sont des archives.
filiation-emancipation.md§« Qui est le parent ? » ramené à la décision et au chemin réel du génome.
Corrigé le 2026-09-28 : il n'y a pas de « SPOF eregion ». D-82, D-83 et ce paragraphe
affirmaient que eregion alimente la forge qui fait autorité (« le poste y pousse, la
forge du site en tire »). Le chemin mesuré dit autre chose : make genome-pousser porte
les commits du poste en bundle au runner du site, qui pousse sur sa forge — eregion
n'y figure pas. C'est une forge héritée (forge.alliance-boreale.ca), porte publique des
contributions, qui ne fera jamais partie de Set-OPS ; la redondance vient d'une forge par
site, chacune inséminée du génome. La phrase avait été recopiée trois fois sans que
personne ne suive le chemin — moi compris, la veille.
Ce qui n'est pas encore sur le réseau. Les .nft régénérés doivent être posés par le
runner du site. Le SDN (zone t29, VLAN 1291-1296), la frontière et le pare-feu Proxmox
n'ont pas pu être relus depuis le poste : le cluster ne répondait pas (22 et 8006).
Mesuré en passant, et corrigé le 2026-09-28. make sdn-plan, cluster injoignable,
annonçait « à créer : 33 » — TOUTES les zones, Chezlepro comprise. Une lecture en échec
rend {'_erreur': …}, et rep or [] parcourait ce dict comme une liste vide.
appliquer_sdn.py s'arrête désormais, comme frontiere-plan. Et t29 rejoint
ANCIEN_NOMMAGE : sortie du devis sans cela, la zone aurait été lue comme étrangère et
laissée en place, avec ses VLAN 1291-1296.
devis_proxmox_fw.py et devis_proxmox_pools.py balayaient encore TOUS les dossiers
frères (decouvrir()), alors que la frontière et le SDN s'en tiennent à
underlay.tenants (decouvrir_du_site(), dont la docstring le demandait déjà pour
« les devis qui équipent un site »). Le runner du site, qui gardait un clone de patient 0,
proposait donc de recréer ses groupes ; le poste comptait un dossier de CI comme tenant.
Les deux devis suivent maintenant la même liste : 2 tenants, et non 3.
Filtré ainsi, le devis ne voyait plus du tout t29 — ni à créer, ni à RETIRER : son
périmètre ne retient que les préfixes des tenants présents. 3 IPSets et 6 groupes t29-
restaient posés sur le cluster. appliquer_proxmox_fw.py ajoute à son périmètre les
étiquettes des zones de ANCIEN_NOMMAGE (une liste, pas deux), et reçoit le même garde
que le SDN contre un cluster muet.
Posé le 2026-09-28, depuis le runner du site. SDN : zone t29, 3 VNets, 3 sous-réseaux
retirés, le plan relu dit « Rien à faire ». Frontière : 44 objets PATI29 et 3 routes
10.29.x retirés, 283 règles inchangées, rien d'autre au plan relu. Pare-feu Proxmox : les
6 groupes et 3 IPSets t29- retirés un par un par l'API — PAS par proxmox-fw-appliquer,
dont le plan mêle 64 changements préexistants sur t17/t23 qui demandent leur propre
revue. Ce retrait a révélé un dernier défaut : l'applicateur envoyait force dans le CORPS
d'un DELETE, que Proxmox refuse (501) ; aucun IPSet n'était donc jamais retiré. Corrigé
(?force=1). La strophe FRR des nœuds de sortie garde t29 : ils sont injoignables en SSH
depuis le runner.
Wiki republié (P60) : deux de ses pages nommaient patient 0. make verifier : 82 OK, 1 échec — P02,
test_ecriture_plan.py sur domaines.yml ('list' object has no attribute 'get'),
présent avant ce changement.
2026-09-25 — Le registre disait « mesure » d'une cible qui coupe l'amont
21 preuves sur test_runbooks.py, verifier à 0 écart. Un outil tiers veut
n'offrir de ce moteur que ce qui n'agit pas. Le seul champ qui le lui dise est
nature. Il fallait donc qu'il ne mente pas — et il mentait une fois.
Une mesure qui demande confirmation n'en est pas une
filiation → emancipation-prouver se déclarait nature: mesure et portait
fixes: {CONFIRMER: "true"}. Les deux ne peuvent pas être vrais ensemble : la
cible refuse sans confirmation, et son propre refus dit pourquoi — « cette
preuve COUPE l'amont quelques secondes pour mesurer ». La coupure est retirée
quoi qu'il arrive, mais pendant ce temps la fonction éprouvée peut échouer.
Le mot « prouver » avait emporté la décision. Un constat rapporté ne rend pas
inerte le geste qui l'obtient. L'étape est désormais nature: ecriture, et son
pourquoi dit ce qu'elle coupe.
verifier() refuse maintenant cette contradiction : rien ne la voyait, et
chaque lecteur du registre refaisait l'enquête. Mesuré avant correction : un
écart, exactement celui-là.
Lire le registre sans analyser un arbre fait pour l'œil
runbooks.py lister --json rend le registre assemblé d'un bloc. Sans lui, un
outil tiers n'avait le choix qu'entre analyser la sortie humaine — qui dérive
au premier changement de mise en page — et importer ce module, ce qui le lie à
nos noms internes. Les deux se paient plus tard.
L'affichage humain ne change pas, et une preuve le tient : un lister qui
rendrait toujours du JSON passerait sinon les épreuves du drapeau sans que
personne ne le voie.
La recette tranche, le registre ne se compare plus à lui-même
Le premier invariant comparait nature à fixes — deux champs écrits par la
même main. Le même mensonge repassait en EFFAÇANT la ligne fixes, et trois
cibles le portaient ainsi : flotte-creer, deployer-tout, reconstruire
refusent sans confirmation, le registre ne le déclarait pas, et un assistant
les lançait telles quelles — sortie en 2. Le contrôle lit désormais la
RECETTE, qui ne ment pas : elle refuse, et c'est ce refus que l'exploitant
rencontre.
Une étape qui agit barre celles qui la suivent
Passer emancipation-prouver à ecriture l'a placée derrière un ménage
destructif : la console débloque d'office une étape « mesure », mais fait
attendre toute autre que la précédente ait réussi dans la session. Un ménage
qu'on peut n'avoir rien à faire rendait donc la preuve injouable.
depots-perimes est marqué facultative — il nettoie ce que la filiation a
laissé, il n'est pas son préalable.
Mesuré sur le registre réel : 17 runbooks, 127 étapes. 84 sont de nature
mesure — c'est la surface qui n'agit pas, et la seule qu'un outil puisse
offrir sans confirmation. Les 110 dont les fixes ne portent pas de
CONFIRMER en contiennent 26 d'écriture, dont « déploie TOUTE la flotte » :
compter sur ce chiffre pour dire « sûr » serait une erreur de lecture.
2026-09-20 (12) — Une décision appliquée à moitié se croit tenue
83 preuves, make test à 0 échec. L'exploitant a demandé : « n'avions-nous pas décidé
d'un plan d'adressage dérivé pour chaque flotte, qu'elle soit tenant ou site ? » Oui. La
décision est écrite dans l'underlay du site, datée du 12 septembre :
« sites et locataires se partagent la classe A, chacun avec SON index. Le site prend 37, le locataire garde 17.
make instancesles compte ensemble depuis ce jour. »
Ce que l'entrée (11) affirmait, et qui était faux
Elle disait qu'un site « déclare ses adresses, il n'a aucun seed dont les faire
descendre ». C'était la recopie d'un commentaire en tête de SITE-Chezlepro/plan/serveurs.yml
— périmé depuis le jour où le site a reçu son index, et lu au lieu d'être mesuré.
Un commentaire périmé se propage : celui-ci s'est retrouvé le même jour dans la docstring
de ConsoleSite et dans une entrée de CHANGELOG. C'est exactement ce que
CARTE-DU-SITE.md décrit chez un locataire — inerte et crédible : le moteur ne le lit
pas, donc rien ne le corrige ; un humain le lit, et le croit.
La mesure
Les sept zones du site suivaient 10.<index>.<vlan>.0/24 à la lettre — et étaient
pourtant écrites : sous_reseau et passerelle stockés pour chacune. C'est
précisément ce que P20 interdit à un locataire, sous le titre « Adressage 100 % dérivé du
seed (aucun stocké) ». Et la portée de P20 était « l'instance liée + les modèles » :
elle ne regardait jamais le site.
Les valeurs concordaient parce qu'un humain les avait tapées juste, ce jour-là. Rien ne le
garantissait : une zone écrite 10.36.x serait passée sans un mot.
Deux natures dans une seule liste
reseaux: mélangeait ce qui peut dériver et ce qui ne le peut pas. Chaque entrée déclare
maintenant sa nature :
| nature: fabric | grappe, iSCSI, Ceph, transit, VXLAN, management | adressage dicté par les participants d'un lien physique (D-02, D-04) — il reste écrit |
| nature: site | les sept zones du site | elles descendent de son index, et ne stockent plus rien |
Le loader dérive sous_reseau et passerelle à la lecture, pour que les consommateurs ne
voient aucune différence. Quatorze valeurs stockées ont disparu du fichier, et ce que
les consommateurs lisent est identique — écart mesuré : zéro. make underlay,
make devis-sdn et make devis-reseau passent.
P20 juge désormais les deux natures, et refuse une entrée qui ne déclare pas la sienne :
on ne peut pas juger ce qu'on ne sait pas lire. Contrôle négatif joué : une zone remise à
10.36.31.0/24 est refusée, en nommant l'index dont elle aurait dû descendre.
Ce qui reste écrit, et qui est une dette nommée
Les machines du site gardent leurs ip, vmid et noeud dans son plan. Ce n'est plus
une doctrine — « le site est le terrain, il n'a rien dont faire descendre » — puisque le
seed existe maintenant pour elles aussi. C'est une dette, et la nommer est le minimum :
un objectif qu'on abandonne sans le dire devient un objectif qu'on croit atteint.
P02 et P60 restent, pour les raisons déjà consignées.
2026-09-20 (11) — Deux fonctions qui doivent rendre la même forme finissent par ne plus la rendre
83 preuves, make test à 0 échec, 8 tests de rendu. La console avait deux
assembleurs de charge — inventaire_api pour un locataire, inventaire_api_du_site pour
un site. Ils ont dérivé deux fois le même jour, et ce n'était pas une malchance : c'est
ce qui arrive toujours à deux copies d'une même obligation.
Ce que la duplication a coûté, mesuré
En forme. serveurs valait [] d'un côté et {} de l'autre. Les deux « vides », et
la page ne les lit pas pareil : charger() levait, et la console d'un site mourait avant
sa première vue.
En contenu. Cinq registres étaient servis vides — « pour ne pas fabriquer un faux plan de tenant ». Le site n'a pas de faux plan : il a le sien, au même format, que les lecteurs du moteur lisent sans broncher. Mesure : 9 serveurs, 21 applications, 2 bases, 1 domaine, tous invisibles à l'écran.
Un tronc, deux branches, et le rôle distinct de la portée
Console assemble — un seul endroit où la charge se compose, et c'est lui qui fixe
les clés et leurs formes. Les branches ne décident que de ce qui leur appartient : d'où
vient le plan, d'où vient l'inventaire, quels pouvoirs elles portent.
| Branche | Ce qu'elle est | Son plan | Son inventaire |
|---|---|---|---|
ConsoleLocataire |
configure ses services | dérivé d'un seed | artefact généré |
ConsoleSite |
matérialise le terrain | déclaré (ip, vmid, noeud) |
un script |
ConsolePoste |
l'atelier — hérite du locataire | idem locataire | idem locataire |
Console |
ne pilote rien, et le dit | aucun | vide |
Le rôle et la portée ne se confondent pas. Le rôle est une propriété de la classe : ce pour quoi cette console existe. La portée se calcule depuis les pouvoirs : ce qu'elle peut vraiment, ici et maintenant. Un poste privé de la voûte du site reste l'atelier du mainteneur et n'engendre pourtant rien — figer la portée sur la classe lui aurait rendu un pouvoir que la mesure lui refuse.
Ce découpage a immédiatement montré un trou : ConsolePoste héritait du refus d'un
locataire — « elle ne sait pas sur quelle fabric poser » — alors qu'il monte la
carte. Ce qui lui manque, c'est la voûte. Un message qui accuse la mauvaise absence fait
chercher au mauvais endroit.
Le jugement des assistants remonte dans le tronc
Il était écrit deux fois : dans la route qui liste les assistants, et dans celle qui
exécute une étape. Deux copies d'une même règle finissent par ne plus dire la même
chose — et celle qui se trompe est toujours celle qui exécute, parce que c'est la moins
relue. Console.peut_conduire() répond aux deux.
Le gestionnaire ne demande plus « est-ce un site ? » pour choisir un assembleur : il demande sa charge à sa console, et ignore laquelle lui répond.
Ce qui a été vérifié
make test à 0 échec, les 8 tests de rendu sous node — dont deux neufs : la console de
site sert bien son plan, et les deux charges portent les mêmes types. La console a été
lancée pour de vrai : contexte poste, 13 serveurs, 24 applications, 17 runbooks,
127 étapes conduisibles, 0 écart.
Et make verifier a trouvé une faute que j'avais laissée passer : le playbook
depots_perimes.yml livré une heure plus tôt violait risky-shell-pipe. J'avais passé
--syntax-check dessus et ansible-lint seulement sur le rôle voisin. Corrigé — les
tubes retirés, pipefail armé sans -e pour qu'un git qui échoue laisse le script
répondre au lieu de faire tomber la tâche.
Deux échecs restent. P02, antérieur, consigné à l'entrée (6). Et P60 : le wiki
publié est en retard d'un commit depuis que l'entrée (6) a documenté les Assistants —
make wiki-publier WIKI_REMOTE=… le republie, et l'adresse publique n'est celle d'aucun
plan, donc elle ne se devine pas.
2026-09-20 (10) — Un vide doit avoir la forme de ce qu'il remplace
83 preuves, make test à 0 échec, 7 tests de rendu. La console de
console.genese.internal — celle du runner de SITE-Chezlepro — ne montrait rien. Elle a
douze machines.
charger() levait, et la page mourait avant de dessiner
inventaire_api sert serveurs en liste ; inventaire_api_du_site le servait en
table. Les deux sont « vides », et la page ne les lit pas pareil :
(data.serveurs || []).map(...) trouve {} — truthy, sans .map — et charger()
lève. Toute la console d'un site s'arrêtait là, avant la première vue, et le message
accusait une méthode manquante plutôt qu'une forme qui ment.
Un vide doit avoir la forme de ce qu'il remplace. Sinon ce n'est pas un vide, c'est un autre objet qui se fait passer pour lui — et le malentendu ne se voit qu'au moment où quelqu'un appelle une méthode dessus.
Un test compare désormais les deux charges type par type pour les clés qu'elles partagent, avec deux écarts admis et motivés. Il ne demande pas les mêmes valeurs — un site n'a pas de plan de locataire — seulement les mêmes formes.
Et la vue regardait au mauvais endroit
Le registre vide était voulu : « on ne fabrique pas un faux plan de tenant pour
remplir l'écran ». C'est juste — un site déclare ses machines dans son propre plan, avec
ip, vmid et noeud écrits, là où un locataire les dérive de son seed ; il est le
terrain, il n'a rien dont les faire descendre.
Mais la vue d'accueil tirait de ce registre vide le message d'un locataire sans serveurs —
« + Serveur », make serveurs-bootstrap — et les douze machines, servies dans hotes,
n'étaient dessinées nulle part. Une console de site montre maintenant ses machines :
mêmes tuiles que les serveurs d'un locataire, en lecture seule, avec leur adresse, leur
VMID, leurs rôles et leur point de vie ; le panneau droit dit ce que cette console peut —
matérialiser le terrain, n'entrer chez aucun locataire — et renvoie aux Assistants.
C'est le défaut du 2026-09-16 par une autre porte. La première fois, l'inventaire était vide et la page dessinait ce vide comme un plan vide. Cette fois l'inventaire est plein et c'est la vue qui regarde ailleurs. Le premier correctif a donné à la console de quoi répondre ; il ne lui a pas appris où regarder.
Ce qui a trouvé le défaut
Pas une lecture : le banc. test_rendu_gui.py dessine les vues sous node dans un
DOM simulé. En lui donnant pour la première fois la charge d'une console de SITE, il a
levé (data.serveurs || []).map is not a function — c'est-à-dire la cause réelle, sous
le symptôme que je m'apprêtais à corriger. Sans lui, j'aurais livré une vue juste par
dessus un charger() qui lève, et la console serait restée vide.
Ce qui a été vérifié
node --check sur le bloc <script> (147 707 caractères), les 7 tests de rendu,
make test à 0 échec, runbooks.py verifier à 0 écart, P81 et P83 vertes. Le test neuf
nomme chaque machine du site attendue à l'écran, refuse le message d'amorçage d'un
locataire, et vérifie que choisir une machine montre ses rôles.
P02 reste en échec, pour la raison antérieure consignée à l'entrée (6).
2026-09-20 (9) — Ce qui n'existe qu'ici ne se détruit pas depuis ici
83 preuves, make test à 0 échec, 133 cibles documentées. Le runner de
Chezlepro-locataire portait encore le clone de SITE-Chezlepro — la carte de son
hébergeur — longtemps après que son plan ait cessé de la déclarer. serveur_ops clone ce
qui est déclaré ; il ne retirait rien.
Pourquoi ce retrait n'est pas dans le rôle
Un rôle qui efface des dossiers à chaque passage est une grenade dégoupillée : une faute
de frappe dans serveur_ops_depots suffirait à perdre du travail local. Le retrait est
donc un geste séparé, qui regarde par défaut et n'efface que sur CONFIRMER=true
— comme raser et site-raser.
make depots-perimes nomme ce qu'un runner porte et que son plan ne déclare plus, puis
dit ce qu'une confirmation emporterait. Trois choses ne sont jamais retirées, même
confirmées : ce qui n'est pas un dépôt git, ce qui porte des modifications non validées,
et ce qui porte des commits qu'aucun distant ne porte.
La garde a servi au premier essai
Le relevé a nommé deux dossiers non déclarés : SITE-Chezlepro, et venv —
l'environnement Python du runner. venv a été écarté parce que ce n'est pas un dépôt
git. Une version naïve de ce geste aurait effacé l'environnement d'exécution du
runner, et l'aurait fait en se disant satisfaite.
C'est la raison d'être de la première des trois règles : un dossier qu'on ne sait pas lire n'est pas un dossier périmé, c'est un dossier qu'on ne comprend pas. Le doute se résout en s'abstenant, pas en effaçant.
Écrire, puis relire — ici : regarder, puis vérifier
Le geste relit après avoir écrit : un state: absent qui se dit satisfait sans que le
dossier ait disparu est exactement le genre de succès qui ment. Mesure après coup — le
runner du locataire ne porte plus que Set-OPS-public et OPS-Chezlepro, ce que son plan
déclare, et sa console rend portee=tenant, configurer=True, materialiser=False,
fabric=False : même le pouvoir de LIRE la fabric est tombé avec la carte, ce qui est
juste — il n'y a plus de miroir à consulter.
Ce qui a été vérifié
--syntax-check sur le playbook, python3 scripts/runbooks.py verifier (0 écart,
la cible neuve est portée par le runbook « Filiation »), make test à 0 échec. Le geste
a été passé deux fois sur le runner réel : une fois pour regarder — rien touché,
changed=0 — puis une fois confirmé, changed=1, avec sa relecture.
P02 reste en échec, pour la raison antérieure consignée à l'entrée (6).
2026-09-20 (8) — Un pouvoir se mesure sur ce qu'on peut ouvrir, pas sur ce qu'on peut lire
83 preuves, make test à 0 échec (21 tests neufs aujourd'hui). La vue Assistants a
rendu visible un écart que les quatre boutons d'avant cachaient : le runner de
Chezlepro-locataire se déclarait poste et offrait 126 étapes sur 126, création et
destruction de machines comprises.
Ce que la console lisait, et ce que le document exigeait
docs/responsabilites-locataire-hebergeur.md §2 attache chaque pouvoir à une voûte :
calculer ne demande aucun secret, configurer demande celle du tenant, matérialiser celle
du site. Le code, lui, lisait les symlinks — c'est-à-dire les cartes.
Le runner de Chezlepro montait la carte du site sans en avoir jamais eu la voûte. Les gestes de fabric qu'il proposait seraient partis puis tombés sur un secret vide : un échec au milieu du chemin, là où un refus net aurait dit la vérité avant de commencer. Et un refus qui arrive trop tard envoie chercher la panne dans la fabric, pas dans le pouvoir.
contexte() dérive désormais les pouvoirs des voûtes présentes, et la portée découle
des pouvoirs au lieu de les précéder. On ne prouve pas qu'une voûte s'ouvre — le mot de
passe se tape à l'exécution — mais son absence, elle, est décisive et se mesure sans rien
ouvrir : une voûte absente ne s'ouvrira jamais. Lire une carte reste permis : le
pouvoir fabric continue de suivre le symlink, parce que consulter un miroir n'est pas
engendrer.
Chezlepro-locataire est un locataire
Il vit dans sa coquille et reçoit de l'hébergeur qui le porte — même quand cet hébergeur est lui-même. Que le même humain tienne les deux rôles ne fusionne pas les deux pouvoirs : ça rend la coupure plus nécessaire, puisque plus rien d'extérieur ne la rappelle.
Son plan cesse donc de déclarer la fabric (serveur_ops_underlay: "", comme TechnoLibre)
et de cloner le dépôt de son hébergeur. Il porte maintenant sa propre copie de la
racine d'autorité du site — un certificat public, identique à l'octet près à celui que
TechnoLibre porte déjà — au lieu de la dériver du symlink qu'il n'a plus.
Retirer une déclaration doit retirer l'artefact
serveur_ops_underlay vide voulait dire « ce poste exploite sans engendrer », et le rôle
se contentait de ne pas poser le lien. Un runner qui l'avait déjà le gardait — avec le
pouvoir que son plan ne lui donnait plus. C'est la même forme que la liste qui suit une
autre : une déclaration qui ne vaut que dans un sens ne décrit plus rien.
Le rôle retire maintenant le lien quand la fabric n'est plus déclarée. Il ne retire qu'un lien, jamais un fichier : si une vraie carte occupe la place, elle n'a pas été écrite par ce rôle, et il le dit plutôt que de détruire un contenu qui n'est pas le sien. Le chemin de cette place est dérivé une seule fois, en défaut du rôle — trois copies d'un même chemin finissent par ne plus désigner le même endroit.
Ce qui a été vérifié
--syntax-check sur playbooks/groupes/serveur_ops.yml, ansible-lint sur le rôle
(profil production, 0 échec, 0 avertissement), make test à 0 échec, P81 et P83 vertes.
Six tests neufs montent quatre faux disques et exigent la portée qui leur revient, dont
le défaut lui-même, nommé : carte présente, voûte absente → tenant, jamais poste.
L'état des trois runners est mesuré avant la correction — site : fabric + voûte du site ;
Chezlepro : instance + voûte du tenant + carte sans voûte du site ; TechnoLibre :
instance + voûte du tenant, aucune fabric. Le premier relevé était faux : readlink -f
rend un chemin même quand la cible n'existe pas, et faisait voir une fabric à TechnoLibre
qui n'en a pas. Refait avec un test d'existence.
P02 reste en échec, pour la raison antérieure consignée à l'entrée (6). Le clone
SITE-Chezlepro subsiste sur le runner du locataire : il ne donne plus aucun pouvoir une
fois le lien retiré, et le retirer serait détruire un dossier que personne n'a demandé de
détruire.
2026-09-20 (7) — Une portée jugée trop haut ferme une séquence à qui elle appartient
83 preuves, make test à 0 échec (15 tests de runbooks). Les assistants livrés à
l'entrée (6) pesaient la portée au runbook. Une console de locataire le montre en
une lecture : locataire-deployer et machine-une y étaient hors de portée — donc un
locataire ne pouvait pas déployer sa propre flotte, ce qui est exactement son métier.
Une seule étape qui matérialise fermait tout le reste
flotte-creer et creer-vm engendrent des VM : ils exigent la fabric, que le locataire
n'a pas. Déclarés dans une séquence dont la portée valait pour l'ensemble, ils faisaient
basculer en poste les étapes voisines — deployer-tout, valider, verifier-hote —
qui ne demandent pourtant que ce que le locataire possède déjà.
Mesuré sur la console de TechnoLibre, portée tenant : 6 runbooks conduisibles sur 17,
et parmi les onze fermés, les deux qui décrivent son travail quotidien.
La portée se pèse désormais à l'étape. Le runbook n'en donne que le défaut ; l'étape qui exige davantage le déclare. Le locataire conduit donc sa séquence et bute précisément là où il faut : sur la machine à engendrer, pas sur le déploiement qui suit. La page ferme l'étape seule, en disant sa raison sur cette étape ; l'inspecteur compte ce qui est hors de portée plutôt que de barrer l'ensemble.
Côté serveur, la garde de /api/runbook-etape lit la portée de l'étape visée par son
index, et non celle du runbook : juger sur le runbook aurait refusé un geste que la
console avait le droit de faire, ou accepté l'inverse.
Ce que cette correction dit du reste
Un droit qui se calcule sur l'ensemble se trompe toujours dans le même sens : il refuse à quelqu'un ce qu'il a le droit de faire, et le refus paraît fondé puisqu'il nomme un vrai manque. Il a fallu une console de locataire réelle pour le voir — sur le poste, qui porte les deux liens, les dix-sept séquences s'affichaient conduisibles et rien ne clochait.
Quatre tests neufs refusent le retour du défaut, dont un qui nomme le cas exact :
deployer-tout et valider restent à portée d'un locataire, flotte-creer non.
Ce qui a été vérifié
python3 scripts/runbooks.py verifier (0 écart), make test à 0 échec, P83 verte. La
correction est mesurée sur les trois consoles après déploiement. Aucun rôle, playbook ou
réglage d'infrastructure modifié. P02 reste en échec, pour la raison antérieure déjà
consignée à l'entrée (6).
2026-09-20 (6) — Cent trente-deux cibles, et aucune ne dit dans quel ordre
83 preuves (P83 est neuve), make test à 0 échec. Le Makefile porte 132 cibles
documentées. Chacune dit ce qu'elle fait ; aucune ne dit quand, ni après quoi, ni ce
qu'il faut avoir mesuré avant. La console offrait donc des boutons sans séquence, et
l'ordre des gestes vivait en prose dans des documents que la console ne porte pas.
Un exploitant devant un site neuf n'avait pas de quoi commencer
Rien dans l'interface n'apprenait que site-creer précède forge-amorcer, que le premier
passage de site-deployer-tout s'arrête sur une forge vide — et que ce n'est pas un
échec mais le maillon suivant — ni que rien n'est « prêt » avant valider. C'est
exactement ce que le principe fondateur exige : un opérateur exploite Set-OPS sans IA.
Sans la séquence, la console ne tenait cette promesse qu'à moitié.
La vue Assistants conduit 17 runbooks, 126 étapes, du premier jour d'un site à la remise au client. Les 132 cibles y sont : chacune portée par un assistant, ou exemptée avec son motif. Une exemption muette est refusée — c'est un oubli déguisé.
Le registre ne recopie pas le Makefile, et la garde est écrite avec lui
docs/runbooks-construction.yml déclare seulement ce que le Makefile ne peut pas
porter : l'ordre, la nature du geste, la portée, le pourquoi. Le libellé de chaque étape
est lu dans le Makefile au moment de servir — jamais recopié. Une cible renommée se
voit donc à l'écran, elle ne s'invente pas.
Ce fichier est malgré tout une seconde liste à côté de la première, et une liste qui suit
une autre prend du retard : vu quatre fois dans ce dépôt en une seule journée. P83 est
donc écrite en même temps que la liste, pas après. Elle refuse une étape qui vise une
cible absente du Makefile, une cible documentée que nul runbook ne porte et que nul
motif n'exempte, une cible à la fois portée et exemptée, et une portée que la console ne
saurait pas traduire en pouvoir. Onze tests unitaires lui présentent des registres faux —
un par forme de retard — et exigent qu'elle les refuse : une garde qui rendrait toujours
« rien à signaler » passerait P83 tous les jours sans rien garder.
Le navigateur ne nomme pas une commande, il nomme une place
/api/runbook-etape ne lance jamais la cible que la page demande : il lance ce que le
registre déclare à cet index-là, avec les seules variables déclarées, et refuse tout
le reste en disant pourquoi. Une page périmée — ou compromise — ne peut pas réclamer
raser depuis un assistant de mesure. L'index compte : « Le premier jour d'un site »
joue site-deployer-tout deux fois, et les deux places n'ont pas le même sens ; chercher
par nom seul les confondrait.
Les valeurs imposées par une étape (CONFIRMER=true et compagnie) ne viennent jamais du
corps de la requête : une confirmation qu'on peut s'envoyer à soi-même n'en est pas une.
Ce qui protège, côté page, c'est d'écrire DETRUIRE — le seul garde-fou qui résiste à
un clic distrait.
La portée se dérive, elle ne se déclare pas. Un assistant de site exige
materialiser, un de locataire configurer, un de poste les deux. La garde de cette
route est plus fine que celle des autres, et c'est voulu : un seul mot y serait soit trop
laxiste, soit trop sévère. Un assistant hors de portée reste visible et lisible —
savoir que le geste existe, et chez qui il se fait, fait partie du métier. Ce qui est
refusé, c'est de le lancer.
La séquence est un garde-fou, pas une décoration
Une étape qui écrit attend que la précédente non facultative ait réussi : sinon on bâtit sur un terrain qu'on n'a pas vérifié. Une étape qui mesure reste toujours offerte, même après un échec — mesurer pour comprendre est exactement ce qu'on fait ensuite. Cette asymétrie est la seule chose que l'interface impose d'elle-même.
Ce qui a été vérifié, et ce qui ne l'a pas été
python3 scripts/runbooks.py verifier (0 écart), make test à 0 échec — 11 tests neufs
compris — et les 83 preuves rejouées. La console a été lancée pour de vrai : GET /
rend 200, /api/runbooks sert 17 runbooks avec les libellés lus au Makefile, six
requêtes malformées sont refusées une à une (cible absente du runbook, runbook inconnu,
bon nom au mauvais index, variable requise absente, valeur commençant par un tiret,
valeur hors du vocabulaire déclaré), et une étape de mesure s'exécute de bout en bout
avec son journal daté.
make verifier n'est pas vert, et ce n'est pas de ce changement. P02
(scripts/tests/test_ecriture_plan.py) échoue sur domaines.yml — trois erreurs dans
_fusion_table, où un bloc réindenté se relit comme une liste là où le code attend une
table. Mesuré sur une copie de HEAD avec la même instance montée : l'échec est
identique avant ce travail. Il reste ouvert, et il a une conséquence pour l'exploitant :
ajouter ou retirer un domaine public depuis la vue Domaines lèverait au moment
d'enregistrer. L'aller-retour sans modification, lui, fonctionne.
Aucun rôle, playbook ou réglage n'est modifié ; aucune VM touchée, aucune voûte ouverte.
2026-09-20 (5) — Celui qui pousse avance son propre clone
82 preuves dans le rapport du 17 septembre, non rejouées ici. Le correctif de
l'entrée (4) attendait sa preuve : il l'a eue en conditions réelles, et en la donnant
il a montré le cas qu'il ne couvrait pas.
Le déclenchement, mesuré deux fois
Le commit f9a20b0 poussé sur la forge du site, rejouer serveur_ops chez les deux
locataires a fait avancer leur clone de 6367d85 à f9a20b0. La tâche
« Redémarrer la console quand le moteur a avancé » est passée en changed sur les deux :
console de TechnoLibre repartie à 15h33m33 contre 15h16m49, celle de Chezlepro à 15h38m02
contre 15h16m47. Les deux sondes rendent 0. Le mécanisme a été vu fonctionner, pas
simulé — et la tâche s'était abstenue plus tôt le même jour, quand les clones étaient
déjà au niveau de la forge.
Le site ne peut pas se voir avancer
genome_pousser.yml fait avancer le clone du runner du site sans qu'aucun rôle ne
passe : c'est lui qui pousse, donc il doit d'abord recevoir. Mesure du même jour — son
clone est passé à f9a20b0 pendant la poussée, sa console est restée démarrée à 15h16.
Le correctif de serveur_ops ne peut rien pour lui : quand le rôle passera, le clone sera
déjà à jour, et la tâche s'abstiendra à juste titre. L'hébergeur gardait donc précisément
le défaut corrigé chez ses locataires.
Le remède tient là où vit la cause. Le playbook nomme déjà la transition dans son verdict
(6367d85 → f9a20b0) : il en tire maintenant la conséquence et redémarre la console du
site quand c'est le moteur qui a bougé. Un plan poussé ne coupe pas les pages
ouvertes, et un runner sans console n'est pas touché — l'unité systemd est vérifiée avant.
Personne d'autre que ce playbook ne sait que ce clone a avancé. Une information qui n'existe qu'à un endroit doit y être utilisée, sinon elle se perd et le défaut se reconstitue au passage suivant.
Ce qui a été vérifié, et ce qui ne l'a pas été
--syntax-check sur playbooks/maintenance/genome_pousser.yml, contre l'inventaire du
site et contre celui d'un locataire ; ansible-lint sur le playbook (profil production,
0 échec, 0 avertissement). La condition de déclenchement est éprouvée sur quatre cas :
moteur avancé, moteur immobile, DEPOT= visant un plan seulement, et la boucle sans
résultat du mode --check.
Le redémarrage de la console du site a été observé à la première poussée suivante.
f9a20b0 → f958739 sur la forge, la tâche est passée en changed, et la console du site
est repartie à 15h46m27 contre 15h16m45 — sonde à 0, vestibule locale rendant 401 à une
requête anonyme. Les cinq autres dépôts, reconnus immobiles, n'ont rien redémarré : la
condition ne réagit qu'au moteur. make genome-etat rend failed=0 sur les six.
Les trois correctifs de la journée ont donc été vus fonctionner sur la machine que chacun concerne : le rôle chez les deux locataires, le playbook de poussée chez l'hébergeur. Aucune preuve du harnais rejouée, aucune voûte ouverte, aucune VM touchée.
2026-09-20 (4) — Le code sur le disque n'est pas le code servi
82 preuves dans le rapport du 17 septembre, non rejouées ici. Les trois runners portaient un moteur périmé, et leurs consoles servaient un moteur plus vieux encore. Deux causes distinctes, l'une dans la chaîne de distribution, l'autre dans le rôle.
Une forge en retard, et trois runners qui ne tirent que lorsqu'on les rejoue
Mesure du 20 septembre : la forge du site portait 19bbadf (16 septembre) pour les six
dépôts, le poste 6367d85. Le runner du site était à 504840f — vingt-trois commits en
arrière — et les deux runners de locataire à 8456d29, quinze en arrière. Aucun écart de
contenu : chaque révision trouvée était un ancêtre du poste, donc du retard seul.
make genome-etat le disait déjà, et nommait le remède. Les commits manquants touchaient
scripts/inventory_gui.py, dont « la console dit sa portée » et « la page cesse de
proposer ce que le serveur refuse » — ce que l'exploitant voyait à l'écran.
make genome-pousser remet la forge au niveau du poste ; rejouer serveur_ops remet les
clones au niveau de la forge. Rien ne tire tout seul : aucune minuterie ne rafraîchit
un runner, et c'est assumé, mais l'écart ne se signalait nulle part entre deux rejeux.
Le handler existait, il n'était branché que sur le fichier d'unité
Après les déploiements, les trois consoles portaient 6367d85 sur leur disque et
servaient toujours le code chargé le 16 septembre à 16h36. La cause tient en deux lignes :
le clone du génome n'était enregistré que pour ses tentatives, sans notify, et la tâche
de service demande state: started, qui ne fait rien quand le service tourne déjà.
python3 scripts/inventory_gui.py lit son code au démarrage, et seulement là — le même
piège que le certificat renouvelé qu'un nginx non rechargé continue de servir périmé.
Le rôle retient désormais si le moteur a avancé — serveur_ops_depot_moteur, jamais
un chemin recopié — et redémarre la console dans ce seul cas. Un plan qui avance ne coupe
pas les pages ouvertes. L'expression du when est éprouvée sur cinq cas, dont le mode
--check où la boucle ne rend aucun résultat.
Une sonde qui ignore son mode fabrique une panne permanente
console-ops rendait exit 2 sur les deux runners de locataire — « LE VESTIBULE EST
OUVERT : deployer, creer et raser sont a la portee de qui trouve l'adresse » — sur des
consoles parfaitement fermées. Elle frappait 127.0.0.1:8090, le seul endroit d'où, en
mode oidc, un 200 est le résultat attendu : nginx y est réduit à la boucle locale
précisément pour qu'on ne puisse pas contourner la passerelle SSO. Mesure du jour :
ss et la configuration déployée concordent, oauth2-proxy écoute sur *:4180 et rend
302 vers Keycloak à une requête anonyme.
La sonde connaît maintenant son mode. En locale, nginx est le vestibule et doit
refuser : le verdict ne change pas. En oidc, la serrure n'est pas un code HTTP mais une
adresse d'écoute — toute écoute du port public hors de la boucle locale est la panne, et
le refus de la passerelle reste mesuré par la sonde passerelle de
serveur_oauth2_proxy, posée sur le même hôte. Deux sondes, deux serrures, aucune qui
devine le port de l'autre.
Une panne permanente finit par ne plus être lue. Un instrument qui ne sait pas d'où il mesure invente des défauts, et use la confiance qu'on met dans les vrais.
Ce qui a été vérifié, et ce qui ne l'a pas été
--syntax-check sur playbooks/groupes/serveur_ops.yml et ansible-lint sur le rôle
(profil production, 0 échec, 0 avertissement, 14 fichiers). Le gabarit de la sonde est
rendu dans ses deux modes, sans reste Jinja, et passe bash -n ; son filtre d'écoute est
éprouvé sur cinq formes d'adresses réelles. Le rôle est appliqué aux trois runners et la
sonde rejouée sur chacun.
Le redémarrage automatique n'était pas observé au moment d'écrire ces lignes : les
clones étaient déjà au niveau de la forge, la tâche s'est donc correctement abstenue.
La preuve est venue le jour même, une fois ce commit poussé sur la forge du site — elle
est mesurée dans l'entrée (5) ci-dessus. Les trois consoles ont été redémarrées
à la main ce jour-là pour servir le code courant. Aucune preuve du harnais n'a été
rejouée, aucune voûte ouverte, aucune VM créée ou détruite.
2026-09-20 (3) — La capacité du système ne se déduit pas de la méthode
82 preuves dans le rapport du 17 septembre, non rejouées ici. Le premier comparatif SOC 2 expliquait la différence entre méthode et attestation. Il répondait à côté de la question : le système construit peut-il satisfaire les critères, et que lui manque-t-il ?
Nommer les contrôles disponibles, puis les réglages qui restent à relire
Le dossier HTML/PDF 2026-09-20-comparatif-setops-soc2, hors dépôt dans livraisons/,
est recentré sur cette capacité : oui sous conditions, pas une conformité acquise.
Les identités, flux, certificats, traces, sauvegardes et moyens de reprise sont rapprochés
de critères identifiés, sans déclarer une couverture exhaustive ni un résultat d'audit.
La lecture relève des points concrets : vérification des clés d'hôtes SSH désactivée, interfaces Prometheus/Loki déclarées sans authentification interne, TLS optionnel dans les défauts de certains rôles et limite du VPN sans MFA. Les défauts de rôle ne sont pas présentés comme les réglages réels d'une instance. La conservation des traces, la résistance des sauvegardes, les objectifs de reprise et les traitements applicatifs restent à vérifier selon les engagements. Le dossier propose une recette, il ne l'exécute pas.
Corriger la réponse sans modifier l'infrastructure
Le générateur et le README de livraison sont mis à jour ; les quatre fichiers précédents sont archivés avant remplacement. Les huit pages PDF, leur texte, leurs liens et leur pagination sont contrôlés ; la page HTML est vérifiée de 320 à 1440 pixels et sans JavaScript. Les références officielles AICPA et les constats de code sont relus. Les dossiers clients, le kit VPN et l'habillage partagé restent inchangés.
Aucun rôle, playbook ou réglage SSH/TLS n'est modifié. Aucun sondage de VM, aucune ouverture de voûte, aucun test d'intrusion : la syntaxe Ansible et les preuves ne sont pas rejouées pour cette modification strictement documentaire.
2026-09-20 (2) — Un contrôle rejouable n'est pas une attestation indépendante
82 preuves dans le rapport du 17 septembre, non rejouées ici. Comparer Set-OPS à un service couvert par SOC 2 exige de distinguer l'architecture, son exploitation et l'assurance indépendante : aucun compteur du harnais ne donne un taux de conformité SOC 2.
Comparer sans faire passer une capacité pour un statut
Un nouveau dossier HTML/PDF de huit pages, 2026-09-20-comparatif-setops-soc2, est ajouté
à livraisons/, hors dépôt, avec son générateur et sa documentation. Il reprend
l'habillage partagé sans modifier les dossiers clients ni le kit VPN. Les références
officielles AICPA, consultées le 20 septembre, accompagnent la distinction entre rapport
et certification du logiciel, les types 1 et 2 et les catégories des Trust Services Criteria.
Le rapprochement nomme les mécanismes de Set-OPS, les limites de leurs preuves, les questions d'exploitation et la frontière hébergeur/locataire. Les écarts proposés restent des questions de préparation, pas un audit de l'entreprise ; aucun rapport SOC 2 d'un fournisseur ou de Chezlepro n'a été examiné. Ni conformité ni supériorité de sécurité ne sont attribuées au projet.
Structure, sources, liens, rendu de 320 à 1440 pixels, lecture sans JavaScript et PDF de huit pages sont vérifiés. Aucun sondage des VM, aucune ouverture de voûte et aucune modification du moteur d'exploitation.
2026-09-20 — Une nouvelle couverture ne rajeunit pas une mesure
82 preuves dans le rapport du 17 septembre, non rejouées ici. Les dossiers de livraison datés du 16 septembre présentaient des réponses HTTP anciennes comme un état de livraison et ne décrivaient pas encore les tunnels d'administration nominatifs. Leur révision distingue les capacités, les faits déclarés au plan et les usages à recevoir.
Donner envie sans transformer le dossier en attestation
Les quatre HTML et leurs quatre PDF de livraisons/, hors de ce dépôt, sont revus
pour Chezlepro et TechnoLibre : bénéfices, services reliés, prise en main, autonomie,
remise des clés et responsabilités. Les noms du 16 septembre restent stables ; la
révision du 20 septembre est visible. Les réponses 200/302 restent des mesures anciennes,
pas une preuve actuelle de SSO. La séparation des runners n'est plus présentée comme
une suppression des pouvoirs de l'hyperviseur.
Une source pour les deux formats, et des responsabilités qui ne disparaissent pas
Les générateurs lisent les machines, applications et expositions dans les plans ; la charte conserve ses vingt lignes, lues dans le tableau canonique. L'habillage commun est distinct de celui du kit VPN confidentiel, laissé intact. Un README décrit la régénération et les originaux sont archivés avant remplacement.
Les quatre pages sont vérifiées de 320 à 1440 pixels ; chaque PDF compte sept pages. Les liens, les titres, les cellules de la charte, les adresses dérivées et la pagination sont contrôlés. Aucun déploiement, aucune lecture de voûte et aucun sondage de VM : ces vérifications portent sur les documents, pas sur l'état actuel des services.
2026-09-19 — L’histoire commence là où les traces commencent
82 preuves dans le dernier rapport versionné, non rejouées pour cette page. Le premier commit contient déjà 308 fichiers : raconter la naissance de Set-OPS comme une succession d'étapes antérieures aurait inventé ce que Git ne montre pas.
Les décisions se racontent, leurs sources restent consultables
promo/histoire.html raconte huit étapes à partir des 641 commits accessibles depuis
05b85eb, du 24 juin au 17 septembre 2026. Les reconstructions, les limites des preuves
statiques et les changements de direction gardent leur date et leur périmètre. Le récit
renvoie aux commits charnières ; les 641 titres originaux, leurs dates et leurs identifiants
sont intégrés dans une archive recherchable, avec un graphique d'activité issu du même
corpus. Aucun service externe, aucune police distante, aucun moteur Ansible modifié.
La page Capacités donne accès à cette chronique. Le README de promo/ précise son
caractère figé, son fonctionnement hors ligne et les éléments à actualiser ensemble
pour prolonger le récit. Les vérifications de cette livraison portent sur la page et
ses interactions, pas sur les playbooks ni sur la flotte.
2026-09-17 (6) — TechnoLibre a son tunnel, et son kit de mise en service
- Instance
admins-technolibre(wg23, port 52023,10.23.29.0/24), pair declare dans le plan de TechnoLibre ; deux regles bornees aSETOPS_TENANT_TECH23; 13 machines redeployees (0 failed). - Defaut d'ergonomie corrige avant de servir :
pair-nouveau --locatairerefusait de proposer une adresse a un ecosysteme qui n'a encore AUCUN pair — c'est-a-dire exactement le premier. L'oeuf et la poule, sur le geste d'ouverture. - Kit remis au client (
livraisons/generer-kit-vpn.py, hors depot) : reseau, port, cle publique du serveur et adresses des machines sont DERIVES ou LUS, jamais recopies. Le document dit lui-meme que la premiere cle privee a voyage et comment la remplacer par une cle tiree sur l'appareil.
make prouver : CONFORME, 82 OK.
2026-09-17 (5) — Un tunnel d'administration par locataire : il declare, le site sert
- Nouveau registre de plan :
plan/acces.yml(acces_admin_vpn), une entree par personne ET par appareil, cle PUBLIQUE seule. Decrit au schema (donc editable a la console), valide parvalider_acces: nompersonne-appareil, cle WireGuard bien formee, adresse en /32 dans le tunnel derive, jamais celle de la frontiere, jamais en double. - Tout derive de l'index : reseau
10.<index>.29.0/24, port52000+index, instancewg<index>. Rien a allouer, aucune collision (P21 garde l'unicite des index). scripts/vpn_admin.pytient desormais le tunnel du site ET celui de chaque locataire ;--locatairevise le bon. Le nom du pair sur le boitier porte l'ecosysteme : deux locataires peuvent avoir chacun leurdaniel-portable.- L'isolement est GARDE :
verifierrefuse une regle dont la source est le tunnel d'un locataire et la destination autre chose que SON supernet — sans quoi un ecosysteme obtiendrait un acces chez un voisin en ajoutant un pair dans son propre plan. Mise en defaut eprouvee ; P24 l'execute avant toute ecriture. - Pare-feu des machines :
resoudre_fluxderive le reseau du tunnel du plan du locataire, et seulement s'il declare un pair ACTIF.
Mesure : wg17 actif en 10.17.29.1 avec son pair, port 52017 ouvert sur le WAN, deux regles
bornees a SETOPS_TENANT_CHEZ17, 13 machines de Chezlepro redeployees (0 failed).
make prouver : CONFORME, 82 OK.
2026-09-17 (4) — Le tunnel d'administration, eprouve en production : deux trous refermes
Le tunnel montait, la poignee de main se faisait, une machine de LOCATAIRE repondait — et aucune machine du SITE, ni la console de la frontiere.
- SSH des machines du site : le socle declare son 22 en
pair: [flotte, externe], jamaisadmin. L'acces vivait du REBOND par la frontiere (la connexion part alors du boitier, que pf laisse sortir sans regle) ; par le tunnel, la source est exterieure et rien ne l'autorisait. Les locataires marchaient deja : leur regle SSH nait denftables_admin_ssh. - Console et API de la frontiere : aucun flux ne la designait comme destination d'administration. Monter le tunnel faisait perdre le moyen de reparer la regle manquante — il a fallu remettre l'adresse d'avant pour appliquer le correctif.
- Une regle par port : OPNsense refuse « 22,443 » (« Please specify a valid portnumber, name, alias or range »). Aucun flux jusqu'ici n'en portait deux.
Mesure, tunnel monte et adresse d'avant retiree : site-mon-01, site-ops-01, site-dnspub-01,
les deux infra-dns-01 des locataires et la console OPNsense (HTTP 200) repondent tous, vus
depuis 10.37.29.2. make prouver : CONFORME, 82 OK.
2026-09-17 (3) — L'administration entre par un tunnel nominatif, pas par le runner
Question posee : « le runner ne devrait-il pas etre le rebond SSH des admins ? » Non — il detient la cle de la voute du site et les cles SSH de toutes les machines. Une session humaine compromise deviendrait le plan de controle, et reconstruire le runner couperait l'acces.
- Instance WireGuard
admins(port 51821,10.37.29.0/24), creee parscripts/vpn_admin.py— a COTE du tunnel site-a-site vers le site pair, jamais melee a lui. Perimetre strict : un pair attache a une autre instance n'est jamais touche. - Un pair = une personne ET un appareil. Le plan ne porte que des cles PUBLIQUES ;
pair-nouveautire la paire et affiche la privee une fois.etat: absentrevoque, et tout pair de notre instance que le plan ne declare plus est RETIRE. - Le reseau du tunnel est un reseau d'administration, et tout en derive : pare-feux des
machines du site (
resoudre_flux), contrat vers les locataires (site_intrants, donc leurs machines aussi), regles de la frontiere (devis_opnsense). devis_opnsenseconnait une troisieme voie d'arrivee. Un CIDR d'administration etait soit « gestion » soit « WAN » ; celui d'un tunnel n'est ni l'un ni l'autre, et sa regle aurait ete posee sur une patte que ce trafic n'emprunte jamais.- Un seul port ouvert sur l'Internet : 51821/udp vers l'adresse publique de la frontiere.
Mesure : wg1 active en 10.37.29.1 ; pfctl porte la regle du port et les 15 acces du
tunnel ; les 31 machines (site + deux locataires) acceptent 10.37.29.0/24 en SSH,
0 failed. make prouver : CONFORME, 82 OK. Document : docs/acces-administration.md.
2026-09-17 (2) — La frontiere rejoint la supervision du materiel
- Exportateur : greffon officiel
os-node_exporterinstalle et configure par l'API d'OPNsense, n'ecoutant que sur la patte de supervision (10.37.36.1:9100), collecteurs utiles seulement. Declare dansopnsense.yml(opnsense_exportateur_metriques) : c'est ce fait du boitier qui fait deriver la cible Prometheus (job="frontiere",hote="bifrost-1") et l'hote Icinga — sans lui, rien n'est derive. - Flux
serveur_prometheus -> frontiere:9100. Le bloc des flux vers la frontiere ouvrait TOUT flux a chaque locataire et chaque zone : juste pour l'heure du socle, beaucoup trop large pour une scrutation. Un role autre que le socle ne part desormais que de SES machines du site. Et le saut des fluxfrontierechez les locataires arrive AVANT la creation de l'alias du role : le plan montrait deux aliasSETOPS_*_SRV_PROMETHEUSque rien ne referencait. - Plan de la frontiere : 1 regle (opt9, tcp/9100,
SETOPS_SITE_SERVEUR_PROMETHEUS -> SETOPS_FRONTIERE), appliquee et chargee. - Carte d'orientation : les equipements ne sont plus « sans supervision » (hyperviseurs et frontiere) ; les commutateurs le restent.
Mesure : up{job="frontiere"} = 1 ; check_materiel.py --hote bifrost-1 : OK, 4 coeurs a
41 °C. make prouver : CONFORME, 82 OK.
2026-09-17 (1) — Le materiel se regarde et se juge : hyperviseurs
Les hyperviseurs exposaient deja 68 temperatures, leurs ventilateurs, SMART et l'usure NVMe, collectes par Prometheus depuis le 2026-09-10 — et regardes par personne.
scripts/materiel.py: un catalogue de capteurs et de seuils, lu par Grafana ET Icinga. Releve sur les trois cartes ASUS TUF X670E / Ryzen 9 7900X :AUXTIN*non cables,PCH_*a 0, DDR5 et cartes reseau exposees seulement par le noyau 6.14 de vishnu, SSD Samsung qui portent leur temperature dans l'attribut 190.- Prometheus : chaque cible de la fabric porte
hote="asgard". - Grafana : tableau « Set-OPS — Materiel » (27 panneaux) genere depuis le catalogue.
- Icinga : un hote par hyperviseur, juge par
up; servicemateriel(check_materiel.py).
TROUVE EN LE CONSTRUISANT : le disque sda de gandalf (WD Red Plus 10 To, OSD HDD osd.4 de
Ceph, 26 225 h) porte 56 secteurs en attente, 17 irreparables et 2 realloues — stables sur une
semaine, maximum a vie 63 °C. Ceph HEALTH_OK. Avertissement dans Icinga ; a remplacer.
Deux fautes eprouvees avant d'etre laissees : jointures refusees apres le reetiquetage (doublons de series — cote droit desormais agrege), et un 422 rapporte comme « Prometheus injoignable » (la sonde dit maintenant ce que Prometheus refuse).
Mesure : asgard OK (6 capteurs), vishnu OK (13), gandalf AVERTISSEMENT (secteurs sda). Toutes
les requetes du tableau repondent. make prouver : CONFORME, 82 OK.
2026-09-16 (10) — Publier un service du site sur l'Internet : la redirection devient une capacite
Phase 2, quatrieme volet. Le devis du site sautait tout flux entrant externe (« rien d'autre
n'entre chez le site depuis l'exterieur ») : aucun service du site ne pouvait etre publie
autrement qu'a la main.
- Un flux porte deux ports :
port, ce que la machine ecoute, etport_public, ce que l'Internet frappe.serveur_dns_publicdeclareWAN:53 -> 1053(UDP et TCP) : le 53 public aboutit au frontal dnsdist, jamais a PowerDNS. devis_opnsenseemet uneredirectionet sa regle WAN (sourceany, destination la machine, port local). Une cible unique ou rien ; pas deport_public, pas de publication (une note le dit). La garde refuse une redirection sans regle, ou vers une cible publique.appliquer_opnsensereconcilie les redirections (d_nat, identitesetopsrdr:), sans regle associee creee par le boitier, reflexion NAT desactivee, et les charge (d_nat/apply). Le formulaire encode desormais les champs imbriques (rule[destination][network]) — un seul niveau aurait envoye une destination vide.- Pare-feu d'hote de
site-dnspub-01regenere et pose :1053ouvert a tous,53reste limite aux supernets des locataires.
Plan de la frontiere, lu et non applique : 4 objets a creer (2 regles, 2 redirections),
286 inchanges, rien a retirer. make prouver : CONFORME, 82 OK.
APPLIQUE PAR L'EXPLOITANT, puis mesure depuis un poste qui sort par une AUTRE adresse publique
(.50) : SOA des deux zones servis, MX en TCP, 2 RRSIG, ANY UDP tronque, AXFR refuse, recursion
et zone .internal REFUSED. pfctl : 2 rdr vers 10.37.37.21:1053 ; plan : 0 a creer.
dns1.chezlepro.ca publie chez Namespro, resolu par 9.9.9.9. Retirage horaire du site observe
(23:04, 23:05). Devis de bascule : 0 perdu ; seul prealable restant, le second serveur de noms.
2026-09-16 (9) — Un frontal devant le serveur public : dnsdist borne ce qu'on demande
Phase 2, troisieme volet. Un autoritatif signe DNSSEC expose sans frontal est un amplificateur : 60 octets a adresse usurpee rendent 1 a 3 Ko a la victime.
serveur_dns_publicinstallednsdistsur<hote>:1053(la frontiere y redirigera son 53) ; PowerDNS garde le 53 de la machine pour les NOTIFY des primaires.- Regles eprouvees dans l'unite reelle, avec le vrai PowerDNS derriere : NOTIFY et UPDATE refuses, AXFR/IXFR refuses, ANY en UDP tronque, debit par /24 (UDP 20 q/s -> tronque, TCP 40 q/s -> abandon). Rafale de 300 requetes : 70 reponses, 230 tronquees. Recursion : REFUSED. Signatures DNSSEC relayees.
- Sonde
zones-publiques: interroge par le frontal, alerte si dnsdist est arrete. - Idempotent (second passage : 0 changement). Aucune console de controle ouverte.
PIEGE DE MESURE : dig envoie ANY en TCP par defaut. Le premier essai « montrait » une regle
inoperante ; +notcp et le compteur de la regle ont montre qu'elle agissait.
Rien n'est encore expose : aucun flux externe, aucune redirection a la frontiere.
2026-09-16 (8) — DNSSEC : le locataire signe avec la cle de sa voute
Phase 2 du DNS public, deuxieme volet. Eprouve sur les vraies machines AVANT d'etre code :
cle importee depuis un fichier, transfert signe vers le site, PRESIGNED pose tout seul,
delv « fully validated » (reponses negatives comprises).
- La cle vit dans la voute du locataire (
vault_dnssec_<zone>), tiree parscripts/dnssec.py generer. Une cle nee sur la machine mourrait a la reconstruction, et le DS publie rendrait le domaine BOGUS. Le DS se calcule depuis la voute seule (make dnssec-ds) ; le calcul local a ete confronte apdnsutil: identique a l'octet. setops-dnssec-zonemet chaque zone dans l'etat du plan (cle unique, conforme a la voute), idempotent (second passage : 0 changement), et REFUSE de changer la cle ou de retirer la signature tant qu'un DS est publie — une mesure qui echoue vaut refus.- CAA
issue letsencrypt.orgsurchezlepro.ca(tous ses certificats publics mesures viennent de Let's Encrypt). Pas surtechnolibre.ca: c'est a son responsable de le dire. - Sonde du site : zone signee servie nue = critique ; signatures sous 6 jours = avertissement, sous 3 = critique. Mise en defaut eprouvee.
valider_domainesrefusednssec: truehorsprimaire-cache.- P18 deplie les familles de secrets derivees du plan :
'vault_dnssec_' ~ zoneexige une cle par zone signee, au lieu d'exiger un nomvault_dnssec_que personne ne peut fournir. Au passage :vault_pg_exportateurmanquait au gabarit de Chezlepro (P18 ne lit que l'instance montee).
UNE FAUTE DE CONCEPTION, TROUVEE PAR LE CALCUL APRES LE DEPLOIEMENT. La premiere version
posait SOA-EDIT INCEPTION-EPOCH : serial servi = max(serial, inception). PowerDNS date
l'inception au debut de la semaine PRECEDENTE ; notre serial suit l'heure du dernier
changement et la depasse donc pendant deux semaines. Le serial n'aurait bouge qu'a
l'expiration meme des signatures du site — une fenetre BOGUS a chaque cycle. Passe a
SOA-EDIT EPOCH : serial servi = l'heure, le site retire la zone a chaque rafraichissement.
Mesure en production : serial servi par le primaire = l'heure exacte ; le site a retire les
deux zones par NOTIFY sans intervention ; ancres = DS calcules depuis les voutes, delv valide
MX, CAA, TXT, A et NXDOMAIN sur les deux zones. make prouver : CONFORME, 82 OK, 0 echec.
LIMITE : le retirage horaire (rafraichissement du SOA) n'est pas encore observe ; le journal du site le dira dans l'heure.
2026-09-16 (7) — Les zones publiques portent le courriel : reproduire avant de basculer
Phase 2 du DNS public, premier volet. Une zone publique ne portait que des A d'exposition :
basculer chezlepro.ca aurait fait disparaitre son MX, son SPF et son DMARC — le courriel de
production — a la minute ou le registraire aurait suivi.
- Enregistrements declares au plan (
domaines_publics.<zone>.enregistrements) : A, AAAA, CNAME, MX, TXT, CAA.valider_domainesles passe au crible ; le schema les decrit (et porte desormais lesenumd'une liste d'entrees) ;zone-publique.db.j2les rend, TXT coupes en tranches de 255. - Le serveur de noms porte le nom que le site declare (
dns_public_nom: dns1.chezlepro.ca), transmis par le contrat du site.ns1.<zone>aurait deplacens1.chezlepro.ca, qui existe en production vers .53. Le role refuse un enregistrement redeclare a ce nom. chezlepro.careproduit la production (releve par 9.9.9.9, Namespro refusant le transfert) ;technolibre.caest preparee (courriel chez Koumbit), sans les marquesheritage=external-dns.make dns-bascule-devis(scripts/dns_bascule.py) : plan contre DNS en service, aucune ecriture. Verdict mesure :chezlepro.ca12 identiques, 0 perdu ;technolibre.ca8 identiques, 0 perdu. Bloquent encore : un seul serveur de noms (le.caen exige deux) etdns1.chezlepro.canon publie.
Deux fautes trouvees par l'epreuve, avant la production :
- une boucle Jinja dans
set_factrend une chaine :iny cherchait une sous-chaine, etlepro.ca« appartenait » a la liste. La liste passe en JSON. shlex.splitsur une valeur du plan (non citee) effacait les espaces :v=spf1 mxdevenaitv=spf1mx, et le devis voyait deux ecarts qui n'existaient pas.
Mesure en production : deploiement des deux primaires 0 failed ; le site a tire les nouveaux
serials par NOTIFY sans intervention ; les 20 couples (nom, type) du plan sont servis a
l'identique par site-dnspub-01. make prouver : CONFORME, 82 OK, 0 echec.
2026-09-16 (6) — Le DNS public en production, et cinq fautes que seule la production montrait
La phase 1 tourne. Chaque ligne ci-dessous a ete MESUREE sur les machines, pas deduite d'un recapitulatif Ansible :
primaires (Chezlepro, TechnoLibre) pdns@public actif ; zone .internal demandee a
l'instance publique : REFUSED ; AXFR sans cle : refuse
site-dnspub-01 2 zones sur 2, serials IDENTIQUES aux primaires ;
tirage AUTOMATIQUE en 80 s, jamais force
recursion, AXFR sortant, zone interne : REFUSED
version annoncee : aucune
AXFR non signe vers un primaire refuse PAR LE PRIMAIRE (pas un delai : le chemin
existe, seule la cle manque)
NOTIFY compteur du secondaire 3 -> 5 pour deux envois
frontiere 8 regles CHARGEES, verifiees dans pf
L'epreuve de l'outil avait tourne dans un /tmp, sous l'utilisateur qui la lancait, sans unite systemd, sur la boucle locale. Elle ne pouvait voir aucune des cinq fautes suivantes.
1. Ecrire n'est pas charger — l'applicateur de la frontiere
Deux coupures reseau pendant frontiere-appliquer : les objets ont ete ECRITS dans la
configuration d'OPNsense, l'applicateur s'est arrete avant filter/apply. Au passage
suivant, le plan comparait la configuration au devis, les trouvait d'accord, et rendait
« Rien a faire » — sans jamais charger. pfctl montrait zero regle du DNS public pendant que
le plan disait « a creer : 0 ». Relancer n'aurait JAMAIS repare : le code connaissait ce
defaut pour les routes seules. Desormais, CONFIRMER=true charge meme quand il n'y a rien a
creer, et l'echec dit que la relance terminera le chargement.
2. site-verifier promettait une verification qu'il ne faisait pas
Son aide : « verifie que playbooks/site.yml correspond aux couches declarees ». Son code :
les couches et le graphe, jamais le fichier. serveur_dns_public etait classe, coherent,
et ABSENT du playbook qui deploie le site. verifier rend maintenant le fichier en memoire
et le compare ; eprouve contre la version committee, il nomme le groupe manquant. P08 appelle
cette commande : la garde vient sans nouvelle preuve.
3. SQLite ecrit son journal a cote du fichier
pdns@public refusait de demarrer : « attempt to write a readonly database ». Le fichier
appartenait a pdns — le REPERTOIRE, /var/lib/powerdns, a root. Reproduit en pdns hors
systemd : ce n'etait pas le confinement, c'etaient les droits. La base vit dans un
sous-repertoire a pdns, toutes les commandes pdnsutil s'executent en pdns, et l'ancienne
base — qui portait une cle TSIG — est retiree.
4. La question precede le transfert
Le secondaire n'a tire AUCUNE zone. Avant un transfert (TCP), il demande le SOA — EN UDP. Seul le TCP etait declare : la frontiere laissait passer l'AXFR et jetait la question. Mesure : SOA en UDP, delai ; en TCP, reponse. Un tirage force a la main passait, et aurait fait croire que tout marchait. P82 exige desormais les deux protocoles.
5. failed_when: false avalait un socket introuvable
pdns_control --config-name=public cherchait son socket a l'emplacement par defaut, alors
que l'unite le cree dans /run/pdns-public. La notification echouait a CHAQUE deploiement,
et le handler le taisait. socket-dir est declare, et le failed_when: false est retire :
un canal de controle casse est un defaut, pas un bruit.
Ce qu'elles ont en commun
Toutes rendaient un SUCCES : un plan conforme, un orchestrateur coherent, un deploiement vert, un tirage force reussi, un handler sans erreur. C'est la famille de P79, et la raison pour laquelle chaque verification de cette entree a ete faite sur la machine qui devait utiliser le resultat.
2026-09-16 (5) — Le DNS public, phase 1 : le locataire ecrit, le site sert
Les noms publics des locataires etaient chez un tiers, et le mode autorite: primaire-cache
du plan etait valide par le schema et consomme par rien. Cette phase le fait exercer, sans
rien exposer a Internet.
LOCATAIRE infra-dns-01 pdns@public primaire cache de chezlepro.ca ─┐ NOTIFY
│ AXFR signe TSIG
SITE site-dnspub-01 secondaire public ◄──────────────┘
10.37.37.21 (zone de publication, pas sur l'edge)
Eprouver l'outil avant le role
Deux instances PowerDNS 4.9.17 jetables, sur une machine du site, en repertoire temporaire. Sept verifications, et trois enseignements que personne n'aurait devines :
allow-axfr-ipset TSIG sont ALTERNATIFS. Une adresse listee obtient la zone sans signature — le journal le dit : « allowed: client IP is in allow-axfr-ips ». La configuration evidente rend TSIG decoratif, sans un message. Et la valeur par defaut autorise la boucle locale : mon premier test negatif n'en etait pas un.- Le primaire notifie aussi les adresses de ses NS — donc, en production, les IP
PUBLIQUES des serveurs de noms, depuis l'interieur du locataire.
only-notifyles borne. bind-dnssec-dbest pris en charge alors queldddu module ne montrait pas SQLite.
Trois fautes evitees en concevant
- Une instance a part. Faire ecouter l'instance principale sur l'adresse de l'hote
aurait permis au serveur public du site d'INTERROGER la zone
.internaldu locataire — de lire la carte de ses machines. C'est ce que la charte des responsabilites refuse a l'hebergeur.pdns@publicest la seule a ecouter l'hote. - Le serial suit le contenu. Le role portait un serial fige : sans secondaire, sans consequence ; avec un secondaire, il garde l'ancienne zone POUR TOUJOURS.
- Un mot de flux, pas un nom de role.
serveur_powerdnsexiste aussi au site : une sortie « vers serveur_powerdns » aurait vise le PowerDNS du site lui-meme.
Ce qui est pose
- le role
serveur_dns_public(secondaire, aucun transfert sortant, notifications des seuls primaires,version-string=anonymous, aucun appel vers les serveurs de l'editeur) ; serveur_powerdnsexerceprimaire-cachedanspdns@public;- les relations se DERIVENT des plans des locataires (
site_inventaire.py), l'adresse du primaire de leur nomenclature ; le site transmetdns_public_siteetip_publique_sitepar son contrat ; - deux mots de flux calques sur
runner_site; l'alias des primaires ne vise que les locataires qui publient (Patient0, qui n'a aucune zone publique, en a ete retire) ; - les cles TSIG dans les trois voutes, ecrites avec les gardes connues ;
- la relation de replication avec
SITE-Technolibre, declaree des deux cotes, inactive.
P82
Refuse une zone .internal publiee, une zone declaree et non servie (ou l'inverse), une
adresse publique privee, une replication declaree d'un seul cote, une instance publique qui
autoriserait autre chose que la boucle locale au transfert — et l'exposition du serveur
public a Internet tant que toutes ses zones ne sont pas signees DNSSEC. La condition de la
phase 2 est ecrite comme une garde, pas comme une promesse.
2026-09-16 (4) — La page cesse de proposer ce que le serveur refuse
La portee etait derivee et gardee au serveur ; la page, elle, proposait encore tout. On apprenait donc l'interdit AU CLIC — au milieu d'un geste, sur une console qu'on croyait la bonne.
Un seul entonnoir. Les quatre gestes d'hote passent par lancer(mode, index) : une
garde la couvre les cinq endroits qui emettent un bouton « Deployer ». POUVOIR_PAR_MODE
y rattache chaque mode a son pouvoir, et le refus NOMME sa raison.
Les boutons demeurent, inertes. Les retirer ferait croire que le geste n'existe pas — meme raisonnement que les integrations universelles, affichees en lecture seule plutot qu'omises. Ils portent leur raison en infobulle : « exploiter n'est pas engendrer » sur la creation de VM chez un locataire, « elle n'a la voute d'aucun locataire » sur la configuration chez un site.
La fabric est marquee. Chez un locataire, les devis de commutateurs et de frontiere sont calcules depuis une COPIE locale de la carte du cluster — et une copie avait deja diverge, deux listes de stockages contradictoires pour le meme materiel. Le panneau reste visible et cesse d'etre lu comme une source.
P81 s'etend : elle refuse desormais aussi une page qui aurait perdu POUVOIR_PAR_MODE
ou l'un de ses quatre modes. Le serveur et la page doivent porter la meme coupure.
Le banc de rendu a attrape ce que node --check ne voit pas
Ma garde de page prenait pour un contexte TOUT ce qu'on lui donnait. Dans le banc, la
reponse d'une autre route est passee pour un contexte, puis peut() a lu
contexte.peut[...] sur undefined : TypeError a l'ouverture, page morte. La syntaxe,
elle, etait juste — node --check restait vert, et c'est exactement pourquoi
test_rendu_gui.py existe : le rendu, et non la seule syntaxe.
Deux corrections, et la seconde compte autant que la premiere :
- on verifie la forme avant d'adopter un contexte (
peutest un objet,porteeune chaine) ; - sans contexte connu, on ne retire rien. Une console qui griserait ses boutons parce qu'elle n'a pas su lire sa portee serait pire que le defaut corrige : elle empecherait un geste legitime sans rien expliquer. Le serveur, lui, refuse quand meme — c'est lui la garde.
Et une couleur ne se relit pas, elle se regarde : mon badge de portee posait un texte sombre sur un fond clair, dans une console en theme sombre. Vu sur une capture, pas dans le code.
Au passage, une faute de ma part, attrapee par le harnais
En eprouvant les refus, j'ai vise une machine REELLE depuis une console de locataire
simulee (SETOPS_UNDERLAY neutralise). /api/instancier n'a pas ete refuse — c'est
correct — et a donc regenere hosts.yml de TechnoLibre SANS la fabric : proxmox_pont et
chrony_serveurs perdus, proxmox_etiquette_vlan change, 13 hotes d'ecart.
P03 l'a vu au passage suivant, et le fichier a ete restaure par git. Une epreuve de garde n'a pas besoin d'une cible vivante ; celle-ci en a pris une.
2026-09-16 (3) — La console affichait un ecosysteme vide, et c'etait le site
Servie par le runner d'un SITE, la console d'exploitation montrait zero serveur, zero application, zero base — sans une erreur, sans un avertissement. Le site a sept machines.
Trois faits corrects, mis bout a bout, produisaient un mensonge :
serveur_ops RETIRE le lien `instance` sur un runner d'hebergeur
(juste : un lien perime vers un tenant serait pire)
charger_yaml rend {"all": {"children": {}}} sur un fichier absent
(juste : une console sans plan ne doit pas tomber)
la page dessine ce vide comme un plan vide
(faux : l'inventaire d'un site est un SCRIPT, pas un hosts.yml)
C'est la forme exacte que P79 garde partout ailleurs — une derivation qui ne trouve rien ne se distingue pas d'une derivation qui n'a rien a trouver — sous son pire visage : celui d'un ecosysteme qu'on croit sans machines.
La portee se DERIVE, elle ne se declare pas
Un serveur_ops_gui_mode: site|tenant au plan aurait ete une SECONDE liste, qui prend du
retard des qu'on reconfigure un runner sans y penser. Les deux symlinks, eux, SONT le
pouvoir — et roles/serveur_ops exige deja de savoir lequel il est :
instance/ -> je configure CET ecosysteme (j'ai sa voute)
underlay.yml -> je materialise sur CETTE fabric (j'ai celle du site)
les deux -> poste du mainteneur instance seul -> console de locataire
underlay seul -> console de SITE aucun -> rien a piloter, et elle le dit
Meme coupure que les trois portees de docs/responsabilites-locataire-hebergeur.md :
calculer, configurer, materialiser.
Ce que ca change
Une console de SITE sert desormais l'inventaire dynamique de ses machines
(site_inventaire.inventaire(), importe — pas un sous-processus par page). Ses registres
de plan partent vides avec leur raison : on ne fabrique pas un faux plan de tenant
pour remplir un ecran.
POUVOIR_REQUIS exige un pouvoir pour CHAQUE route POST, et le refus dit pourquoi :
« interdit » sans raison envoie chercher une panne la ou il n'y en a pas. La garde est au
serveur ; le bouton grise n'est qu'une politesse.
Et la page nomme la console. Trois consoles identiques a trois URL differentes etaient un piege pour qui en ouvre deux.
P81
Elle refuse une portee sans source d'inventaire, un site dont l'inventaire ne rend aucune machine, et toute route POST absente de la table — c'est la garde anti-derive : une route d'ecriture ajoutee demain s'executerait sinon sur une console qui n'en a pas le pouvoir, et personne ne le verrait avant l'incident.
2026-09-16 (2) — Les responsabilites, deduites des pouvoirs
Un partage de responsabilites se redige d'habitude en distribuant des devoirs. Celui-ci les DEDUIT, et la regle tient en une ligne :
Qui peut, doit. Qui ne peut pas, ne peut pas etre tenu — et personne ne peut se decharger sur celui qui ne peut pas.
docs/responsabilites-locataire-hebergeur.md part donc des trois portees qui existent
deja dans le moteur — calculer, configurer, materialiser — et en tire vingt lignes,
chacune citant le mecanisme qui la rend vraie : un role, une garde, une commande.
Les lignes qui ne se devinaient pas
Les sauvegardes se partagent en deux, et la coupure est nette. L'hebergeur repond de ce que sa sonde peut constater : le depot accepte-t-il encore une ecriture ? Il ne peut pas juger un instantane — il heberge des octets chiffres qu'il ne peut pas ouvrir. « Cet instantane est-il recent, complet, restaurable ? » se demande a qui detient la cle. La verification suit la cle.
L'acces de secours est dit plutot que taire. D-40 donne a l'hebergeur un sudo sur
les machines qu'il exploite : exploiter, c'est pouvoir tout lire. La charte l'ecrit, et
enchaine sur sa consequence — c'est la raison d'etre du second temps de la remise, et de
sa DATE.
Une ligne sans vis-a-vis est un trou, pas une zone partagee. C'est la garde de lecture du tableau : le silence n'a jamais cree de responsabilite.
Le PDF LIT la doctrine, il n'en porte pas de copie
Les chartes remises a Chezlepro et a TechnoLibre sont engendrees depuis le tableau du depot, entre ses ancres. Deux textes qui diraient deux choses differentes seraient pires que l'absence de charte : chacun aurait raison de croire le sien. Le generateur REFUSE de rendre si l'ancre a disparu, plutot que de livrer une charte tronquee.
Au passage, les deux generateurs partagent desormais commun.py — la liste des
locataires et la feuille de style existaient en double.
Ce qui n'est pas tranche est nomme
Le recouvrement de la cle du responsable designe, le changement de responsable, et la duree de retention avant purge. Une ligne rassurante sans mecanisme derriere vaut moins qu'un point ouvert ecrit.
2026-09-16 (1) — Remettre un ecosysteme : une procedure, un outil, une garde
Livrer se terminait par une phrase : « tes cles te seront remises separement ». Ce qui se passait ensuite n'etait ecrit nulle part — ni ce qu'on remet, ni dans quel ordre, ni ce qu'on garde. Le geste qui donne le controle d'une organisation etait le seul geste lourd du depot sans procedure, sans outil et sans preuve.
Et il portait une faute qui ne se serait vue de personne : les cles vivent toutes dans le
meme dossier (~/.config/setops-vault-*). Remettre « les cles » d'un revers de main,
c'est remettre celles du SITE et celles des autres locataires.
Deux temps, et ils ne se confondent pas
| Ce qui passe | Ce que le client peut | |
|---|---|---|
| Temps 1 — l'identite | la cle de SA voute, sa voute chiffree, la racine de SON AC | creer, retirer, habiliter ses gens — des le premier jour |
| Temps 2 — la machine | sa cle SSH entre, celle de l'hebergeur sort, la voute change de mot de passe, les secrets tournent | tout, y compris se passer de nous |
Le second temps applique a une LIVRAISON ce que migration-tenant.md applique deja a un
DEPART : revoquer, pas transmettre. Sans lui, l'hebergeur garde a vie l'acces aux
secrets d'un client qui se croit chez lui — et personne ne decide jamais de le garder :
on oublie de le rendre, et le silence transforme l'oubli en etat de fait.
Ce que l'outil refuse
scripts/remise.py reprend la forme d'exporter_cles.py — ecrire puis RELIRE, ne jamais
afficher une valeur, refuser une destination dans l'infrastructure — et y ajoute deux
refus propres a la remise : partir sans la racine de l'AC (le client apprendrait a
cliquer sur « continuer quand meme »), et emporter autre chose que l'ecosysteme monte.
Cette derniere garde tient en une ligne, _cle_de_voute(), qui ne retient que le role
instance parmi les trois que voutes.py nomme.
remise-recleer mesure avant d'estampiller : une cle presente, une cle revoquee, une
cle de voute differente de celle remise. Un registre qui dirait « revoque » pendant que le
plan garde la cle de l'hebergeur flatterait tout le monde.
Le registre, et une question ouverte depuis longtemps
remise.yml se pose chez le locataire a cote de parente.yml : de qui il descend d'un
cote, a qui il appartient de l'autre. Il porte des empreintes SHA256, jamais des
valeurs — il est versionne et pousse sur trois forges.
Il declare enfin le responsable designe. D-18 le decide depuis longtemps ;
migration-tenant.md §9 listait « ou est-il declare ? » parmi ses questions ouvertes. La
reponse est la seule qui ne devine rien : c'est la personne qui RECOIT, nommee au moment
ou elle recoit.
P80
Elle refuse un registre incomplet, un second temps echu et non fait, un second temps declare fait pendant que le plan ne revoque aucune cle, et un secret qui se serait glisse dans un fichier versionne. Elle ne juge PAS un ecosysteme sans registre : le lab, patient 0 et l'ecosysteme de l'hebergeur ne seront jamais remis a personne.
2026-09-15 (7) — P79 : une derivation qui ne trouve rien ne passe plus pour un succes
En deux jours, pour monter l'edge du site puis les trois consoles, le meme defaut est revenu sous sept noms. Chaque fois un repli rendait un SUCCES au lieu d'un refus : deploiement vert, devis muet, harnais vert. Une derivation qui ne trouve rien ne se distinguait pas d'une derivation qui n'a rien a trouver.
Ce que P79 regarde
| Temoin | Le repli qu'il attrape |
|---|---|
| jumeaux | dns_amorcage derive sans artefacts_amorcage : le gabarit garde son ancien proxy apt |
| patte | une zone occupee absente de opnsense_if_zones : regles posees sur la patte plate |
| edge | une exposition qui retombe sur son propre groupe dans un ecosysteme qui A un edge |
| bouchon | l'edge du site sert ssl-cert-snakeoil.pem, faute de group_vars |
| rechargement | un edge qui renouvelle son certificat sans recharger nginx |
| internet | une sortie vers un role absent traduite en sortie vers l'Internet |
| flux | les regles d'hote rejouees pour CHAQUE locataire, pas seulement l'instance montee |
Chaque temoin lit sa source directement — inventaire, plan, table ecrite, meta/flux.yml
— et ne passe jamais par la derivation qu'il juge. La lecon de P43 et de P67 : une garde
qui reproduit le raisonnement qu'elle verifie ne verifie rien.
Le temoin des flux rejoue resoudre_flux.py nftables dans un dossier temporaire qui
reprend l'instance par liens : rien n'est ecrit dans les depots.
Eprouvee en lui remettant chaque faute sous les yeux
Chaque temoin prend ses donnees en parametre. Les sept fautes ont ete reinjectees une a
une dans les donnees reelles du site (zone retiree de la table, edge retire de
domaines.yml, certificat, rechargement et jumeau retires de l'inventaire, regle
!SETOPS_INTERNES sur 636 ajoutee au devis) : sept refus, et zero sur les donnees saines.
Sans edge, le repli « le service se sert lui-meme » reste juste, et la garde se tait.
Et elle a trouve deux ecarts reels des sa premiere execution
OPS-Chezlepro-lab 14 regles de machines retirees du plan, aucune pour ops-01
OPS-Patient0 4 regles perimees (admin en 10.17.0.0/24, sans le refus
`admin-prohibited`), aucune pour ops-01
Les deux dataient d'avant le renumerotage du site : make flux n'avait jamais ete relance
avec eux montes. Regeneres par SETOPS_INSTANCE, sans basculer l'instance. Ce sont des
apercus, rien n'est applique a une machine.
Ce qu'elle ne couvre pas
La collision des noms publics par groupe (P67 la garde). client_pki qui rend
changed=0 sans comparer ses SAN : cela se mesure sur la machine (make certificats-plan). Un registre facultatif exige par include_vars : celui-la echouait
bruyamment.
Au passage
Les gabarits de voute de OPS-Patient0 et OPS-Chezlepro-lab portaient
vault_setops_console_oidc depuis la session precedente, jamais commite, et le premier
avait un commentaire coupe de sa cle. Remis en forme et commites.
2026-09-15 (6) — Les trois consoles repondent, chacune derriere sa propre serrure
console.genese.internal 401 vestibule HTTP Basic (pas d'annuaire)
console.chezlepro.internal 302 -> Keycloak oauth2-proxy, client `setops-console`
console.technolibre.internal 302 -> Keycloak oauth2-proxy, client `setops-console`
Chaque ecosysteme sert sa console, chez lui, sous le nom que son plan lui donne. Le site avec un mot de passe parce qu'il n'a pas d'annuaire ; les locataires avec le leur.
La pile, identique partout, verifiee sur la machine
127.0.0.1:8765 le GUI — sa seule serrure est de n'ecouter que la
127.0.0.1:8090 le vestibule boucle locale ; il sert sa page a qui la demande
0.0.0.0:4180 la passerelle — seule publiee, et elle renvoie vers Keycloak
Ce que le deploiement a demande
Un secret OIDC de 48 caracteres dans chaque voute de locataire (memes gardes : copie de
surete, dechiffrement vers un fichier, relecture, en-tete verifie, empreinte comparee,
clair passe au shred), un client setops-console dans chaque Keycloak, et la regle
d'hote edge -> ops-01:4180.
CETTE DERNIERE A MANQUE DEUX FOIS, POUR LA MEME RAISON. make flux ecrit pour l'INSTANCE
MONTEE : lance avec Chezlepro monte, il n'a rien regenere pour TechnoLibre. Le devis etait
juste, le fichier de TechnoLibre n'existait simplement pas — et l'edge rendait 502
derriere un TLS parfait.
Chez un locataire ce flux ne traverse pas la frontiere (le SDN de Proxmox tient les passerelles de zone) : c'est le pare-feu d'HOTE qui le porte. Au site, c'etait la frontiere. Le meme flux, deux couches differentes, selon qui route.
Deux mesures a moi qui ont menti
tls=1 sur TechnoLibre. J'ai conclu a une chaine invalide. L'AC que j'avais
recuperee faisait 0 octet : le ssh qui devait la lire avait echoue sans que je
regarde son code de sortie. Le certificat servi portait le bon nom et le bon emetteur.
Les cles d'hote de TechnoLibre ont change a sa reconstruction, et known_hosts porte
encore les anciennes. Je n'y ai pas touche — c'est le fichier de l'exploitant, et une
entree qui change est exactement ce qu'un avertissement doit faire remarquer. La chaine a
donc ete lue dans ce que l'edge SERT, pas dans ce qu'une machine aurait bien voulu me
donner.
Consequence assumee : la console de TechnoLibre est verifiee par son COMPORTEMENT (302 vers son Keycloak) et par le nom de son certificat, pas par une verification complete de chaine depuis le poste — il y faudrait sa racine, que je n'ai pas pu lire.
2026-09-15 (5) — La console chez les locataires, une collision de noms, et un fichier que j'ai ecrase
Les deux locataires declarent desormais leur console derriere leur SSO. Le chemin a traverse une collision reelle et une faute de ma part.
console.<domaine>, derriere oauth2-proxy
ops-01 serveur_ops_gui_actif: true, auth: oidc
le GUI sur 127.0.0.1:8765, le vestibule nginx sur 127.0.0.1:8090
la passerelle sur 4180, publiee par l'edge
oidc ET PAS locale : ces ecosystemes ont un annuaire. Cette console lance des
deploiements et peut RASER — un groupe se revoque sans deploiement (D-66), un mot de passe
partage devant ce pouvoir est un accident qui attend.
ET LE VESTIBULE N'ECOUTE PLUS QUE LA BOUCLE LOCALE EN oidc. Le gabarit annoncait cette
protection — « lui seul doit pouvoir frapper ce port » — en s'en remettant a
meta/flux.yml, qui ouvre le 8090 depuis [edge, admin]. L'edge pouvait donc joindre la
console SANS passer par la passerelle. Un port qui n'est pas ouvert ne se contourne pas ;
une regle, si.
UNE COLLISION DE NOMS QUE LA GARDE NE VOYAIT PAS
serveur_oauth2_proxy est mono-instance par machine : une devant la vigie sur le noeud de
supervision, une devant la console sur le runner. La table des noms publics etait indexee
par GROUPE :
mon-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
ops-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
La passerelle de la VIGIE se croyait la console. Son URL de retour OIDC aurait vise l'autre machine, et le SSO de la supervision serait tombe — pour un service auquel on n'avait pas touche.
Une application nomme un COUPLE (machine, role). instancier et site_inventaire en
ont la clef juste desormais.
ET P67 NE LE VOYAIT PAS, parce qu'elle indexait par groupe comme la derivation qu'elle garde : elle comparait la valeur a elle-meme. Une garde qui reproduit le raisonnement qu'elle verifie ne verifie rien. Elle compare maintenant par machine, et elle a ete eprouvee en lui remettant la faute sous les yeux.
CE QUE J'AI CASSE, ET LA MESURE QUI ME L'A CACHE
J'ai ecrit group_vars/serveur_ops.yml avec cat > sans regarder s'il existait. Il
existait : 84 lignes chez Chezlepro, 77 chez TechnoLibre. J'en ai detruit 68 — la liste
des depots du genome, la branche master de TechnoLibre, l'amont de la forge, la source
de la cle de voute.
Deux preuves sont tombees (P05, P32). J'ai d'abord accuse le retrait des forges de la veille, et construit une explication complete : « le site expose l'amont, le locataire ne le porte pas ». Elle etait fausse — le fichier restaure le portait deja.
LA MESURE QUI M'A RASSURE A TORT :
(cd $e && git diff -- inventories/*/group_vars/serveur_ops.yml)
Le glob est developpe par le shell PARENT, ou inventories/ n'existe pas. Le motif est
passe tel quel, n'a rien matche, et git diff n'a rien affiche. J'ai lu ce silence comme
« aucun fichier ecrase ». C'est git status — sans glob — qui a fini par montrer le M.
Une commande qui ne trouve rien et une commande qui trouve que rien n'a change rendent le meme silence. Encore la meme forme que les six replis des deux derniers jours, cette fois dans mes propres mains.
Restaure par git checkout, puis les trois lignes de la console ajoutees SANS toucher au
reste. Harnais : 77 OK.
2026-09-15 (4) — La console d'exploitation est allumee, et publiee par l'edge
https://console.genese.internal/ 401 sans justificatif, la console avec
Le site est le premier ecosysteme a servir son GUI par un nom, en TLS verifie. Il a fallu qu'il ait un edge pour que ce soit possible.
Le vestibule reste, et c'est le contraire d'une contradiction
La veille, on RETIRAIT le vestibule de la vigie : Icinga Web 2 sait s'authentifier, en poser un devant reinventait sa page de connexion. Ici on en GARDE un, et la difference est tout le sujet :
Icinga Web 2 a une page de connexion, des comptes, des groupes -> pas de vestibule
GUI Set-OPS `GET /` sert la page A QUI LA DEMANDE, jeton inclus -> le vestibule EST
sa seule serrure
Le jeton du GUI garde contre le CSRF, pas contre un visiteur. Ce qui protege la fabric,
c'est que le service n'ecoute que 127.0.0.1 — verifie sur la machine :
LISTEN 127.0.0.1:8765 <- le GUI, joignable de nulle part ailleurs
LISTEN 0.0.0.0:8090 <- le vestibule, seul publie
locale (HTTP Basic) parce qu'un site n'a ni annuaire ni Keycloak. Chez un locataire ce
sera oidc, derriere oauth2-proxy — et c'est la que l'integration a sa place.
Controle dans les deux sens
Une serrure ne se prouve qu'en la forcant ET en l'ouvrant :
sans justificatif HTTP 401
avec 152 805 octets — « Set-OPS · Votre artisan numerique »
182 vues du GUI, le jeton injecte dans la page
Et la sonde console-ops mesure exactement ca — elle exige un REFUS sur une requete
anonyme, parce qu'un 200 y serait la pire des reponses :
site-ops-01 | console-ops | OK | Console vivante, vestibule (locale) en place :
une requete anonyme rend HTTP 401.
Le mot de passe le plus lourd de la voute
48 caracteres la ou les autres en ont 32 ou 40. Il n'ouvre pas une console de lecture : il
ouvre deployer, creer, instance-utiliser et l'edition du plan. Le cout d'un caractere
de plus est nul ; celui d'une console forcee ne l'est pas.
2026-09-15 (3) — Le charabia de la console : deux bases confondues, et un etat sans domicile
L'exploitant, apres s'etre connecte : « icingaweb2 affiche un tas de charabia quand j'ouvre une session ». Deux causes, dont la premiere etait de mon fait.
UN ROLE PARTAGE REND DES FAITS PARTAGES
serveur_icingaweb2 appelle resoudre_base DEUX fois : une pour la base du MOTEUR
(icingadb, en lecture) et une pour celle des COMPTES (icingaweb2). Le second appel
ecrase les faits du premier — resoudre_base_entree, resoudre_base_db_host... — et les
gabarits, rendus apres les deux, lisaient la SECONDE base partout :
[icingadb] dbname = "icingaweb2" <- la base des comptes
[icingaweb_db] dbname = "icingaweb2"
La console cherchait donc icingadb_schema dans la base des comptes, ne l'y trouvait pas,
et rendait une trace PHP a chaque page. Les 66 tables du moteur etaient intactes a
cote. Un defaut qui accuse la base pendant que la base va bien.
Celui qui appelle un role partage deux fois doit NOMMER ses resultats avant de le rappeler. C'est « une liste qui suit une autre », appliquee au temps plutot qu'a l'espace.
Introduit la veille, en remontant le bloc de la base au-dessus du rendu des .ini — un
correctif d'ORDRE qui a cree un defaut de PORTEE.
LA CONSOLE N'AVAIT PAS DE DOMICILE POUR SON PROPRE ETAT
config_backend = "ini" laissait les preferences en fichiers. Tenable — sauf que le cadre
de MIGRATION d'Icinga Web 2 veut une instance de base quoi qu'il arrive :
Failed to load pending migrations : Please check if a db instance exists at all
Cannot load preferences for user "icinga-admin" : Cannot load resource config ""
La seconde n'apparaissait qu'A LA CONNEXION. Un journal muet ne prouvait donc rien tant que personne n'avait ouvert de session — et c'est pour ca que le controle a consiste a en ouvrir une vraie, pas a regarder le journal d'une console au repos.
Des lors que la console a une base, il n'y a aucune raison d'y ranger les comptes et pas
le reste : config_backend = "db", config_resource = "icingaweb_db". Les preferences
d'un utilisateur le suivent alors d'un navigateur a l'autre.
Eprouve en ouvrant une session
session ouverte oui
tableau de bord « Current Incidents :: Dashboard »
/icingadb/services 44 459 octets, 0 trace
/icingadb/hosts 31 169 octets, 0 trace
journal 1 ligne en 100 s (aucune erreur)
CE QUE J'AI ATTRIBUE A TORT A MON PROPRE GESTE
J'ai recharge php-fpm a la main et conclu que c'etait ca. Les horloges disent le contraire :
derniere erreur 10:15:32
php-fpm redemarre 10:15:44 <- par le handler du role
mon rechargement ~10:22 <- sur un systeme deja sain
Le role etait deja juste. Mes mesures intermediaires portaient sur une fenetre
(--since "-5min") qui enjambait le correctif : je lisais des erreurs d'AVANT en croyant
observer l'APRES. Une fenetre de mesure qui contient le moment du changement ne mesure ni
l'un ni l'autre etat.
2026-09-15 (2) — Un vestibule devant une application qui savait deja s'authentifier
L'exploitant, en voyant la boite du navigateur sur vigie : « Dans le contexte d'un
site, cela complexifie inutilement les choses puisque Icinga Web 2 permet nativement de
gerer des comptes. Ce genre d'intervention n'est pertinente que pour integrer Icinga a
Keycloak chez les tenants. »
Il a raison, et la correction retire du code au lieu d'en ajouter.
Ce que le mode locale faisait, et pourquoi c'etait de trop
Il posait un auth_basic nginx devant le backend external : nginx demandait le mot de
passe, Icinga Web 2 croyait le REMOTE_USER qu'il recevait. Ca marchait. Ca reinventait
une page de connexion devant une application qui en a une, et ca privait l'exploitant
de ce que l'application sait faire seule.
UN VESTIBULE N'A DE SENS QUE DEVANT UNE APPLICATION QUI NE SAIT PAS S'AUTHENTIFIER. Celle de Set-OPS — le GUI — est dans ce cas, et son vestibule reste. Icinga Web 2, non.
Le mode db : le backend natif
authentication.ini backend = "db" resource = "icingaweb_db"
groups.ini backend = "db" — les groupes aussi sont natifs
nginx plus aucun auth_basic
CE QUE CA REND, ET QUI MANQUAIT. La gestion des comptes DANS l'interface : creer, desactiver, changer un mot de passe ne demande plus un deploiement ni un passage par la voute. Le moteur ne pose qu'UN compte — celui qui permet d'entrer la premiere fois — et il ne l'ecrase jamais : un mot de passe change dans l'interface appartient a celui qui l'a change.
ET D-66 REDEVIENT APPLICABLE SANS ANNUAIRE. Les groupes d'Icinga Web 2 vivent en base :
roles.ini habilite donc sysadmin, un GROUPE, et non plus une personne nommee en dur.
Revoquer quelqu'un redevient un clic.
UNE BASE A ELLE, PAS UN COIN D'icingadb. Les tables de comptes appartiennent a
l'application web ; celles du moteur sont reecrites par ses migrations. Les meler ferait
disparaitre les comptes le jour d'une mise a jour, sans que personne n'ait touche aux
comptes.
LE HACHAGE EST CELUI QUE L'APPLICATION VERIFIE : password_hash() de PHP, la fonction
exacte qu'Icinga Web 2 appelle a la connexion. Un hachage d'un autre outil donnerait une
base valide et une connexion impossible.
Deux pieges du renommage
when: serveur_icingaweb2_auth != 'locale' gardait l'appel a resoudre_annuaire.
Juste tant que locale etait le seul mode sans annuaire ; renomme en db, la garde a
cesse de garder et le role a reclame un secret de liaison LDAP qui n'existe pas. Les
conditions nomment desormais les modes qui VEULENT un annuaire (ldap, external) :
une condition qui dit ce qu'elle veut survit a un renommage, une qui dit ce qu'elle
refuse, non.
L'ordre des taches. Le bloc de la base avait pris la place de l'ancien vestibule —
c'est-a-dire APRES le rendu des .ini, qui nomment cette base. Le gabarit lisait des
variables inexistantes, et l'echec etait censure par no_log : changed: true et
rien d'autre. Une tache qui manipule des secrets ne peut pas dire ce qui lui manque ;
c'est a l'ordre de ne pas la mettre dans cette situation.
Eprouve
auth_basic dans nginx 0
backend declare db / icingaweb_db
compte en base icinga-admin, actif
depuis le poste 302 -> /authentication/login -> 200
la page « Icinga Web 2 Login », champs username et password
2026-09-15 (1) — L'edge publie : trois noms, en TLS verifie de bout en bout
observatoire.genese.internal http=302 tls=0 (Grafana, vers sa connexion)
vigie.genese.internal http=401 tls=0 (le vestibule tient)
forge.genese.internal http=200 tls=0
tls=0 : verification reelle contre la racine du site, pas un -k complaisant.
expositions n'etait resolu nulle part
serveur_nginx declare egress port: derive, pair: expositions — « je sors vers ce que
je publie ». Ce mot n'etait consomme par AUCUN moteur : ni le pare-feu d'hote, qui ne
filtre pas l'egress, ni le devis de frontiere, qui ne le connaissait pas.
IL N'AVAIT JAMAIS EU BESOIN DE L'ETRE. Chez un locataire, les passerelles de zone sont tenues par le SDN de Proxmox : le trafic edge -> amont ne traverse pas le boitier. Au SITE, elles sont tenues par la frontiere — premier endroit ou un edge doit franchir la bordure pour atteindre ce qu'il relaie. Une declaration peut dormir des mois avant que la premiere fabric ne la reveille.
UNE REGLE PAR AMONT, avec SON port :
opt10 tcp 443 SERVEUR_NGINX -> SERVEUR_FORGEJO
opt10 tcp 3000 SERVEUR_NGINX -> SERVEUR_GRAFANA
opt10 tcp 8080 SERVEUR_NGINX -> SERVEUR_ICINGAWEB2
Jamais une regle large. Un edge qui publie trois services ne doit joindre que ces trois-la : l'ouvrir sur « tout le site » annulerait ce qu'une zone de publication separee cherche a obtenir.
LA MEME FAUTE QU'HIER, AVALEE DE LA MEME FACON
Premiere ecriture de cette resolution : U.lire_plan_site(...) dans un fichier ou le
module s'importe sous underlay_mod. Le NameError est tombe dans un
except Exception large, et le devis a rendu une note plausible — « le plan du site
n'en declare aucune avec un port » — alors que le plan en declarait trois.
C'est exactement le defaut du 2026-09-14 sur site_inventaire.py, refait 24 h plus tard
par la meme main. Le except est resserre a (OSError, ValueError) : une faute de frappe
doit faire du bruit, pas une phrase credible.
Le schema de l'amont suivait une constante, pas le port
proxy_pass http://… etait ecrit en dur. La forge termine deja son TLS sur 443 : elle
recevait du clair sur un port qui attend une poignee de main, et rendait 400. Du TLS
parfait a l'entree, une erreur de protocole a la sortie.
Le schema suit desormais le port (serveur_nginx_ports_tls), et l'amont est verifie :
proxy_ssl_verify on contre l'AC interne. Un edge qui relaie en TLS sans verifier ne fait
que DEPLACER la confiance — le visiteur croit l'edge, l'edge ne croit personne.
2026-09-14 (23) — Du TLS qui ressemblait a du TLS, et une regle qu'un tenant n'a jamais eu besoin d'ecrire
Trois defauts de plus sur le chemin de l'edge, et le premier est le plus grave de la journee.
L'edge servait ssl-cert-snakeoil.pem
Le certificat bouchon de Debian, sur les trois noms. serveur_nginx l'a pour DEFAUT,
avec un commentaire qui date d'avant la PKI : « a remplacer par des certificats de l'AC
interne plus tard ». Un locataire le remplace dans group_vars/serveur_nginx.yml ;
l'inventaire du site est DYNAMIQUE et n'a pas de group_vars.
LES AUTRES REPLIS RENDAIENT UN SERVICE MUET OU UNE REGLE INERTE. Celui-la rend du TLS qui ressemble a du TLS : le cadenas s'affiche, la connexion est chiffree, et rien n'est prouve — le certificat n'est signe par personne et ne porte aucun des noms servis. C'est la seule sorte de panne qui rassure.
Deux ecritures d'une meme regle, et seule l'une a appris
site_inventaire.py construisait sa PROPRE liste d'expositions, avec edge = le groupe
de l'application. Le commentaire disait pourquoi : « le site n'a pas d'edge, donc chaque
service se sert lui-meme ». C'etait vrai jusqu'a hier.
client_pki retient un FQDN si son edge est un GROUPE DE CET HOTE. L'edge appartient a
serveur_nginx ; cette boucle rendait serveur_grafana, serveur_icingaweb2... Aucun
nom ne correspondait. La boucle est remplacee par un appel a
expositions_des_applications — la regle n'existe plus qu'une fois.
La cicatrice refaite sur la machine suivante
site-forge-01 porte ce commentaire depuis des semaines : « Un cert renouvele sur disque
reste servi perime tant que le consommateur n'est pas recharge. La cicatrice est deja dans
le depot ; on ne la refait pas. » Elle a ete refaite sur site-edge-01, qui n'avait pas
de client_pki_reload_services.
disque : forge, pki, dns, sauvegarde, observatoire, vigie, site-edge-01
servi : site-edge-01
Ou en est l'edge
certificat servi les 6 noms, signe par l'AC du site
verification TLS verif_tls=0 depuis le poste, contre la racine du site
vhosts forge, observatoire, vigie — en 443
CE QUI RESTE, ET C'EST UNE VRAIE PIECE
Les trois noms repondent en TLS verifie, puis rien : http=000. L'edge ne joint pas
ses amonts, et le devis le dit lui-meme :
note : serveur_nginx declare un port `derive` que le plan du site ne resout pas
— aucune regle emise.
serveur_nginx declare bien egress port: derive, pair: expositions. Ce flux n'a JAMAIS
eu besoin d'etre resolu a la frontiere : chez un locataire, les passerelles de zone sont
tenues par le SDN de Proxmox, et le trafic edge -> amont ne traverse pas le boitier. Au
site, elles sont tenues par la frontiere — et c'est le premier endroit ou un edge doit
franchir la bordure pour atteindre ce qu'il relaie.
Resoudre expositions en une regle PAR AMONT (son hote, son port) est la piece qui
manque. Elle se voit maintenant parce que le site est la premiere fabric ou un edge et
ses services vivent dans des zones differentes.
Et forge relaie en http://10.37.33.11:443 — du clair vers un port TLS, d'ou son 400.
L'amont d'une exposition deja servie en TLS doit etre https://.
2026-09-14 (22) — L'edge du site est debout, et quatre replis silencieux l'ont retarde
site-edge-01 est nee, deployee, et sert forge, observatoire et vigie en 443. Le
chemin jusque-la a traverse quatre defauts qui ont tous la MEME forme : un repli qui rend
un succes au lieu d'un refus.
1. Le gabarit dore porte une adresse d'avant le renumerotage
Failed to update apt cache after 5 retries
Acquire::http::Proxy "http://10.0.33.21:3142"
Le fichier le dit lui-meme : « Gere par Set-OPS pendant la FABRICATION DU GABARIT »,
date du 2026-09-01, avec l'ancienne adresse du cache. Le socle ne le remplace que
when artefacts_amorcage is defined — et site_inventaire.py derivait dns_amorcage
sans deriver son JUMEAU. Les machines nees AVANT le renumerotage n'ont rien vu :
l'adresse etait juste, puis client_artefacts a pose un fichier qui trie apres.
La premiere machine neuve du site l'a revele. Le site fournissait deja cette valeur a
ses locataires (site_intrants.py) ; il ne se la donnait pas a lui-meme.
2. Une table de pattes tenue a la main, et un repli qui deplace au lieu d'omettre
Le devis rendait « rien a faire », et le cache restait injoignable. opnsense_if_zones
associe chaque zone du site a sa patte de frontiere ; site-publication n'y etait pas, et
_if_de() retombe alors sur l'ANCIENNE PATTE PLATE. Les 20 regles de l'edge etaient
posees sur vlan030 — syntaxiquement correctes, jamais rencontrees, puisque le trafic de
l'edge entre par vlan037.
Le fichier documentait deja un incident identique, un mois plus tot, pour la patte de la fabric : « La regle etait syntaxiquement correcte et ne correspondait jamais. » Ajoutee, le devis a rendu 20 a creer, 20 a retirer — le meme jeu de regles qui change de patte.
3. Un registre facultatif qui faisait echouer un role
serveur_nginx chargeait applications.yml ET domaines.yml sans condition. Un SITE ne
publie rien a l'Internet : son plan n'a pas le second.
Could not find or access '.../SITE-Chezlepro/plan/domaines.yml'
Le role releve desormais ce qui EXISTE avant de charger. Un registre absent laisse
domaines_publics indefini, et le filtre le traite deja comme vide.
4. Le vhost genere faisait 43 octets, et le deploiement etait vert
C'est le plus retors des quatre. expositions_des_applications resout, pour chaque nom
expose, l'edge de son domaine PARENT — et sans registre, retombe sur le groupe de
l'application elle-meme (serveur_grafana, serveur_icingaweb2). Jamais
serveur_nginx. Aucune exposition n'etait donc retenue, le fichier ne contenait que son
en-tete, et rien n'echouait.
Une derivation qui ne trouve rien ne se distingue pas d'une derivation qui n'a rien a
trouver. Le site declare donc plan/domaines.yml : genese.internal, autorite
auto-heberge, edge serveur_nginx, sans DNSSEC — le TLD internal. est nie par la
racine signee, signer sous une chaine rompue ajoute du travail sans ajouter de preuve.
Et grafana n'avait pas de port: : le vhost le disait dans son propre en-tete — « une
application sans hote actif, ou sans port, est listee mais NON publiee ».
Etat
site-edge-01 10.37.37.11 deployee (174 taches, 66 changements)
nginx ecoute 80 et 443
vhosts forge -> 10.37.33.11:443
observatoire -> 10.37.36.11:3000
vigie -> 10.37.36.11:8080
frontiere 274 regles, devis muet
Ce qui reste, et c'est precis
Le certificat de l'edge ne porte encore que site-edge-01.genese.internal. Il a ete
emis avant que les expositions existent, et client_pki rend changed=0 : il ne compare
pas son jeu de SAN a celui qu'il derive maintenant. Tant que ce n'est pas fait, les trois
noms repondent sur un certificat qui ne les couvre pas.
forge relaie par ailleurs en http://10.37.33.11:443 — du clair vers un port TLS, d'ou
son 400. L'amont d'une exposition deja servie en TLS doit etre https://.
Deux corrections a faire, et aucune n'est une surprise : ce sont les deux dernieres jointures entre le plan et ce que l'edge en tire.
2026-09-14 (21) — Le site gagne un edge, et P23 a nomme le geste qui manque
Le site servait trois applications web sans jamais les PUBLIER. L'observatoire, la vigie
et la console d'exploitation ne se joignaient que par adresse:port, en clair, depuis le
plan d'administration — et GF_SERVER_ROOT_URL promettait un https:// que personne ne
terminait.
Une zone a elle seule, et pas un coin d'une autre
site-publication, VLAN 37, 10.37.37.0/24. Toutes les autres zones du site separent ce
qui AGIT de ce qui est AGI : le runner seul, l'autorite seule, le temoin a part. Celle-ci
separe autre chose — ce qui est adresse de l'exterieur de ce qui ne doit jamais
l'etre.
Un edge est par definition la machine qu'on attaque en premier : c'est la seule dont
l'adresse est publiee. La loger dans site-supervision l'aurait mise dans le meme domaine
de diffusion que la base d'Icinga ; dans site-genome, a cote de la forge dont tout
descend. Une machine exposee ne partage pas son voisinage.
site-edge-01 10.37.37.11 2 vCPU, 2 Go, 20 Go vmid 9012
PETITE, ET SANS SAUVEGARDE, parce qu'elle ne porte aucun etat : un edge termine du TLS et relaie. Ce qu'il perdrait en tombant se recompose en le redeployant — sauvegarder un relais, ce serait sauvegarder une copie du plan.
ELLE NE S'EXPOSE PAS ELLE-MEME. Ce sont les expose: des AUTRES applications qui
deviennent ses vhosts et les SAN de son certificat. Elle n'est le nom de rien ; elle est
la porte de tous.
P23 a nomme le geste que l'outillage ne sait pas poser
reseau 'site-publication': passerelle 10.37.37.1 n'est l'adresse d'aucun hote
declare sur ce reseau — passerelle fantome
appliquer_opnsense sait poser des REGLES et des ROUTES ; il ne cree pas d'INTERFACE.
Declarer la patte 10.37.37.1 dans les hotes de l'underlay n'est donc pas une formalite :
c'est ce qui rend le geste manuel visible et verifiable. Tant que vlan037 n'existe
pas sur la frontiere, cette ligne est une promesse que le reel doit tenir — et le harnais
la relit a chaque passage.
C'est le meme principe que partout ici : on ne cache pas ce qu'on ne sait pas faire, on le DECLARE, et la garde se charge de le rappeler.
Ce qui est pret, et ce qui attend une main
Pret et derive : la zone, la machine, l'application, les 28 regles de frontiere (aucun
retrait), le trunk du commutateur — switchport trunk allowed vlan 1,31,32,33,34,35,36,37,40
— et le vlan 37 / name site-publication a declarer.
Attend une main, parce qu'aucune API du depot ne le couvre :
1. OPNsense — creer le VLAN 37 sur le parent des zones du site, l'assigner,
lui donner 10.37.37.1/24, l'activer.
2. Le commutateur — les deux lignes ci-dessus.
Ensuite seulement : make site-creer, make frontiere-appliquer, et le deploiement.
Creer la VM avant la passerelle donnerait une machine sans route — pire qu'aucune machine.
Harnais : 77 OK, 0 echec.
2026-09-14 (20) — La console d'exploitation devient un service, et elle n'avait aucune serrure
Decision de l'exploitant : « j'aimerais que chaque runner expose le GUI de Set-OPS a travers ce edge ». La construire a d'abord demande de mesurer ce qu'on allait publier.
CE QUI A CHANGE LA FORME DE TOUT LE RESTE
Le GUI n'a AUCUNE authentification. Mesure du 2026-09-14, dans son propre code :
GET / sert la page A QUI LA DEMANDE, avec le jeton ECRIT DEDANS
POST /api/* exige ce jeton — que la page vient de donner a tout le monde
Le jeton est une garde CSRF, pas une serrure. La seule serrure est
--hote 127.0.0.1 : le GUI est sur parce qu'il n'est joignable que de sa propre machine.
Ce qu'il offre a qui entre : deployer, creer, instance-creer, instance-utiliser,
pousser, et l'edition du plan. La fabric entiere. L'exposer tel quel aurait livre
un ecosysteme a quiconque trouve l'URL.
Ce qui est construit
Le service reste sur la boucle locale, toujours. Ce qui est publie est un nginx local qui authentifie D'ABORD et relaie ensuite.
setops-gui.service |
le GUI, 127.0.0.1:8765, sous le compte setops, dans le venv du runner |
| nginx local | port declare au plan, server_name derive du plan (P67) |
| vestibule | oidc (oauth2-proxy -> Keycloak) par defaut, locale (HTTP Basic) en repli |
| flux | ingress [edge, admin] — la meme paire que Grafana et la vigie, pour la meme raison |
| sonde | console-ops |
oidc PAR DEFAUT, ET CE N'EST PAS UNE PREFERENCE. Cette console peut raser un
ecosysteme. Un mot de passe partage devant ce pouvoir est un accident qui attend ; un
groupe d'annuaire se revoque sans deploiement (D-66). locale existe pour un ecosysteme
qui n'a pas d'annuaire — un SITE — et c'est ecrit comme un repli, pas comme un choix.
L'AUTHENTIFICATION EST POSEE AU NIVEAU DU server, pas d'un location : placee dans le
seul bloc de relais, elle laisserait passer tout chemin qu'un location plus specifique
attraperait en premier. Meme geste que pour la vigie, et meme raison.
DEUX REGLAGES QUI NE SONT PAS DU CONFORT. proxy_read_timeout a 3600 s : un deploiement
dure, et le delai par defaut de nginx couperait la reponse au milieu — la page dirait
echec pendant que la fabric continue. proxy_buffering off : sans lui, la sortie d'un
deploiement arriverait d'un bloc a la fin, et la console resterait muette des minutes.
La sonde mesure une SERRURE, ce qu'aucun greffon ne pense a faire
console-ops verifie deux choses, et la seconde est l'inverse d'une sonde ordinaire :
1. le GUI repond sur la boucle locale -> sinon plus personne ne pilote
2. une requete ANONYME au vestibule est REFUSEE (401/403, ou renvoi en `oidc`)
Un 200 y est la pire des reponses. Il veut dire que le vestibule laisse entrer. Ca ne fait echouer personne — c'est exactement pourquoi il faut le mesurer.
P54 a attrape une contrainte que j'avais ratee
serveur_ops est un role d'insemination : le SITE le pose sur le runner d'un
locataire qui vient de naitre, sans detenir la voute de ce locataire. Citer
vault_setops_gui_admin dans ce role rendait l'insemination impossible — le site
reclamait un secret qu'il n'a pas, et par construction ne doit pas avoir.
serveur_ops cite vault_setops_gui_admin dans roles/serveur_ops/defaults/main.yml :
le SITE ne detient pas cette voute
Le role declare donc un PARAMETRE vide, et la couche qui detient le secret est la seule a le nommer. La garde a tenu deux fois : la seconde, le nom ne survivait plus que dans le TEXTE d'un message d'erreur — ou il etait de toute facon faux, puisque la voute varie selon qui publie.
Ce qui reste
Le site n'a pas d'edge. L'exploitant vient d'en autoriser un : il publiera l'observatoire, la vigie et la console du site sous leurs noms, en TLS. C'est la suite.
2026-09-14 (19) — Une console pour le site, et un repli qui ouvrait vers l'Internet
Le site calculait 88 verdicts que personne ne pouvait lire. Il a desormais sa console — et la construire a revele un defaut du devis de frontiere qui, lui, etait deja applique.
Un troisieme mode d'authentification : locale
Icinga Web 2 n'a pas d'OIDC natif. Ses deux modes existants supposent l'un un ANNUAIRE
(ldap), l'autre une PASSERELLE SSO (external). Un SITE n'a ni l'un ni l'autre :
ce sont des services d'ECOSYSTEME, et l'hebergeur n'en est pas un.
locale : nginx authentifie en HTTP Basic et pose REMOTE_USER ; l'application croit ce
que le serveur web lui dit — c'est exactement le contrat du backend external, avec un
vestibule plus simple. Ni backend d'annuaire, ni backend de groupes, ni ressource LDAP :
un backend ldap qui vise une ressource inexistante ne rend pas une liste vide, il fait
ECHOUER chaque ouverture de session.
C'est le meme choix que serveur_grafana_connexion_locale et le compte local de Forgejo
au site. L'habilitation nomme alors une personne, ce qui contredit D-66 (« le groupe,
jamais des personnes ») — la regle suppose un annuaire, et il n'y en a pas. C'est ecrit
dans roles.ini.j2 plutot que contourne en silence.
auth_basic est pose au niveau du server, pas du seul bloc PHP : place la, il aurait
laisse passer tout ce que try_files sert directement. Une authentification qu'on
contourne par un chemin voisin n'en est pas une.
Deux pieges rencontres en chemin
resoudre_annuaire etait appele sans condition, et tombait sur un plan sans annuaire :
object of type 'NoneType' has no len()
Un message qui ne nomme ni l'annuaire, ni le role qui le demandait, ni la raison. Deux
corrections : le consommateur ne resout plus d'annuaire quand son mode n'en a pas, et
resoudre_annuaire emploie default('', true) — le second argument dit « remplace aussi
ce qui est FAUX », c'est-a-dire None. Sans lui, None traverse et heurte | length.
serveur_icingaweb2 ne declarait son entree que pour l'edge. Le site n'en a pas :
nginx ecoutait, php-fpm repondait, et le pare-feu ne laissait entrer personne.
serveur_grafana portait deja la reponse — joignable depuis le plan d'administration
partout, sans quoi un deploiement sans edge n'a plus de console. Les deux consoles de
l'observabilite avaient la meme contrainte et une seule des deux l'avait ecrite.
LE DEFAUT DU DEVIS : un role absent traduit en « tout l'Internet »
make frontiere-plan proposait cinq regles. Quatre etaient attendues. La cinquieme :
+ regle opt9 tcp 636 SETOPS_SITE_SERVEUR_ICINGAWEB2 -> !SETOPS_INTERNES
serveur_icingaweb2 declare une sortie LDAPS vers serveur_openldap. Le site n'en a pas.
Le devis cherchait la destination parmi les roles PRESENTS, n'en trouvait aucun, et
retombait sur !SETOPS_INTERNES — la forme de « vers l'Internet ». La frontiere aurait
autorise la console a parler LDAPS a n'importe quelle machine du monde, pour joindre
un annuaire qui n'existe pas.
C'est « une source vide ouvre le port », cote DESTINATION. Le repli est juste quand le pair est lointain — un depot Debian, un serveur NTP. Il est faux des que le pair NOMME un role : le flux ne parle alors pas de l'Internet, il parle d'une machine, et elle n'est pas la. Le devis distingue desormais les deux, et le dit :
note : serveur_icingaweb2 declare une sortie vers serveur_openldap, absent de ce
site — aucune regle emise (le repli aurait ouvert le port vers l'Internet).
ET TROIS REGLES DU MEME DEFAUT ETAIENT DEJA POSEES. Le devis corrige les signale perimees :
- opt9 TCP 24 SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES (vers serveur_dovecot)
- opt9 TCP 12345 SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES (vers serveur_dovecot)
- opt9 TCP 636 SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES (vers serveur_openldap)
Le relais de courriel du site n'a ni Dovecot ni annuaire a joindre. Ces trois regles ne pouvaient porter aucun trafic utile — elles ne faisaient qu'elargir la frontiere. Les retirer est un RETRECISSEMENT.
Etat
Console deployee, verifiee sur la machine : nginx et php-fpm actifs, 401 sans
identifiants, verify-full vers PostgreSQL, zero reference LDAP dans les trois .ini.
vault_icingaweb2_admin pose dans la voute du site avec les memes gardes que la veille,
et ajoute aux quatre gabarits d'ecosysteme.
LA FRONTIERE N'EST PAS ECRITE. Elle porte la production, et frontiere-appliquer
exige CONFIRMER=true — ce garde-fou existe pour un humain. Le devis est lu, les quatre
ajouts et les trois retraits sont compris ; l'ecriture attend un mot.
2026-09-14 (18) — La supervision du SITE etait morte depuis 11:10, et rien ne le disait
L'exploitant : « je ne vois pas de serveur icinga pour le site ? ». Il y en a un. Il tournait. Et il ne servait a rien.
La panne
icingadb.service : failed depuis 11:10:27
pq: aucune entree dans pg_hba.conf pour l'hote « 10.37.36.11 »,
utilisateur « icingadb », base « icingadb », aucun chiffrement
icinga2 tournait, icingadb-redis tournait, les sondes poussaient — et rien
n'atteignait la base. Les verdicts se calculaient dans le vide. Aucun deploiement n'a
echoue, aucun service visible n'est tombe : le moteur de supervision etait mort et
personne n'avait de quoi l'apprendre, puisque c'est precisement lui qui le dirait.
La cause : une seconde liste, tenue a la main
serveur_postgresql_tls_force pose hostssl dans pg_hba — toute connexion non chiffree
refusee. Chaque consommateur avait alors SON interrupteur a allumer separement, dans les
group_vars de chaque ecosysteme :
| Chezlepro | SITE | |
|---|---|---|
serveur_postgresql_tls_force |
true | true |
serveur_icinga_db_tls |
true | absent |
serveur_keycloak_db_sslmode |
verify-full | absent |
Chezlepro avait les trois. Le site avait le premier. Et le commentaire de
site_inventaire.py cite deja cette erreur mot pour mot : le cote SERVEUR avait ete
corrige le 2026-09-12 (reseaux_autorises), le cote CONSOMMATEUR jamais.
serveur_forgejo disait pire que rien : sslmode: "disable" ecrit en dur. Le
consommateur affirmait le contraire de ce que le serveur imposait.
Le remede : le consommateur suit son serveur
resoudre_base expose desormais resoudre_base_db_tls_force, lu dans les hostvars de
la machine qui PORTE la base. Un consommateur n'a plus a savoir qu'il doit chiffrer — il
le deduit de ce que sert son serveur. Les trois interrupteurs en derivent.
default(false) : un serveur qui ne declare rien ne force rien, et le consommateur reste
en clair. On ne casse pas un ecosysteme qui n'a pas bascule.
P78 refuse qu'un consommateur porte une valeur ECRITE : elle doit deriver. Une valeur
ecrite est une seconde liste, et une liste qui suit une autre prend du retard. La preuve
nomme aussi les deux consommateurs SANS reglage TLS — serveur_icingaweb2,
serveur_nextcloud — plutot que de rendre un vert muet sur eux.
Apres
icingadb active
connexions vues par PostgreSQL icingadb | t | TLSv1.3 | 10.37.36.11
en base 16 hotes, 88 services
Et les cinq sondes ecrites hier pour les marqueurs du site etaient INCONNU : Icinga
avait bien defini les services depuis leurs meta/supervision.yml, mais leurs roles
n'avaient pas ete redeployes, donc aucun script n'etait pose. Les cinq roles appliques,
un rapport force, et elles disent leur phrase :
site-backup-01 depot-locataires OK 2 locataire(s) etanche(s), 374 Go libres
site-cache-01 cache-site-racine OK racine sans amont, et l'index se sert
site-dns-01 resolution-locataires OK 3 supernet(s) de locataire admis
site-forge-01 genome-servi OK 6 depots servis sous « genome », aucun vide
site-ops-01 pouvoir-materialiser OK carte en place, voute chiffree, cle en 600
Ce que la supervision, une fois vivante, a immediatement signale
site-mon-01 journaux-frontiere CRITIQUE LA FRONTIERE NE JOURNALISE PLUS
site-forge-01 / site-pki-01 / site-mon-01 sante CRITIQUE setops-verification-depot.service
Deux constats a instruire, et c'est exactement ce qu'on attend d'un moteur qu'on vient de remettre en marche : il ne rassure pas, il rapporte.
Ce qui manque encore, et c'est une decision
Le plan du site declare serveur_icinga et pas serveur_icingaweb2. Le site calcule
88 verdicts que personne ne peut LIRE. Grafana montre les series, pas les verdicts. Ajouter
la console est une machine de zero (elle se co-localise) et un nom expose de plus.
2026-09-14 (17) — La garde regardait un seul cote de la cloture
L'exploitant, en cherchant son tableau : « grafana c'etait observatoire.genese.internal ». Il avait raison, et Grafana ne le savait pas.
Le defaut
Le plan du site expose observatoire.genese.internal. serveur_grafana devinait
grafana.{{ domaine_interne }} — sa valeur par defaut. Grafana fabriquait donc son
GF_SERVER_ROOT_URL, ses liens d'alerte et son URL de retour OIDC avec un nom que rien
ne sert.
C'est MOT POUR MOT le defaut du 2026-09-10, quatre jours plus tard, de l'autre cote de la
cloture. instancier derive <groupe>_hostname de l'exposition du plan depuis ce
jour-la, et P67 le garde. Les deux ne connaissent que l'instance MONTEE — donc un
locataire. L'inventaire du SITE, lui, n'en derivait aucun :
| groupe expose | le plan dit | le role devinait |
|---|---|---|
serveur_forgejo |
forge.genese.internal |
forge… — juste par convention |
serveur_step_ca |
pki.genese.internal |
pki… — juste |
serveur_resolveur |
dns.genese.internal |
dns… — juste |
serveur_backup_site |
sauvegarde.genese.internal |
juste |
serveur_grafana |
observatoire.genese.internal |
grafana.… — faux |
Quatre devinettes sur cinq tombaient juste, et c'est precisement ce qui rend ce defaut invisible : tant que le plan suit la meme convention, la devinette tombe juste et personne ne voit qu'il y a deux sources. Le seul nom qui s'ecarte de la convention est le seul qui ment.
Ce qui est corrige
site_inventaire.py derive <groupe>_hostname de l'exposition unique du plan du site —
le meme geste, au meme endroit, que chez un locataire. Les cinq noms viennent maintenant
du plan.
P67 regarde les deux inventaires. Le site a un inventaire DYNAMIQUE : la preuve l'EXECUTE au lieu de lire un fichier — juger sa source reviendrait a relire le raisonnement au lieu du resultat. Eprouvee dans les deux sens : sans la derivation, elle nomme les cinq services fautifs ; avec, elle en compte 10 sur 2 inventaires.
Environment=GF_SERVER_DOMAIN=observatoire.genese.internal
Environment=GF_SERVER_ROOT_URL=https://observatoire.genese.internal/
La lecon, et elle est generale
Une garde posee la ou un defaut s'est montre ne couvre pas la ou il peut se montrer aussi. Le site et le locataire ont deux inventaires, et le depot en compte plusieurs autres qui ne regardent qu'un des deux. C'est la meme forme que « une liste qui suit une autre prend du retard », appliquee aux gardes elles-memes.
Et la console etait joignable depuis le debut
Le poste porte 10.37.0.17/24, et nftables accepte le port 3000 depuis 10.37.0.0/24. Il
manquait une ROUTE : sans elle, le noyau envoyait les paquets a la passerelle du LAN avec
l'adresse du LAN comme source, et le pare-feu du site voyait arriver un inconnu. Forcer la
source ne sert a rien — c'est la route qui la choisit.
Les routes du poste pointaient aussi les deux locataires vers 10.17.0.1, une passerelle
que la frontiere ne porte pas (elle route les tenants par 10.0.4.41 sur le vlan040).
Cinq routes vers un routeur inexistant, dont les /16 des deux ecosystemes : c'est ce qui
donnait « No route to host » en cherchant la console de Chezlepro. Routes refaites par
l'exploitant ; les trois runners repondent.
2026-09-14 (16) — Le dernier maillon : l'exportateur PostgreSQL, et huit panneaux qui repondent
L'entree precedente laissait le tableau de PostgreSQL assemble, charge, et vide :
serveur_postgresql saute son exportateur tant que vault_pg_exportateur n'est pas dans
la voute. Il y est maintenant, et la chaine est complete de bout en bout.
Le secret, pose avec ses gardes
vault_pg_exportateur etait deja au gabarit de voute — scripts/voute.py lister le
reclamait pour serveur_postgresql. Il manquait a la voute REELLE du site. Le gabarit
disait quoi mettre, personne ne l'avait mis : c'est exactement ce que ce gabarit existe
pour attraper, et il l'a attrape.
Quarante caracteres alphanumeriques, et ce n'est pas de la pruderie : ce mot de passe
entre dans une URI de connexion PostgreSQL (DATA_SOURCE_NAME). Un « @ », un « : » ou un
« / » y couperait l'URI en deux, et l'echec dirait « mauvais mot de passe » au lieu de
« URI mal formee ».
LES GARDES, toutes passees : copie de surete avant toute ecriture ; dechiffrement vers un
FICHIER et jamais vers un tube — ansible-vault ecrit sur stderr, et un tube ferme le fait
echouer en silence ; relecture du YAML avant de rechiffrer ; en-tete $ANSIBLE_VAULT
verifie apres ; empreinte comparee ; les quinze cles relues une a une ; copies en clair
passees au shred, pas au rm.
UN DETOUR, CORRIGE AVANT DE NUIRE. Le premier rechiffrement, fait sous
--encrypt-vault-id, a produit du format 1.2 avec une etiquette la ou la voute portait
du 1.1 sans etiquette. Le runner du site ouvre cette voute avec un unique fichier-cle : une
etiquette qu'il ne connait pas etait un risque pour rien. Rechiffree en 1.1, comme avant.
Le compte, et ce qu'il peut
setops_metriques superuser=f createdb=f createrole=f membre de : pg_monitor
pg_monitor donne les vues de statistiques et elles seules — aucune donnee
applicative. Faire tourner un exportateur en postgres serait donner les cles de la base
pour lire des compteurs.
Les huit panneaux, mesures
| tableau | panneau | ce que Prometheus rend |
|---|---|---|
| client_metrique | Memoire disponible | 10 series, 20 % a 87 % |
| Espace libre (le plus serre) | 10 series, 28 Go a 401 Go | |
| Charge par coeur | 10 series | |
| Trafic reseau entrant | 10 series, 65 o/s a 5,4 Mo/s | |
| serveur_postgresql | Connexions utilisees | 1 serie |
| Taux de succes du cache | 1 serie | |
| Taille des bases | 4 series, 7,5 a 10,9 Mo | |
| Transactions par seconde | 1 serie |
DEUX PANNEAUX ONT MIS DEUX MINUTES A REPONDRE, et il fallait savoir pourquoi avant de
conclure. Les deux emploient rate(...[5m]) : un taux exige au moins deux echantillons
dans la fenetre, et l'exportateur venait de naitre. La verification n'a pas attendu en
esperant — elle est allee lire les metriques BRUTES chez l'exportateur (4 series chacune)
puis dans Prometheus (4 series chacune) : les noms etaient bons, seul le temps manquait.
Un panneau vide parce qu'une metrique n'existe pas et un panneau vide parce qu'il est trop
tot se ressemblent parfaitement, et n'appellent pas le meme geste.
2026-09-14 (15) — Le generateur de tableaux : les panneaux declares deviennent des tableaux
Les roles declaraient leurs panneaux dans meta/metriques.yml, Prometheus en derivait deja
ses cibles, les expressions repondaient — et RIEN ne les assemblait. Un panneau declare que
personne ne peut regarder n'est pas une observabilite, c'est une intention.
Ce qui a ete construit
serveur_grafana lit desormais les memes meta/metriques.yml que serveur_prometheus, et
en assemble un tableau par role. Exactement le patron du voisin : le role declare, le
moteur derive.
meta/metriques.yml --exportateur--> cible de scrutation (serveur_prometheus)
--panneaux-----> tableau Grafana (serveur_grafana)
UN TABLEAU PAR ROLE, pas un grand. La question qu'on se pose est « comment va PostgreSQL », pas « comment va la flotte » — celle-la est deja repondue par Icinga. Le role est l'unite qui declare, donc l'unite qui s'affiche.
LA raison DEVIENT LA DESCRIPTION DU PANNEAU. C'est le seul champ qui ne produit aucun
pixel, et le plus important : Grafana l'affiche au survol du titre. Sans elle, celui qui
regarde six mois plus tard voit une courbe sans savoir ce qu'elle annonce.
LA MOISSON, comme pour les sondes orphelines. Un tableau qu'aucun role ne declare plus est
retire. Un tableau orphelin est moins grave qu'une sonde orpheline — il n'echoue pas, il
MENT : il affiche « aucune donnee » pour un service disparu, et celui qui regarde croit a
une panne. Le prefixe setops-role- protege journaux-flotte.json, ecrit a la main.
LE JSON EST VALIDE AVANT D'ETRE POSE. Grafana n'echoue pas sur un tableau illisible : il
le SAUTE, en silence. Sans validate:, une expression mal repliee ferait perdre un tableau
sans qu'aucune tache ne devienne rouge.
Un second rôle qui déclare, et il donne des données tout de suite
client_metrique declare quatre panneaux sans exportateur — le cas symetrique de
PostgreSQL. Le job node est universel et ecrit une fois dans prometheus.yml.j2 ; le
deriver le ferait exister deux fois.
Le panneau qui compte : memoire disponible en part du total. Depuis que le ballon est actif, l'hyperviseur reprend de la memoire a une VM qui n'en a pas besoin, 100 Mio par cycle. La VM ne voit pas son plafond bouger, elle voit sa marge fondre. Aucun verdict ne peut se poser la-dessus : il n'y a pas de seuil juste, c'est la PENTE qui dit si le plancher a ete pris trop bas.
Eprouve sur le site, de bout en bout
| fichiers assembles | setops-role-serveur_postgresql.json, setops-role-client_metrique.json |
| Grafana les a-t-il charges ? | oui — setops-postgresql et setops-client-metrique dans le stockage unifie |
| moisson | un setops-role-serveur_fantome.json pose a la main a ete retire, les deux autres conserves |
| les panneaux repondent-ils ? | les 4 de client_metrique : 10 series chacun, donnees reelles |
Le journal ne prouvait rien. finished to provision dashboards s'ecrit aussi quand
Grafana saute un fichier. La verification est allee lire ce que Grafana a VRAIMENT
enregistre — et Grafana 13 range les tableaux dans le stockage unifie (table
resource), plus dans la table dashboard, qui est vide et le restera. Une premiere
lecture y a vu « zero tableau » ; c'etait la mauvaise table.
Un panneau faux, trouve en le regardant
« Espace libre — le point de montage le plus serre » rendait 0 octet pour les trois
hyperviseurs. Le coupable : /var/lib/lxcfs, un systeme FUSE virtuel qui rapporte toujours
zero, et qui n'est pas en lecture seule — aucun filtre malin ne l'ecartait.
Un min() est impitoyable : un seul montage pathologique rend le panneau inutile pour
toujours, et sa courbe plate ressemble a un disque plein. La pire sorte de faux — lisible,
alarmant, et faux.
Corrige en liste blanche (ext4|xfs|btrfs|zfs) plutot qu'en liste noire. Une liste
noire suit ce qui existe, donc prend du retard. Une liste blanche ignore par defaut : un
montage inconnu MANQUE au graphe, ce qui se voit, au lieu de l'ecraser, ce qui ne se voit
pas. Apres correction : min 28 Go, max 401 Go.
La garde
P77 exige de chaque panneau un titre, une expression, une unite et une raison — et que
l'unite figure dans la table de traduction de serveur_grafana. C'est encore une liste qui
en suit une autre : une unite inventee ne casse rien, le panneau retombe sur short, et un
graphe d'octets gradue en unites brutes reste parfaitement lisible et parfaitement faux.
Eprouvee dans les deux sens.
docs/supervision-conception.md porte desormais la moitie « metriques » a cote de la
moitie « sondes ».
Et la fiche cachait ce que le role declarait
fiche_role.py imbriquait le tableau des panneaux DANS la condition de l'exportateur. Le
premier role a declarer des panneaux sans exportateur affichait donc « aucun exportateur
declare » — et cachait ses quatre panneaux.
Une fiche qui tait ce qu'un role declare est pire qu'une fiche absente : elle affirme que rien n'existe. Les deux moities sont desormais lues separement, et chacune dit ce qu'elle trouve — y compris « series collectees, aucun panneau declare : personne ne les regarde encore », qui est une carte de ce qui reste a faire.
Ce qui reste
Le tableau de PostgreSQL est assemble, charge, et vide : l'exportateur exige
vault_pg_exportateur dans la voute du SITE, qui n'y est pas. Ajouter un secret a cette
voute-la n'est pas un geste a poser sans le dire.
2026-09-14 (14) — L'echappatoire du wiki etait documentee cinq fois et n'existait pas
En publiant les huit fiches neuves, make wiki-publier a refuse : l'ecosysteme monte est
OPS-Technolibre, qui lit son genome ailleurs et ne republie donc pas d'ici. C'est le
comportement voulu, et le message propose le remede :
Ex: make wiki-publier WIKI_REMOTE=ssh://git@forge.<domaine>/<proprio>/<depot>.wiki.git
Le remede ne marchait pas. La cible lisait
remote="$$remote"
— c'est-a-dire $remote, une variable de SHELL que rien ne definit — au lieu de
$(WIKI_REMOTE), la variable de make. L'option etait documentee cinq fois dans ce
Makefile, dont dans le message d'erreur qui la proposait, et la cible ne l'a jamais lue.
CE QUI LA REND VISIBLE : WIKI_BRANCHE, deux cibles plus bas, est ecrit $(WIKI_BRANCHE)
depuis le debut. Deux options soeurs, deux ecritures. Une option qui se lit autrement que
sa voisine est l'endroit ou regarder.
Le wiki est publie sur eregion : 95 pages, et les huit fiches neuves portent bien leur sonde — verifie en clonant la forge, pas en lisant le temoin.
2026-09-14 (13) — Les huit derniers roles sans sonde, et deux defauts que l'epreuve a trouves
Les 33 roles serveur_* declarent desormais une supervision. Les huit qui manquaient
n'avaient pas ete oublies par hasard : ce sont ceux dont la verite ne ressemble pas a
« ce service repond-il ».
Quatre marqueurs du site, quatre verites qu'aucun installateur ne voit
Un marqueur n'installe rien. Il dit qu'une machine rend un service AUX LOCATAIRES, et
tasks/main.yml le verifie — une fois, au deploiement. Ce qu'il verifie cesse d'etre vrai
sans que rien ne tombe.
| role | sonde | ce qui se degrade en silence |
|---|---|---|
serveur_cache_site |
cache-site-racine |
la racine se retrouve chainee, ou ne remplit plus |
serveur_forge_site |
genome-servi |
la forge repond et n'a plus rien dedans |
serveur_resolveur_site |
resolution-locataires |
un locataire n'est plus admis a resoudre |
serveur_backup_site |
depot-locataires |
l'isolation glisse, ou la place manque |
genome-servi mesure exactement la panne du 2026-09-14 : le wiki du site avait zero
commit apres une reconstruction, et la forge etait verte tout du long. Un depot vide ne
fait pas echouer un clone — il rend un arbre sans fichiers.
Les quatre autres
serveur_ops_site ne sert rien : il DETIENT un pouvoir. pouvoir-materialiser verifie que
la carte de la fabric est la, que la voute du site est chiffree et que sa cle est en
0600 — sans jamais lire le contenu d'aucun des trois. Une voute dechiffree en transit ne
fait echouer aucun deploiement : Ansible la lit tres bien, elle expose simplement tout.
serveur_icingaweb2 porte la sonde la plus retorse du dispositif : la supervision
surveille sa propre vitrine. Si la console meurt, le moteur collecte toujours, les
verdicts restent verts, et l'exploitant est aveugle.
serveur_web_frontal et serveur_web_dorsal frappent chaque site et chaque app declares.
Une webapp ne tombe presque jamais en failed — elle se coince : le processus vit, systemd
la dit active, et plus une requete n'aboutit. Le rapport d'unites en echec ne verra jamais
ca.
DEFAUT 1 — quatre gabarits qu'Ansible aurait refuse de rendre
${#tableau[@]} est la seule facon d'obtenir la longueur d'un tableau en bash. Elle
contient {#, que Jinja lit comme un debut de commentaire :
TemplateSyntaxError: Missing end of comment tag
Rien ne le signalait : le fichier est valide en shell, valide a la lecture, et
--syntax-check ne rend pas les gabarits. La panne serait arrivee sur la machine, pendant
un deploiement.
Le depot connaissait deja le remede — une en-tete #jinja2: qui deplace le delimiteur — et
l'appliquait trois fois, exactement la ou quelqu'un s'etait fait prendre. Nulle part
ailleurs. Quatre sondes neuves l'ont refait d'un coup.
P76 est la garde qui manquait : elle REND chaque gabarit de role, avec les memes delimiteurs qu'Ansible en tirerait. Pas une recherche de motif — un rendu, qui attrapera aussi le prochain piege, quel qu'il soit.
DEFAUT 2 — la doctrine promettait des greffons que la flotte n'a pas
docs/supervision-conception.md annoncait « le paquet en fournit 54 » et citait
check_pgsql. Mesure du 2026-09-14 : client_sante installe
monitoring-plugins-basic — 53 greffons, et ni check_pgsql, ni check_dns, ni
check_ldap n'en font partie. Ils sont dans monitoring-plugins-standard, qui traine
samba, smbclient et une pile SNMP sur chaque machine de la flotte.
La premiere ecriture de resolution-locataires appelait check_dns. Elle sortait en
127 — qui n'est pas un code Nagios : la sonde serait devenue illisible au lieu
d'echouer proprement. Elle emploie dig, comme la sonde voisine le faisait deja.
Le paquet n'est pas ajoute : le durcissement dit le contraire d'installer Samba partout pour trois greffons. C'est le document qui est corrige.
Un defaut de plus, trouve en eprouvant
genome-servi interrogeait d'abord /api/v1/repos/search?owner=<org>. Avec
owner=organisation-qui-nexiste-pas, elle rendait les six depots de la forge et sortait
VERTE : le parametre est ignore. Elle n'aurait jamais mesure le genome, seulement « cette
forge a des depots ». La route /api/v1/orgs/<org>/repos, elle, rend 404.
Controles negatifs
Chaque sonde a ete mise en defaut sur la machine reelle avant d'etre ecrite dans son role —
21 cas au total, sur site-cache-01, site-forge-01, site-dns-01, site-backup-01 et
site-ops-01. Les huit gabarits rendent et passent bash -n. ansible-lint profil
production : 0 sur 91 fichiers. Harnais : 75 OK, 0 echec.
2026-09-14 (12) — La decision appliquee a TOUTE la flotte : une machine de moins par ecosysteme
L'entree precedente prend la decision. Celle-ci la pose partout, sur ordre de l'exploitant : « meme principe pour tous les tenants et pour tous les modeles, sauf celui qui doit avoir une forge ».
Ce qui est parti
| plan | avant | apres | ce qui est retire |
|---|---|---|---|
OPS-Technolibre |
14 machines | 13 | forge-01 |
OPS-Chezlepro |
14 machines | 13 | forge-01 |
OPS-Chezlepro-lab |
15 machines | 14 | forge-01 |
Modeles/origine |
5 machines | 4 | forge-01, et serveur_artefacts avec elle |
Modeles/identite |
8 | 8 | serveur_artefacts (colocalise, pas de VM) |
Modeles/observabilite |
8 | 8 | serveur_artefacts |
Modeles/collaboration |
9 | 9 | serveur_artefacts |
Modeles/presence-web |
8 | 8 | serveur_artefacts |
Retirer un service n'est jamais une ligne : c'est cinq points d'attache. Le service
dans applications.yml, sa machine dans serveurs.yml, sa base dans bases-donnees.yml,
son client SSO dans group_vars/serveur_keycloak.yml, et sa configuration de role. En
oublier un laisse un inventaire qui se genere et un deploiement qui echoue plus tard, sur
une machine qui n'existe plus.
origine compte doublement : tout ecosysteme neuf en descend. Y laisser ces deux
services les ferait renaitre dans chaque enfant.
Les deux modeles qui gardent leur forge, et pourquoi c'est ecrit
forge et integral la gardent. Ce n'est pas une exception concedee, c'est une fonction
differente : ils vendent une forge au code des gens — l'offre Atelier. La raison est
desormais dans leur plan, pas dans la tete de celui qui l'a decidee.
Ce que ca vaut
Par ecosysteme : une VM de 80 Go a sauvegarder, superviser, durcir et reconstruire ; une organisation Forgejo ; ses depots ; un mot de passe d'admin en voute ; un miroir Debian a tenir a jour. Pour republier ce que le locataire vient tout juste de lire chez son site.
Preuve statique apres coup : 74 OK, 0 echec.
Reste a trancher
OPS-Patient0 n'est pas touche. Il est enregistre comme « l'ecosysteme d'origine,
detenteur du genome — pas un locataire ordinaire », et le genome de la flotte y vit.
Retirer sa forge est une decision d'un autre ordre que les autres.
Et les VM forge-01 de Chezlepro et de TechnoLibre tournent encore. Le plan ne les
declare plus ; l'hyperviseur les porte toujours. Raser une machine est une action
destructive : elle attend un ordre explicite.
2026-09-14 (11) — Le wiki suit le genome, et un locataire n'en est pas depositaire
Decision de l'exploitant : seuls les SITES portent la forge, le cache APT et les artefacts. Un locataire n'a pas besoin de sa propre forge pour le moteur — il le lit chez son hebergeur, comme il y prend ses paquets et son gabarit.
C'est le meme geste que le retrait de serveur_artefacts du plan d'un locataire, un cran
plus loin, et la meme phrase le porte : le site fournit tout ce dont un tenant a besoin
pour venir au monde.
Ce que la soiree avait deja chiffre
Une forge par locataire, c'est une organisation, des depots, un mot de passe d'admin en
voute, un mecanisme d'amorcage, un wiki, un miroir a tenir synchrone, et une preuve qu'il
ne derive pas. Mesure du 2026-09-14 sur TechnoLibre : forge servie en https=200, cle du
runner acceptee, zero depot. La chaine n'existait pas — pour UN locataire.
La distinction qui reste vraie
« Un locataire n'a pas besoin d'une forge pour le genome » n'est pas « un locataire n'a jamais de forge ». L'offre Atelier vend precisement une forge a des equipes qui developpent : depots, tickets, revues, pour LEUR code. Ce n'est simplement pas l'endroit ou vit le moteur.
Ce qui a ete defait, et pourquoi
La generalisation de forge_amorcer.py a un locataire est revenue en arriere : elle
implementait le modele ecarte. La garder en ferait du code mort au mieux, un piege au pire
— quelqu'un finirait par le lancer.
ma_forge.py change de regle : il ne derive plus « MA forge » mais la forge qui porte
mon genome. Le signal est deja au plan — serveur_ops_forge_externe: true dit « je lis
mon genome ailleurs ». Un ecosysteme qui le declare ne publie pas : il consulte, la ou le
genome vit.
OPS-Technolibre -> refus : lit son genome sur 10.37.33.11
SITE-Chezlepro -> forge.genese.internal
2026-09-14 (10) — Le temoin du wiki pointait une forge VIDE
En publiant les fiches de role, make wiki-publier a refuse : le wiki vise par le temoin
— forge.genese.internal, la forge du SITE — n'a aucun commit.
Refus: ce wiki est VIDE (aucun commit).
Mesure des deux forges :
| forge | pages | branche |
|---|---|---|
eregion.chezlepro.ca |
26 | main |
forge.genese.internal |
0 | — |
Le wiki vivant est sur eregion. Le temoin, lui, affirmait depuis le 2026-09-12 avoir publie sur la forge du site — dont le depot wiki n'a pas survecu a sa reconstruction.
C'est exactement ce que P60 annonce d'elle-meme : « un temoin dit ce qui est PARTI, jamais ce qui est ARRIVE ». La preuve etait verte, la documentation n'existait nulle part a l'adresse qu'elle nommait. Elle avait raison sur ce qu'elle mesurait, et sa limite etait ecrite — encore fallait-il aller chercher.
Le garde-fou a tenu. Publier sur un wiki vide aurait invente un nom de branche —
master depuis ce poste, alors que le wiki en service vit sur main. Deux wikis, deux
branches, et une divergence silencieuse de plus.
Publie sur eregion : 95 pages, dont les 68 fiches et leur index. Le temoin nomme desormais la bonne forge.
2026-09-14 (9) — Une fiche par role, generee, et publiee au wiki
Soixante-huit roles, soixante-huit fiches : qui lui parle, ce qu'il rend a la supervision, ce qu'il expose en series, ce qu'il coute, qui entre. Un schema mermaid en tete, puis les tableaux — et chaque ligne porte la raison que le role a declaree.
Generees, jamais ecrites. Soixante-huit pages redigees a la main seraient perimees
avant la fin du mois : c'est « une liste qui suit une autre prend du retard », et une
soixante-neuvieme liste n'y echapperait pas. make fiches les relit depuis meta/flux,
supervision, metriques, empreinte, authentification.
Une section vide est une information. Un role sans sonde l'affiche. L'index Rôles
recompte a chaque generation ce que la flotte ne declare pas encore : 36 sans flux
entrant, 40 sans sonde, 67 sans metrique. La carte de ce qui reste, tenue a jour
toute seule.
Elles vivent au wiki, et la charte a du etre amendee
Home.md disait : « le detail du comment vit dans le depot — ce wiki y POINTE, ne le
RECOPIE pas (pour eviter la derive) ». La regle reste juste ; son MOTIF ne s'applique pas
a une page qui relit sa source a chaque generation. L'exception est donc nommee dans la
charte plutot que prise en silence.
Trois gardes ont mordu, et toutes avaient raison
Un script qu'aucune cible n'appelle est du code mort. Le premier commit est parti sans
make fiches ; la preuve l'a vu, pas moi.
La navigation exigeait une citation DIRECTE. Son propre motif dit pourtant « n'est lue
par personne » — or une page citee par une page citee EST lue. L'exigence litterale
interdisait toute page d'index : citer les soixante-huit fiches dans la navigation en
ferait un mur ou plus personne ne trouverait les vingt-sept unites redigees. La garde
forcait a degrader ce qu'elle protegeait. Elle suit desormais les liens de proche en
proche. Eprouvee dans les deux sens : coupe le lien depuis Home, elle refuse les 69.
Au passage, son motif de lien ne contenait pas _ : un lien vers Rôle-serveur_postgresql
n'etait NI suivi, NI signale casse. Un motif trop etroit ne rend pas une garde prudente,
il la rend aveugle.
Le compte du wiki serait passe de 27 a 96. Une fiche generee n'est pas une unite d'apprentissage — celles-la suivent le moule en quatre temps. Le nombre aurait ete exact et l'affirmation fausse.
2026-09-14 (8) — La chaine des metriques, de la declaration au graphe
Premiere verticale complete du second versant, eprouvee sur TechnoLibre.
| etape | ce qui a ete verifie |
|---|---|
| declaration | meta/metriques.yml : exportateur postgresql, port 9187, quatre panneaux |
| flux | meta/flux.yml ouvre 9187 depuis l'observatoire seul |
| installation | prometheus-postgres-exporter actif, compte pg_monitor en lecture seule |
| exposition | 657 series servies, valeurs reelles par base |
| derivation | Prometheus ecrit de lui-meme job_name: postgresql / ["10.23.18.11:9187"] |
| scrutation | cible up |
| interrogation | sum(pg_stat_activity_count) = 11 · cache = 0,9982 · bases = 84 Mo |
Le crochet serveur_prometheus_cibles_supplementaires n'est plus vide pour la premiere
fois : un role declare, le moteur derive. Les cibles ecrites au plan le completent
toujours — un equipement tiers n'a aucun role Set-OPS pour se declarer.
Ce que la voute a rappelé au passage
Le runner a d'abord saute l'exportateur sans rien signaler : changed=0. Sa voute datait,
parce que vault.yml est gitignore — aucun secret ne transite par la forge. Elle
n'arrive chez un runner que par serveur_ops_tenant, depuis le poste qui la detient.
Ce n'etait pas un defaut : c'est la ligne de partage du pouvoir qui se rappelle a nous. Le geste d'armement n'est pas une formalite d'installation, c'est le seul transport de secret du systeme — et il se refait a chaque fois qu'un secret change.
Et une garde qui a servi
L'ecriture dans la voute a d'abord pendu : ANSIBLE_VAULT_IDENTITY_LIST n'etait pas
dans le shell (le Makefile l'exporte, pas un appel direct), donc ansible-vault attendait
une saisie qui ne venait jamais. Le filet a tenu : le clair faisait 0 octet, la voute
etait inchangee, et la comparaison d'empreinte l'a dit avant qu'on ne croie au succes.
empreinte CHANGEE a403fd82165b0e0c -> 1577e3c1439a5aa9
33 cles -> 34
2026-09-14 (7) — meta/metriques.yml : le second versant de la supervision
Aucune metrique de SERVICE n'etait collectee. Prometheus ne scrutait que les
node_exporter : du systeme, et rien de PostgreSQL, de l'annuaire, des boites ou du
cache. Le crochet existait pourtant —
serveur_prometheus_cibles_supplementaires, documente, et que personne ne remplissait.
Le pendant de meta/supervision.yml, et son contraire
| fichier | ce qu'il declare | la question |
|---|---|---|
supervision.yml |
une sonde rend un verdict avec un TTL | est-ce casse ? |
metriques.yml |
un exportateur expose une serie | depuis quand, et vers ou ? |
Ce n'est pas une frontiere inventee : docs/supervision-conception.md la pose deja dans
l'autre sens — « une metrique a seuil appartient a Prometheus et Grafana ». Ce fichier est
l'autre moitie de cette phrase.
Le critere qui choisit les panneaux
Une serie a sa place ici si elle PRECEDE un verdict, ou si elle n'en aura JAMAIS.
Le taux de succes du cache n'aura jamais de verdict, et c'est pourquoi il compte : quand
les donnees depassent shared_buffers, la base va chercher sur disque de plus en plus
souvent. Rien ne casse, rien n'alerte, tout devient lent. C'est la panne qu'un graphe voit
et qu'une sonde ne verra jamais.
Ce que le moteur derive, et ce qu'il ne derive pas
Il derive la cible de scrutation — le nom du dossier du role est le nom du GROUPE, donc les cibles sont ses hotes actifs. Un role declare sans hote ne produit aucun job.
Il ne derive pas le flux : le port 9187 s'ouvre par meta/flux.yml, la ou vivent deja
tous les flux de ce role. Deux fichiers pour un meme fait finissent par diverger.
Deux choses dites plutot que tues
Le compte de metriques est en lecture seule (pg_monitor), et ne lit que les vues de
statistiques — pas une ligne de donnee applicative. Faire tourner l'exportateur en
postgres serait donner les cles de la base pour lire des compteurs.
Le flux est en CLAIR, et c'est une dette inscrite au fichier. client_metrique sert
deja ses metriques en TLS ; celui-ci pas encore. La dette est ecrite dans la raison du
flux, avec son remede — --web.config.file + client_pki.
2026-09-14 (6) — Le site avait l'orchestration, pas le moyen de la lancer
playbooks/site.yml est genere par orchestrer.py et ordonne les couches pour
n'importe quelle instance — son en-tete le dit. Mais deployer-tout ne vise que
l'inventaire d'un LOCATAIRE, et le site n'avait que site-appliquer GROUPE=<un seul>.
Le site ne se deployait donc que groupe par groupe, a la main, dans un ordre qu'il fallait se rappeler. Un hebergeur qu'on ne peut remonter qu'en enchainant onze groupes de memoire n'est pas reconstructible : il est reparable par quelqu'un qui se souvient. C'est nommement l'une des trois limites du jalon de reconstruction autonome — « le SITE jamais reconstruit ».
site-deployer-tout comble le manque. Trois differences avec son equivalent locataire,
aucune cosmetique : l'inventaire est un script (un site se derive de son underlay, il
ne se fige pas dans un hosts.yml), la voute vit a cote de la carte
(underlay.vault.yml, hors depot), et le perimetre reste hotes_actifs — les
hyperviseurs et la frontiere sont dans l'inventaire mais ne se deploient pas.
Quatre gardes que --check rendait folles
Le Makefile CONSEILLE l'essai a blanc — « tester d'abord en idempotent ». Suivre ce conseil rendait sept machines en echec sur un site parfaitement sain. Quatre causes, une seule famille : une garde qui compare contre ce qu'une tache du meme play vient de produire n'a rien a dire tant que rien n'a ete ecrit.
| role | ce que --check cassait |
remede |
|---|---|---|
hosts_statiques |
la lecture des deux fichiers etait sautee : ne portent pas le meme nombre d'entrees () |
check_mode: false sur la lecture |
hosts_statiques |
l'assertion comparait l'AVANT a l'APRES : /etc/hosts=13 | tmpl=2 |
not ansible_check_mode sur l'assertion |
serveur_icinga |
le telechargement simule, l'installation cherchait un fichier absent | check_mode: false sur get_url |
serveur_ops |
la version du controleur non lue : Le controleur tourne Python |
check_mode: false sur la lecture |
Les parentheses vides et l'espace apres « Python » sont tout le diagnostic : il n'y avait rien a comparer. Un essai a blanc qui ment est pire qu'aucun — il apprend a ne plus le lancer.
Apres correction : 7/7, 0 echec, de 174 a 309 taches par machine.
2026-09-14 (5) — Le site ne savait pas se configurer lui-meme
Parti pour eprouver une sonde, arrive sur un defaut structurel : le runner du SITE ne pouvait entrer sur aucune machine du site. Il sait inseminer un locataire — c'est prouve — et il ne savait pas configurer l'hebergeur qui le porte. Tout le site avait donc ete deploye depuis le poste de l'exploitant, et rien ne disait que c'etait la seule voie.
Trois causes empilees, et un message qui accusait la mauvaise
-
Le site n'avait aucun moyen d'autoriser une cle d'administration. Un locataire declare
ssh_baseline_cles_admin; cote site, rien ne transmettait cette liste. Ses machines n'autorisaient que la cle posee par cloud-init. -
Le rebond etait pose sans condition, y compris pour un controleur vivant DANS le site. Il devait alors s'authentifier aupres de la frontiere, ou sa cle n'est pas autorisee. OpenSSH rend alors :
Host key verification failed. Connection closed by UNKNOWN port 65535— un message qui accuse les cles d'HOTE alors que l'echec est une AUTHENTIFICATION, et sur le SAUTEUR, pas sur la cible.
-
cle_sshdesigne le chemin de la cle SUR LE POSTE. Impose au runner, il nomme un fichier qui n'existe pas chez lui.
Le rebond et la cle decrivent tous deux comment on arrive, pas ce vers quoi on va. Ce qui depend de l'endroit d'ou l'on part n'a pas sa place dans la description d'un site.
La faute que j'ai ecrite en corrigeant, et qui merite d'etre gardee
La fonction qui repond « suis-je une machine du site » appelait underlay_mod — un nom
qui n'existe pas dans ce fichier, le module y etant importe as U. Python levait un
AttributeError a chaque appel, et le except Exception: return False le rendait muet.
La fonction repondait donc toujours non, le rebond etait toujours pose, et rien ne le
disait. Une garde qui se tait ne garde rien — le defaut exact que ce depot traque
ailleurs, ecrit ici. Le except ne couvre plus qu'un plan illisible ; une faute de
programmation remonte.
Le critere lui-meme a demande trois formulations : « dans un RESEAU du site » (faux, le
poste porte 10.37.0.17), « dans underlay.hotes » (faux, hotes ne contient que les
equipements), puis par le NOM — le runner s'appelle site-ops-01, une cle du plan.
L'amorcage est circulaire, et il se rompt par le poste
Lui seul entrait : il pose la cle, le runner devient autonome. Verifie apres
make site-appliquer GROUPE=serveur_debian — 7/7, 0 echec — puis 5/5 machines
atteintes par le runner, qui a ensuite configure site-backup-01 lui-meme.
Et la sonde, enfin prouvee
| verdict | code | |
|---|---|---|
| depot sain | accepte l'ecriture — 4 depot(s), 374G libres |
0 |
| chemin non inscriptible | refuse l'ecriture pour restic |
2 |
| chemin inexistant | n'existe pas |
2 |
Le vrai depot n'a pas bouge : zero fichier temoin residuel.
2026-09-14 (4) — Les greffons standard manquaient a la flotte
docs/supervision-conception.md dit qu'un greffon Nagios est une sonde valide, « sans
la moindre colle », et compte sur les 54 que fournit monitoring-plugins. Mesure du
2026-09-14 sur une machine de la flotte :
check_http : ABSENT
Aucun des 54 n'etait installe. Le document decrivait une possibilite qui n'existait pas — et chaque role ayant besoin d'un controle HTTP n'avait d'autre choix que d'ecrire du shell, precisement ce que la doctrine refuse.
Pose par le PORTEUR, pas par chaque role
C'est l'affaire de client_sante de pouvoir executer des sondes. Les poser role par role
en ferait autant de copies de la meme decision, et laisserait sans greffon les machines
dont aucun role n'en reclame — alors qu'elles en auront besoin le jour ou on leur en
ajoute un. 1,2 Mo par machine.
Trois sondes, trois enveloppes
| role | sonde | ce qu'elle voit |
|---|---|---|
serveur_collabora |
edition |
/hosting/discovery est l'adresse que Nextcloud interroge lui-meme pour savoir quels documents Collabora sait ouvrir. Sans elle, le bouton « ouvrir » meurt pour tout le monde — et Nextcloud reste vert. |
serveur_rspamd |
filtrage |
Un filtre muet ne bloque pas le courrier : il le laisse passer. Postfix sans verdict delivre sans filtrer ou differe, et le service a l'air sain pendant que le pourriel entre. |
serveur_oauth2_proxy |
passerelle |
Ce qui tombe avec elle n'est pas elle. Les services derriere restent debout et deviennent injoignables : on cherche la panne du mauvais cote. |
Chacune delegue a check_http et rend son code tel quel. Chacune exige une chaine que
seul le bon service produit — un 200 peut venir d'une page d'erreur ou d'un cache perime.
Le controle negatif, rejoue sur TechnoLibre
edition |
filtrage |
passerelle |
|
|---|---|---|---|
| sain | 0 | 0 | 0 |
| chaine introuvable | 2 | 2 | 2 |
La chaine attendue est la mise en defaut : la remplacer rend CRITIQUE sans rien casser.
Couverture : 24 roles serveur_* sur 33 declarent leur supervision (21 avant ce lot).
2026-09-14 (3) — La sonde de Redis, et son controle negatif rejoue
Premier des treize roles serveur_* qui ne declaraient aucune supervision. Vingt sur
trente-trois en avaient une ; celui-ci n'en avait pas, alors qu'il porte les sessions.
La panne qu'elle voit, et que rien ne voyait
Borne a maxmemory avec allkeys-lru, un Redis plein n'echoue jamais : il EVICTE.
Les sessions disparaissent une a une, les gens sont deconnectes au hasard, et le service
reste vert. La panne se presente comme un defaut d'application — personne ne regarde le
cache, puisqu'il va bien.
Une seule sonde, parce qu'il n'y a qu'une cause d'action : le cache ne sert plus. Elle s'atteint par deux chemins, et le verdict appelle le meme geste.
Le compteur d'evictions de Redis est CUMULATIF depuis le demarrage ; la sonde en fait un debit entre deux passages, sinon une seule mauvaise journee laisserait le voyant rouge pour toujours.
Le controle negatif, rejoue sur data-sql-01 de TechnoLibre
| verdict | code | |
|---|---|---|
| etat sain, premier passage | Cache sain — premier releve |
0 |
| etat sain, second passage | Cache sain — 0 eviction(s) |
0 |
| Redis arrete | Redis n'est pas actif. |
2 |
seuil impossible (CRIT=0) |
Redis evicte : … des sessions se perdent en silence. |
2 |
Les donnees de performance sortent au format Nagios et portent le vrai plafond :
evictions=0c memoire=808232B;;;0;268435456.
serveur_redis_sonde_evictions_crit est la mise en defaut par parametre — le poser a
0 rend CRITIQUE sans rien casser, ce qui rend la seconde preuve REJOUABLE. C'est le meme
patron que les seuils de PostgreSQL.
Ce qui n'est PAS dans cette sonde, et c'est la doctrine
Occupation memoire, taux de succes du cache, latence : ce sont des series, elles appartiennent a Prometheus. Icinga repond a une seule question — est-ce casse ?
2026-09-14 (2) — Redis EST borne : le releve d'hier lisait le mauvais fichier
L'entree du 2026-09-13 (6) affirmait « aucun maxmemory configure ». C'etait faux, et la
faute est de METHODE : le grep visait /etc/redis/redis.conf, alors que le role ecrit
dans /etc/redis/setops.conf. Interroge la ou la verite vit — redis-cli config get —
Redis repond :
maxmemory 268435456 (256 Mo)
maxmemory-policy allkeys-lru
La conclusion ne change pas, sa raison si. Redis reste hors de la table des planchers, non plus parce qu'il serait sans limite, mais parce qu'il est BORNE a 256 Mo : il ne peut pas prendre la machine, et lui epingler plusieurs gigaoctets serait absurde.
Le commentaire porte desormais le bon seuil de vigilance : le jour ou ce plafond montera,
le plancher devra le suivre — et c'est le maxmemory EFFECTIF qu'il faudra lire.
La lecon est celle de la veille, appliquee a moi-meme : verifier d'ou l'instrument mesure. Un fichier de configuration n'est pas la configuration ; c'est une de ses sources.
2026-09-13 (10) — Un runner TIRE ce qu'on pousse ; il ne le recoit pas
Trois fois dans une meme soiree, un genome perime a menace de rebatir un etat depasse :
- le runner du SITE, treize commits en retard, s'appretait a materialiser un locataire avec son ancien plan — serveur de sauvegarde inutile, cache d'artefacts en trop, runner sans pouvoir de configurer, et aucune machine avec son plancher de memoire ;
- le runner du LOCATAIRE, clone pendant l'insemination donc avant trois correctifs, s'appretait a reposer un plan d'administration pointant le WAN d'un AUTRE site — et a refermer derriere lui la porte que l'exploitant venait tout juste de rouvrir.
Pourquoi c'est le pire mode de defaillance de cette famille
Aucun des deux n'aurait echoue. Un deploiement depuis un genome perime REUSSIT : il applique fidelement un etat qui n'a plus cours. Il se presente en vert. Rien, dans la sortie, ne distingue « la flotte converge vers ce que tu veux » de « la flotte converge vers ce que tu voulais il y a trois heures ».
La garde
scripts/verifier_genome_a_jour.py, en tete de deployer-tout :
| etat du depot | verdict |
|---|---|
| en RETARD | refus — deployer poserait un etat qu'on sait depasse |
| en AVANCE | note, on continue — un mainteneur qui travaille localement est normal |
| DIVERGE | note, on continue — c'est a l'humain de trancher, pas a une garde |
| forge injoignable | note, on continue — une forge en panne ne doit pas bloquer une exploitation |
Ce dernier point est delibere : le silence serait la faute, pas le passage. Une garde qui immobilise la flotte quand la forge tousse serait pire que le defaut qu'elle surveille.
FORCE=1 passe outre, pour le cas legitime ou l'on deploie sciemment un etat local.
Eprouvee dans les trois sens sur un clone recule de deux commits : elle mord, elle se
tait, et FORCE=1 la leve.
2026-09-13 (9) — Le site expose sept intrants ; le controle n'en comparait que cinq
site_intrants --verifier rapportait CONFORME avec un aplomb complet sur un locataire
dont le plan d'administration pointait encore les bouts de WAN d'un AUTRE site.
OU_LE_LOCATAIRE_LE_DIT couvrait dns_amorcage, artefacts_amorcage,
setops_depot_binaires, serveur_ops_forge_amont et client_backup_cible. Pas
nftables_admin_ssh. Pas passerelle_sortie.
Ce que le silence a coute
Quatorze machines materialisees, le runner insemine — et l'exploitant incapable d'entrer
sur son propre runner pour l'armer. Connection timed out, sans qu'aucune garde n'ait rien
eu a dire. La frontiere avait bien pose une regle d'administration, mais sur son interface
WAN, puisque c'est ce que le plan declarait. Bonne source, mauvaise porte.
Ce qui change
Les deux entrees manquantes sont ajoutees. nftables_admin_ssh etant une liste, la
comparaison passe par une normalisation : l'ordre d'une liste de sources ne porte aucun
sens, et comparer str(['a','b']) a str(['b','a']) ferait crier une difference qui n'en
est pas une.
Les deux ecosystemes passent de cinq a six intrants compares — le septieme,
passerelle_sortie, n'est declare par aucun des deux : il se derive, et la garde le dit
plutot que de l'inventer.
La regle, cinquieme occurrence
Une liste qui en suit une autre prend du retard. Le site expose ; la table compare. Deux listes, et la seconde ne suivait pas. La garde s'ecrit EN MEME TEMPS que la seconde liste, jamais quand on s'en sert.
2026-09-13 (8) — Le plancher traversait trois modules et se perdait au troisieme
Trouve en preparant la creation des quatorze machines de TechnoLibre, une commande avant
de les creer. instancier derivait bien proxmox_memoire_min dans l'inventaire ; le
playbook de clonage savait bien poser balloon: ; entre les deux, la table
CHAMPS_PROXMOX de inventory_host.py ne transmettait rien.
Pourquoi rien n'aurait echoue
Trois silences qui s'enchainent :
$SETOPS_MEMOIRE_MINnon defini vaut la chaine vide- le Makefile ne passe alors pas
-e proxmox_clone_memoire_min - la garde
when:de la tache saute proprement
Quatorze machines seraient nees avec le ballooning desactive, sans une seule erreur. C'est le patron d'une liste qui en suit une autre et prend du retard — deja rencontre quatre fois.
La garde, ecrite en meme temps que le remede
P75 lit la cible creer-vm du Makefile, releve chaque $SETOPS_X qu'elle consomme, et
exige que la table qui les EMET le declare. Elle ne juge aucune valeur : elle refuse qu'un
maillon manque. Eprouvee dans les deux sens — elle mord quand on retire le maillon, elle
se tait quand il est la.
La regle qui en sort : quand une variable traverse trois modules, le troisieme s'ecrit en meme temps que le premier, pas quand on s'en sert.
test_inventory_host.py a signale le changement de contrat de son cote, comme il l'avait
fait pour SETOPS_DOMAINE et SETOPS_CLES_AMORCAGE.
2026-09-13 (7) — Un hote retire du plan laissait son pare-feu derriere lui
En retirant backup-01 du plan de TechnoLibre, flux-genere/backup-01.nft est reste sur
le disque : un ruleset complet, pour une machine qui n'existe plus, indiscernable des
autres au premier coup d'oeil. La generation ECRIVAIT sans jamais RETIRER.
Ce que ca coute : flux-genere/ cesse d'etre une IMAGE du plan pour devenir le cumul de
tous les plans successifs. Qui lit le dossier pour savoir ce qu'un ecosysteme expose lit
alors un etat qui n'a jamais existe.
P53 le voyait deja — un ruleset perime n'a pas de refus audible — mais il designait le fichier, pas la cause. La generation supprime desormais les orphelins et le dit :
note : backup-01.nft retire — cet hote n'est plus au plan.
C'est le meme patron que les neuf resolutions d'instance : un dossier GENERE doit etre le reflet exact de sa source, donc la generation doit aussi supprimer.
2026-09-13 (6) — balloon: 0 ne desactive pas le ballooning : il retire le peripherique
Soixante-et-un gigaoctets de RAM etaient declares a vingt-et-une machines. Mesure a l'interieur des invites : douze reellement utilises. Les quarante-neuf autres etaient tenus sans etre lus, et l'hote ne pouvait pas en reprendre un seul octet.
Ce que la mesure a corrige, deux fois
La premiere table de planchers etait ecrite sur une crainte : 0,75 a PostgreSQL et a
Keycloak « parce qu'ils se dimensionnent au demarrage ». Releve sur les machines reelles :
| ce qu'on croyait | ce qui est |
|---|---|
shared_buffers derive de la RAM |
128 Mo, le defaut Debian |
| une JVM qui reserve son tas | 605 Mo sur 3 Go |
| Redis « tout en memoire » | 16 Mo residents, borne a 256 Mo |
Ces machines tiennent du cache de pages, pas un jeu de donnees. Le reprendre ralentit des lectures — sur du NVMe — ; il ne fait pas swapper. Leurs planchers sont redescendus au cas general. Ce qui reste haut le reste pour une raison mesurable : un collecteur garde ses series EN MEMOIRE et grossit avec le temps.
Le piege, et il n'etait pas dans la doctrine
balloon: 0 se lit « ballooning desactive ». C'est plus fort que ca : le peripherique
n'est pas sur la ligne de commande QEMU. Le moniteur repond « No balloon device has been
activated ». Donc qm set --balloon N ecrit la config et ne change rien tant que la VM n'a
pas ete redemarree depuis sa config — un reboot a l'interieur de l'invite ne suffit
pas, et le branchement a chaud est refuse : sur q35, pcie.0 n'est pas enfichable.
qm reboot fait l'arret propre puis le demarrage depuis la config. Les vingt-et-une
machines y sont passees une par une, feuilles d'abord, DNS et PKI en dernier, chacune
verifiee sur son balloon_min avant la suivante.
Comment l'hote decide, lu dans la source et non dans une doc
pvestatd vise 80 % de la RAM de l'hote (goal = memtotal × 0,80 − memused) et ne
deplace au plus que 100 Mio par VM par cycle. Le filet ne se tend donc que quand la
charge arrive — c'est le comportement voulu, et c'est pourquoi il ne s'est rien passe de
visible au moment de poser les planchers.
Le resultat
| declare | plancher | reprenable | |
|---|---|---|---|
| locataire Chezlepro (14) | 40 960 Mo | 22 912 Mo | 17,6 Go |
| site (7) | 20 480 Mo | 11 264 Mo | 9,0 Go |
asgard est passe de 46,1 a 36,0 Go utilises. Le moteur derive desormais le plancher sur
les deux chemins de creation — instancier pour un locataire, site_machines pour le
site — et le playbook de clonage le pose a cote de memory, de sorte qu'une machine neuve
nait avec son plancher.
Un plancher ecrit au plan (memoire_min) prime toujours : la table est un defaut raisonne,
pas une contrainte.
Ce qui reste ouvert, dit franchement
Les grosses VM hors Set-OPS — ERPLibre, ZIMBRA, Nextcloud, eregion — portent 92 Go
declares avec balloon: 0 sur gandalf et vishnu, pour environ 48 Go reellement lus.
C'est la que dort le reste de la marge. Elles n'ont pas ete touchees : elles ne relevent
pas de ce depot.
2026-09-13 (5) — Le gabarit dore etait declare deux fois, et les deux clonaient
plan/10-intrants.yml disait 9006 (modeleSetOPS-minimal). underlay.yml disait
99998 — que le plan nomme lui-meme precedent.
Le Makefile derive VMID_MODELE de underlay.gabarit(), donc du PLAN : les locataires
clonaient 9006. site_machines lisait l'autre : les machines du site clonaient 99998.
Pourquoi ca ne s'est jamais vu
Les deux VMID designent un gabarit valide. Les deux clonent, les deux demarrent, rien n'echoue. La flotte etait simplement issue de deux souches — et aucun message ne pouvait le dire, puisqu'aucune operation n'avait echoue.
Ce qui a ete fait
site_machines lit desormais underlay.gabarit(), la meme source que tout le monde. Le
motif d'origine de la seconde declaration est preserve : cette source lit le PLAN DU SITE,
jamais celui du tenant actif — elle ne depend d'aucun symlink instance.
materialisation.vmid_modele est retire des deux cartes. precedent: reste au plan : il
dit d'ou l'on vient, et il n'est jamais clone.
P74 refuse toute redeclaration. Eprouvee dans les deux sens.
Au passage
SITE-Technolibre visait 99998 — l'ancien — parce que sa valeur avait ete reprise de la
carte de Chezlepro plutot que de son plan. Corrige avant son premier clonage.
2026-09-13 (4) — La cle USB ne savait pas relire ce qu'on venait d'y ecrire
exporter_cles --support-chiffre ecrit un TAR CLAIR quand le support est deja chiffre au
repos : empiler GPG par-dessus ajouterait une phrase de passe a perdre sans rien proteger
de plus. Cette capacite a ete ajoutee cote EXPORT le 2026-09-12, et pas cote
RESTAURATION.
restaurer_cles.py — le script qui rend la cle autonome — ne savait lire que du GPG.
Constate en eprouvant l'export, pas en le supposant reussi :
gpg: aucune donnée OpenPGP valable n'a été trouvée.
ECHEC du dechiffrement.
Phrase de passe erronee, ou archive abimee.
Le message accusait une phrase de passe qu'on n'avait jamais posee, sur une archive parfaitement saine. C'est le pire genre de diagnostic : il envoie chercher la faute la ou elle n'est pas, au moment ou l'on restaure — c'est-a-dire au moment ou l'on sait le moins.
tarfile.is_tarfile reconnait la forme ; les deux sont desormais lues. On ne demande pas
un drapeau : ce serait une chose de plus a savoir le jour ou tout a brule.
Ce que l'export a aussi montre de bon
Le garde-fou a REFUSE d'ecraser l'archive du 12 — « on n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule ». La nouvelle est datee, l'ancienne reste.
Dix cles sortent desormais, dont setops-vault-site-technolibre, creee le jour meme.
2026-09-13 (3) — Un site expose ce dont ses locataires ont besoin pour l'habiter
Le constat
Un locataire ECRIT dans ses intrants les adresses des services de son site : ou resoudre, ou prendre ses paquets, ou cloner le genome, ou deposer son etat. Une copie se perime, et deux l'avaient fait en deux jours, avec exactement la meme forme :
serveur_ops_forge_amont: https://10.0.33.11alors que la forge sert en10.37.33.11depuis que le site a pris son propre index. Le commentaire juste au-dessus expliquait encore pourquoi l'ancienne adresse figurait dans les SAN du certificat : le raisonnement etait intact, la valeur non.ac-racine-site.crtversionne portait la racine du site d'AVANT sa reconstruction.
Aucune des deux n'etait relue par quoi que ce soit.
make site-intrants
Le site lit son propre plan et rend le contrat — sept valeurs, toutes DERIVEES :
dns_amorcage · artefacts_amorcage · setops_depot_binaires · serveur_ops_forge_amont
client_backup_cible · nftables_admin_ssh · passerelle_sortie
make site-intrants-verifier les confronte a ce que le locataire monte declare, et P73
en fait une preuve. Elle a trouve le defaut de la forge des sa premiere execution.
Le contrat se DERIVE du plan du site, pas d'une liste tenue a part : ajouter un service prete au site l'ajoute au contrat, sans qu'on ait a y penser.
P69 restreinte au couple monte
Elle balayait TOUS les depots OPS-* et les comparait au site MONTE. Elle avait raison
tant qu'un seul site existait : une adresse « en 10.x.3z » ne pouvait designer que lui.
Deux sites decoupent leurs zones de la meme facon — c'est le but, un locataire doit pouvoir
habiter l'un ou l'autre sans se renumeroter. Le troisieme octet a donc cesse de distinguer
« mon site » d'« un autre site » : 10.31.34.11, parfaitement juste pour un locataire de
TechnoLibre, etait declare faux parce que Chezlepro etait monte.
Un locataire n'appartient a aucun site — il en habite un, choisi par le symlink au moment du deploiement. La seule paire qu'on puisse juger est donc celle qui est montee.
Une marche payee en chemin
La premiere version de site_intrants ecrivait RACINE / "instance" / "inventories" / "principal". P41 a mordu : neuf modules avaient deja porte chacun leur copie de cette
resolution, et cinq defauts en etaient sortis en cinq jours. La preuve a attrape la
dixieme avant qu'elle serve.
2026-09-13 (2) — L'annuaire cesse de tout ouvrir avec la meme cle
Ce que la mesure a montre
Les quatre services qui interrogent l'annuaire — Keycloak, Dovecot, Postfix, Icinga Web 2
— s'y liaient tous avec cn=admin, le compte d'administration de la base. C'est le
rootDN : slapd lui fait CONTOURNER TOUTES LES ACL. Un seul secret, quatre services, tous
les droits sur l'arbre — pour ce qui est, trois fois sur quatre, une simple lecture.
Et les droits livres par Debian etaient intacts : to * by * read. Sur ldap://, sans
s'authentifier, une machine du reseau enumerait tous les comptes et toutes les adresses.
L'indice etait deja dans le depot. validatePasswordPolicy existe parce que « slapd
n'applique pas ses controles de qualite au rootDN ». La consequence etait compensee, la
cause intacte.
Ce qui a ete fait
ou=serviceset un compte par consommateur, secret propre en voute.- Sept regles d'acces, posees en entier (
state: exact) plutot qu'inserees : l'ordre est la regle, et inserer c'est parier sur ce que le paquet aura mis avant nous. Keycloak ecrit (mots de passe, creations), les trois autres lisent, les comptes de service ne se voient pas entre eux, et la lecture anonyme est fermee. amorcage_accesgarde le compte d'administration, et c'est nomme comme l'exception : il ne consomme pas l'annuaire, il le PROVISIONNE, depuis la socket locale.- La sonde passe de
-xa-Y EXTERNAL. Elle lisait en anonyme : elle aurait annonce un annuaire VIDE — la panne la plus grave — sur un annuaire parfaitement sain. - La rotation du compte d'administration est enfin possible. Son mot de passe n'etait pose qu'a l'installation, par debconf : le faire tourner en voute ne descendait nulle part, et les deux divergeaient en silence. Un secret qu'on ne peut pas faire tourner est un secret qu'on ne fera pas tourner.
Quatre marches payees en chemin
- Un cinquieme appelant.
amorcage_accesinclut aussi le role partage. Oublie, il echouait — etno_log, qui protege la valeur, masquait aussi la RAISON. La garde refuse desormais dans une tache SANSno_log: elle nomme la cle absente, jamais son contenu. - Une garde qui surveillait deux champs sur trois. La federation Keycloak ne
reecrivait son
bindDnque si l'URL ou le mode changeaient. Nouveau secret, ancien nom :error code 49 - Invalid Credentials, qui accuse les identifiants sans dire lequel des deux a bouge. - Une rotation placee trop tard. Les taches qui se lient en administrateur passaient AVANT la mise a jour du mot de passe. Un secret qui tourne doit descendre avant que quoi que ce soit s'en serve.
ansible-vaultet son tube. Sortie non bloquante = echec silencieux ; la voute paraissait tournee et etait identique a l'octet. La garde qui compare l'empreinte du FICHIER avant et apres l'a rattrape — c'est la lecon deja ecrite dans ce depot, et elle vient de se repayer.
La garde
P72 exige que tout role incluant resoudre_annuaire NOMME son compte de service, et
qu'aucun sauf amorcage_acces ne nomme admin. Eprouvee dans les deux sens.
Rotation
vault_openldap_admin et vault_ldap_bind_postfix ont ete renouveles : les deux avaient
transite en clair par une session d'exploitation. Verifie sur l'infrastructure — les
anciennes valeurs rendent Invalid credentials (49), les nouvelles ouvrent, et les quatre
services repondent.
2026-09-13 — Le genome ne nait plus chez un locataire
Ce que la console montrait
Chezlepro-17 contenait vingt et une VM : les quatorze du locataire ET les sept du
genome, melangees. OPS-Chezlepro et OPS-Technolibre, crees a la main, etaient vides.
Le gabarit vivait seul dans un pool Set-OPS.
La cause, et pourquoi le rangement n'aurait pas tenu
site-creer appelle cloner-vm, qui derive son pool par --pool-actif — c'est-a-dire
le pool du tenant lie. Les machines du site heritaient donc du locataire courant.
Range a la main, ca se serait defait au prochain site-creer, sans un mot : la VM est
bien creee, bien nommee, bien adressee. Seule son appartenance est fausse, et rien ne la
regarde.
Ce qui a ete fait
devis_proxmox_pools.py --pool-siterend le nom invariable du pool du genome. Une option DISTINCTE, pas un drapeau sur la premiere : le site et un tenant ne repondent pas a la meme question, et une fonction qui repond aux deux finit par se tromper d'appelant.cloner-vmaccepte une surchargePOOL=;site-creerla nomme. La substitution se fait au niveau make, pas shell —POOLarrive du sur-make comme variable make, et$${POOL}ne l'aurait jamais vue.P71exige quesite-creernomme son pool. Eprouvee dans les deux sens : elle passe sur le Makefile sain, elle tire des qu'on retire l'argument.
Applique au cluster
| pool | avant | apres |
|---|---|---|
Site-OPS |
— | 9 (7 du site + 2 gabarits) |
OPS-Chezlepro |
0 | 14 |
OPS-Patient0 |
— | 5 |
Chezlepro-17, Patient0-29, Set-OPS |
21 / 5 / 1 | supprimes, vides |
Les pools anterieurs a Set-OPS (Prod.Chezlepro, Prod.TechnoLibre, Dev.TechnoLibre,
Lab.TechnoLibre) et les quinze VM hors pool n'ont pas ete touches.
2026-09-12 (6) — Le site tient aussi les binaires, pas seulement les paquets
Ce qui manquait
Le cache du site couvre apt depuis longtemps : 1,34 Go tires de l'amont, 6,13 Go servis
a la flotte pendant la reconstruction d'un locataire — un rapport de 4,58. Mais quatre
artefacts arrivent autrement, parce qu'ils ne vivent dans aucun depot apt : Forgejo,
Keycloak, Nextcloud, oauth2-proxy. Le CONTROLEUR les tire (delegate_to: localhost) puis
les pousse par SSH.
Mesure du 2026-09-12 : /opt/setops/.cache/setops est ABSENT sur le runner du site.
Un second locataire monte depuis ce runner sortait donc chercher 570 Mo sur
codeberg.org, github.com (deux fois) et download.nextcloud.com — alors que le meme
ecosysteme ne demandait plus un seul paquet a Debian. Le poste du mainteneur, lui, a ces
1,6 Go depuis toujours : c'est pourquoi personne ne l'avait vu.
Pourquoi pas un relais transparent
Les Remap-* du cache font deja passer quatre fournisseurs HTTPS. Mesure des quatre
amonts avant de decider :
| amont | reponse |
|---|---|
codeberg.org |
200, aucune redirection |
download.nextcloud.com |
200, aucune redirection |
github.com (x2) |
302 vers release-assets.githubusercontent.com, URL signee valable une heure |
L'URL finale de GitHub change a chaque requete. Un cache qui la prend pour cle ne fait jamais mouche, et ce qu'il garderait serait perime avant d'etre relu. Le relais marche pour deux amonts sur quatre : ce n'est pas un mecanisme, c'est une coincidence.
Ce qui a ete fait
Un vrai depot de fichiers, dans le service qui existe deja. LocalDirs d'apt-cacher-ng
publie un repertoire du disque sous un prefixe — eprouve sur site-cache-01 AVANT d'ecrire
le role : 200, 90 158 octets, identiques a l'octet. Donc aucun service, aucun port,
aucun certificat, aucun flux nouveaux : l'ingress 3142 pair: flotte deja declare couvre
exactement ce chemin.
serveur_artefactspublie/var/lib/setops/artefacts-directssoussetops-binaires, et le remplit depuis les amonts — une seule machine sort, pour tout le site et pour les ecosystemes qui naitront demain.- Les versions ne sont pas recopiees. Le role lit les defauts des quatre roles
consommateurs (
include_varsSANSname:— enfermees dans un dictionnaire, les valeurs qui se citent entre elles ne se resolvent plus ; le devis des certificats avait deja paye cette marche). - Les quatre roles recoivent une tache ajoutee, placee avant leur
statde cache. Si le depot sert le fichier, lestatle voit et la tache de telechargement amont se saute d'elle-meme : aucune tache existante n'a change. - Les deux signatures detachees passent aussi par le depot. Elles pesent 228 octets, mais elles sont demandees a chaque passage : une sortie reste une sortie.
setops_depot_binairesest derive, jamais ecrit deux fois — deartefacts_amorcagechez le locataire, du plan du site danssite_inventaire.py.
La garde, ecrite le meme jour que la liste
P70 exige que tout dest: ecrit sous un <role>_cache_local figure au depot. Sans elle,
une version montee dans un role sans l'etre dans le depot produirait exactement le defaut
que ce depot traque : le runner ne trouve pas le fichier, retombe sur Internet, et tout
fonctionne — sans que rien ne le dise.
Une liste qui suit une autre prend du retard. Celle-ci est nee avec son garde-fou.
Une erreur corrigee en cours de route
La premiere version des gardes de signature s'appuyait sur failed_when: false puis testait
is not succeeded. failed_when: false REECRIT le verdict : la tache n'est plus jamais
failed, donc la garde etait toujours vraie et ne gardait rien — la faute exacte que
client_artefacts documente depuis le 2026-08-31. Les gardes mesurent le fichier
desormais, pas le verdict de la tache.
Ce que ca ne couvre pas encore
Le cache du contrôleur reste per-runner : un runner tout neuf demande le depot, et si le depot ne l'a pas encore, il sort. Le depot se remplit au deploiement du site — donc un locataire monte avant que le site n'ait ete redeploye sortira une derniere fois.
2026-09-12 (5) — Une reprise conditionnelle ne reprend rien
Cinq roles telechargeaient une cle de signature avec une boucle until exigeant une taille
non nulle. Des que le fichier existe — meme a zero octet — get_url emet une requete
CONDITIONNELLE ; l'amont repond 304 Not Modified ; la tache reussit sans rien ecrire ; et
la boucle retente cinq fois contre un serveur qui repondra toujours 304 :
HTTP Error 304: Not Modified size: 0 attempts: 5
La boucle de reprise se battait contre elle-meme. force: true sur les cinq
(client_pki, client_journal, serveur_collabora, serveur_grafana, serveur_loki) —
le when: qui precede garantit deja qu'on ne retelecharge pas une cle valide, donc force
ne concerne que le cas ou l'on a DECIDE d'aller chercher.
2026-09-12 (4) — Pools nommes comme les depots, et les cles sortent du poste
Le nom d'un pool est celui de son depot
Chezlepro-17 devient OPS-Chezlepro. Le seed se lisait dans le nom, ce qui etait juste
mais obligeait a connaitre le codage — et surtout le nom CHANGEAIT si l'index changeait :
la renumerotation du site l'a montre le jour meme. Ce qu'on lit dans la console est
desormais ce qu'on tape dans un terminal.
L'unicite ne vient plus de l'index mais du nom de dossier, deja unique par construction.
Site-OPS ne derive de rien, et c'est le point. Les machines du genome — cache, forge,
AC, noms, depot, supervision — ne dependent d'aucun index : elles sont l'infrastructure SUR
laquelle les index vivent. Un nom invariable dit cela, et il reste le meme d'un hebergeur a
l'autre. Sans ce bloc, les sept restaient hors de tout pool : sept orphelines a cote de
trois flottes rangees.
CONFORME : 4 pool(s) Proxmox, 41 VM placee(s), aucun nom ni VMID en collision.
P69 — l'amorcage d'un tenant designe-t-il le site REEL ?
dns_amorcage et artefacts_amorcage sont ecrits A LA MAIN dans les intrants d'un tenant,
et c'est voulu : au moment ou ils servent, la premiere machine ne resout aucun nom. Une
adresse, pas un nom.
Mais ces adresses designent des machines DU SITE. Le site a change d'index ; elles n'ont
pas suivi. La reconstruction du locataire s'est arretee sur infra-pki-01 avec
« Failed to update apt cache » — a quinze couches de sa cause.
La preuve ne juge que les valeurs qui PRETENDENT designer le site : un tenant peut
legitimement s'amorcer sur un resolveur public, et OPS-Technolibre comme OPS-Patient0
visent 9.9.9.9. C'est un choix, pas un oubli.
Les cles sortent du poste, en clair, et c'est raisonne
Les neuf fichiers (1 644 o) sont sur la cle USB LUKS. En tar clair, par
--support-chiffre, option ajoutee ce jour et explicite — le defaut reste GPG.
Deux menaces, deux reponses :
le support est perdu ou vole -> LUKS s'en charge
le poste est compromis -> la 2e couche n'aide pas : les originaux sont
dans ~/.config sur ce meme poste
La couche GPG n'ajoutait donc presque rien, et coutait une phrase de passe STOCKEE NULLE PART — le seul point que la procedure elle-meme ne couvre pas. On echangeait un risque de divulgation deja couvert contre un risque de PERTE qui ne l'etait pas.
Pourquoi ce n'est pas le defaut : un support non chiffre est le cas le plus frequent, et le silence ne doit jamais pencher du cote de la divulgation.
Et la note qui disait les cles sorties depuis le 2026-09-05 etait fausse : le support ne portait que le depot hors site du 1er. Verifie avant d'ecrire, pas apres.
2026-09-12 (3) — Trois reconstructions : trouver, verifier, prouver
La premiere reconstruction avait trouve seize defauts. Deux tours de plus ont ete faits, et c'est le second qui compte le plus : un runbook ecrit d'apres un recit de depannage contenait une erreur qu'aucune relecture n'aurait montree.
Tour 1 8 passages ~2 h 30 16 defauts trouves
Tour 2 3 passages 53 min 2 defauts trouves
Tour 3 2 passages 37 min 24 0
Ce que le tour 2 a corrige dans le runbook
J'avais annonce DEUX passages. Il en fallait TROIS, et pour une raison instructive : les deux blocages connus — la cle de Forgejo et la forge vide — sont en serie. On ne peut pas amorcer une forge qui ne tourne pas, et elle ne tourne qu'apres la convergence de la cle. Je les croyais franchissables dans le meme passage.
Il fallait SUIVRE le runbook pour le voir.
Deux murs de plus, invisibles au tour 1
#17 — site-creer rend la main avant que les machines repondent. Mesure : 3 min 20 au
tour 3. Le chemin locataire a _attendre-flotte ; le site n'a pas d'equivalent. Un
enchainement automatique echouerait sur UNREACHABLE qui n'est qu'une impatience.
#18 — les cles d'hote changent a chaque reconstruction. known_hosts porte l'ancienne,
git refuse — a juste titre — et sans terminal il ne peut rien demander. Le message qu'il
rend parle de droits d'acces et de depot inexistant.
accept-new accepte une PREMIERE rencontre, jamais un CHANGEMENT : il ne suffit donc pas
pour une machine reconstruite sur une adresse deja connue. forge-amorcer purge desormais
l'entree perimee — celle de l'hote que git va contacter, lu dans git remote get-url,
et non celle de l'API : quand l'amorcage passe par un tunnel, les deux different et purger
la mauvaise ne se verrait qu'au push.
Le cycle de la cle, denoue
client_pki posait les droits de la cle d'hote pour le groupe git, que le paquet de
Forgejo cree dans une couche POSTERIEURE. Aucun ordre de couches ne denoue ce cycle : les
certificats doivent venir tot, tout le monde en depend.
C'est la PROPRIETE DU GESTE qui a change de main. serveur_forgejo revendique la cle au
moment ou il cree le groupe ; client_pki garde la sienne et la repose a chaque passage,
parce que step reecrit la cle a chaque renouvellement. Les deux se recouvrent, et c'est
voulu : l'une amorce, l'autre entretient.
Gain mesure : un passage entier, et 300 secondes d'attente perdue.
Ce que mon propre outil masquait
forge-amorcer n'affichait que la DERNIERE ligne de stderr — or git termine toujours
par « assurez-vous que le depot existe », quelle que soit la cause. Un aller-retour de
diagnostic pour une cle d'hote. Il rend maintenant les quatre premieres lignes.
C'est le meme defaut que ceux trouves dans le moteur, commis par moi : un message qui pointe ailleurs que sa cause.
La sequence est desormais mesuree, pas reconstituee
docs/runbooks-exploitation.md §9 porte les six etapes du tour 3, avec leurs durees
reelles et les dix-huit murs. Le troisieme tour n'a rien trouve de nouveau : c'est le
premier ou reconstruire le site est une PROCEDURE et non une enquete.
2026-09-12 (2) — Le site est reconstruit depuis zero, et il a fallu seize corrections
SITE-Chezlepro n'avait jamais ete rase. Il avait ete monte par ajouts successifs, sur des
semaines, avec un service deja debout a chaque etape. La limite qu'on repetait partout —
« l'infrastructure d'accueil n'a jamais ete reconstruite depuis zero » — se lisait comme de
la prudence.
7 machines 0 echec 0 injoignable 46 couches
16:31 -> 19:00 creation 12 min 08 s, puis HUIT passages de deploiement
Ce qui rend un site different d'un locataire
Un locataire naît dans un monde deja peuple : le site lui fournit les paquets, les noms, le genome, l'heure et le depot de sauvegarde. Un site n'a personne au-dessus de lui, sauf sa frontiere. Tout ce qu'un locataire recoit, un site doit se le donner — et pendant qu'il se le donne, il ne l'a pas.
Onze des quinze murs viennent de la.
Deux capacites qui n'existaient pas
make site-raser. raser.py ne visait que l'instance active — un locataire. Rien ne
detruisait les machines du site. Ce n'est donc pas que personne n'avait essaye de le
reconstruire : l'outil n'en offrait pas le moyen. La limite etait un trou d'outillage,
pas une fatalite. Les quatre verrous de raser.py sont repris tels quels.
make forge-amorcer. Le runner clone le genome depuis SA PROPRE forge, et
serveur_forgejo ne cree ni organisation ni depot. A froid la forge naît vide. Le geste
part du POSTE, et c'est structurel : a cet instant il est le seul endroit qui detienne le
genome. Un amorcage vient toujours de l'exterieur de ce qu'il amorce.
Trois gardes qui verifiaient la forme au lieu du resultat
C'est la famille la plus couteuse : elles donnent l'apparence d'une verification.
Le resolveur. « Le resolv.conf nomme-t-il deja le resolveur declare ? » — juste en
regime etabli. A froid, les sept machines naissent avec nameserver <site-dns-01>, qui est
l'une des sept et n'a aucun resolveur. La garde concluait « elle pointe deja au bon
endroit » et protegeait l'etat casse. Elle mesure desormais si la resolution ABOUTIT.
L'administration reconnue a son port. elif "22" in _ports(fl) — juste tant que le seul
flux administratif etait SSH. L'exploitant declare admin sur le 443 de sa forge, la machine
accepte, la frontiere refuse, et les deux couches se croient d'accord.
Un flux a deux paires n'obtient qu'une branche. La forge declare pair: [flotte, admin];
la chaine de elif rangeait le flux dans le premier cas et la part administrative
disparaissait en silence. La portee administrative s'AJOUTE desormais.
Un ecart de securite, que seule la construction pouvait montrer
Le PostgreSQL du SITE servait le certificat AUTO-SIGNE du paquet Debian, pas celui de son
AC — serveur_postgresql_tls_actif vaut false par defaut et le site ne le declarait
nulle part, quand le locataire le met a true. Ni reseaux_autorises, ni tls_force.
Personne ne l'avait vu parce qu'aucun client n'exigeait la verification. Le premier a
l'exiger — l'import du schema Icinga DB — a echoue sur certificate verify failed.
Le defaut n'a pas casse la construction : la construction a revele le defaut.
L'acces de l'exploitant etait accidentel
L'ancien plan d'administration 10.17.0.0/24 vivait DANS 10.17.0.0/16, que les
expositions du site acceptent deja pour les locataires. Sortir le site de ce supernet a
revele qu'aucune declaration n'avait jamais accorde cet acces.
C'est l'explication complete d'une mesure du matin meme — « le site a une console deployee et sans chemin d'acces ». Ce n'etait pas Grafana : c'etait tout le site. La decision de separer les index n'a pas cause le probleme, elle a retire le hasard qui le masquait.
Deux passages, au minimum
client_pki pose les droits d'une cle pour un consommateur qu'une couche ULTERIEURE cree.
Au premier passage le groupe git n'existe pas, la cle reste root:root — donc FERMEE,
jamais plus ouverte — et Forgejo ne demarre qu'au second.
C'etait d'abord un blocage CIRCULAIRE : l'echec arretait le play avant la couche qui aurait cree le groupe. Rejouer n'y changeait rien. La tache constate desormais l'absence et reporte, au lieu d'echouer.
La sequence est ecrite
docs/runbooks-exploitation.md §9 — « Le premier jour d'un site », avec les seize murs et
ce que chacun enseigne. Le seizieme s'est montre en ECRIVANT cette entree : make wiki-publier refuse, parce que Forgejo ne cree <depot>.wiki.git qu'a la premiere page.
Sans cette section, le prochain site les rencontrerait tous.
2026-09-12 (1) — Un site prend son propre index, et la garde les compte enfin
Decision de l'exploitant : sites et locataires se partagent la classe A, chacun avec son propre index. L'exception du guide — « un site derive du meme index que son tenant » — disparait. Il reste une seule regle : tout prend un index.
Ce que l'exception cachait
Chez l'hebergeur de reference, le plan d'administration du site vit dans 10.17.0.0/24,
a l'interieur du supernet du locataire OPS-Chezlepro. Ce n'est pas dangereux — les
zones d'un tenant commencent a l'octet 16 par construction, mesure faite — mais
10.17.0.0/16 designait alors deux choses : un locataire, et le plan d'administration de
celui qui l'heberge. Deux sens pour une adresse.
Les zones du site, elles, ne derivaient de rien : 10.0.31 a 10.0.36, tapees a la main
dans underlay.yml. Le site etait la seule partie du systeme qui n'avait pas de seed.
La seconde liste, et sa garde ecrite le meme jour
Tant que les sites n'avaient pas d'index, ils ne pouvaient heurter personne. Depuis cette
decision, un site et un locataire peuvent reclamer le meme nombre — et
make instances ne voyait que les depots OPS-* :
for chemin in sorted(FRERES.glob("*/plan/nomenclature.yml")):
Un site n'a pas de nomenclature.yml : il etait invisible a la garde des collisions, celle
qui refuse deux instances aux memes VLAN et VMID. La decouverte lit desormais aussi
SITE-*/underlay.yml et son champ index. Un site qui n'en declare pas reste hors du
compte — il y entre le jour ou il en prend un.
OPS-Chezlepro 17 1171-1176
OPS-Technolibre 23 1231-1236
OPS-Patient0 29 1291-1296
SITE-Technolibre 31 —
SITE-Technolibre
Squelette du deuxieme site, pour la visite du 14 septembre. Un seul noeud Proxmox, en
version 9, derriere un OPNsense. Meme forme que le site de Chezlepro — sept machines, six
zones, memes services — adresse depuis l'index 31 : gestion en 10.31.0.0/24, zones en
10.31.31 a 10.31.36 (le 3e octet reste le numero de VLAN).
Trois differences ecrites dans son README plutot que decouvertes sur place : le SPOF est total (gabarit et clones sur la meme machine), les clones sont COMPLETS (un clone lie n'achete rien sans second noeud), et rien n'a jamais tourne contre un PVE 9.
COLLECTE.md liste les 35 valeurs a relever, dont la question de forme : ce qu'il y a
entre le noeud et l'OPNsense decide du mode de routage.
Ce qui reste a faire, et qui appartient a l'exploitant
OPS-Chezlepro passe a l'index 37, ce qui libere 17 pour le site qui l'utilise deja.
C'est un renumerotage de quatorze machines : il se fera quand l'exploitant le decidera.
SITE-Chezlepro ne declare donc pas encore son index — il entrera dans le compte ce
jour-la.
2026-09-11 (5) — Les correctifs de securite reviennent au quotidien, sans personne
Decision de l'exploitant, et elle corrige un mauvais jugement de ma part. J'avais decrit la situation correctement — « la seule operation de la flotte qui exige un humain » — puis propose un minuteur MAISON pour appliquer le socle, plutot que le mecanisme que Debian fournit exactement pour ca.
Ce qui etait desarme, et pourquoi ca ne tenait plus
Depuis le 2026-08-25, common_packages masquait apt-daily.timer,
apt-daily-upgrade.timer et unattended-upgrades.service sur toute la flotte.
LE MOTIF ETAIT REEL : apres six redemarrages et des heures sans reseau, le rattrapage
d'unattended-upgrades a tenu le verrou dpkg 48 minutes et fait echouer trois
deploiements de suite.
MAIS LA CONTREPARTIE ETAIT ECRITE ICI SANS ETRE MESUREE — « les correctifs ne s'appliquent plus qu'au passage de Set-OPS ». Mesure le 2026-09-11 : les sauvegardes tournent seules, les certificats se renouvellent seuls, la sante se rapporte seule. Les correctifs, non. Une absence de trois jours devenait une exposition sur vingt et une machines.
Ce qui rend la coexistence tenable, et qui n'existait pas en aout
_attendre-hote attend apt-daily* ET le verrou avant tout deploiement
lock_timeout: 300 pose en module_defaults sur chaque playbook de groupe
Origins-Pattern `-security` SEULEMENT — pas un `upgrade: full`
Automatic-Reboot false : aucun demon ne redemarre un service seul
La course de premier demarrage reste. Elle est desormais ATTENDUE plutot que supprimee — c'est la difference entre subir un concurrent et le connaitre.
Rearmer ne se deduit pas de ne plus desarmer
Passer le drapeau a false SAUTE la tache de desarmement ; il ne defait rien. Une machine
deja masquee le serait restee pour toujours, et le drapeau aurait dit le contraire de
l'etat reel. Une tache de rearmement explicite est donc posee le jour meme ou la decision
s'inverse — masque retire, unite activee, unite demarree.
On arme, on ne declenche pas — et la nuance a coute dix machines
La premiere version du rearmement faisait state: started sur les trois unites. Demarrer
unattended-upgrades.service apres des semaines de masquage lance son RATTRAPAGE
immediatement — en plein deploiement :
'apt-get autoremove' failed: E: Could not get lock /var/lib/dpkg/lock-frontend.
It is held by process 185135 (unattended-upgr)
Exactement la course qui avait motive le desarmement en aout, rejouee par la tache censee
le defaire, et malgre lock_timeout: 300. Dix machines sur vingt et une.
LE TRAVAIL QUOTIDIEN NE PASSE PAS PAR CE SERVICE : apt-daily-upgrade.timer appelle
apt.systemd.daily, qui invoque unattended-upgrade lui-meme. Le .service ne sert
qu'aux passages de demarrage et d'extinction. L'ACTIVER suffit — il partira au prochain
redemarrage, sur une machine au repos. Armer un minuteur, en revanche, ne declenche pas
sa cible : il la programme.
Ce que la sonde correctifs devient
Elle ne mesure plus un geste manquant mais un mecanisme en panne. Son seuil de 72 h cesse d'etre une tolerance pour devenir une alarme : avec un passage quotidien, trois jours de retard veulent dire que quelque chose ne tourne plus.
2026-09-11 (4) — L'heure vient de la frontiere : la derniere dependance vivante tombe
L'exploitant a presume qu'un ecosysteme fonctionnel n'avait plus besoin d'Internet pour ses operations internes. Mesure sur les quatorze machines : presque.
sorties TCP etablies vers une adresse publique aucune, sur 14/14
apt par le cache du site
noms internes autoritaires, en local
unattended-upgrades / apt-daily masked, masked, masked
pool 2.debian.pool.ntp.org iburst QUATRE pairs publics, sur les 14
Le role chrony posait le fuseau et INSTALLAIT le demon sans jamais toucher a ses sources.
Le defaut de Debian tenait depuis le premier jour : herite, jamais choisi.
Pourquoi c'etait la mauvaise a laisser trainer
step-ca emet des certificats a duree courte ; TLS refuse un certificat pas encore valide ;
Loki rejette une ligne trop loin dans le futur ; les silences d'Icinga reposent sur des
ttl. Une flotte dont les horloges divergent tombe en panne DE L'INTERIEUR, avec des
symptomes qui ressemblent a tout sauf a une horloge.
Et la derive est lente : couper Internet ne casse rien le premier jour. Le systeme parait souverain jusqu'a ce qu'il ne le soit plus, sans signal entre les deux.
L'autorite est la frontiere — decision de l'exploitant
Elle etait deja stratum 2, synchronisee, et ecoutait en 123 sur chaque patte. Il ne
manquait que le passage : son pf est en refus par defaut et aucun flux NTP n'etait
declare vers elle.
a creer : 14 | a retirer : 9
Les NEUF retires sont les anciennes regles port 123 -> !SETOPS_INTERNES : le 123 etait
autorise vers N'IMPORTE QUELLE adresse d'Internet. Le changement resserre autant qu'il
centralise. La destination est desormais nommee, alias SETOPS_FRONTIERE.
Deux chemins, parce que la topologie en a deux
Un tenant n'atteint PAS la frontiere par sa passerelle de zone : 10.17.x.1 est tenue par
le SDN de Proxmox. Il sort par le lien de transit — 10.0.4.1, patte libellee TENANTS,
qui manquait a opnsense_if_zones pour la meme raison que grappe-controle la veille.
Une machine du site, elle, a la frontiere pour passerelle directe.
La valeur est DERIVEE des deux cotes, jamais ecrite : instancier prend la patte sur le
reseau de transit (via reseau_transit(), qui le derive de passerelle_sortie — le
reconnaitre a son prefixe d'adresse aurait marche ici et menti chez le prochain
hebergeur) ; site_inventaire prend la patte de la zone de la machine.
14 machines du tenant ^* 10.0.4.1 publiques=0
7 machines du site ^* 10.0.3x.1 ecarts en nanosecondes
Le drop-in ajoute, il ne retire pas
/etc/chrony/conf.d/ est lu EN PLUS du fichier principal. Y declarer notre serveur sans
neutraliser le pool de Debian aurait laisse cinq sources, dont quatre sur Internet —
l'horloge centralisee en apparence, la dependance intacte.
La sonde horloge, ecrite en meme temps
Elle mesure DEUX choses, et la seconde est celle qui manquait : la machine est-elle synchronisee, ET contre la source DECLAREE ? Une machine peut etre parfaitement a l'heure en interrogeant quatre serveurs publics — c'est exactement l'etat d'avant, et mesurer la seule derive l'aurait laisse invisible.
Le seuil d'ecart (500 ms) est tres large, et c'est voulu : il doit crier sur une horloge qui
DECROCHE, pas sur la respiration d'un chronyd. L'autre moitie, elle, ne tolere rien.
Ce qui aura toujours besoin d'Internet, et c'est normal
Les correctifs de securite, la construction d'un ecosysteme neuf au-dela de ce que le cache detient, la resolution des noms externes, le courriel sortant. Aucun n'est une operation interne.
2026-09-11 (3) — make expositions-etat : le certificat SERVI, pas celui du disque
Renommer une exposition touche cinq choses. Quatre suivent au deploiement ; la cinquieme, non — et c'est celle que le navigateur regarde.
serveur_powerdns la zone publie le nouveau nom OK
serveur_keycloak le client OIDC accepte le retour OK
le service il fabrique ses URL avec le bon nom OK (P67)
serveur_nginx le vhost repond sur le nouveau nom OK
client_pki le SAN du certificat porte le nom NON il faut le rejouer
Mesure DEUX FOIS le 2026-09-10, sur grafana -> observatoire puis icinga -> vigie. Le
symptome est trompeur : le site repond, la page s'affiche, et c'est le NAVIGATEUR qui
refuse — avec une erreur de certificat que personne ne relie a un renommage fait la veille.
Il regarde dans les deux sens
Un nom RESTE dans le SAN apres avoir quitte le plan est un nom que le certificat continue d'authentifier. C'est exactement ce qu'avait laisse le premier renommage.
Le piege de ce second sens, trouve en l'ecrivant : client_pki met TOUJOURS le FQDN et le
nom court de la machine dans le SAN. Les compter comme vestiges faisait crier le controle a
chaque execution — et un controle qui crie toujours ne se lit plus.
Deux formes de terminaison, une seule question
Le locataire termine son TLS sur un edge ; sans_exposition y designe le porteur. Le SITE
n'a pas d'edge — site-forge-01 ecoute lui-meme sur 443 — et la variable n'y existe donc
pas. Le porteur se derive alors de l'application : son hote dit quelle machine la sert.
OPS-Chezlepro 6 expositions toutes servies, certificat conforme, rien en trop
SITE 5 expositions INJOIGNABLE depuis ce poste
Le second resultat n'accuse pas le service, et le controle le dit : les zones du SITE ne sont pas routees depuis le plan d'administration du locataire. Le mur est la frontiere.
Pourquoi pas une preuve
make prouver est STATIQUE — il lit le depot, zero appel reseau, et c'est ce qui le rend
rejouable partout. Rien de statique ne peut lire un certificat SERVI : il faut ouvrir la
connexion.
Les quatre controles a la demande n'etaient nommes nulle part comme famille —
routes-fabric-etat n'apparaissait dans aucune documentation. Ils ont maintenant leur
section (§8 des runbooks). Une garde que personne ne sait lancer ne sert a rien.
2026-09-11 (2) — La piste d'audit sort de la machine auditee
auditd tournait, onze regles etaient armees, et rien de tout cela ne servait a grand-chose.
Quatre manques, mesures avant d'etre combles.
1. Rien ne sortait de la machine
C'etait le trou decisif. La configuration d'Alloy contenait zero reference a l'audit, et
les cinq greffons d'auditd etaient tous active = no. La piste vivait uniquement sur la
machine auditee : qui la compromet possede la preuve de l'avoir fait, et le fichier est
precisement ce qu'on efface en premier.
Alloy lit maintenant /var/log/audit/audit.log et le pousse vers Loki. Aucune permission
a poser — mesure, pas suppose :
uid=999(alloy) groupes=989(alloy),4(adm),999(systemd-journal)
drwxr-x--- 2 root adm /var/log/audit
ON A ECARTE L'AUTRE VOIE, et pour une raison precise : activer le greffon syslog.conf
ferait passer les evenements par journald, deja collecte — mais journald applique une LIMITE
DE DEBIT. Une rafale d'evenements d'audit, c'est-a-dire exactement le moment qui compte,
serait tronquee en silence.
L'activation DERIVE de l'appartenance au groupe (serveur_durci dans group_names), pas
d'une liste par instance : une liste de plus qui suit une autre prendrait du retard.
2. Aucune sonde, alors que le depot racontait lui-meme la panne
roles/auditd/ n'avait pas de meta/ du tout. Et son propre defaults/main.yml documentait
l'incident du 2026-08-30 : onze regles armees dans le noyau sur quinze machines ou auditd
etait mort, auditctl -l les affichant toutes.
La sonde audit mesure donc la COLLECTE — service actif et journal qui a bouge. Le
nombre de regles part en perfdata, jamais en verdict : c'est exactement le chiffre qui
restait bon pendant la panne.
Declaree dans roles/serveur_durci/meta/ (le GROUPE) et deposee par roles/auditd/ : la
derivation de supervision ne traverse pas les roles-taches. Meme lecon que la sonde
correctifs, deplacee la veille.
3. La retention n'etait declaree nulle part
Mesure sur infra-edge-01 au repos, deploiement termine :
3 380 octets / 60 s -> ~4,6 Mo/jour
Debian : 8 Mo x 5 = 40 Mo -> ~9 jours
Sauf qu'une reconstruction a elle seule en brule 4 Mo. Portee a 16 Mo x 8 = 128 Mo (~28 jours), et le raisonnement a change en route : depuis (1), l'archive n'est plus ici, elle est chez le collecteur. Le local n'est plus qu'un TAMPON pour traverser une panne de Loki.
4. Les regles ignoraient le materiel qui signe tout le reste
Elles surveillaient passwd, sudoers, sshd_config, /etc/apt/ — les fichiers par
lesquels on prend un COMPTE. Aucune ne regardait /etc/step/certs/, la cle privee de
l'hote : qui la copie parle ensuite au nom de la machine devant toute la flotte, sans
toucher a un mot de passe ni declencher une seule des regles precedentes.
-w /etc/step/ -p wa -k pki
-w /etc/step-ca/ -p wa -k pki-autorite (autorite seulement)
LA SECONDE EST CONDITIONNELLE, ET CE N'EST PAS UN DETAIL. auditctl REFUSE un -w vers
un chemin absent, et augenrules fait alors echouer le chargement ENTIER — auditd ne
demarre plus, faute de sa dependance. Poser cette ligne sur les treize machines qui ne sont
pas l'autorite rejouerait exactement la panne du 2026-08-30. Verifie sur l'autorite d'abord,
seule : 13 regles armees, audit-rules non en echec, sonde a 0.
Un effet de bord assume
Creer roles/serveur_durci/ pour porter la declaration en fait un role a part entiere : il
lui faut son README et sa meta/authentification.yml, comme serveur_debian. Trois preuves
l'ont exige (P29, P31, P48) — elles ont fait exactement leur travail.
2026-09-11 (1) — Reconstruction a froid : les noms derives naissent justes
Quatrieme reconstruction depuis zero de Chezlepro, demandee pour une raison precise : trois
roles dependent desormais d'une variable que le GENERATEUR pose. En incrementiel ils avaient
marche parce que la configuration existait deja. A froid, si instancier ne posait pas
<groupe>_hostname sur un chemin quelconque, le defaut du role reprendrait la main en
silence — et il tomberait juste pour forge et cloud (la convention coincide), faux pour
observatoire.
14/14 VM rasees puis recreees 32 min 14 s
un seul echec, et il n'a rien a voir avec les noms
Le certificat ne A FROID porte observatoire et vigie, et aucun ancien nom. Les deux
retours SSO derivent juste, sans reprise :
observatoire redirect_uri=https%3A%2F%2Fobservatoire.chezlepro.internal%2Flogin%2Fgeneric_oauth
vigie redirect_uri=https%3A%2F%2Fvigie.chezlepro.internal%2Foauth2%2Fcallback
En incrementiel, le SAN avait demande un second passage de client_pki. A froid, il naît
juste du premier coup — la sequence penible n'existe qu'en incrementiel.
Une reussite n'est pas un contenu
L'echec unique, sur infra-mail-01 et sur elle seule : get_url a rendu 0 octet sans
erreur. Le cache a servi un 200 au corps vide. Le role a continue, satisfait.
Le defaut ne s'est pas lu la. Il s'est lu deux cents lignes plus loin, dans un apt qui
accusait la SIGNATURE :
Missing key 35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3, which is needed to verify signature
Un message qui envoie chercher une cle revoquee chez le fournisseur, alors que le fichier local faisait zero octet. Meme famille que l'index tronque du cache la veille : l'octet manquant se denonce toujours ailleurs qu'ou il manque.
Et la garde posee hier ne mordait pas. « Retirer une ressource VIDE avant de la redemander » ne vaut qu'au passage SUIVANT : au premier, le fichier n'existe pas encore, il n'y a rien a retirer. Elle repare le second essai, elle ne protege pas le premier.
Les cinq roles qui telechargent une cle la MESURENT maintenant dans la meme execution — le
until exige une taille non nulle (cinq tentatives), puis une assertion nomme la vraie
cause. P68 garde le motif. Les roles qui deposent une cle embarquee (copy depuis
files/) n'entrent pas dans le compte : rien de reseau ne s'interpose.
infra-mail-01 redeployee : la cle fait 1022 octets, comme les treize autres.
Les trois bruits connus
Revenus comme prevu, et ce ne sont pas des regressions : auditd livre sans regles au
gabarit, la course sur le verrou dpkg, l'index d'apt.grafana.com absent du cache
hors-ligne.
2026-09-10 (18) — vigie pour Icinga, et la quatrieme liste tombe
Suite du renommage precedent, decide par l'exploitant : vigie est le nom de la console de
supervision. Le mot avait ete ecarte pour Grafana precisement parce qu'il connote la guette
du danger — c'est le metier d'Icinga, pas celui d'un tableau de bord.
icinga.chezlepro.internal -> vigie.chezlepro.internal
icinga.technolibre.internal -> vigie.technolibre.internal
icinga.lab.chezlepro.internal -> vigie.lab.chezlepro.internal
Les trois locataires en meme temps : deux vocabulaires entre ecosystemes, c'est le defaut qu'on vient de corriger.
La quatrieme liste
serveur_oauth2_proxy_redirect_url etait posee A LA MAIN dans les group_vars de chaque
instance, avec le FQDN recopie du plan. Quatrieme recopie du meme nom, apres les SAN (deja
derives), les clients Keycloak (P66) et les noms publics des roles (P67).
Elle derive maintenant de serveur_oauth2_proxy_hostname, que instancier pose depuis
expose:. Le repli reste VIDE et non fabrique : l'assertion du role doit refuser un
deploiement sans exposition, pas inventer un nom que personne ne resout. La ligne a ete
retiree des trois instances.
Ce qu'il faut savoir pour le prochain renommage
Changer une exposition ne suffit pas a refaire le certificat de l'edge. Deployer
serveur_nginx pose le vhost mais laisse le SAN en arriere ; c'est client_pki qui
reemet. L'ordre eprouve deux fois aujourd'hui :
serveur_powerdns -> serveur_keycloak -> le service -> serveur_nginx -> client_pki
Le certificat servi a ete VERIFIE apres coup, pas suppose : il porte observatoire et
vigie, et plus aucun des deux anciens noms. Aucune garde ne compare encore le SAN SERVI aux
expositions du plan — ce serait un controle a la demande, pas une preuve statique.
2026-09-10 (17) — La console d'observabilite s'appelle pareil des deux cotes
Deux ecosystemes, deux noms pour le meme service : grafana.chezlepro.internal chez le
locataire, tableaux.genese.internal au site. Le second etait de notre fait, pose le jour
meme en montant la pile d'observabilite du site.
grafana.chezlepro.internal -> observatoire.chezlepro.internal
tableaux.genese.internal -> observatoire.genese.internal
Pourquoi pas grafana
Un nom de produit dans une URL se grave ailleurs qu'a l'ecran : dans les SAN du certificat, dans les URI de redirection du SSO, dans les signets de l'exploitant. Remplacer Grafana obligerait alors a renommer le service — donc a refaire le certificat et le client Keycloak pour une raison qui n'a rien a voir avec eux.
Le plan du locataire nommait deja quatre services par leur FONCTION (auth, cloud,
bureau, forge) et deux par leur PRODUIT (grafana, icinga). Celui-ci rejoint le
registre majoritaire. icinga reste, pour l'instant : c'est un autre geste.
Ce que le renommage a revele
Les URI de redirection des clients Keycloak repetent a la main les FQDN que
plan/applications.yml declare dans expose:. Rien ne les relie. Renommer l'exposition
regenere hosts.yml, le certificat et la zone DNS — et laisse le client OIDC viser l'ancien
nom. Keycloak ne proteste pas : c'est l'utilisateur qui le decouvre au retour du SSO.
P66 (preuve_clients_oidc_vises_sur_une_exposition) : chaque redirect_uris et chaque
web_origins doit viser un FQDN qu'une application expose. Ecrite EN MEME TEMPS que le
renommage, parce que c'est le seul moment ou l'on sait encore que les deux listes existent.
P66 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose
Et une TROISIEME liste, que le deploiement a revelee
Le renommage deploye, nginx servait le nouveau nom, le certificat le portait, les deux zones le publiaient — et Grafana fabriquait toujours son URL de retour OIDC avec l'ancien :
redirect_uri=https%3A%2F%2Fgrafana.chezlepro.internal%2Flogin%2Fgeneric_oauth
Quatre roles — grafana, forgejo, nextcloud, keycloak — portaient en defaut une DEVINETTE du
nom sous lequel ils sont servis (grafana.{{ domaine_interne }}). Tant que le plan suit la
meme convention, la devinette tombe juste et rien ne revele qu'il y a deux sources. Keycloak
avait deja son remede, dans le role, en relisant le plan a l'execution — un remede par role,
donc trois roles sans remede.
instancier derive desormais <groupe>_hostname de l'exposition declaree, quand elle est
UNIQUE (deux expositions ne designent aucun nom canonique : le role garde alors la main).
Six services en heritent : collabora, forgejo, grafana, keycloak, nextcloud, oauth2_proxy.
P67 garde la derivation. Les quatre instances ont ete regenerees.
Une erreur de lecture, au passage
En cherchant pourquoi tableaux.genese.internal ne resolvait pas depuis le poste, on a
conclu que le nom n'etait pas dans la zone. Il y etait — PowerDNS le publiait correctement,
comme sauvegarde.genese.internal. Ce qui ne les connaissait pas, c'est le RESOLVEUR DU
POSTE (192.168.10.10), qui porte trois entrees faites a la main et aucune delegation de
genese.internal. L'index consulte n'etait pas la source.
2026-09-10 (16) — La frontiere journalise a nouveau, et une garde veille cette fois
Doctrine posee par l'exploitant : des lors que le site a son Loki, la journalisation de la frontiere et des hyperviseurs doit y etre dirigee.
46 681 lignes recues de la frontiere en 30 min
site : 68 services, tous OK
Ce qui etait casse, et depuis quand
La destination syslog d'OPNsense pointait 10.17.20.11:3100 — une adresse de TENANT,
sur le port de Loki, en UDP, ce que Loki ne sait pas lire. La VM a ete rasee ; une cible
morte a arrete TOUTE la journalisation d'OPNsense pendant dix jours. La destination a ete
ETEINTE pour reparer, et jamais remplacee.
Deux fautes en une : la frontiere ne journalisait plus nulle part, et sa cible etait chez un locataire — ce que D-87 refuse dans l'autre sens.
L'intention, elle, etait juste : les bonnes facilites, info et au-dessus. On l'a REPRISE
telle quelle et change uniquement la destination — ces choix avaient ete faits, ce n'etait
pas a nous de les redecider.
Un traducteur, parce que Loki ne parle pas syslog
loki.source.syslog dans l'Alloy qui tourne DEJA sur le collecteur — un second agent
n'aurait servi qu'a tenir deux configurations en phase. Port 1514 et non 514 : au-dessus de
1024, donc ecoute sans privilege. Un collecteur qui aurait besoin des droits du systeme
pour entendre un equipement serait un mauvais echange.
Une regle de relabel sans garde n'ignore pas : elle EFFACE
Trois quarts d'heure sur un symptome absurde — la configuration deposee portait
host = "bifrost-1", visible a l'oeil dans le fichier, et le flux arrivait sans etiquette.
rule { source_labels = ["__syslog_connection_ip_address"]
target_label = "host" } # sans regex
Sans regex, la regle correspond toujours — meme quand sa source n'existe pas — et pose
host = "". Loki jette une etiquette vide, et celle de l'ecouteur disparaissait avec elle.
Les deux autres regles etaient gardees ; celle-la ne l'etait pas.
Et la verification finale a corrige une seconde erreur, la mienne : label/host/values
rendait une liste en CACHE. La requete directe {host="bifrost-1"} rendait bien un flux.
Un index qui ne montre pas une chose ne prouve pas qu'elle n'existe pas.
La garde, qui manquait depuis l'incident
journaux-frontiere demande a Loki ce qu'il a RECU, pas a la frontiere ce qu'elle croit
avoir envoye — la destination est le seul juge, et un emetteur qui parle a un trou noir se
porte tres bien.
La fenetre est DERIVEE du debit observe, pas choisie : ~1 500 lignes/minute mesurees. Une frontiere qui filtre ne se tait jamais dix minutes.
Trois etats, tous eprouves sur une copie :
frontiere muette rc=2 « LA FRONTIERE NE JOURNALISE PLUS »
Loki injoignable rc=3 « la sonde ne peut rien affirmer »
debit normal rc=0 « 973 ligne(s) recue(s) en 10m »
Le 3 compte autant que le 2 : « je ne peux rien affirmer » n'est pas « c'est casse ».
Une sonde qui confondrait les deux accuserait la frontiere d'un silence qui serait le sien.
2026-09-10 (15) — Une source vide n'est pas « tout le monde »
Les journaux de la fabric, et le defaut de classe que leur mise en place a revele.
journaux 10 hotes dans Loki (7 VM du site + 3 hyperviseurs)
site 67 services, tous OK (51 avant)
Metriques TIREES, journaux POUSSES — et ce n'est pas un caprice
Un scrape part du collecteur et doit REVENIR : il exige un chemin symetrique, que la route par defaut gelee (D-57) interdit. Un push part de la source et n'attend qu'un accuse : la route SPECIFIQUE vers les zones du site suffit — celle qu'on venait justement de completer.
Trois trous dans les generateurs, du meme jour
Le devis de la frontiere ne connaissait fabric qu'en DESTINATION. Un flux entrant
depuis la fabric tombait dans « rien d'autre n'entre » et n'emettait AUCUNE regle, sans
rien dire. Meme forme que le trou des integrations universelles, trouve le matin meme.
La regle etait sur la mauvaise patte. D-61 fait raisonner le devis en ARRIVEE ; la
regle etait posee sur l'interface de la DESTINATION. opt7 — la patte face a la fabric,
libellee PROXMOX — manquait a la table des zones, qui ne recensait que le site. Elle y est,
et l'interface se derive desormais du reseau ou vivent les hyperviseurs.
Et le plus grave : fabric etait un mot RECONNU mais NON RESOLU. Le generateur de
pare-feu d'hote l'acceptait comme valide, ne trouvait aucune adresse, et une source vide
produit une regle sans saddr :
tcp dport 3100 accept # ouvert a tout le monde
Un mot reconnu mais non resolu est pire qu'un mot inconnu : celui-ci serait refuse a la validation, celui-la produit une porte grande ouverte qui a l'air d'un flux precis.
La classe entiere, refermee
Le defaut n'etait pas propre a fabric. Toute paire nommant un ensemble et ne
resolvant rien ouvrait le port. Trouve en lisant les fichiers generes :
tcp dport 5665 accept # serveur_icinga, sur le mon-01 d'un TENANT
L'API de supervision ouverte a tous, parce que pair: serveur_backup ne resout rien — cet
ecosysteme depose son etat chez le site et n'a pas de depot a lui.
expositions et externe, EUX, veulent bien dire « tout le monde » : ce sont des services
publies, et leur ouverture est une INTENTION. Ailleurs : pas de source, pas de regle —
et le generateur le DIT, parce qu'un flux tu en silence est une porte qu'on croit fermee.
Trois regles se sont refermees. Chacune verifiee AVANT d'appliquer :
| regle | verdict |
|---|---|
site-forge-01:3000 |
rien n'ecoute — la forge sert 443 |
mon-01:5665 (tenant) |
deux autres regles couvrent les pousseurs |
site-mon-01:3000 |
Grafana ecoute — a corrige avant de fermer |
Grafana : edge ET admin
Chez un tenant, le nginx d'edge termine le TLS : edge resout, c'est le seul chemin. Le
SITE n'a PAS d'edge — chaque service s'y sert lui-meme. edge n'y resolvait donc rien, et
la console etait ouverte a l'Internet PAR ACCIDENT, sous un commentaire qui parlait d'un
edge inexistant.
Ajouter admin rend explicite ce qui etait accidentel. Et la mesure a montre autre chose :
Grafana etait deja injoignable depuis le poste — la frontiere ne laisse pas passer le
3000. La regle ouverte ne servait a rien, sauf a rester ouverte si la frontiere s'ouvrait
un jour.
Le site a donc une console deployee et sans chemin d'acces. Ce n'est pas corrige ici, mais c'est dit.
2026-09-10 (14) — Le site surveille enfin sa fabric
La supervision du site voyait ses sept VM et rien d'autre. Mesure de depart, depuis
site-mon-01 : les trois hyperviseurs, les neuf pattes de la frontiere et sa propre
passerelle par defaut rendaient tous 100 % de perte au ping.
16 hotes UP / 16 dont 9 pattes de frontiere en controle ACTIF
11 cibles Prometheus dont 3 hyperviseurs, job `fabric` separe
Le partage : ce qui peut porter un agent, et ce qui ne le peut pas
Les hyperviseurs portent node_exporter — D-48 l'autorise. Chacun expose 6 600 a
7 900 lignes de metriques, dont 1 005 unites systemd avec leur etat : soit exactement
ce que la sonde sante mesurait, plus la charge, le disque et l'horloge.
Ils n'entrent PAS dans le socle, et c'est le point delicat. Un hyperviseur Proxmox
n'est pas une VM de la flotte : lui appliquer serveur_durci reecrirait son pare-feu, son
SSH et ses sysctl — sur la machine qui tient tout le reste. Ils vivent dans leur propre
groupe, hors de GROUPE_SOCLE et de hotes_actifs.
La frontiere n'accueille aucun agent : controle ACTIF, une entree par PATTE. La
frontiere est un seul boitier, mais chaque zone depend de SON interface — une interface
eteinte coupe une zone pendant que les autres vont bien, et on en a deja vu (les routes
creees disabled le 2026-09-02). Un ping vers une seule adresse dirait « la frontiere est
debout » et manquerait ce cas.
On TIRE, on ne pousse pas — et c'est la route gelee qui le decide
J'avais propose du passif, et il avait ete valide. La mesure a dit non :
asgard -> 10.0.36.11 via 192.168.11.254 (le routeur du site)
route par defaut via 192.168.11.254 GELEE (D-57)
Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site : le porteur de sante y expirait en 20 s. Le remede evident — router 10.0.0.0/8 par la frontiere — touche la route par defaut d'une machine EN SERVICE, ce que D-57 interdit. On tire donc, dans le sens que la frontiere route deja.
Le vrai defaut : une liste qui n'a pas suivi
Le mecanisme de routage EXISTAIT sur vmbr0, avec un commentaire du 2026-08-26 tenant
exactement le raisonnement qu'on venait de refaire. Sa liste s'arretait a 10.0.34.0/24 :
le site declare 6 zones (31 -> 36)
les hyperviseurs 4 routes (31 -> 34)
Les zones sauvegarde (35) et supervision (36) sont nees, les routes n'ont pas suivi.
Le symptome ne ressemblait pas a une route manquante : il ressemblait a un pare-feu, puis a
un probleme de reseau chez l'exploitant.
make routes-fabric-etat compare desormais TROIS choses : les zones declarees, les
routes declarees dans /etc/network/interfaces, et les routes vivantes dans le noyau. Le
cas le plus traitre est le troisieme — vivante mais non declaree : tout fonctionne, la
supervision est verte, et la panne attend la prochaine maintenance. Une garde qui ne
comparerait que le vivant ne le verrait jamais. Eprouvee dans les deux sens.
Deux defauts trouves en construisant
bifrost-2 est un nom reserve, pas un boitier. L'underlay le disait — « pour que les
noms soient reserves » — mais en PROSE, illisible par le moteur. Le surveiller aurait donne
deux CRITICAL permanents pour un equipement absent. Il porte desormais etat: reserve ; le
jour ou le second boitier arrive, retirer la cle le fait entrer dans la supervision.
Un service passif n'existe que pour un hote qui peut POUSSER. Les hyperviseurs sont
dans client_metrique — c'est par ce groupe qu'on leur deploie node_exporter — et la
derivation en tirait asgard!metriques. Icinga refusait la configuration ENTIERE. Meme en
definissant l'hote, le service serait reste UNKNOWN pour toujours. L'appartenance a un
groupe sert deux choses qui ne coincident pas toujours : a qui l'on deploie, et de qui l'on
attend un rapport. La seconde se lit sur client_sante, et nulle part ailleurs.
Les commutateurs restent dehors
Choix de l'exploitant, coherent avec D-48 : ils sont hors flotte, et rien ne les rend interrogeables sans leur ouvrir un acces qu'on ne veut pas leur ouvrir.
2026-09-10 (13) — D-88 : le noeud du gabarit, point unique de la REPRODUCTION
Question de l'exploitant : « le modele vit sur vishnu, les clones sont sur asgard — qu' arriverait-il si vishnu tombait ? ». Mesuree, la reponse se coupe en deux.
Les donnees survivent.
pool CephNVMe size=3 min_size=2
OSD sur asgard, gandalf, vishnu
base-9006-disk-0, base-9006-disk-1 repliquees
L'image reste lisible avec un noeud en moins, et les quatorze VM d'un ecosysteme tournent ailleurs sur leurs propres disques Ceph : elles ne s'apercoivent de rien.
La reproduction, non. La configuration du gabarit porte le nom du noeud dans son chemin
meme — /etc/pve/nodes/vishnu/qemu-server/9006.conf — et le clonage appelle
nodes/vishnu/qemu/9006/clone. Noeud eteint, API muette, aucune VM nouvelle ne peut
naitre. Or la reproduction est ce que ce depot existe pour garantir.
La decision est d'ASSUMER la dependance et de la rendre COURTE
Pas de la supprimer. Depuis que le disque du gabarit vit sur un stockage partage (migration
du matin), la remise en route est un deplacement de fichier de configuration — quelques
minutes, aucun mouvement de donnees — suivi de la declaration gabarit.noeud. Sur un
stockage local, il aurait fallu recopier 16 Go ou refabriquer le gabarit.
Un benefice de la migration sur Ceph qu'on n'avait pas cherche : elle a raccourci une panne qu'on n'avait pas encore nommee.
Trois endroits, parce qu'un seul ne suffit pas
- D-88 dans les decisions : la dependance est nommee et son perimetre borne ;
runbooks-exploitation.md§7 : la manoeuvre, dans l'ordre, avec ce qui casse si on l'inverse — deplacer sans declarer laissegabarit_etaten ecart, declarer sans deplacer fait echouer le clonage ;- le plan du site, dans le bloc
gabaritlui-meme : c'est la que l'exploitant litnoeud: vishnu, et c'est donc la que l'avertissement doit vivre.
Ce qui n'est PAS fait, et qui est dit
Rien ne MESURE cette dependance. gabarit_etat compare le declare au reel ; il ne
demande pas si le noeud du gabarit heberge autre chose que le gabarit. Deux remedes de fond
restent ouverts : deplacer le gabarit la ou vivent deja les VM (ce qui ne supprime pas le
point unique mais cesse d'en avoir DEUX), ou une garde qui refuse quand la reproduction
depend d'un noeud qui ne porte rien d'autre.
Une dependance qu'on documente sans la mesurer reste une dependance qu'on decouvrira au mauvais moment.
2026-09-10 (12) — Un fichier vide existe, et une sonde pour les correctifs
La garde « fichier entier » — et pourquoi il a fallu DEUX corrections
Un telechargement interrompu laisse un fichier de zero octet, qui existe. Toutes les gardes de ce depot demandaient « ce fichier est-il la ? » :
- name: Cette ressource est-elle deja recuperee ?
ansible.builtin.stat: ...
- name: Telecharger la cle
when: not ..._present.stat.exists
infra-mail-01 a garde une cle smallstep de 0 octet apres l'epreuve hors ligne. Treize
machines portaient 1022 octets, elle portait le vide — et apt refusait le depot.
La premiere correction n'a pas suffi. Ajouter le controle de taille a bien fait
s'executer la tache (ok: [infra-mail-01]) — et le fichier faisait toujours 0 octet au
passage suivant. get_url sur une destination existante emet une requete CONDITIONNELLE :
l'amont repond « non modifie », le module rend ok, la ruine reste. Le play etait vert et
ne reparait rien.
Il faut donc effacer avant de redemander. state: absent ne mord que sur un fichier
vide : une cle valide n'est jamais retiree.
Controle negatif : fichier vide volontairement, role rejoue → 0 → 1022 octets, 0 erreur apt. Cinq roles portent le patron complet.
Cette etape intermediaire est la lecon de la journee en miniature : un failed=0 ne dit
pas que quelque chose a ete fait.
La sonde correctifs — 23e sonde
Set-OPS desarme unattended-upgrades (masque sur les vingt et une machines) et applique
les correctifs par upgrade: full au passage du socle. Choix defendable — un minuteur de
fond qui se dispute le verrou dpkg avec un deploiement est un tirage au sort, et il a fait
decrocher infra-mail-01 d'une reconstruction entiere le matin meme.
Mais rien ne disait quand le geste etait du. Vingt-deux sondes, aucune sur le retard de securite : une flotte pouvait deriver des mois en restant verte.
Elle mesure les paquets en attente venant d'un depot de SECURITE, et depuis quand — le
retard seul ne dit rien, c'est sa duree qui transforme un correctif publie en exposition
acceptee. Trois etats eprouves sur une copie : rc=0 a jour, rc=1 des le premier
correctif, rc=2 au-dela du seuil.
Deux choix dits franchement. Elle ne lance PAS apt-get update : une sonde qui
rafraichit l'index toutes les quinze minutes deviendrait la cause de la panne qu'elle
surveille. Et le seuil de 72 h est un CHOIX D'EXPLOITATION, pas une derivation — les autres
seuils se deduisent d'un mecanisme (le certificat vit 24 h, donc on alerte a 6 h) ; ici le
mecanisme est un geste humain, il n'y a rien a en deduire.
P64 refusait une declaration correcte
Declaree dans common_packages, la sonde etait invisible d'Icinga : la derivation croise
les GROUPES, et ce role n'en est pas un. Pire, client_sante l'aurait retiree comme
orpheline au passage suivant — la garde ecrite le matin meme.
Mais la declarer dans serveur_debian faisait echouer P64, qui exigeait declaration et depot
dans le MEME role. Or serveur_debian et serveur_durci sont des roles de declaration
pure : ils portent flux.yml, authentification.yml, supervision.yml, et pas une tache.
Le travail est fait par les roles que leur playbook applique.
Une garde qui force a contourner ce qu'elle protege est un defaut. P64 suit desormais le playbook du groupe : ce qu'il applique compte comme depose. Elle ne s'affaiblit pas — elle apprend ou le depot a le droit de vivre. Controle negatif refait : depot desactive → refus, restaure → 65 OK.
Etat mesure
sonde `correctifs` 21 / 21 machines vertes
prouver 65 preuves, 23 sondes, 0 echec
ansible-lint 0 defaut
2026-09-10 (11) — Le trou reste ouvert derriere la porte qu'on croyait fermee
Troisieme reconstruction complete de Chezlepro : 32 minutes, un seul echec — le mien, introduit par le passage au cache et revele par la naissance.
clonage 3 min 52
placement {'asgard': 14}
`fatal:` dans le journal 0, pas meme un ignore
servi par le cache +963 Mo
tire de l'Internet +183 Mo -> 81 % servis localement
get_url IGNORE la configuration d'apt
Le mandataire pose dans /etc/apt/apt.conf.d/ ne vaut que pour apt. Les six cles de
signature sortaient donc TOUJOURS en direct, malgre tout le travail sur le remap.
Et ce n'etait pas theorique. Depuis collab-01 :
en direct grafana 200 collabora TIMEOUT
via le cache collabora 200
La route directe vers Collabora ne passe pas depuis cette zone. Trois cles sur quatre avaient reussi PAR CHANCE — parce que leurs fournisseurs, eux, etaient joignables. Le meme geste corrige les deux : plus rien ne sort, et la machine qui n'avait pas de route en trouve une.
Pourquoi seule une NAISSANCE pouvait le montrer
Mes deploiements de convergence rendaient failed=0 — parce que les cles etaient deja
sur disque et que la tache etait sautee. Le defaut existait depuis le premier commit du
remap, invisible a tout deploiement sur une flotte existante.
C'est l'argument de la reconstruction depuis zero, applique a moi-meme : un correctif qu'on ne verifie que sur une machine deja construite n'est pas verifie.
La derive s'est effacee toute seule
apt-cacher-ng tournait encore sur forge-01, que plus aucun plan ne declarait. Je
proposais de l'arreter a la main. La reconstruction l'a fait :
apt-cacher-ng : absent paquet : 0 sondes : les 4 legitimes
Ce qui n'est pas au plan n'existe pas apres une naissance. La propriete centrale du depot, verifiee sur un cas qu'on n'avait pas provoque pour elle.
Etat mesure
14 / 14 machines 0 source en HTTPS direct
14 / 14 machines 0 erreur `apt-get update`
prouver 65 OK, 0 echec
ansible-lint 0 defaut sur 79 fichiers
Les trois reconstructions de la journee
1re (matin) 3 echecs 1 h 08
2e (confirmation) 0 echec 34 min
3e (avec le cache) 1 echec 32 min — le mien, corrige
2026-09-10 (10) — Plus rien ne sort chercher ses paquets, et un tenant de moins a nourrir
Deux mouvements d'une seule doctrine : le site fournit tout ce dont un tenant a besoin pour venir au monde.
1. Les depots tiers passent enfin par le cache
Set-OPS tire ses paquets applicatifs de fournisseurs qui ne publient qu'en HTTPS —
Grafana, Icinga, Smallstep, Collabora. apt-cacher-ng ne relaie pas un tunnel, et le socle
pose donc deliberement Acquire::https::Proxy "DIRECT" : chaque machine sortait
elle-meme sur Internet. Mesure du matin : 58 references de depot, quatorze machines sortant
chacune de son cote.
Le remede tient en deux moities, et une seule ne sert a rien :
- le cache DECLARE un
Remap-*par fournisseur — le client demande enhttp://, le cache va chercher enhttps://; - chaque role DEMANDE en
{{ ..._depot_schema }}://, qui vauthttpdes qu'un cache d'amorcage est declare,httpssinon. Degrader, jamais deviner.
Le TLS n'est rompu nulle part : il est termine au cache, qui est notre machine. Et
l'integrite ne vient pas du transport mais des signatures du depot — le raisonnement deja
tenu pour deb.debian.org depuis toujours.
Trois choses que la mesure a apprises :
Le remap appartient au cache qui SORT. Pose aussi sur le cache du tenant — chaine vers
celui du site — il tentait le HTTPS a travers son amont, ce qui exige un CONNECT que
l'amont ne fait pas :
remap sur le cache du tenant (chaine) -> 503
remap sur le seul cache du site -> 200
Un cache qui relaie n'a rien a remapper : il passe la demande a qui sort. Meme forme que la filiation elle-meme.
apt_repository AJOUTE, il ne remplace pas. Passer une source de https a http y
ecrivait une SECONDE ligne ; apt interrogeait les deux, et l'ancienne sortait toujours.
Le symptome le disait sur les quatorze machines : « La cible Packages est specifiee
plusieurs fois dans grafana.list:1 et :2 ».
La liste des fournisseurs ne se devine pas. J'en avais recense trois ; l'audit des
sources en a revele un quatrieme — Collabora, sur une seule machine. P65 refuse
desormais tout role visant un depot RELAYE en https:// ecrit en dur. Sa limite est dite :
elle empeche une regression sur ce qui est connu, elle ne decouvre pas l'inconnu.
avant : 4 fournisseurs en HTTPS direct, 14 machines sortant seules
apres : 0 source en HTTPS direct, 0 erreur apt sur 14 machines
2. Le cache d'artefacts quitte le plan du tenant
La ligne portait son propre retrait depuis toujours :
« c'est un service MUTUALISABLE — un ecosysteme au premier age peut aussi bien pointer sur celui de son hote »
Retiree. client_artefacts le voit tout seul : sans hote portant serveur_artefacts, il
n'ecrit rien et laisse en place le plancher d'amorcage qui vise site-cache-01.
Ce qu'on perd, dit franchement : les quatorze machines interrogent desormais le cache du
SITE a travers la frontiere, au lieu d'un cache local a la zone. Plus de trafic inter-zone,
plus de charge sur site-cache-01 — contre un service de moins a poser, superviser et
reproduire dans chaque ecosysteme.
3. Un role qu'on retire doit pouvoir DEFAIRE ce qu'il a fait
Retirer le role a revele que rien ne nettoie derriere lui. Deux fois :
-
le fichier apt
00-setops-artefactscontinuait de viser un cache eteint, en ecrasant le plancher qui, lui, fonctionnait — exactement le defaut deja paye (« quinze machines ont perdu apt d'un coup »). Le SOCLE le retire desormais, parce qu'il est le seul a tourner dans les deux cas ; -
les sondes
cache-aptetcache-apt-volumerestaient sur la forge, et le porteur poussait pour des services qu'Icinga ne definit plus :ECHEC du rapport Icinga pour « cache-apt » : {"error":404,"status":"No objects found."}client_santederive maintenant les sondes ATTENDUES sur chaque hote — ses groupes croises avec lesmeta/supervision.yml— et retire celles dont plus aucun groupe ne repond. Meme derivation que celle deserveur_icinga, du cote du porteur.
Etat mesure
sources apt en HTTPS direct 0 / 14 machines
erreurs `apt-get update` 0 / 14 machines
mandataire site-cache-01 seul, partout
Icinga 87 OK | 9 UNKNOWN sur 96 services
(les 9 = `sauvegarde`, minuteur nocturne)
prouver 65 OK, 0 echec
ansible-lint 0 defaut
Reste, et c'est dit : apt-cacher-ng tourne toujours sur forge-01, que plus aucun
plan ne declare. La prochaine reconstruction ne l'installera pas ; sur la machine actuelle,
il subsiste. Un service qu'aucun plan ne reclame est une derive, meme benigne.
2026-09-10 (9) — La reconstruction de confirmation : 0 echec, 34 minutes
Seconde reconstruction complete de Chezlepro dans la journee, cette fois pour EPROUVER les quatre correctifs livres entre les deux. Rien de nouveau n'a ete construit : c'est une mesure.
rc=0 0 echec 0 injoignable 14 / 14 machines
34 min contre 68 le matin meme
Les quatre points, et leur verdict
| ce qui etait a eprouver | verdict |
|---|---|
gabarit sur CephNVMe |
phase de clonage 4 min 30 contre ~30 min |
placement declare (noeud: asgard) |
{'asgard': 14} — les quatorze au bon endroit |
hosts_statiques conditionne a cloud-init |
changed au 1er passage, skipping au 2e |
monitoring-plugins-basic au role |
ping4 14/14 OK sur une mon-01 nee neuve |
Le troisieme est le plus instructif : les deux branches sont exercees dans la meme
execution. infra-pki-01 recoit le gabarit maitre de cloud-init au premier passage
(cloud-init est encore la), puis la tache est SAUTEE au second (le durcissement l'a
retire). Aucune preuve statique ne pouvait montrer cela — il fallait une naissance.
Le facteur 6,6, et pourquoi ce n'est pas 45
Un clone isole sur Ceph prend 8 s contre 352 a 480 s sur TrueNAS : facteur 45. En conditions reelles il tombe a 6,6 — quatre clones se disputent le meme pool, et le redimensionnement du disque s'ajoute a la copie. On retient la mesure en conditions reelles : celle qui compte est celle qu'on paie.
Ce que les echecs du matin sont devenus
/etc/cloud— corrige, prouve dans ses deux branches.- Verrou
dpkg— ne s'est pas reproduit. C'etait une course, pas un defaut de structure ; rien ne dit qu'elle ne reviendra pas, et le dire vaut mieux que la declarer reglee. - Cache apt — n'etait pas un defaut. Les deux seuls
fatal:de cette execution sont suivis de...ignoring: ce sont les sondes declient_artefacts, qui cherchent le cache du tenant, ne le trouvent pas, et laissent la machine servie par le plancher du SITE. C'est la filiation en train de fonctionner. Le matin, j'avais compte ces deux lignes comme des echecs sans lire la suivante. auditdsans regles dans le gabarit — toujours la, mais invisible : aucune machine n'ayant decroche avant le socle, le CRITICAL transitoire a ete repare partout avant d'etre mesure. Un defaut qui ne se voit que lorsqu'autre chose casse reste un defaut.
Etat mesure
Icinga 89 OK | 9 UNKNOWN sur 98 — aucun WARNING, aucun CRITICAL
les 9 tous des `sauvegarde` : le minuteur nocturne n'a pas encore visite
une flotte nee il y a vingt minutes
La condition posee avant de retirer les disques unused0/unused1 du gabarit sur TrueNAS
est levee : une flotte complete est nee du nouveau gabarit, sans un echec.
2026-09-10 (8) — Une garde qui survit a ce qu'elle gardait
Le defaut le plus couteux de la reconstruction, corrige. Il n'arretait rien de visible : il faisait taire la supervision de deux machines.
hosts_statiques pose /etc/cloud/templates/hosts.debian.tmpl — le gabarit maitre dont
cloud-init regenere /etc/hosts a chaque demarrage. Or D-85 fait retirer cloud-init
par serveur_durci. Le repertoire part avec lui :
Destination directory /etc/cloud/templates does not exist
L'echec n'a aucun sens : ce gabarit ne sert qu'a survivre a une reecriture qui n'a plus lieu. Plus de cloud-init, plus de reecriture — le plancher tient tout seul, ce qui est le resultat recherche par D-85.
Ce que ca a coute, et pourquoi c'etait invisible
_amorcer-socle monte infra-pki-01 et infra-dns-01 EN ENTIER d'abord, durcissement
compris. Cloud-init y est donc deja parti quand la flotte rejoue hosts_statiques. La
tache echouait, l'hote sortait du play — et tout ce qui suivait n'etait jamais pose,
dont icinga-ca.crt. Leur porteur de sante s'installait ensuite normalement, tournait, et
chaque rapport echouait sur curl: (77) error setting certificate file.
Deux machines ont supervise dans le vide sans que rien ne le dise. Un echec bruyant au bon endroit avait produit une panne muette ailleurs.
Et le correctif evident aurait deplace l'echec d'un cran
Sauter la tache ne suffisait pas : la garde qui SUIT compare le nombre d'entrees du plancher a celui du gabarit maitre. Sans gabarit, elle compare 21 a 0 et echoue — elle accuserait une divergence la ou il ne reste qu'un seul fichier.
Une garde qui survit a ce qu'elle gardait ne mesure plus rien : elle invente. Les trois taches — pose, releve, assertion — suivent desormais la meme condition : le repertoire des gabarits maitres existe-t-il encore ?
Verifie sur les deux machines memes qui echouaient : failed=0, cloud-init absent,
plancher a 21 entrees.
2026-09-10 (7) — D-86 mis a l'epreuve : Chezlepro rasee et refaite depuis zero
Quatorze machines detruites, quatorze refaites. 1 h 08, 0 injoignable. La reconstruction a confirme D-86 — et revele quatre defauts qu'aucune preuve statique n'aurait pu voir, parce qu'ils n'existent QUE pendant une naissance.
D-86 tient, et voici la mesure
L'ordre declare est l'ordre reel : socle → step_ca → client_pki → observabilite
(postgresql, prometheus, loki, grafana, icinga) → agents (metrique, journal, sante) →
services → apps.
Mais l'ordre ne prouve pas que quelqu'un REGARDAIT. Ce qui le prouve :
30 resultats de controle recus a 12:20:41
fin de la reconstruction 12:35:29
Trente services rapportaient leur etat pendant que Keycloak, Forgejo et Nextcloud montaient encore. Prometheus scrutait 14 cibles sur 15. Ce qui se deploie ensuite l'est bien sous l'oeil de la supervision.
La limite, dite franchement : le plancher n'a pas ete eprouve
D-86 affirme que le DNS peut rester en couche 6 grace au plancher /etc/hosts. Ce n'est
pas ce qui s'est passe : _amorcer-socle monte infra-dns-01 EN ENTIER d'abord —
serveur_powerdns puis serveur_resolveur, bien avant l'observabilite. Le DNS etait
debout ; le plancher n'a rien eu a porter. La partie la plus audacieuse de la decision
reste non testee, et l'eprouver demanderait de retirer l'amorcage DNS — une autre
decision.
Le placement des VM n'etait declare NULLE PART
Les quatorze machines vivaient sur asgard depuis toujours. SETOPS_NOEUD valait le
VIDE : ce placement n'existait que dans l'etat d'execution de Proxmox. Sans cible, le clone
reste sur le noeud du gabarit — vishnu, qui a 14 Go libres et heberge eregion et
site-forge-01. Quarante Go de VM neuves y auraient atterri.
Le defaut avait survecu a la reconstruction du 2026-09-02 : les VM existaient deja, et le
clone les sautait. Un etat qui n'est declare nulle part ne se reproduit pas — il faut
un from-zero VRAI pour le voir. noeud: asgard est desormais au plan.
Le clonage etait 45 fois trop lent, et la cause n'etait pas le reseau
Mesure sur le vrai gabarit :
disque sur `TrueNAS` (lvm sur iSCSI) 352 a 480 s par clone
disque sur `CephNVMe` (rbd) 8 a 9 s par clone
Source ET destination etaient sur le meme stockage : rien ne traversait d'hyperviseur a hyperviseur. La cause est que le LVM epais interdit a Proxmox tout clone autre que complet — 16 Go copies par machine, a travers iSCSI.
Le clone LIE descend a 1 s, mais enchaine chaque VM a l'image de base pour toujours. Pour 7 s gagnees sur 480, l'echange ne vaut pas la dependance : on garde les clones complets.
Gabarit deplace par qm move_disk sans --delete — les anciens disques restent attaches
en unused, le retour arriere tient en une commande.
Un champ stockage: au gabarit, branche en quatre points
Declarer sans consommer, c'est decorer. Le champ vit donc partout ou il compte :
| ou | quoi |
|---|---|
plan/10-intrants.yml |
la declaration, avec la mesure qui la justifie |
underlay.py --gabarit |
l'expose |
Makefile |
STOCKAGE_PROXMOX en derive — le clone ne l'HERITE plus en silence |
gabarit_etat.py |
compare le declare au reel, eprouve dans les deux sens |
Et le remede devient contextuel : le conseil « NE PAS CONVERTIR… REFABRIQUER le gabarit » s'imprimait pour TOUT ecart, y compris un deplacement de disque qui se repare en une commande. Un remede plus lourd que le mal se fait ignorer, puis le vrai avec lui.
Icinga ne pouvait pas faire ses propres controles
ping4 UNKNOWN execvpe(/usr/lib/nagios/plugins/check_ping) failed: No such file
icinga2 n'apporte AUCUN greffon. Toute la supervision de Set-OPS etant PASSIVE, le manque
etait masque : les services qui rapportent d'eux-memes etaient verts, et personne ne
regardait les autres. Quatorze ping4 muets d'une seule cause.
monitoring-plugins-basic entre au role — -basic et non le metapaquet : -standard
ajoute des greffons LDAP/SMTP/PostgreSQL que Set-OPS n'appelle jamais.
Le site l'avait deja — pose A LA MAIN plus tot dans la journee, jamais declare. Meme defaut que le placement, troisieme occurrence du jour : un etat qui ne vit que sur la machine ne survit pas a une reconstruction.
Trois defauts trouves, laisses ouverts
infra-pki-01etinfra-dns-01attendentforge-01:3142pendant l'amorçage — le cache apt naitra bien plus tard. Un ordre qui se mord la queue.- Regression D-85 :
Destination directory /etc/cloud/templates does not exist. Un role ecrit encore la ou cloud-init a ete supprime. C'est ce defaut qui a fait decrocher deux hotes de la tache deposanticinga-ca.crt— leur porteur de sante tournait, et chaque rapport echouait surcurl: (77), en silence. auditddans le gabarit, sans regles :audit-rules.serviceechoue au premier demarrage de CHAQUE VM neuve, jusqu'au passage du socle. Le CRITICAL ressemble alors a un probleme de la machine alors qu'il vient du modele.
Etat mesure, apres correction
Icinga Chezlepro 93 OK | 1 WARNING | 1 CRITICAL | 3 UNKNOWN sur 98
Icinga du site 51 / 51 OK
Prometheus 15 / 15 cibles
prouver 64 OK, 0 echec
ansible-lint 0 defaut, profil production
Les quatre non-OK restants sont tous des sauvegarde : le minuteur nocturne n'a pas encore
visite une flotte nee il y a deux heures.
2026-09-10 (6) — La frontiere passe, et trois sondes disaient faux
Application de ce que l'entree precedente avait prepare, puis correction de ce que l'application a revele.
La frontiere
make frontiere-appliquer : 49 regles creees, 5 retirees. Effet mesure immediatement :
cibles Prometheus 2/8 -> 8/8
hotes dans Loki 1/7 -> 7/7
Trois defauts, tous de la meme famille : un reglage qui ne suit pas son interrupteur
1. La sonde de Loki interrogeait en HTTPS un Loki servant en clair.
serveur_loki_sonde_url disait https en dur alors que serveur_loki_tls_actif vaut
false par defaut. Chez le tenant, qui l'active dans ses group_vars, l'accord etait
fortuit ; au site, qui ne l'active pas, la sonde rapportait « Loki ne repond pas » sur un
service en parfaite sante. Une fausse alarme est pire qu'une sonde absente : elle apprend
a ne plus lire la sonde. Le schema se derive desormais du commutateur.
C'est exactement le meme defaut que GF_AUTH_DISABLE_LOGIN_FORM chez Grafana, corrige la
veille au soir. Deux occurrences en douze heures.
2. La sonde des journaux criait une perte deja reparee. Elle comparait au passage precedent sans dire QUAND ce passage avait eu lieu. Elle a annonce PERTE EN COURS sur deux machines alors que le delta reel etait nul — les lignes avaient ete perdues avant la reparation. Mesure de controle, deux lectures a 90 s :
jetees 11340 -> 11340 (delta 0)
envoyees 68 -> 86 (delta +18)
Un ecart sans sa fenetre n'est pas une mesure, c'est un nombre. L'etat retient maintenant l'horodatage, et le message porte la duree.
3. Et surtout : elle alertait sur la cicatrice, pas sur la plaie. Le compteur d'Alloy est cumulatif — il ne redescend qu'au redemarrage. Les six machines qui avaient perdu des lignes pendant que la frontiere etait fermee les portaient donc pour toujours, condamnees a l'orange permanent. Ce qui alerte est la CROISSANCE. Le total reste dans le texte et dans les metriques, la ou il sert au diagnostic sans crier.
En echange, un cas qui ne levait rien le fait desormais : envoyees == 0 et jetees == 0
n'est pas « tout va bien », c'est « Alloy ne fait rien du tout ».
Controles negatifs, sur des COPIES des sondes :
port d'Alloy inexistant -> CRITICAL rc=2
compteur de pertes en hausse -> CRITICAL rc=2
port de node_exporter ferme -> CRITICAL rc=2
Neuf services qu'Icinga attendait et que personne ne lui envoyait
Le temoin declarait 51 services et n'en recevait que 42. Les neuf muets avaient une sortie
vide : jamais un seul resultat. setops-sondes.conf derive les services de tous les
meta/supervision.yml — Icinga savait donc les attendre bien avant que les roles n'aient
ete rejoues pour deposer les scripts.
Une declaration suffit a creer l'attente ; il faut un deploiement pour creer la reponse.
Huit roles rejoues au site : serveur_artefacts, serveur_resolveur, serveur_powerdns,
serveur_forgejo, serveur_postgresql, serveur_postfix, serveur_step_ca, serveur_ops.
Grafana
vault_grafana_admin depose dans la voute du site. Verifie sur la machine : formulaire
local offert, zero ligne GENERIC_OAUTH. Sonde tableaux verte.
Note d'exploitation : ansible-vault refuse de tourner quand stdout n'est pas bloquant —
il faut le faire passer par un tube. Un premier essai a tronque la voute de 11,8 K a 873 o
parce que le chiffrement s'est execute apres une lecture qui avait echoue. Restauree depuis
la copie prise avant, identique a l'octet pres. Le remede est dans l'ordre des gardes :
lire, VERIFIER le nombre de cles, ecrire vers un fichier neuf, verifier ce fichier, et
seulement alors remplacer.
Etat mesure
| cibles Prometheus | 8/8 |
| hotes dans Loki | 7/7 |
sonde journaux |
7/7 vert |
sonde metriques |
7/7 vert |
| services Icinga | 51, dont 42 OK avant redeploiement des huit roles |
make prouver |
64 OK, 0 echec |
ansible-lint |
0 defaut, profil production |
Suite : le runner ne pouvait plus cloner, et trois sondes de plus disaient faux
Redeployer serveur_ops au site a echoue sur le clonage du genome. Mesure depuis les sept
machines :
10.0.33.11:443 injoignable de PARTOUT sauf depuis la forge elle-meme
Y compris depuis site-cache-01, qui est dans le MEME sous-reseau — ce qui excluait la
frontiere et designait le pare-feu de l'hote. Le diff de flux-genere/ l'a confirme : la
ligne fautive etait la AVANT nos changements, qui n'ajoutaient que les regles des sondes.
Defaut preexistant, revele par le redeploiement, pas cause par lui.
La cause : generer_nftables(site=True) lisait le plan du TENANT. ports_plan = _ports_du_plan(), sans condition. derive se resolvait donc contre
OPS-Chezlepro/plan/applications.yml, ou Forgejo vaut 3000 — le port qu'il ecoute DERRIERE
un edge. Le plan du site dit 443, et le disait explicitement :
# 443, ET NON 3000. [...] garder 3000 aurait grave `:3000` dans ROOT_URL
La regle d'hote de la forge ouvrait donc un port que personne n'ecoute et laissait 443 ferme a toute la flotte. Un seul registre interroge pour deux verites opposees.
Trois sondes de plus corrigees, toutes de la meme famille — un parametre qui ne suit pas l'interrupteur dont il depend :
| sonde | ce qu'elle disait | la cause |
|---|---|---|
runner |
2 depots divergent | comparait les 6 depots a UNE branche, quand le plan en declare une PAR depot (master pour deux d'entre eux) |
forge |
reponse illisible | http:// en dur sur un port 443 servant du TLS |
forge |
ne repond pas | curl -s sans -k : cert step-ca emis pour le FQDN, interroge sur 127.0.0.1 |
La sonde runner est l'exemple le plus net : elle contredisait la declaration qu'elle
etait censee verifier. Elle n'accusait pas la machine, elle s'accusait elle-meme.
Avec Grafana, Loki et Forgejo, cela fait cinq occurrences du meme defaut en une journee. Le motif merite d'etre nomme : un reglage corrige a moitie ne dit rien tant que le deploiement ne change pas de camp. Le tenant restait juste dans les cinq cas — c'est le site, qui deploie ces roles autrement, qui les a tous reveles d'un coup.
Etat final mesure
Icinga 51 / 51 services OK
Prometheus 8 / 8 cibles up
Loki 7 / 7 hotes presents
prouver 64 OK, 0 echec
lint 0 defaut sur 82 fichiers, profil production
Controles negatifs, sur des COPIES : port d'Alloy, compteur de pertes, node_exporter, forge — les quatre rendent CRITICAL.
2026-09-10 (5) — Le site prend sa propre pile d'observabilite
Suite directe de D-87. La decision disait : l'hebergeur n'a pas le droit de voir les journaux de ses locataires. Elle avait une face cachee — a force de refuser de voir ceux des autres, le site s'etait prive des siens. Ses sept machines n'expediaient nulle part.
Le remede n'est pas d'assouplir la frontiere, c'est de donner au site sa pile :
prometheus, loki et grafana sur site-mon-01, pour les machines du site, sans
aucun lien avec ceux d'un tenant.
Ce qui bloquait etait deja documente, dans le plan lui-meme
10-intrants.yml exemptait toutes les machines du site de client_metrique et
client_journal. La prose de l'exemption portait sa propre condition de levee :
« Le jour ou le site prend un Loki et un Prometheus, on retire ces deux lignes. »
Ce jour-la etant venu, l'exemption s'est refermee toute seule. Elle aura tenu cinq jours.
Grafana au site n'a pas de SSO, et le role l'ignorait
Le site n'a ni Keycloak ni domaines.yml — l'identite ne monte pas dans le site, c'est
D-87. serveur_grafana reclamait pourtant l'IdP avant de regarder s'il en voulait un :
le deploiement echouait sur un registre absent, pour deriver une URL qu'aucun gabarit
n'allait ecrire. L'interrupteur serveur_grafana_oidc_actif existait, il n'etait pas honore.
Trois corrections, dont deux depassent le site :
resoudre_idpn'est appele que si le SSO est actif ;- le role refuse SSO eteint et formulaire local eteint — la combinaison deploie un Grafana en parfaite sante ou personne ne peut entrer ;
GF_AUTH_DISABLE_LOGIN_FORMsort du{% if %}du SSO. Il y disparaissait quand le SSO etait eteint, et c'est le defaut amont qui decidait en silence. Un reglage d'authentification qu'aucun fichier n'ecrit est un reglage que personne ne peut relire.
Deux manques se cachaient l'un l'autre dans le devis de la frontiere
Prometheus voyait 1 cible sur 7. Les regles d'hote etaient justes ; c'est le devis OPNsense qui avait deux trous, et le second masquait le premier.
1. Le devis ne connaissait pas les integrations universelles. Il derivait les groupes
d'une machine du site de applications.yml seul — qui declare les services. Il ne voyait
donc ni client_metrique, ni client_journal, ni client_pki, ni client_sante, alors
que ces groupes portent des flux et ouvrent des ports d'ecoute. La source est desormais
l'inventaire du site, seule autorite sur ce qu'une machine porte vraiment.
2. Une sortie vers un role du site visait « l'exterieur ». Symetrique du correctif du
2026-09-02, qui n'avait traite que l'entree. !SETOPS_INTERNES est la bonne destination
quand le pair est lointain ; quand il nomme un role du site, elle dit exactement
l'inverse du flux declare — elle exclut la seule machine visee :
client_journal -> !SETOPS_INTERNES port 3100
Le devis autorisait a expedier les journaux du site a n'importe quel Loki du monde, et a nul autre endroit qu'a celui-la. Neuf flux etaient dans ce cas : PKI, sante, resolveur, sauvegarde, courriel de la forge, base d'Icinga.
Et les deux declarations ne font qu'une regle. appliquer_opnsense pose tout en
direction: in (D-61) : deux regles qui ne different que par leur sens sont le meme
filtre pose deux fois. Dedoublonnage sur ce que la frontiere applique vraiment — la forme
ingress gagne, pour ne pas retirer-puis-recreer des regles deja justes.
Devis : 49 regles a creer, 5 a retirer — les cinq etant exactement les sorties trop larges dont la jumelle etroite existe deja.
Les deux agents se supervisent enfin eux-memes
client_metrique et client_journal etaient parmi les groupes sans sonde. Ils en ont une.
metriques interroge l'endroit que Prometheus interroge, pas le gestionnaire de
services : un node_exporter actif mais muet est vert pour systemd. Elle declare aussi les
dix collecteurs qui cherchent du materiel qu'une VM n'a pas (zfs, mdadm, infiniband…)
— ils echouent identiquement sur les sept machines, et une sonde rouge partout est une
sonde qu'on cesse de lire.
journaux ne demande pas si Alloy tourne : elle lit ce qu'il a du jeter, et le compare
au passage precedent — ce qui augmente est une perte en cours, ce qui stagne est une
cicatrice.
Elle a fait ses preuves le jour meme. Sur six machines :
Alloy: active (running) /-/ready: 200 dropped_entries_total: 50
Trois indicateurs verts, cinquante lignes perdues. La regle de frontiere manquait encore.
Etat mesure
metriques |
7/7 vert |
journaux |
1/7 — les six autres attendent la regle de frontiere, et le disent |
| cibles Prometheus | 2/8 pour la meme raison |
make prouver |
64 OK, 0 echec |
ansible-lint |
0 defaut, profil production |
Reste a la main de l'exploitant : make frontiere-appliquer CONFIRMER=true, et le
secret vault_grafana_admin a deposer dans underlay.vault.yml.
2026-09-10 (4) — Ce que l'hebergeur n'a pas le droit de VOIR (D-87)
Question posee : « quels roles ne dois-je pas embarquer dans le site ? »
La table de mutualisation de filiation-emancipation.md existait deja et repondait presque.
Mais mise a l'epreuve, une de ses lignes contredisait ce qui tourne.
La ligne qui se contredisait
| observabilite | oui | l'hebergeur surveille ses locataires |
Or chaque ecosysteme a son propre Icinga, et celui du site ne voit que ses sept machines (mesure). L'implementation avait raison, la doctrine avait tort.
Et surtout : cette case autorisait ce que la ligne du dessous interdit. Les journaux contiennent du CONTENU — un mot de passe dans un message d'erreur, une donnee metier dans une trace, qui a fait quoi et quand. Un hebergeur qui ingere les journaux de son locataire en sait plus que s'il detenait son annuaire : l'annuaire dit qui existe, les journaux disent ce qu'ils font.
Decoupee en trois :
disponibilite (est-ce debout ?) oui une VM tombee est un fait de la fabric
metriques (charge, disque) oui, reserve disent quand et combien, pas quoi
journaux NON contiennent le contenu
Et la ligne PKI, « a trancher », est tranchee : non
Elle notait qu'une AC intermediaire signee par l'hote est possible. Elle l'est techniquement — et c'est precisement ce qu'il ne faut pas faire.
Une intermediaire signee par l'hote lui donne le pouvoir d'emettre des certificats valides pour les noms du locataire. Il peut alors se presenter comme n'importe lequel de ses services, devant les propres machines du locataire — qui les accepteront, puisque c'est exactement ce que la chaine de confiance leur demande de faire.
Meme pouvoir que l'annuaire, sous une forme moins visible : rien dans la configuration du locataire, aucune trace de son cote, et la verification passe.
Une PKI par ecosysteme, jamais derivee de l'hote. C'est deja ce qui tourne ; la ligne cesse de laisser la porte entrouverte.
Le revers, mesure et assume
Le site ne porte ni client_journal ni client_metrique, et aucun loki ni
prometheus. Ses sept machines n'expedient rien nulle part : l'hebergeur ne peut pas
lire ses propres journaux.
C'est la consequence directe de la frontiere — a refuser de voir ceux des locataires, il s'est prive des siens. Le remede n'est pas d'assouplir la regle, c'est de lui donner sa propre pile, sans aucun lien avec celle d'un tenant.
Ce que la mesure a confirme par ailleurs
Rien d'intime n'est au site aujourd'hui. openldap, keycloak, oauth2_proxy, loki,
nextcloud, collabora, dovecot, rspamd, web_frontal, web_dorsal : tous
tenant seulement. La frontiere etait tenue en pratique avant d'etre ecrite au net.
2026-09-10 (3) — Les cinq sondes qui manquaient le plus
20 sondes sur 19 roles. 23 services distincts, 70 instances, 67 au vert.
D'abord une correction de compte
J'avais annonce « cinq groupes sans sonde ». Mesure : vingt-sept. J'avais compte ceux que j'avais en tete, pas ceux que le depot contient. Il en reste vingt-deux.
Les cinq posees, par ordre de degat silencieux
autorite (step_ca) — la plus urgente de l'ecosysteme. Nos certificats vivent 24 h :
une AC muette ne casse rien aujourd'hui, elle casse TOUT demain, d'un coup, sur les
vingt-et-une machines a la fois. Elle surveille aussi l'expiration de la RACINE, que
personne ne regarde jamais parce qu'elle vit des annees — releve : 3642 jours. Le jour ou
elle expire, toute la confiance interne tombe d'un bloc, et aucun renouvellement de
certificat d'hote n'y change rien.
base (postgresql) — une VRAIE requete, pas pg_isready. Celui-ci ouvre une connexion
et la ferme : il dit que le port repond, pas que la base sert. Une base en recuperation, en
lecture seule ou a court de connexions le passe et refuse tout travail. La sonde compte
aussi les connexions : a saturation, chaque application tombe en meme temps sans que la
base ait l'air morte.
annuaire (openldap) — elle COMPTE les entrees. Un annuaire vide repond success a
tout, et plus personne ne s'authentifie nulle part : c'est exactement le mensonge des
sauvegardes vides, vert et sans contenu.
zones (powerdns) — un autoritatif sans zone repond NXDOMAIN a tout, ce qui se lit
comme « ce nom n'existe pas ». La panne la plus trompeuse du DNS.
edge (nginx) — elle valide la configuration SUR DISQUE. nginx garde la derniere
configuration valide et continue de servir ; une configuration cassee ne se voit qu'au
prochain demarrage, c'est-a-dire au pire moment, souvent des mois plus tard.
Les cinq eprouvees vertes sur le sain, puis rouges PAR PARAMETRE — port ferme, seuil impossible, port qui n'ecoute pas — sans toucher a un seul service.
Ce qui reste, et pourquoi ce n'est pas le meme genre
Vingt-deux groupes. Ils ne sont pas de la meme nature :
- couverts ailleurs :
client_metrique(parcollectechez Prometheus),client_backup(parsauvegarde),client_sante(sa fraicheur EST sonttl) ; - chemins de report :
client_smtp,client_artefacts,client_journal,client_resolveur— leur panne se voit deja par le silence de ce qu'ils portent ; - du site :
serveur_cache_site,serveur_forge_site,serveur_backup_site,serveur_resolveur_site,serveur_ops_site— a poser depuis l'instance du site ; - applications et socle :
redis,rspamd,collabora,web_frontal,web_dorsal,oauth2_proxy,icingaweb2,backup,debian,durci.
2026-09-10 (2) — cache-apt scindee, et le defaut que la scission a revele
La scission
cache-apt fondait deux causes dont les DELAIS different : « ne repond pas » arrete tout
apt de l'ecosysteme — on agit dans la minute — tandis que « volume a 90 % » est un billet
pour demain. Les fondre obligeait soit a reveiller quelqu'un pour un disque, soit a traiter
une panne comme un billet.
Deux sondes desormais, chacune avec son etat, son historique et son acquittement. Prouve qu'elles sont INDEPENDANTES, et c'etait tout l'enjeu :
port ferme -> cache-apt [2] CRITIQUE cache-apt-volume [0] OK
seuil impossible-> cache-apt [0] OK cache-apt-volume [1] AVERTISSEMENT
Ce que la verification a revele : moteur aurait alarme PARCE QUE tout allait bien
En verifiant que les deux services arrivaient bien dans Icinga, un resultat pousse et
accepte (code 200) n'apparaissait pas en base. Hypothese testee et confirmee :
IcingaDB n'ECRIT service_state QUE SUR CHANGEMENT D'ETAT. Un OK identique repete ne
produit aucune ecriture ; un AVERTISSEMENT pousse ensuite est ecrit en 13 secondes.
Or la sonde moteur, ecrite quelques heures plus tot, lisait exactement
max(last_update) de service_state pour juger que « l'etat est frais ». Elle mesurait
donc le CHANGEMENT, pas la fraicheur.
Consequence : sur un ecosysteme parfaitement stable — celui qu'on veut — plus rien ne
change, last_update vieillit, et la sonde serait passee en avertissement a 15 minutes
puis en critique a 90. Une alarme qui se declenche PARCE QUE tout va bien, avec un delai
qui l'aurait rendue difficile a rattacher a sa cause.
C'est le piege que docs/supervision-conception.md interdit — une alarme toujours allumee
apprend a ne plus regarder — sous sa forme la plus sournoise : differee.
La bonne source existait : icingadb_instance porte le battement du synchroniseur,
ecrit en continu qu'il y ait ou non des changements. Releve a 1 seconde sur un systeme
sain. Les seuils passent de 15/90 minutes a 1/5 minutes : sur un battement, une minute
de silence est deja anormale.
Controle negatif rejoue par parametre.
Etat
Dix-huit services, tous verts sauf sauvegarde a 7/9 — deux noeuds sans donnee a
emporter, deja connu et confirme.
2026-09-10 — Chaque role porte desormais sa sonde
Quatorze sondes, declarees dans meta/supervision.yml, deposees par le role qui
possede la verite, derivees en objets Icinga sans qu'une ligne soit ecrite a la main :
boites (dovecot) certificat (client_pki, 14/14) collaboration (nextcloud)
cache-apt (artefacts) collecte (prometheus) file-courriel (postfix)
forge (forgejo) identite (keycloak) ingestion (loki)
moteur (icinga) resolution (resolveur) runner (serveur_ops)
tableaux (grafana) voute (ops_tenant)
Les dix-neuf lignes surveillance: ecrites en prose et jamais executees commencent a
devenir des mesures.
Deux principes que la premiere sonde a imposes
Une sonde doit pouvoir etre mise en defaut PAR PARAMETRE. Cible et seuils sont des
variables du role : on prouve le rouge avec un port ferme ou un seuil impossible, sur une
machine reelle, sans rien casser, aussi souvent qu'on veut. Une sonde qu'on ne peut
prouver qu'en cassant un service ne sera prouvee qu'une fois. Eprouve sur cache-apt :
vert, CRITIQUE par port ferme, AVERTISSEMENT par seuil, retour au vert.
La sonde vit la ou vit la verite. « Ce noeud est-il collecte ? » est une sonde de
serveur_prometheus, pas de client_metrique : une seule y voit les N noeuds, et surtout
elle voit le cas silencieux — celui qui a cesse d'etre collecte ne peut pas s'en
plaindre lui-meme.
On demande au service ce qu'il pense de lui-meme
Quand il sait le dire : /api/healthz de Forgejo (base + cache), /api/health de Grafana,
/ready de Loki, status.php de Nextcloud, la decouverte OIDC de Keycloak. C'est plus
juste que tout critere invente de l'exterieur — une forge dont la base est tombee sert
encore ses pages et repond a /api/v1/version.
Et quand il ne sait pas : on va chercher la verite de terrain. moteur ne regarde ni le
service ni le port — il demande a la base depuis combien de temps elle n'a pas ete
rafraichie. C'est la lecon des sauvegardes appliquee a la supervision elle-meme : un
moteur vert dont la synchronisation est tombee affiche eternellement le dernier etat connu.
Quatre fois j'ai ecrit la sonde avant de mesurer, quatre fois elle a eu tort
La forge : port 443 et chemin des depots INVENTES. Elle ecoute en 3000 derriere l'edge et n'a legitimement AUCUN depot — elle est neuve. Deux cris sur un service sain.
Loki : conclu « panne persistante » sur deux lectures prises a quelques secondes
d'intervalle, juste apres un redemarrage. L'anneau etait ACTIVE et la reponse est passee
a ready moins d'une minute plus tard. Deux mesures rapprochees ne distinguent pas un
etat d'un instant. Le delai de stabilisation est desormais un AVERTISSEMENT nomme.
Keycloak : vise en 8443, il ecoute en 8080 derriere l'edge.
Le runner : git en root refuse les depots d'un autre proprietaire — la sonde aurait
rendu un echec qui parle de git au lieu de parler du depot.
À chaque fois le remede est le meme : lire la verite du role, ne pas la supposer.
Et le meme piege Jinja, une seconde fois
${#tableau[@]} contient {#. Le remede etait deja au depot depuis client_sante ; je
l'ai reecrit au lieu de le chercher. La correction balaie desormais tous les gabarits de
sonde.
Ce qui reste
client_smtp, client_artefacts, client_journal, serveur_icingaweb2 et
serveur_ops_site n'ont pas encore la leur. Le premier lot couvre les services dont la
chute arrete l'ecosysteme ; ceux-la sont des chemins de report, dont la panne se voit
deja par le silence des sondes qu'ils portent.
2026-09-09 (9) — La supervision passe juste apres la PKI (D-86)
On n'allume pas la lumiere une fois la maison finie.
L'observabilite et le moteur de supervision etaient en couches 4 et 5 sur six — donc deployes APRES presque tout ce qu'ils surveillent. Une reconstruction depuis zero est pourtant le moment ou l'on a le plus besoin de voir, et c'etait le seul moment ou l'on ne voyait rien.
1. socle serveur_debian, serveur_durci
2. pki_racine serveur_step_ca
3. pki_client client_pki
4. observabilite postgresql, prometheus, loki, grafana, icinga <- NOUVEAU
5. agents_supervision client_metrique, client_journal, client_sante <- NOUVEAU
6. services openldap, powerdns, resolveur, nginx, postfix...
7. apps keycloak, forgejo, icingaweb2, nextcloud...
8. agents client_smtp, client_backup, client_resolveur, client_artefacts
Deplacer les serveurs sans les agents n'aurait rien change
C'est le point qui a decide de la forme : ce sont les AGENTS qui rapportent, et ils etaient
en derniere couche. Monter prometheus et icinga en laissant client_metrique et
client_sante a la fin aurait produit une supervision allumee et aveugle. Les deux
couches vont donc ensemble.
Des la couche 5, chaque hote expedie ses metriques, ses journaux et l'etat de ses unites systemd. Tout ce qui se deploie ensuite est mesure PENDANT qu'on le construit.
Ce que ca a coute, et ce qui reste tard a dessein
serveur_postgresql monte aussi : icinga l'exige, et lui n'exige rien. C'est le seul
entrainement.
Restent en couche 7 : icingaweb2 et oauth2_proxy, qui reclament LDAP et Keycloak.
C'est la CONSOLE, pas la mesure — l'interface humaine peut attendre, l'etat non.
Ce qui rend ce deplacement possible
Le DNS (powerdns, resolveur) reste en couche 6, donc APRES la supervision. Or icinga
joint sa base par un NOM, et client_sante pousse vers un NOM.
Ca tient parce que le plancher /etc/hosts, pose des la couche 1 par
hosts_statiques, porte deja les 34 entrees de l'ecosysteme — verifie sur mon-01. C'est
exactement ce pour quoi il existe : « il ne s'installe pas, il rend installable ». Sans
lui, cette decision serait impossible.
La limite, dite franchement
Les notifications dependent de client_smtp, encore en derniere couche. Pendant une
reconstruction, l'etat est mesure et consultable — mais rien ne part par courriel avant la
fin. Le deplacer demanderait de monter postfix et son relais, ce qui entrainerait bien
plus que PostgreSQL.
Et ceci : l'ordre est valide, pas eprouve. P08 accepte (aucune arete en arriere), le
graphe des dependances accepte, playbooks/site.yml est regenere et passe le
--syntax-check sur ses 782 lignes de plan. Mais la seule preuve reelle d'un ordre de
reconstruction, c'est une RECONSTRUCTION — et celle-la se decide, elle ne se glisse pas
dans une soiree.
2026-09-09 (8) — L'hote de supervision saturait par sa propre demonstration
« mon-01 tape dans l'fond. » Il tapait, en effet.
load average: 5,10 sur 4 coeurs
8 processus check_disk, 70 a 99 % de CPU chacun, jusqu'a 28 minutes de vie
check_disk 2.4.0-3+deb13u1 ne rend jamais la main sur cet hote (etat R, il boucle).
Icinga en relancait un a CHAQUE intervalle pour le service disk de son hote de
DEMONSTRATION, et aucun ne mourait. L'hote de supervision etait sature par la
configuration d'exemple livree avec le paquet.
Ce qui a ete retire, et pourquoi l'hote plutot que les services
conf.d/hosts.conf definit object Host NodeName — un localhost de demonstration avec
vars.disks, vars.http_vhosts, vars.os. Les apply Service s'y accrochent : disk,
http, swap, apt, load, procs, users, ssh, icinga. Aucun ne decrit cet
ecosysteme, et trois etaient rouges en permanence — swap sur une VM sans swap, http
sur un port ou rien n'ecoute, apt pour un paquet a mettre a jour.
On retire l'HOTE, pas les services : sans lui les apply ne s'accrochent a rien, et on ne
touche pas a un fichier que le paquet remplacera a la prochaine mise a jour.
Et ca garde ce qui servait : apply Service "ping4" vise tout hote ayant une adresse,
donc les notres. Il est vert 14/14 au tenant. Retirer services.conf l'aurait emporte
avec le reste.
avant : load 5,10 — 8 check_disk — 3 alarmes rouges permanentes
apres : load 0,77 — 0 check_disk — certificat 14/14, sante 14/14, ping4 14/14
Trouve en verifiant : la supervision du SITE croyait tout mort
hotes UP : 1/7 (site) 14/14 (tenant)
Nos object Host sont verifies par hostalive, c'est-a-dire un ping. Le site est decoupe
en zones separees ; l'ICMP inter-zones n'etait declare nulle part, donc la frontiere
l'avalait — 100 % de perte, mesure. Et Icinga SUPPRIME les notifications des services
d'un hote DOWN : une supervision qui croit tout mort n'alerte plus de rien, tout en ayant
l'air de fonctionner.
Le flux est desormais declare des DEUX cotes (serveur_icinga en egress, serveur_debian
en ingress) — et le registre a refuse la premiere moitie seule, ce qui est exactement sa
raison d'etre. CODES_ICMP apprend echo-request (un TYPE, pas un code de
destination-unreachable). Les regles d'hote sont posees sur les 21 machines.
Le generateur de la frontiere : corrige, et il cachait un second manque
La cause exacte. devis_opnsense.py traitait tout port non numerique comme
SYMBOLIQUE — a resoudre par le plan. C'est vrai pour derive, le port d'un service que
seul le plan connait. C'est FAUX pour l'ICMP : echo-request et frag-needed sont des
litteraux, pas des inconnues. Ils tombaient dans la branche « le plan ne resout pas », et
aucune regle n'etait emise.
Une ligne de condition, et le silence de toute une flotte.
Ce que la correction a revele en plus. Huit regles a creer, zero a retirer — et SIX d'entre elles sont du PMTUD, absent lui aussi pour la meme raison. Le depot dit de ces deux messages ICMP : « Bloques, la connexion s'etablit, les petites requetes passent et les grosses reponses restent suspendues — la panne la plus couteuse a diagnostiquer. »
CORRECTION (meme jour, apres mesure). La premiere redaction de ce paragraphe disait que cette panne « etait la, silencieuse, sur les sept machines de l'hebergeur ». C'est faux, et la mesure ne le soutient pas :
site-mon-01 -> autre zone du site : MTU de chemin 1500
site-mon-01 -> Internet : MTU de chemin 1500
ops-01 (tenant, overlay EVPN) : MTU 1450, chemin vers Internet 1450
Le site est a 1500 de bout en bout. Rien n'y etait donc casse entre zones : aucun lien du chemin ne plafonne plus bas, donc aucun message « fragmentation necessaire » n'avait lieu d'etre emis. La contrainte a 1450 vit chez les TENANTS, sur l'overlay — pas chez l'hebergeur.
Les six regles restent un vrai manque : elles couvrent le site parlant vers l'EXTERIEUR, ou un lien distant peut tres bien plafonner plus bas, et elles seraient necessaires le jour ou une zone du site passerait sur l'overlay. Mais c'etait une protection absente, pas une panne active — et decrire l'un comme l'autre est exactement le genre d'exageration que ce fichier ne doit pas contenir. Une correction qu'on ne mesure pas se raconte toujours plus grande qu'elle n'est.
Ce que la regle emise autorise, exactement — et pourquoi on ne pretend pas mieux.
appliquer_opnsense n'envoie destination_port que pour TCP et UDP : une regle ICMP ouvre
le PROTOCOLE entre deux pairs, pas le seul type declare. Le type reste porte par la regle
d'HOTE, que resoudre_flux emet precisement (icmp type echo-request accept). La
frontiere dit qui peut parler a qui ; l'hote dit ce qu'il accepte d'entendre. Emettre un
icmptype a la frontiere aurait ete plus fin — et aurait demande de traduire frag-needed
(un CODE de destination-unreachable) dans le vocabulaire de pf, au risque de casser le
PMTUD pour gagner de la precision sur une barriere qui n'est pas la derniere.
Mesure apres application :
ping site-mon-01 -> les six autres zones : 6/6 repondent
hotes UP : 1/7 -> 7/7
ping4 : 1/7 -> 7/7 certificat 7/7, sante 7/7
2026-09-09 (7) — Le contrat des sondes ETAIT celui de Nagios, sans le savoir
Question posee : « tu connais le paquet monitoring-plugins ? »
Oui — et le contrat que docs/supervision-conception.md decrivait quelques heures plus
tot EST le sien, mot pour mot : une ligne sur stdout, 0/1/2 en code de sortie. Je l'avais
donc reinvente sans le nommer, alors que positionnement.md dit exactement l'inverse :
adopter aux seuils, ne pas reimplementer.
Le document nomme desormais l'API des greffons Nagios, ajoute 3 = INCONNU et la
partie | metriques qui manquaient, et dit la consequence : un greffon standard EST une
sonde valide, sans la moindre colle. Le paquet en fournit 54 — check_disk,
check_load, check_procs, check_ntp_time, check_smtp, check_pgsql... On n'ecrit du
shell que lorsque la verite a mesurer est propre a Set-OPS, comme
client_pki/certificat, qui compare l'empreinte SERVIE a celle du disque : aucun greffon
ne sait ca.
Le porteur separe maintenant le texte des metriques (performance_data), parce que c'est
ce que le contrat porte et qu'Icinga sait les tracer.
Essayer un vrai greffon a revele DEUX defauts du porteur
1. Un envoi refuse faisait taire toutes les sondes suivantes. rapporter sortait en
exit 1 : une sonde deposee mais non declaree — donc refusee par le filtre d'API —
supprimait le rapport de toutes les autres, sante comprise. Le tableau ne devenait pas
rouge, il devenait VIDE, et le ttl le perimait des heures plus tard sans dire pourquoi.
Un rapporteur qui ne peut pas dire UNE chose doit quand meme dire les autres.
2. Aucun delai de garde sur les sondes. Et ce n'est pas theorique :
check_disk 2.4.0-3+deb13u1 sur mon-01 : etat R, ne rend jamais la main
quels que soient ses arguments (-p /, sans -p, seuils en % ou en G)
Un greffon STANDARD, sur une machine saine, qui boucle. Sans delai de garde il figeait
le rapport entier, toutes les quinze minutes, indefiniment. Chaque sonde tourne desormais
sous timeout 20 ; au-dela, on rapporte INCONNU en le disant.
La lecon n'est pas « les greffons standards sont mauvais ». C'est qu'adopter un standard ne dispense pas de l'eprouver — et que le porteur doit survivre a une sonde qui se comporte mal, standard ou maison.
Trois voyants rouges permanents, sur l'hote de supervision
Constate en cherchant : la configuration d'exemple livree par Icinga surveille localhost
et crie en permanence sur mon-01 —
swap : SWAP CRITICAL - 0% free (une VM sans swap)
http : connect to 127.0.0.1:80 (rien n'ecoute la)
apt : 1 package upgradable
Trois alarmes qui ne peuvent que rester rouges, dans le seul endroit qui doit rester lisible. Non corrige — c'est une decision d'exploitation, pas une correction de code.
2026-09-09 (6) — La supervision se DECLARE dans le role, comme les flux
64 preuves (P01-P64). Nouveau document : docs/supervision-conception.md.
Premiere sonde : client_pki/certificat, 14/14 OK sur la flotte.
Le constat qui a decide
Mesure du depot sur lui-meme, au 2026-09-09 :
39 roles declarent leurs flux -> nftables + OPNsense, derives
32 declarent leur empreinte -> ressources des VM, derivees
32 declarent leur authentification -> habilitations, derivees
19 groupes declarent `surveillance:` -> RIEN
Les dix-neuf lignes surveillance: de docs/dependances-groupes.yml sont ecrites,
versionnees, relues — et aucune n'etait executee. Icinga surveillait deux choses.
C'est la classe d'echec de ce depot, en version documentaire : la carte dit ce qui est surveille, et personne ne surveille. Une intention ecrite n'est pas une mesure.
Le mecanisme
Un role declare ses sondes dans meta/supervision.yml (nom, ttl, raison) et depose
lui-meme son script dans /usr/local/lib/setops/sondes/ — il connait ses chemins, ses
seuils, son consommateur. Le porteur (client_sante) fait tourner tout ce qui vit dans ce
repertoire et pousse un resultat passif par sonde ; il ne sait pas ce qu'elles mesurent, et
c'est le but. serveur_icinga derive les object Service ET le filtre de permission
d'API des memes declarations.
Ajouter une sonde ne demande de toucher ni au porteur, ni a Icinga. Comme un flux declare devient une regle nftables.
La sonde du certificat, et ce qu'elle mesure VRAIMENT
Nos certificats vivent 24 heures. Une machine dont le renouvellement s'arrete ne casse pas tout de suite : elle casse le LENDEMAIN, et tout ce qui parle TLS avec elle tombe ensemble.
Ce qu'on ne mesure pas, et c'est delibere : « le minuteur est-il actif ? » — un minuteur vert qui echoue chaque nuit est exactement le mensonge deja paye avec les sauvegardes. « le certificat existe-t-il ? » — un certificat perime existe.
Ce qu'on mesure : les heures restantes sur le certificat REELLEMENT pose, la chaine verifiee contre notre racine, et — quand un service le consomme — l'empreinte SERVIE comparee a celle du disque.
Trois obstacles, et le premier est le plus instructif
La sonde a rendu 14 machines sur 14 en CRITIQUE — sur une PKI qui se portait tres bien.
Elle utilisait openssl verify -CAfile racine ; or nos certificats d'hote sont signes par
un INTERMEDIAIRE, et seule la racine vit sur le disque. step certificate verify, l'outil
que le role installe deja, repond VALIDE.
Une alarme toujours allumee ne vaut pas mieux qu'une alarme jamais allumee : elle apprend a ne plus regarder. Une sonde se prouve donc DEUX FOIS — verte sur le sain, rouge sur le casse. Le document de conception porte la regle.
Le filtre d'API etait ecrit avant la lecture des declarations. Les services existaient, et Icinga aurait refuse leurs resultats avec un « 404 No objects found » sur des objets bien presents — une heure perdue sur ce message le 2026-09-02. La lecture est remontee avant le compte d'API, avec le pourquoi ecrit la ou l'on serait tente de la redescendre.
Mon controle negatif a casse un service reel. Substituer le certificat d'hote par un
auto-signe a fait virer la sonde au rouge, comme voulu — et le script de synchronisation a
propage ce certificat vers node_exporter, dont la clef etait restee l'ancienne :
tls: private key does not match public key. Un service tombe pour eprouver une sonde. La
regle qui en decoule est au document : un controle negatif se fait sur une copie,
jamais en substituant l'artefact que d'autres consomment.
Les quatre etats, tous eprouves
certificat absent -> [2] CRITIQUE
signe par une autre autorite -> [2] CRITIQUE (24 h restantes : c'est la CHAINE qui parle)
sous le seuil d'avertissement-> [1] AVERTISSEMENT
etat sain -> [0] OK
Puis relu dans IcingaDB : certificat : 14/14 OK, sante : 14/14 OK.
P64 tient les deux bouts
Une sonde DECLAREE dont le script n'est pas depose donnerait un service qui n'a jamais de
resultat. Un script DEPOSE que rien ne declare serait refuse par le filtre d'API. Les deux
se rompent seuls, la preuve tient les deux — plus la presence d'une raison et d'un ttl.
Trois controles negatifs rejoues.
Ce qu'elle ne fait pas, et qu'aucune lecture statique ne fera : juger qu'une sonde MESURE quelque chose. Ca reste au controle negatif de chacune, exige par le document et trace ici.
Et une QUATRIEME fois la meme lecon : les seuils criaient trop tot
Deployee sur le site, la sonde a rendu 5/7 — deux machines en AVERTISSEMENT, sur des certificats parfaitement sains.
Le seuil etait a 12 h. Or cert-renewer@.service porte
ExecCondition=step certificate needs-renewal, qui dit oui au TIERS RESTANT : 8 h pour
un certificat de 24 h, reessaye toutes les 15 minutes. La sonde criait donc AVANT que le
mecanisme ne soit cense agir.
Les seuils sont desormais SOUS le point de renouvellement — 6 h (le renouvellement est du depuis deux heures, huit fenetres manquees : ce n'est plus un hoquet) et 3 h. Un seuil au-dessus du point de renouvellement ne previent pas : il ment.
Un seuil ne se choisit pas a l'intuition : il se DERIVE du moment ou le mecanisme surveille
est cense agir. C'est la meme faute que openssl verify quelques heures plus tot, sous une
autre forme — et c'est encore la flotte reelle qui l'a dite.
Etat final : 14/14 au tenant, 7/7 au site, sur certificat comme sur sante.
Reste
Dix-huit groupes attendent encore leur sonde. Le mecanisme, lui, ne demande plus rien.
2026-09-09 (5) — Retrait d'une garde qui ne pouvait pas se declencher
client_sante portait une branche « aucun serveur_icinga : rien a poser », et un when
sur tout son bloc. Ni l'une ni l'autre ne pouvait s'executer.
Eprouve sur le modele public, dans une copie jetable : instancier ne pose une
integration UNIVERSELLE que si son serveur existe dans l'ecosysteme. Sans Icinga au plan,
le groupe client_sante est absent de l'inventaire genere — exactement comme
client_metrique et client_journal le sont deja. Le role n'est donc jamais appele sans
destinataire, et sa branche defensive etait du decor.
Une garde qui ne peut pas se declencher n'est pas une garde : elle rassure sans rien tenir. Et elle coute deux fois — a la lecture, puis le jour ou l'on cherche pourquoi rien n'a alerte.
Ce qui la remplace se declenche vraiment, et les deux cotes sont eprouves :
-e client_sante_icinga_hote='' -> FAILED (assertion sur l'hote)
-e client_sante_icinga_motdepasse='' -> FAILED (assertion sur le secret)
L'echec dit LEQUEL des deux manque. Le bloc, prive de sa condition, est aplati : douze
taches a plat au lieu de onze indentees sous un when toujours vrai.
Rejoue sur les deux flottes : zero changement sur zero hote. make prouver :
CONFORME, 63 OK, 0 echec, 0 saute.
2026-09-09 (4) — systemctl --failed entre dans la supervision
63 preuves. make prouver : CONFORME, 63 OK, 0 echec, 0 saute.
Nouveau role client_sante, integration universelle (67 roles).
Ce qu'il ferme
Le defaut trouve quelques heures plus tot : openipmi.service echouait a CHAQUE
demarrage sur les quatorze machines depuis le 2026-09-02, et systemctl --failed rendait
ZERO partout — non parce qu'elles allaient bien, mais parce qu'AUCUNE n'avait redemarre
depuis. Il a fallu qu'un humain redemarre une machine pour que le defaut existe aux yeux
de quelqu'un.
Un controle qui ne peut echouer qu'au demarrage ne mesure rien tant que rien ne demarre.
PASSIF, et a duree de vie
Un controle ACTIF ne voit pas la machine MUETTE : si elle ne repond plus, la sonde echoue
et on met ca sur le compte du reseau. Ici c'est le NOEUD qui parle, et le ttl de son
envoi fait la fraicheur — sans nouvelle, Icinga perime le service tout seul. Le silence
alerte autant que l'echec, et le silence est precisement ce qui n'a alerte personne.
Le minuteur declenche au demarrage (OnBootSec=2min) autant que toutes les 15 min :
les echecs de cette famille NAISSENT au boot, et attendre le premier quart d'heure
laisserait une fenetre pendant laquelle la machine est en panne et le tableau au vert.
CRITIQUE des la premiere unite, jamais un seuil. Une unite en echec est soit un vrai probleme, soit du bruit a retirer : dans les deux cas il faut agir. Un seuil ferait vivre le bruit indefiniment — exactement ce qu'on vient de corriger.
Les tolerances se nomment une par une (client_sante_unites_tolerees, vide par
defaut). Jamais un motif large : un filtre qui cache une unite en cache d'autres, et on ne
s'en apercoit que le jour ou l'on cherche pourquoi rien n'a alerte.
Le controle negatif
Une garantie qu'on n'a jamais vue echouer n'est pas une garantie. Unite factice posee sur
obs-01, rapport rejoue, etat relu dans IcingaDB :
obs-01 sante CRITICAL 1 unite(s) systemd en echec : setops-controle-negatif.service
(13 autres) sante OK Aucune unite systemd en echec.
Le verdict NOMME l'unite. Apres nettoyage : 14/14 OK.
Un conflit evite de justesse
setops-sauvegardes.conf definissait les object Host. Un second fichier de controle
aurait redefini les memes, et Icinga refuse un objet en double : la configuration
entiere aurait ete rejetee, donc AUCUNE supervision — en voulant en ajouter. Les hotes
vivent desormais dans setops-hotes.conf, definis UNE fois ; les fichiers de controle
n'attachent que des services. Verifie : 14 hotes, 14 services, zero doublon,
icinga2 daemon -C valide.
Trois obstacles, et ce qu'ils apprennent
${#tableau[@]} contient {#, que Jinja lit comme un debut de commentaire — le rendu
echouait sur « Missing end of comment tag ». Le shell a besoin de ${#...} ; c'est donc a
Jinja de s'ecarter (#jinja2: comment_start_string:... en tete du gabarit).
Ma premiere sonde a traduit un refus en « 0 service ». Le compte setops-depot ne peut
que POSER un resultat, pas lire — moindre privilege voulu. L'API rendait
{"error":403,"status":"Missing permission: objects/query/service"}, et mon script, qui
cherchait une clef results, a affiche « 0 service(s) sante ». Encore un « echec » qui
ecrasait « permission ». L'etat se lit dans IcingaDB, pas par ce compte.
Et un echec apt transitoire sur mon-01 : 503 unexpected range puis « Message has
been manipulated » sur la signature de debian-security. Alarmant a la lecture, local a
une machine (13 autres propres), et disparu au second essai — un hoquet du cache
apt-cacher-ng, pas un incident de signature. Mesure avant conclusion : les deux
mandataires servaient le meme contenu, au meme condensat.
Le meme compte d'API, elargi de deux noms a trois
Un second compte serait plus pur — un secret par usage. Il exigerait une clef de voute de
plus dans CHAQUE ecosysteme, donc un geste manuel a chaque nouvel ecosysteme, pour une
portee identique : ce compte ne peut deja que poser un resultat passif, sur des services
NOMMES. Le filtre passe de sauvegarde: * / sauvegarde a ces deux-la plus sante. Aucun
pouvoir nouveau.
Le SITE : fait, et il a fallu deux corrections pour que ca MARCHE
7 machines, 0 echec au deploiement — et cinq rapports sur sept en TimeoutError.
Un playbook vert ne prouve pas qu'une chose fonctionne. make site-appliquer GROUPE=client_sante rendait 7/7, 0 echec, sur un flux qui ne passait pas. Seul l'essai
de bout en bout l'a dit.
1. Le pair de la frontiere. Le role client declarait son egress 5665, la politique
de sortie etait accept, la regle d'entree de l'hote autorisait bien la source — et les
paquets mouraient ENTRE les deux. La frontiere filtre le trafic inter-zones du site et ne
connait que ce que le registre lui dit ; or elle ne resout que les roles que les machines
PORTENT AU PLAN. Une integration universelle n'y figure pas : elle est derivee, pas
declaree. pair: client_sante produisait donc une regle est-ouest correcte et AUCUNE
regle a la frontiere.
Le pair est desormais serveur_debian — le vocabulaire du depot pour « tout noeud », que
le generateur de frontiere traite deja comme tel, et qui est exact au sens strict : tout
noeud rapporte sa sante. Six regles creees, zero retiree, une par patte de zone. Le meme
piege explique pourquoi le flux client_backup juste au-dessus ne suffisait pas seul :
c'est serveur_backup, role REEL, qui ouvrait la porte pour le depot.
2. Le rapporteur s'accusait lui-meme. Pendant l'heure ou le pare-feu bloquait,
setops-sante.service a echoue — et une fois debloque, cinq machines ont rapporte
CRITIQUE en citant... leur propre rapporteur. Le blocage corrige, l'accusation restait :
systemd garde l'etat failed jusqu'a un reset-failed. Un rapporteur qui trebuche une
fois s'accuserait indefiniment.
Sa propre unite est donc exclue du compte, et ce n'est pas se menager : sa sante est
deja mesuree, et mieux, par la FRAICHEUR de ses envois. S'il ne peut plus parler, le
ttl perime le service — ce qui se voit precisement quand il ne peut PAS ecrire, alors
que sa propre unite en echec ne se voit que quand il le peut. Se compter soi-meme, c'est
mesurer deux fois la meme chose, dont une fois mal.
Etat final : 7/7 au site, 14/14 au tenant, tous OK. Controle negatif rejoue sur les deux flottes.
2026-09-09 (3) — Le redemarrage a tenu, et il a montre autre chose
Ce que le redemarrage prouve
obs-01 a redemarre avec son disque cloud-init retire de Proxmox — donc sans paquet
ET sans source de donnees. Elle est revenue avec son adresse (10.17.20.11/24), sa
passerelle (10.17.20.1) et son resolveur (10.0.34.11). C'est la confirmation que les
mesures annoncaient : /etc/network/interfaces.d/50-cloud-init n'appartient a aucun
paquet, il survit, et ifupdown le relit au demarrage.
Le retrait de cloud-init est desormais prouve par un demarrage reel, pas seulement par inference.
Ce que le redemarrage a REVELE, et qui n'a rien a voir
Une unite en echec : openipmi.service.
openipmi[655]: Starting ipmi drivers ipmi failed!
systemd[1]: Failed to start openipmi.service
La chaine : prometheus-node-exporter recommande
prometheus-node-exporter-collectors, qui recommande ipmitool, qui recommande
openipmi. Trois recommandations en cascade, chacune raisonnable sur du metal. Dans une
VM il n'y a pas de BMC — /dev/ipmi* n'existe pas — et le script d'init echoue a chaque
demarrage.
LE POINT N'EST PAS LE PAQUET, C'EST POURQUOI PERSONNE NE L'AVAIT VU. openipmi est
arrive le 2026-09-02, avec la reconstruction. systemctl --failed rendait pourtant
ZERO sur les quatorze machines — non parce qu'elles allaient bien, mais parce qu'aucune
n'avait redemarre depuis. Six jours et vingt heures pour obs-01 (last -x reboot :
boot du 2026-09-02 19:24, jusqu'a aujourd'hui 16:10).
Un controle qui ne peut echouer qu'au demarrage ne mesure rien tant que rien ne demarre. C'est la meme famille que le cheque vert sur un perimetre vide, avec le temps comme perimetre.
Et le degat n'est pas cosmetique : une unite en echec permanent use le seul signal qui
devrait alerter. Le jour ou une vraie unite tombe, systemctl --failed rend « 2 » au lieu
de « 1 », et personne ne fait la difference.
La correction, a la source et conditionnelle
client_metrique retire ipmitool et openipmi — mais seulement dans une VM
(ansible_facts.virtualization_role == 'guest'). Sur du metal le collecteur IPMI est
legitime : c'est la raison meme de la recommandation. Le role s'appuie sur ce qu'Ansible
SAIT de la machine, pas sur une supposition.
Ils ne sont que RECOMMANDES, donc les retirer n'emporte pas
prometheus-node-exporter-collectors, dont les collecteurs textfile servent vraiment.
Un handler efface l'etat failed laisse derriere : retirer le paquet ne l'efface pas,
systemd le garde jusqu'au prochain demarrage. Sur une flotte qui ne redemarre pas, ce
serait conserver par inadvertance exactement le bruit qu'on vient de supprimer.
Applique aux quatorze : ipmi=0, collectors=1, node_exporter=active, echecs=0
partout. Second passage : zero changement sur zero hote — idempotent.
Ce qui reste ouvert
Rien dans le harnais ne regarde systemctl --failed sur la flotte. Ce defaut-ci a ete
trouve parce qu'un humain a redemarre une machine, pas parce qu'une mesure l'a dit.
Les treize autres portaient la meme unite condamnee, silencieuses.
2026-09-09 (2) — Le wiki d'origin : une garde qui bloquait au lieu de proteger
Les deux forges servent desormais le meme wiki, 26 pages, contenu identique au fichier
pres (diff -rq : aucune difference).
Ce que je disais, et qui etait faux
« Il n'y a rien sur origin » — non. Le depot y est, et a jour : git ls-remote rend
exactement notre HEAD. C'est le WIKI qui etait vide. L'ecart entre « le depot est absent »
et « son wiki n'a pas de branche » est tout l'ecart entre une panne et une formalite, et je
l'avais recopie d'une session precedente sans le remesurer.
La garde avait raison de refuser, et tort de s'arreter la
make wiki-publier refuse un wiki VIDE : sans commit il n'a aucune branche, et publier en
inventerait une — master sur ce poste, alors que le wiki en service vit sur main. Le
message disait « creer une premiere page dans l'interface Forgejo ». Or l'interface n'est
pas joignable depuis ce poste, et ce n'est pas une panne : la forge du site n'accepte le
443 que des machines qui DECLARENT le flux.
Forgejo declare pourtant la reponse. GET /api/v1/repos/genome/set-ops-public rend
wiki_branch. Interroge depuis ops-01 — une machine qui a le flux — il repond main.
On ne devinait donc pas : on ne demandait pas.
WIKI_BRANCHE= a ete ajoute a la recette. Le refus reste le DEFAUT ; qui a la reponse peut
la donner. Les deux cotes sont eprouves : un wiki vide sans la variable est refuse, avec
WIKI_BRANCHE=main il est initialise sur main exactement.
Au passage, une lecon d'instrument
Trois sondes fausses avant la bonne. Un connect() direct sur 10.0.33.11:443 depuis le
poste rend TimeoutError — route presente, paquets avales : une POLITIQUE, pas une route
manquante. Un tunnel par la frontiere ne repondait pas davantage. Et pendant ce temps
git ls-remote fonctionnait tres bien, parce que ~/.ssh/config passe par un
ProxyJump ansible@10.17.0.1 que la sonde ignorait.
L'instrument juste etait ops-01 : la machine dont le flux vers la forge est DECLARE. Elle
repond HTTP 200 en 443, et 22 muet — l'exact inverse du poste. Verifier d'ou l'instrument
mesure, encore une fois.
2026-09-09 — cloud-init nait avec la VM et ne lui survit pas
63 preuves (P01-P63). make prouver : CONFORME, 62 OK, 0 echec, 1 saute.
Le second maitre
cloud-init n'est pas un logiciel d'installation : c'est une source de verite externe. Il ne s'arrete pas apres la premiere seconde — il se reveille a CHAQUE demarrage et relit le lecteur cloud-init attache par l'hyperviseur, lequel peut redefinir les comptes, les cles SSH autorisees, les mots de passe et le reseau.
Sur une machine que le plan possede desormais, c'est un second maitre : le plan ne le
decrit pas, make valider ne le mesure pas, et il parle en premier.
Sa tache est pourtant finie a la premiere seconde. C'est precisement parce qu'il a REUSSI a poser l'adresse, le nom et les cles d'hote qu'Ansible a pu entrer.
Trois moities, et elles se defont separement
- le gabarit le garde. Sans lui, un clone n'a ni adresse ni nom : il ne nait pas.
- le socle ne l'installe plus. Le garder produisait un va-et-vient a chaque
deploiement — le socle installe, le durcissement retire, deux
changedpar passage, l'idempotence perdue etmake validerbruyant pour rien. - le durcissement le retire (
roles/cloud_init_retrait, dernier role deserveur_durci, apres que tout le reste soit pose).
Une seule des trois qui bouge et la decision devient son contraire en silence : un gabarit sans cloud-init donne des VM mortes ; un socle qui le reinstalle rend le pouvoir a chaque passage ; un durcissement qui ne le retire plus laisse le second maitre en place. P63 garde les trois, plus le contenu du role — un role vide passerait les trois premiers controles sans rien fermer. Les quatre controles negatifs ont ete rejoues.
Ce qui rend le retrait sur est MESURE, pas suppose
L'adresse d'une VM vit dans /etc/network/interfaces.d/50-cloud-init. Le risque evident
etait que le retrait l'emporte — une machine qui perd ce fichier ne se plaint pas : elle
repart au prochain demarrage sans adresse, et plus personne ne peut entrer pour le
constater. Mesure du 2026-09-09 sur obs-01 :
dpkg -S /etc/network/interfaces.d/50-cloud-init -> aucun paquet ne le possede
/var/lib/dpkg/info/cloud-init.postrm -> ne nomme jamais interfaces.d
Le fichier survit donc, meme en purge, et interfaces fait toujours
source interfaces.d/*. Son en-tete annonce que les modifications « ne persistent pas au
redemarrage » : c'etait vrai TANT QUE cloud-init pouvait le reecrire. Le paquet parti,
plus personne ne le reecrit.
Le role le verifie quand meme, avant et apres, et n'accuse que si le retrait l'a
emporte — une machine qui n'a jamais eu ce fichier ne doit pas faire echouer le
durcissement. Essai a blanc sur obs-01 : cloud-init* a retirer, configuration reseau
intacte, drapeau pose.
Deux choix dits franchement
cloud-guest-utils reste. C'est growpart : ni service, ni port, ni source de
donnees. Le retirer ne fermerait aucune surface, et il faudrait le reinstaller au premier
agrandissement de disque.
Les orphelins ne sont pas retires par defaut. Le retrait laisse ~29 paquets qui
n'etaient la que pour cloud-init (python3-jsonschema, python3-jinja2,
python3-oauthlib...). Ce sont des bibliotheques : elles pesent, elles n'ouvrent rien.
autoremove calculerait exactement quoi retirer — a partir des drapeaux « installe
manuellement » de dpkg, et une machine dont ces drapeaux ont derive y perdrait autre
chose. Un durcissement ne doit pas pouvoir surprendre. cloud_init_retrait_autoremove
existe pour qui veut, en connaissance de cause.
Et un drapeau, pour le retour par dependance
cloud-init peut revenir en recommandation d'un autre paquet.
/etc/cloud/cloud-init.disabled est lu par cloud-init lui-meme au demarrage et l'arrete
avant qu'il ne lise la moindre source de donnees.
Ce que cette decision NE ferme PAS
Ajoute le meme jour, apres la question « cloud-init est-il vraiment une valeur ajoutee ici ? ». La premiere redaction de D-85 se lisait comme une emancipation. Elle n'en est pas une, et il valait mieux le dire que de laisser un lecteur presse en conclure trop.
Retirer cloud-init n'ote AUCUN pouvoir a l'hebergeur. qemu-guest-agent est au
gabarit — il doit y etre (P56 : c'est par lui que creer-vm confirme la materialisation
sans entrer chez le tenant). Releve du 2026-09-09 sur edge-mta-01, avec le jeton d'API
du site, les sous-chemins que l'API expose sur son dos :
exec · exec-status · file-read · file-write · set-user-password · shutdown · ...
C'est un pouvoir STRICTEMENT PLUS GRAND que le lecteur cloud-init, et il est deja la, sur chaque VM vivante de la flotte.
Ce que D-85 ferme est donc precis et etroit :
- une reapplication AUTOMATIQUE, a chaque demarrage, depuis un support que le plan ne possede pas et qu'aucune preuve ne lit ;
- le code de cloud-init lui-meme — un interpreteur Python complet execute en root au demarrage, et ses ~29 dependances.
La mainmise d'un hyperviseur sur ses invites est une propriete de la VIRTUALISATION, pas de cloud-init. Elle appelle sa propre decision, qui n'est pas prise.
Le seuil ou le remplacer deviendrait juste
L'alternative existe et sa piece est deja au gabarit : ecrire l'adresse et la cle par
agent/file-write + agent/exec depuis l'hyperviseur, sans reseau. Aujourd'hui ce serait
REIMPLEMENTER UN STANDARD — ce que positionnement.md interdit — et echanger un mecanisme
eprouve contre du code maison au moment le plus fragile, dont le mode de panne est le
pire : une VM injoignable.
Le jour ou une premiere seconde ne pourra plus etre amorcee par Proxmox — autre hyperviseur, metal nu, hebergeur sans API — ce chemin cesse d'etre une reimplementation et devient LE CHEMIN PORTABLE. C'est l'axe emancipation que le depot suit deja. Le seuil est nomme ici pour ne pas etre franchi sans le voir.
Deploye le 2026-09-09 — 14/14
Applique a la flotte de Chezlepro apres confirmation explicite. serveur_durci :
14 hotes, 0 echec, 0 injoignable. Ordre suivi : obs-01 seul d'abord, verifie, puis
les treize autres.
Releve sur les quatorze machines apres coup :
paquet=0 unites=0 reseau=oui drapeau=oui networking=enabled echecs=0
et, sur chacune, ifquery rend EXACTEMENT l'adresse vive (10.17.20.11, 10.17.21.11...).
C'est le point : ifquery ne lit pas la memoire du systeme, il relit le fichier que le
retrait aurait pu emporter — donc il repond ce que le prochain demarrage fera.
Second passage sur obs-01 : changed=1, et ce seul changement est la tache
nftables_baseline qui se declare toujours modifiee, anterieure a ce chantier. Le retrait
est idempotent.
make valider apres deploiement : 0 echec, 0 injoignable sur les quatorze.
CE QUI N'A PAS ETE PROUVE, ET IL FAUT LE DIRE. Aucune machine n'a ete REDEMARREE. Le
redemarrage etait le controle que je voulais faire — la garde de securite du poste l'a
refuse, et on ne contourne pas une garde. Le chemin de demarrage a donc ete prouve
AUTREMENT, sans redemarrer : networking.service (ifupdown) est le service actif,
systemd-networkd est desactive, NetworkManager absent ; ifquery relit le fichier et
rend la bonne adresse ; zero unite cloud-init subsiste dans
systemctl list-dependencies multi-user.target ; zero unite en echec ; le nom d'hote et
les trois cles d'hote SSH persistent hors de cloud-init.
C'est fort, ce n'est pas un redemarrage. La confirmation finale tient en une ligne, a lancer par un humain sur une machine de son choix.
Le SITE : fait le 2026-09-09 — 7/7
make site-appliquer GROUPE=serveur_durci : 7 hotes, 0 echec, 0 injoignable.
Configuration reseau intacte sur les sept, ifquery rend l'adresse attendue sur chacune
(10.0.31.11, 10.0.33.11...), zero unite cloud-init restante, zero unite en echec.
Le site HEBERGE — c'est ce qui rendait l'operation plus lourde que chez le tenant. Verifie
apres coup, depuis les machines qui ont le flux : la forge repond HTTP 200 et sert
toujours ce depot au bon commit ; le cache APT repond HTTP 200 ; le DNS resout
forge.genese.internal ; step-ca rend {"status":"ok"}.
Le site ne portait pas le piege openipmi : il n'applique pas client_metrique.
(Ce paragraphe remplace celui qui disait le changement « arme, pas applique » — et qui
disait aussi, a tort, qu'il fallait passer par site-ops-01. make site-appliquer
fonctionne depuis le poste.)
Trouve en verifiant : site-backup-01 n'a jamais recu client_pki
Elle est DANS le groupe client_pki — et elle n'a ni /etc/step, ni minuterie de
renouvellement, ni meme la racine de l'AC dans /usr/local/share/ca-certificates/. Les
six autres machines du site ont leur certificat et leur minuterie quotidienne, qui tourne.
L'appartenance au groupe dit « couverte ». La machine dit le contraire. C'est la meme famille que le reste : un perimetre declare n'est pas un perimetre mesure. Sans rapport avec le retrait de cloud-init — trouve en le verifiant.
Corrige le meme jour : make site-appliquer GROUPE=client_pki — 7 hotes, 0 echec.
site-backup-01 a recu quatorze changements : depot apt Smallstep, step-cli, STEPPATH,
mot de passe du provisioner, etablissement de la confiance dans l'AC, certificat
d'hote emis, unite et minuteur de renouvellement, actives.
Verifie apres coup sur les sept :
chaine = VALIDE (`step certificate verify` contre la racine locale)
racine = /etc/step/certs/root_ca.crt presente partout
timer = cert-renewer@<fqdn>.timer [enabled/active], prochaine passe ce soir
site-pki-01 est la seule sans ancre dans /usr/local/share/ca-certificates/, et c'est
juste : elle PORTE l'autorite, elle ne s'enrole pas aupres d'elle-meme.
Trois de mes sondes ont menti avant la bonne : is-enabled sur un nom d'unite devine,
puis la colonne « service active » lue a la place de la minuterie — un service declenche
par minuteur est normalement disabled, ce qui se lit comme une panne quand on regarde la
mauvaise ligne. La question etait pourtant serieuse : sans minuteur, les certificats du
site expiraient dans 24 h.
Et le changed=2 que les six autres machines rapportaient n'etait pas une
non-idempotence : c'etait le depot initial de step-cli. Rejoue sur site-forge-01 :
changed=0.
Ce qui restait a faire (historique)
SITE-Chezlepro est une autre instance de ce depot — meme
playbooks/groupes/serveur_durci.yml, memes roles. Ses sept machines actives
(site-ops-01, site-cache-01, site-forge-01, site-pki-01, site-dns-01,
site-backup-01, site-mon-01) sont durcies depuis le 2026-09-02 : leur prochain passage
de serveur_durci retirera cloud-init chez elles aussi, sans que personne ne le decide a
nouveau.
Ce n'est pas un oubli, c'est un fait a connaitre : le site se deploie depuis SON runner
(site-ops-01), pas depuis ce poste, et son inventaire n'est meme pas genere ici. Y aller
est un acte distinct, sur une machine distincte, et le site porte la forge qui sert ce
depot et le cache qui nourrit apt — un rayon d'action different.
2026-09-08 (4) — Les SIX registres ont un formulaire genere
62 preuves. make prouver : CONFORME, 61 OK, 0 echec, 1 saute.
CHAMPS_ECRITS_A_LA_MAIN est vide : plus un seul champ recopie a la main.
L'epreuve qui compte
Ouvrir chaque vue et enregistrer sans rien toucher doit renvoyer au serveur exactement le plan qu'on vient de lire. C'est ce qui separe un formulaire genere d'un formulaire qui a l'air genere : un champ visible a l'ecran et perdu en silence a l'enregistrement serait le pire des deux mondes.
/api/serveurs 14 entite(s) IDENTIQUE
/api/applications 25 entite(s) IDENTIQUE
/api/domaines 2 entite(s) IDENTIQUE
/api/bases 4 entite(s) IDENTIQUE
test_rendu_gui.py le mesure desormais a chaque make prouver.
Trois defauts trouves en chemin
Le formulaire annoncait des defauts inventes. « 2048 » pour la memoire, « 2 » pour les
coeurs, « 16G » pour le disque. Il n'existe aucun defaut fixe : deriver_ressources
calcule la taille depuis les ROLES que l'hote porte — 1024 Mo et 1 coeur pour
infra-pki-01, 5632 et 4 pour collab-01. Un repere faux est pire qu'aucun : il fait
croire qu'on connait la valeur. Le schema nomme maintenant le champ derive
(x-defaut-derive), et le formulaire affiche la valeur REELLE de cet hote. De meme,
l'option vide d'un <select> dit le defaut qu'elle produira : « (défaut : asgard) ».
Une SECONDE occurrence du defaut d'hier dormait. sourceDeValeurs lisait encore
data.nomenclature — le meme data qui n'existe pas. Elle n'avait jamais leve parce que
la vue Serveurs, seule a emprunter cette source, avait encore un formulaire ecrit a la
main. Elle a leve a la seconde ou le generateur l'a prise, et c'est le banc de rendu
qui l'a dit.
Le banc ne voit que les chemins VIVANTS. verifier_gui.py fait donc desormais une
verification STATIQUE : une fonction qui lit data. sans le declarer ni le recevoir est
refusee. Elle voit aussi ce qui dort. Son premier essai a signale dessinerReseau() a
tort — data y est declare en second declarateur d'un const multiple ; corrige, parce
qu'un faux positif dans une garde finit toujours par la faire desactiver.
La validation client s'accrochait a data-v, pose a la main sur trois champs. Le
formulaire genere l'aurait perdu, et querySelectorAll aurait rendu une liste vide : la
validation serait passee au vert sur ZERO champ. Le generateur marque maintenant chaque
controle (data-champ), et la sauvegarde REFUSE si elle n'inspecte aucun champ — un
controle qui ne trouve rien ne dit pas « tout va bien ».
Deux champs gardent leur editeur, et le schema le dit
integrations et liens ne sont PAS generes, volontairement. La matrice des integrations
montre aussi les universelles (non decochables) et les exemptions sauf_role : un champ
texte genere ferait lire un plan silencieux comme « cet hote n'est pas supervise »,
l'inverse exact de la politique. L'editeur de liens contraint le role a ce que le groupe
porteur accepte (meta/liens.yml). Le schema porte donc x-editeur, et le generateur
s'efface — au lieu de remplacer un editeur qui en sait plus que lui.
La limite
Je n'ai toujours pas ouvert ces pages dans un navigateur. Le banc prouve qu'elles rendent sans lever et qu'un aller-retour ne perd rien ; il ne dit rien de leur lisibilite.
2026-09-08 (3) — Quarante lignes de memoire que chaque « Sauvegarder » detruisait
62 preuves (P01-P62). make prouver : CONFORME, 61 OK, 0 echec, 1 saute.
Quatre registres sur six ont desormais un formulaire GENERE.
Ce que j'ai trouve en voulant generer deux formulaires de plus
Avant de basculer les vues Bases (serveurs de BD) et Domaines sur le generateur, j'ai confronte le schema a ce que les VALIDATEURS acceptent — pas seulement a ce que les plans contiennent. Trois ecarts, et un quatrieme trouve en chemin.
edge designe un GROUPE, pas un hote. Le schema declarait source_valeurs: serveurs.
instancier compare pourtant cette valeur aux GROUPES d'un hote (e.get("edge") in groupes) pour lui deriver ses SAN de certificat. Un formulaire genere aurait offert
web-frontal-01, qu'aucun hote n'aurait reconnu : aucun SAN, donc un certificat correct
sur un nom que personne ne peut appeler. C'est mot pour mot la panne du 2026-08-25, qu'une
liste mal choisie aurait reintroduite.
exposition manquait au schema. valider_domaines le valide entierement — une liste
de {nom, cible, type} — et le schema l'ignorait. Aucun plan ne s'en sert : P61 etait au
vert. Le formulaire genere n'aurait donc jamais pu offrir une fonctionnalite que le moteur
possede deja.
applications.liens etait decrit items: {type: object} — une liste d'objets sans
forme. Le moteur exige vers et role.
mail faisait l'inverse : offert par la vue Domaines depuis sa creation, decrit ici
comme un booleen, saisi la-bas comme du texte, et lu par RIEN. Retire.
D'ou P62 : les champs qu'un validateur lit sur l'entite qu'il valide doivent tous etre
decrits. Elle separe l'entite du reste mecaniquement — un validateur lit son entite par
des variables LOCALES, et les autres registres par ses PARAMETRES (srv.get("fonction")
contre nomenclature.get("fonctions")). Aucune liste a tenir. Controle negatif rejoue.
Et le degat qui etait deja la
En verifiant que le nouveau formulaire n'abimerait pas domaines.yml, j'ai mesure les
quatre ecrivains de registre sur les fichiers REELS :
domaines.yml 6 lignes de commentaire -> 3 (PERD 3)
applications.yml 27 -> 5 (PERD 22)
serveurs.yml 18 -> 3 (PERD 15)
bases-donnees.yml 4 -> 4 (intact — il n'en portait pas)
Quarante lignes, detruites par n'importe quel clic sur « Sauvegarder » dans les vues
Serveurs, Applications ou Domaines. Parmi elles, celle qui explique pourquoi backup-01 a
ete retire — « une supervision creuse est pire qu'aucune : elle est verte » — et celle
qui dit dans quel ORDRE les deux roles du runner s'appliquent.
C'etait l'incident du 2026-08-18 (94 lignes perdues dans les fichiers d'intrants), jamais
corrige pour les registres du plan : _fusion_chirurgicale avait ete ecrite pour les
intrants seuls, et les quatre ecrire_* etaient restes au safe_dump. Ils passent tous
par _ecrire_registre maintenant. Aller-retour a vide : les quatre fichiers sont
identiques a l'octet. Une modification reelle ne touche que ses lignes.
scripts/tests/test_ecriture_plan.py le mesure sur les vrais fichiers, controle negatif
compris.
Les deux formulaires
Serveurs de BD et Domaines sont generes, chargement et sauvegarde compris. Le
generateur a appris une forme de plus : la liste d'objets (exposition, liens), avec
son bouton d'ajout et le retrait par ligne. L'ajout passe par le meme setter que les
champs — on lui remet le tableau entier, serialise dans l'attribut — plutot que par une
fonction globale a resoudre au clic : ce qui marche sous le banc marche dans le navigateur.
La limite
Quatre registres sur six. Restent serveurs et applications, les deux plus gros.
Et je n'ai toujours pas ouvert ces pages dans un navigateur.
2026-09-08 (2) — La vue Nomenclature, et deux fautes que mes bancs ne pouvaient pas voir
61 preuves. make prouver : CONFORME, 60 OK, 0 echec, 1 saute.
couverture_gui verifier : les 28 champs des plans reels sont editables — le dernier trou
est ferme.
La vue
La nomenclature etait le seul registre que le GUI ne savait pas ecrire DU TOUT : ajouter une fonction exigeait d'ouvrir le YAML. Elle a maintenant sa vue, et son formulaire est GENERE depuis le schema — deuxieme registre sur six.
Elle n'est pas un registre comme les autres : elle ne decrit pas des objets, elle decrit la REGLE dont VMID, VLAN, adresse et passerelle se derivent. D'ou trois partis pris :
- chaque fonction affiche ce qu'elle derive (zone, VLAN, sous-reseau, bloc d'hotes) et les VM qui la portent — sans ca, changer un chiffre est un geste a l'aveugle ;
indexest montre mais PAS editable ici : le fichier dit lui-meme qu'il est RECU du site et non decide par le tenant ;valider_nomenclaturerefuse de retirer une fonction encore portee par une VM, ou de designer une zone non declaree —deriver_nomenclaturerendraitNoneEN SILENCE.
Les deux fautes, et pourquoi mes bancs ne les voyaient pas
1. Le formulaire des bases, livre la veille, etait casse dans un navigateur.
Il lisait data.schema — or il n'existe aucun data global dans cette page : c'est une
const locale de charger(). ReferenceError a l'ouverture. Pire dans sauvegarderBases,
ou const data est declare PLUS BAS dans la meme fonction : zone morte temporelle,
l'enregistrement jetait avant meme d'envoyer.
Je l'avais « eprouve » sous node — en PASSANT data a la fonction. Le banc reproduisait la
fonction, pas sa PORTEE. verifier_gui.py, lui, ne verifie que la syntaxe.
Remede : un global schemaPlan, et surtout scripts/tests/test_rendu_gui.py, qui charge le
JS entier dans un DOM simule, appelle charger() sur la VRAIE reponse de l'API, et dessine
les douze vues. Son controle negatif remet une reference absente et exige que le banc la
voie.
2. Le schema decrivait reservations comme une table de zones. Le fichier reel est un
bloc plat, et underlay.py lit reservations.passerelle a plat. P61 ne voyait rien : elle
comparait des NOMS aplatis, et passerelle existe des deux cotes — a des profondeurs
differentes. Un formulaire genere depuis cette description aurait offert « ajouter une zone »
et ecrit une forme que le moteur ne lit pas.
P61 compare desormais aussi la FORME : scalaire, bloc a clefs fixes, table a clefs libres. Controle negatif rejoue, elle echoue.
Ecrire dans la nomenclature sans deplacer un commentaire
Le fichier porte vingt-deux lignes qui disent pourquoi index est recu, et laquelle des
fonctions est le poste d'exploitation. _fusion_chirurgicale les gardait toutes — mais elle
remplace le BLOC entier des qu'une valeur change. Mesure : ajouter une fonction transformait
quinze entrees compactes en quarante-deux lignes, et HISSAIT le commentaire du poste
d'exploitation en tete du bloc, ou il affirmait que collab etait le poste d'exploitation.
Un commentaire deplace n'est pas laid : il est FAUX.
_fusion_table edite donc les tables LIGNE A LIGNE. Le diff d'un changement reel fait
desormais trois lignes, le style compact survit, et le commentaire garde son voisin.
scripts/tests/test_nomenclature_ecriture.py le fige, son controle negatif compris.
Au passage
sort_keys=True triait le schema genere alphabetiquement — donc l'ordre des cases a
l'ecran. reserve_max passait avant reserve_min. L'ordre de REGISTRES est délibéré et
un dict Python litteral est deja stable : le tri ne servait a rien et desservait les deux
formulaires.
Et _ecrire_index_nomenclature ecrivait encore par write_text : oubliee au passage des
ecritures atomiques du 2026-09-06. Une coupure y laissait le seed a moitie ecrit,
c'est-a-dire tout l'adressage de l'ecosysteme.
La limite, dite franchement
DEUX registres sur six sont generes. Les quatre autres formulaires restent ecrits a la main. Et je n'ai toujours pas ouvert cette page dans un navigateur : le banc prouve qu'elle rend sans lever, pas qu'elle est lisible.
2026-09-08 — La forme des registres cesse d'etre recopiee : elle est derivee
61 preuves (P01-P61, dont une conditionnelle). make prouver : CONFORME, 60 OK, 0 echec,
1 saute.
Le probleme
La forme du plan etait ecrite TROIS FOIS : dans les constantes du moteur (ETATS_SERVEUR,
PORTEES_BD, AUTORITES_DNS), dans les formulaires du GUI, et dans la documentation. Rien
ne les tenait ensemble. Ajouter une portee de base de donnees demandait trois gestes, et le
troisieme s'oubliait sans qu'aucun controle ne s'en apercoive.
Ce qui change
scripts/schema_plan.py (nouveau) derive un JSON Schema des SIX registres du plan
— serveurs, applications, bases_donnees, serveurs_bd, domaines_publics,
nomenclature — en IMPORTANT les enumerations du moteur plutot qu'en les recopiant. Le
resultat est versionne dans docs/audit/schema-plan.json : 42 champs, regenerable par
make schema. Il est versionne, et non recalcule a chaud, pour qu'une divergence se voie
dans un diff.
Le schema porte ce que JSON Schema seul ne dit pas : x-source-valeurs (liste fermee
alimentee a l'execution depuis l'inventaire), x-source-selon (liste dependant d'un autre
champ), x-clef (l'attribut qui identifie l'entite), x-requis.
Le formulaire des bases de donnees du GUI n'est plus ecrit a la main. Il est construit
au chargement depuis le schema servi par /api/inventaire. Le chemin de SAUVEGARDE aussi
derive du schema : les champs ecrits sont ceux que le schema declare, avec ses valeurs par
defaut — plus une liste de champs recopiee dans le JS.
P61 garde l'ensemble : le schema doit couvrir tout ce que les plans REELS contiennent.
Son controle negatif : retirer un champ du schema le fait echouer.
La limite, dite franchement
UN registre sur six est genere. Les cinq autres formulaires restent ecrits a la main.
Et le trou connu reste ouvert : couverture_gui.py verifier echoue toujours sur
nomenclature.categorie et nomenclature.service — deux champs presents dans les plans
reels que le GUI ne sait pas ecrire. Le schema les DECRIT deja ; c'est le passage de la vue
nomenclature au generateur qui fermera le trou, par construction.
Enfin : le formulaire genere a ete eprouve en rendant son HTML avec la vraie reponse de l'API, pas dans un navigateur.
2026-09-06 — Tournee des 74 documents : ce que le depot disait de lui-meme avait vieilli
57 preuves (P01-P57, dont une conditionnelle). make prouver : CONFORME, 56 OK, 0 echec,
1 saute. Aucun comportement ne change. Ce qui change, c'est que les documents cessent de
decrire un depot qui n'existe plus.
La lecon de methode, d'abord
La revision a commence par un BALAYAGE PAR MOTIFS — chemins morts, cibles make absentes,
comptes derives. Il a trouve une trentaine d'ecarts, et il a rate presque tout le reste. Un
motif ne voit que ce qui s'exprime en motif.
make hote-planifier en est l'exemple exact : la cible EXISTE, donc le controle passait au
vert. C'est une cible DEPRECIEE qui refuse et sort en 2 — recommandee par AGENTS.md, et
contredisant la REGLE D'OR du meme fichier trois ecrans plus haut. Il a fallu lire pour la
voir. D'ou la tournee : les 74 documents, un par un.
Les affirmations franchement fausses
AGENTS.md — la source d'autorite — annoncait « pas encore execute contre des VM reelles ».
La flotte a ete rasee et remontee depuis zero le 2026-08-13, puis DEUX FOIS le 2026-09-02.
docs/ecosysteme-chezlepro.md, qui est le document montre a un client, portait la meme
phrase : il se sous-vendait gravement.
docs/courriel-conception.md s'ouvrait sur « Statut : CONCEPTION. Aucun role n'est encore
ecrit » — au-dessus de son propre §1 qui nomme les trois roles, deployes et prouves.
docs/autorisation.md se terminait sur « Rien n'est construit », alors que le meme document
rapporte des mesures DATEES prises sur le role en fonctionnement.
docs/hebergeur-exploitation.md disait « Rien n'est fait » d'un depot qui existe :
SITE-Chezlepro, avec son plan, ses sept VM et le symlink en place.
docs/filiation-emancipation.md se contredisait a deux ecrans de distance : une section
decrivait make emancipation-prouver, une autre affirmait que cet instrument n'existait pas.
Les modeles decrits d'apres un monde anterieur
- Le resolveur.
dns-interne.md,integrations-vm.md, deux unites du wiki etintrants-communs.mddecrivaient un Unbound par VM, en opt-in. Depuis le 2026-08-24,client_resolveurN'INSTALLE PLUS RIEN et son integration est UNIVERSELLE. Trois documents le donnaient meme en exemple d'integration facultative — l'inverse exact. - L'adressage.
nomenclature-vm.mddecrivait un reseau unique10.0.0.0/16, des VLAN 11 a 15 et des VMID a cinq chiffres : le modele d'avant le multi-instance. - Le nommage SDN.
sdn-evpn.mdannoncaitCHEZ17/chez174; le code produitt17/t17serv. C'est le wiki qui avait raison. - La bascule D-77. Trois documents la donnaient « en cours » ;
10.0.0.0/24n'existe plus depuis le 2026-08-22. - Le mecanisme de voute. Cinq documents — dont le runbook de REPRISE — designaient un
ANSIBLE_VAULT_PASSWORD_FILEunique. Une voute, une cle depuis le 2026-08-28.
Ce qui casse au premier essai
Le nom du gabarit dore etait faux a quatre endroits, dont la procedure qui le FABRIQUE
(debian13-template) et le critere de reussite R2 de l'epreuve de l'operateur independant.
Le defaut du code est modeleSetOPS, et le clonage cherche sa source PAR CE NOM.
preparer-un-site-hebergeur.md avertissait qu'une VM creee a la main serait detruite par
l'outil. C'est l'inverse : raser derive sa liste du plan, il ne la detruira JAMAIS — elle
survit sans DNS, sans certificat, sans sauvegarde, et son VMID n'est garde par aucune preuve.
Un mot de passe d'essai en clair dans wiki/Courriel.md, dans un depot public.
Le nom de l'inventaire n'en est pas un
Douze chemins ecrivaient instance/inventories/production/hosts.yml, la REGLE D'OR
d'AGENTS.md comprise. Cet inventaire n'existe pas ici : la flotte dit principal. Mais le
modele public dit bien production, et le moteur CHERCHE le nom au lieu de l'imposer :
ecrire l'un des deux en dur etait faux pour la moitie des lecteurs.
Deux preuves etendues, et une qui se trompait elle-meme
P57 (comptes en prose) couvre desormais les GROUPES. Elle a signale dans la seconde qui
a suivi que catalogue-services.md annoncait « 30 groupes classes » la ou il y en a 40, et
« les 29 groupes » au-dessus d'un tableau qui en cite 40 — une contradiction A UNE LIGNE DE
DISTANCE.
P29 confronte desormais le tableau de docs/authentification.md aux declarations
reelles. La ligne sans-auth-humaine annoncait 12 roles ; il y en a 21. La preuve lisait ces
declarations depuis le debut sans jamais regarder ce que le document en disait.
Et P57 imposait un chiffre faux. Elle mesurait len(PREUVES) = 56, mais le depot porte
57 preuves : P16 est conditionnelle et vivait DANS main(), hors de tout comptage. Un
garde-fou qui fait respecter une erreur est pire qu'aucun garde-fou — il ajoute l'assurance
a l'erreur.
Ce que la tournee laisse en place, et qu'aucune preuve ne tient
Deux comptes trouves a la main : le README annoncait cinq portes et en ouvrait six ;
implanter-un-tenant-sur-un-site.md renvoyait aux « huit lignes » d'une fiche qui en compte
dix. Et une lacune reelle, nommee dans autorisation.md : rien ne garde les
meta/acces.yml — ni qu'un service web-sso en porte un, ni que le groupe qu'il nomme
existe. P29 garde les POSITIONS d'authentification ; personne ne garde les HABILITATIONS.
2026-09-05 (2) — Rouvrir les cles : la cle USB doit se suffire a elle-meme
56 preuves. Les cles sont sorties du poste. Restait la moitie qui compte : savoir les REMETTRE. Une sauvegarde qu'on ne sait pas rouvrir n'est pas une sauvegarde.
Le piege, et pourquoi la procedure ne peut pas vivre dans le depot
Le jour ou l'on s'en sert, le poste est mort. Le depot Set-OPS est replique trois fois — eregion, la forge du site, patient 0 — mais le CLONER demande la cle SSH, qui est justement dans l'archive qu'on essaie d'ouvrir. Une procedure de restauration rangee dans le depot serait donc inaccessible exactement quand elle sert.
exporter_cles.py depose donc, A COTE DE L'ARCHIVE : restaurer_cles.py et un
LISEZ-MOI-RESTAURATION.txt. La cle ne demande plus que gpg, python3 et la phrase de
passe.
Ce que le script fait de plus qu'un tar -x
- il remet chaque fichier a sa place selon son NOM, pas selon un chemin enregistre — on restaure souvent sur une machine neuve, parfois sous un autre compte ;
- il repose les droits a 0600.
sshREFUSE une cle privee que d'autres peuvent lire, et son message ne dit pas qu'il s'agit d'un droit — une archive extraite depuis un support FAT arrive toujours dans cet etat ; - il refuse d'ecraser une cle deja presente, et regarde AVANT d'ecrire : refuser a mi-parcours laisserait la moitie des cles en place et l'autre non, un etat que personne ne sait diagnostiquer.
Et si meme ce script ne tourne pas
Le LISEZ-MOI porte les trois commandes manuelles — gpg, tar, chmod. C'est le vrai
filet : un outil peut avoir un defaut, gpg et tar seront la.
EPROUVE, PAS SUPPOSE
Cycle complet sur des fichiers factices : export vers une cle, poste neuf entierement vide, restauration depuis la cle seule, puis comparaison.
IDENTIQUES — 4 empreintes sur 4
700 .ssh 600 .ssh/id_git_ed25519 600 .config/setops-vault-…
REFUS : ces cles existent deja ici — Rien n'a ete ecrit.
Le filet manuel a ete passe au meme test, separement : identiques, 4 sur 4.
Le test le moins cher, a refaire souvent
make cles-restaurer ARCHIVE=/media/…/setops-cles-*.tar.gpg
Il refusera, puisque les cles sont en place — et ce refus est la preuve que l'archive s'ouvre et que la phrase de passe est la bonne. A refaire apres chaque changement de cle.
2026-09-05 — 1 644 octets valent l'installation, et ils n'existaient qu'a un endroit
56 preuves. En cherchant par quoi reprendre, une mesure a change l'ordre des
priorites : le CODE de Set-OPS est replique trois fois — eregion, la forge du site,
patient 0 — et les voutes chiffrees y sont aussi. Le coffre est solide.
Les CLES qui l'ouvrent vivaient dans neuf fichiers de ~/.config et ~/.ssh, 1 644
octets au total, sans aucune copie ailleurs. C'est le pire rapport valeur/fragilite de
l'installation : six cles de voute qui ouvrent tous les secrets — jetons Proxmox et
OPNsense, mots de passe de step-ca, des bases, et les vault_restic_password — plus les
cles SSH par lesquelles on entre partout.
Ce qu'on perdait avec le poste, sans exagerer la gravite :
- le poste seul : les mots de passe restic restent lisibles sur les machines vivantes
(
/etc/setops/restic.pass) — recuperable, mais douloureux, et plus aucun deploiement possible entre-temps ; - le poste et une machine : l'etat de cette machine devient illisible ;
- le poste et le site : terminal.
make cles-recenser et make cles-exporter
Le recensement montre ce qui sortirait sans rien ecrire — nom, taille, empreinte, jamais le contenu. L'export met le tout dans une archive chiffree (AES256, phrase de passe symetrique) sur un support choisi.
A LANCER SOI-MEME. gpg demande une phrase de passe : elle ne doit passer ni par un
journal, ni par le contexte d'un assistant. La cible le dit dans son propre commentaire.
ECRIRE PUIS RELIRE (D-68), applique a ce qui compte le plus : l'outil REDECHIFFRE ce qu'il vient d'ecrire et compare les empreintes une a une. Une sauvegarde de cles qu'on n'a pas rouverte n'est pas une sauvegarde, c'est un fichier dont on espere quelque chose.
Trois refus, eprouves en les faisant echouer
- destination dans l'infrastructure — ces cles ouvrent les sauvegardes ; les y ranger
ferait un coffre dont la cle est a l'interieur.
~/Espace Chezleproet/srv/resticsont refuses nommement ; - archive existante — on n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule ;
- archive illisible — l'archive est SUPPRIMEE. Elle donnerait le sentiment d'etre protege sans l'etre.
Les trois ont ete essayes sur des fichiers factices avant d'approcher les vraies cles —
et le premier essai du premier refus etait FAUX : le shell developpait $HOME avant que
je le remplace, l'instrument mesurait donc ailleurs que la cible. Reteste correctement,
le refus tire. Encore une fois : verifier d'ou l'instrument mesure.
Ce que l'outil ne couvre pas, et qui reste a l'humain
La phrase de passe (perdue, l'archive est du bruit) et la seconde copie dans un autre
lieu. Un support unique dans un tiroir unique, c'est le probleme qu'on vient de fermer,
deplace de quelques metres. Ces deux gestes sont ecrits dans la sortie du script et dans
docs/sortir-les-cles-du-poste.md — pas en note de bas de page.
2026-09-03 — Les paquets non Debian passent par le cache : le dernier obstacle au hors-ligne
56 preuves. Trois depots tiers etaient configures sur la flotte — Smallstep, Icinga,
Grafana — tous en HTTPS. Or client_artefacts pose Acquire::https::Proxy "DIRECT",
sans quoi le cache du site refuse les tunnels HTTPS (« 403 CONNECT denied ») et aucun
depot tiers n'est joignable.
Cette ligne est juste, et elle a ete posee pour une bonne raison. Sa CONSEQUENCE n'avait pas ete vue : ces trois depots contournent le cache par conception, et chaque VM neuve allait les chercher sur Internet a sa naissance.
step-cli (Smallstep) et alloy (Grafana) sont poses par des integrations
universelles : sans lien, une machine neuve n'obtenait ni son client d'autorite ni ses
metriques — elle n'entrait dans aucun flux chiffre. C'etait le dernier obstacle a une
reconstruction hors ligne, et il tenait dans un mot : DIRECT.
Ce qui change
make cacher-paquets lit paquets-tiers.yml, va chercher l'index de chaque depot, tire
les 21 .deb aux versions epinglees et verifie l'empreinte SHA256 que l'index annonce.
Tout atterrit dans ~/.cache/setops/paquets/, a cote de Forgejo, Keycloak et Nextcloud —
meme patron depuis toujours : telecharger une fois, verifier, deposer ensuite.
Le role partage paquets_tiers les depose et les installe en un seul appel a apt : apt
sait resoudre un ensemble de fichiers locaux qui se dependent mutuellement, la ou une
installation paquet par paquet echouerait sur l'ordre. Icinga en apporte dix-sept dans ce
cas.
Six roles y sont branches — client_pki, client_journal, serveur_icinga,
serveur_icingaweb2, serveur_grafana, serveur_loki. Chacun retombe sur le depot
distant pour ce que le cache n'a pas fourni, et rien d'autre : reinstaller ce que le
cache vient de poser ferait un update_cache inutile qui, hors ligne, echouerait APRES un
travail deja fait.
La coupure, eprouvee pour de vrai
Sur web-dorsal-01 : packages.smallstep.com renvoye vers 127.0.0.1, step-cli
desinstalle, cache local efface. Le role rejoue :
step-cli : 0.30.6-1
installe depuis : step-cli_0.30.6-1_amd64_...deb
certificat present : 2
Zero echec. Le depot etait injoignable et la machine a obtenu son certificat.
Deux defauts de mon propre outil, trouves en le construisant
Smallstep sert son index NON COMPRESSE. Icinga et Grafana servent Packages.gz ;
Smallstep sert Packages en clair et rend 404 sur le reste. La premiere version ne
demandait que .gz : le depot echouait, et step-cli — le paquet de CHAQUE machine — n'a
jamais ete mis en cache.
Et le script rapportait « 20 tires, 20 declares ». Un depot injoignable faisait
continue AVANT d'incrementer le total : il disparaissait du decompte, et l'echec se
lisait comme un succes. Une garde qui ne peut pas echouer ne garde rien — troisieme
occurrence cette semaine, cette fois dans un outil ecrit le jour meme.
Corrige, puis eprouve en le faisant echouer : depot rendu injoignable, le script rend
INCOMPLET et sort en 1 ; tout present, il sort en 0.
Ce qui reste hors ligne
Deux dependances mineures, nommees pour qu'on sache : le temps (NTP externe, la derive est lente) et l'expedition des alertes (le relais livre directement aux MX). La supervision continuerait de VOIR sans pouvoir le DIRE.
2026-09-02 (6) — Deuxieme reconstruction de validation : une course que la premiere n'avait pas montree
56 preuves. Reconstruction : 14/14 hotes, 0 echec. make valider : 0 echec sur 13
hotes. Chezlepro a ete rasee une seconde fois et refaite depuis le gabarit minimal, pour
eprouver tout ce qui avait change depuis la veille.
Ce que ce cycle a VALIDE
Les corrections de la veille ont toutes tenu en conditions reelles :
- La garde de clonage —
edge-mta-01,infra-mail-01etops-01ont clone « AVEC AVERTISSEMENT ». Les trois auraient ete declarees perdues l'avant-veille ; le clonage continue, et l'avertissement s'affiche au lieu d'etre avale. - La degradation de
client_artefacts—infra-pki-01etinfra-dns-01, premieres machines debout, sont restees sur le cache du SITE et l'ont dit. Elles ont bascule sur celui du locataire dans la passe principale. enableda la frontiere, la delegation de zone, le durcissement du site — aucun incident.
LE DEFAUT QUE SEULE UNE SECONDE RECONSTRUCTION POUVAIT MONTRER
forge-01 bouclait :
migration[v14a_add-foreign-keys-collaboration] ... failure to delete inconsistent
records before foreign key sync: la relation « collaboration » n'existe pas
La base contenait 5 tables avec version=305 deja inscrite. A moitie faite.
C'est une course. Le role DEMARRE Forgejo, qui entame son initialisation de premier
lancement — creation du schema et migrations, plusieurs dizaines de secondes. Puis
flush_handlers le REDEMARRE, parce qu'app.ini vient de changer. Redemarre au milieu,
il laisse la version cible inscrite et le schema absent.
Il ne s'en releve jamais seul : ORM engine initialization attempt #1/10, #2/10…
indefiniment. Le service reste active — il n'a pas plante, il reessaie — et n'ecoute
jamais son port. Une machine verte qui ne sert rien.
Pourquoi la premiere reconstruction ne l'avait pas vu. Aux deploiements suivants la base est deja migree, le premier demarrage est instantane, et le redemarrage ne tombe au milieu de rien. Le defaut n'existe que sur une base VIERGE, et meme la il depend du timing. Il a fallu deux reconstructions completes.
La correction attend la fin du premier demarrage AVANT tout redemarrage. Le schema a ete remis a zero — il ne contenait rien — et Forgejo est monte du premier coup.
La chaine de sauvegarde, prouvee sur une flotte entierement neuve
Neuf detenteurs d'etat ont depose sur le depot du SITE, puis chacun a verifie SON depot distant et l'a rapporte a l'Icinga de l'ecosysteme :
collab-01 OK : instantane il y a 0 h, 108 fichier(s)
data-sql-01 OK : instantane il y a 0 h, 1 fichier(s)
edge-mta-01 OK : instantane il y a 0 h, 145 fichier(s)
forge-01 OK : instantane il y a 0 h, 29 fichier(s)
idm-01 OK : instantane il y a 0 h, 1 fichier(s)
infra-mail-01 OK : instantane il y a 0 h, 7 fichier(s)
infra-pki-01 OK : instantane il y a 0 h, 12 fichier(s)
web-dorsal-01 N'EMPORTE RIEN — a confirmer
web-frontal-01 N'EMPORTE RIEN — a confirmer
Le cycle complet — deposer chez l'hebergeur, verifier avec la cle qu'on est seul a detenir, rapporter a sa propre supervision — tourne sur des machines nees il y a une heure.
2026-09-02 (5) — La verification suit la cle : chaque noeud constate SON depot
56 preuves. make valider : 0 echec sur 13 hotes, test de restitution compris.
backup-01 est retire du plan de Chezlepro et sa VM detruite. Elle ne gardait plus rien :
son /srv/restic ne contenait que les fichiers de demarrage du compte restic, aucun
depot, aucun instantane — et sa verification rapportait consciencieusement « tout va
bien ». Une supervision creuse est pire qu'aucune : elle est verte.
Pourquoi la verification a change de main
serveur_backup verifiait pour tout le monde, et c'etait juste : un noeud sait qu'il a
LANCE sa sauvegarde, il ne sait pas qu'elle a ABOUTI — le depot etait le seul a voir ce
qui arrivait vraiment.
Depuis que les ecosystemes deposent chez leur HEBERGEUR, ce n'est plus vrai. Le site heberge des octets chiffres COTE CLIENT, avec un mot de passe qui ne quitte pas la voute du locataire : il ne peut ni les lire, ni les ouvrir, ni dire s'ils valent quelque chose. C'est la propriete qui rend la mutualisation acceptable, et elle deplace la verification chez le seul qui detient la cle — le noeud lui-meme.
Il verifie donc SON depot distant, pas le fait d'avoir lance sa sauvegarde. La nuance est tout : une unite verte sur un depot vide est exactement ce qui a menti pendant un mois (2026-07-03 -> 2026-08-11).
Deux modeles, et le role sait desormais dans lequel il est
serveur_icinga exigeait un hote serveur_backup dans l'ecosysteme et attachait les
services sauvegarde: <noeud> a cet hote. Sans depot local, il refusait de se deployer.
Il se branche maintenant :
- depot local — il rapporte pour tous, les services vivent sur SON hote, et leur nom
dit de quel noeud on parle :
sauvegarde: idm-01; - pas de depot — chaque noeud rapporte le sien, le service vit SUR LUI, et s'appelle
simplement
sauvegarde. Repeter le nom donnerait « idm-01 / sauvegarde: idm-01 ». Et c'est plus juste : la sauvegarde d'idm-01est un attribut d'idm-01.
Ce qui reste exige, c'est le secret d'API — sans lui, personne ne peut rien rapporter.
Le 404 qui n'etait pas une absence
Les noeuds recevaient {"error":404,"status":"No objects found."} — le message d'un objet
ABSENT. icinga2 object list montrait pourtant idm-01!sauvegarde charge et vivant.
Le filtre du compte d'API portait match("sauvegarde: *", service.name) : la premiere
forme de nom seulement. C'etait la PERMISSION qui refusait, et elle le disait avec les
mots d'une absence. Le filtre accepte desormais les deux formes, sans s'elargir au-dela :
ce compte ne peut poser un resultat que sur un service de sauvegarde.
serveur_icinga declare aussi ingress 5665 depuis client_backup — le pair ne
nommait que serveur_backup, qui n'existe plus chez ce locataire.
Ce que la supervision dit maintenant, chez le locataire
collab-01 OK : instantane il y a 20 h, 108 fichier(s)
data-sql-01 OK : instantane il y a 20 h, 1 fichier(s)
edge-mta-01 OK : instantane il y a 20 h, 138 fichier(s)
forge-01 OK : instantane il y a 20 h, 29 fichier(s)
idm-01 OK : instantane il y a 20 h, 1 fichier(s)
infra-mail-01 OK : instantane il y a 20 h, 7 fichier(s)
infra-pki-01 OK : instantane il y a 20 h, 12 fichier(s)
web-dorsal-01 N'EMPORTE RIEN : instantane sans aucun fichier — a confirmer
web-frontal-01 N'EMPORTE RIEN : instantane sans aucun fichier — a confirmer
Les deux avertissements sont honnetes : ces repertoires sont reellement vides, aucune webapp n'est deployee. La machine ne peut pas distinguer « les donnees ont disparu » de « il n'y en a pas encore » — c'est a un humain de trancher, mais il doit le VOIR.
Constat non corrige
Retirer une VM du plan ne la detruit pas : make raser ne connait que les hotes DU plan,
donc plus celle-ci. Il a fallu la detruire a la main sur l'hyperviseur. Un hote retire du
plan devient un orphelin qu'aucune cible ne ramasse.
Et Prometheus a continue de scruter son exportateur jusqu'a ce que serveur_prometheus
soit rejoue — make valider l'a attrape, ce qui est exactement son role.
2026-09-02 (4) — Le site a un temoin ; et une panne dormait depuis des semaines dans un mot
56 preuves. L'hebergeur a desormais sa propre supervision : site-mon-01 (VLAN 36,
zone site-supervision) porte PostgreSQL, Icinga et un relais de courriel. Le depot de
sauvegarde lui rapporte, et son premier verdict a ete un vrai defaut — site-mon-01
n'avait jamais depose son propre etat. Corrige, les trois sont au vert :
sauvegarde: site-forge-01 -> OK : instantane il y a 5 h, 835 fichier(s)
sauvegarde: site-pki-01 -> OK : instantane il y a 5 h, 13 fichier(s)
sauvegarde: site-mon-01 -> OK : instantane il y a 0 h, 1 fichier(s)
serveur_backup_verification_locale repasse a true sur le depot du site. Ce drapeau ne
dit plus « on renonce » mais « verifie ce que tu peux ouvrir » :
serveur_backup_noeuds_attendus derive de l'inventaire OU TOURNE LE ROLE, donc des seules
machines du site, dont il detient le mot de passe restic. Il reste aveugle aux locataires,
et c'est le but.
serveur_postfix gagne un mode relais
Le role est le serveur de courriel d'un ecosysteme : il exige un bind LDAP et un magasin
Dovecot. Un site n'heberge aucune boite — il lui faut EXPEDIER, et rien d'autre. Plutot
qu'un role jumeau (qui aurait duplique le certificat, sa resynchronisation, la resolution
dans le chroot et la validation), un mode : complet par defaut, relais pour le site.
Le site expedie DIRECTEMENT par sa frontiere. Emprunter le MTA d'un locataire ferait dependre l'hebergeur d'un ecosysteme qu'il peut outvivre — une emancipation emporterait son alerte avec elle.
LA PANNE QUI DORMAIT DANS UN MOT : disabled au lieu de enabled
Le modele de routes d'OPNsense lit enabled. appliquer_opnsense lui envoyait
disabled: "0" — un champ qu'il IGNORE. enabled restait donc a son defaut, c'est-a-dire
ETEINT. Chaque route creee par Set-OPS depuis l'origine l'etait desactivee.
Pourquoi personne ne l'avait vu : une route eteinte EXISTE dans le modele. frontiere-plan
la comptait « posee » et annoncait « 15 routes inchange ». Elle n'etait simplement pas
installee dans la table de routage — et tant que le boitier n'avait pas ete recharge depuis
sa creation, le noyau gardait les routes ajoutees a la main lors de la mise en place. Tout
fonctionnait.
Le rechargement complet lance pour activer la patte du VLAN 36 a fait reprendre au noyau sa table depuis le modele : quatorze routes sur quinze ont disparu, et onze machines de Chezlepro sont devenues injoignables — le lendemain de sa reconstruction. Le devis, lui, restait vert.
Trois corrections, parce qu'il y avait trois defauts empiles :
- La cause :
enabled: "1". - La cecite : le plan lit desormais la TABLE DU NOYAU (
/api/diagnostics/interface/getRoutes, en GET, reponse en liste nue — sondee en POST puis en{"rows": ...}, elle rendait « impossible de lire » sur une frontiere qui repondait tres bien) et signale toute route declaree mais absente, ainsi que toute route presente mais eteinte. - L'inaction : la reconfiguration n'etait declenchee que
if routes_creer or routes_retirer. Aucun changement, donc aucune reparation :appliquerrendait « OK » sur un routage casse. Elle se declenche maintenant aussi quand le noyau a perdu quelque chose.
Et « rien a faire » ne s'affiche plus quand le noyau, lui, a du travail : le message disait vrai du modele et faux du service.
Un role du site peut enfin en appeler un autre
serveur_icinga declare ingress 5665 depuis serveur_backup, et serveur_backup
l'egress en face. Les deux etaient justes, et la regle de frontiere n'existait pas : le
devis traitait tout pair nomme comme « l'exterieur » et sautait le flux. C'etait vrai
tant qu'un pair designait quelque chose de lointain — mais un pair peut nommer un role DU
SITE, pose dans une AUTRE ZONE, et depuis le decoupage en zones deux machines du site ne se
parlent qu'a travers la frontiere. Le depot pouvait joindre un Icinga du monde entier sauf
celui de son propre site.
Le plan d'administration se DERIVE, il ne se recopie pas
L'intrant nftables_admin_ssh du site listait a la main les pattes de la frontiere, sous ce
commentaire : « oublier une seule de ces adresses rend une zone entiere inadministrable ».
Une zone a ete ajoutee le meme jour, la liste ne l'a pas suivie, et site-mon-01 est
devenue injoignable des l'application de son pare-feu — rouverte par l'agent invite de
l'hyperviseur. La liste LIT desormais la carte ; l'intrant ne sert plus qu'aux voies
qu'elle ne peut pas connaitre.
Ce qui reste ouvert
Le destinataire des alertes est encore root@localhost — le defaut d'Icinga. La
supervision voit et sait ; elle ne sait pas encore a QUI parler.
2026-09-02 (3) — Le site sauvegarde son propre etat, et la preuve le lui demande
56 preuves. L'hebergeur protegeait l'etat de tous ses locataires et pas le sien. Deux choses qu'il detient et que personne ne peut regenerer :
- la racine de son autorite de certification (
/etc/step-ca) — compromise, elle forge tout nom ; perdue, il faut redistribuer la confiance a chaque machine de chaque ecosysteme ; - la forge du genome (
/var/lib/forgejo) — les quatre depots dont tout descend. Ils vivent aussi sur les postes et les runners, mais la forge est le seul endroit ou ils se rejoignent.
Tout le reste est reconstructible par le code : le cache se re-remplit, la zone DNS se regenere depuis le plan, les depots du runner vivent dans git.
Preuve faite, pas annoncee. Sauvegarde reelle sur les deux hotes, puis restic check
et restitution : 13 fichiers / 152 Ko pour l'AC — secrets/root_ca_key et
secrets/intermediate_ca_key compris — et 835 fichiers / 31 Mo pour la forge.
Son propre compte, sur son propre depot
Le site depose avec SON identite (backup_pubkey au plan, moitie privee dans
underlay.vault.yml), sur le compte restic que serveur_backup lui cree — celui dont le
home est /srv/restic/site, a cote des comptes des locataires et separe d'eux par les
memes permissions. Celle des locataires ne lui sert a rien et ne doit pas lui servir.
La cible est DERIVEE du expose de l'application qui porte serveur_backup_site — le nom
du SERVICE, la meme source qu'un locataire utilise. Une adresse gravee dans
client_backup_repo rendrait le depot indeplacable.
P36 lisait le plan de l'instance montee, donc jamais celui du site
La preuve qui exige qu'un detenteur d'etat porte client_backup ne regardait que
l'ecosysteme. L'hebergeur y echappait entierement — et c'est lui qui detient le plus.
Elle lit desormais les deux plans, avec le meme catalogue et la meme regle : ce qui se lit
statiquement se prouve statiquement, sinon on l'apprend le jour de la restauration (D-75).
9 hote(s) de l'ecosysteme et 2 du site.
Verifiee en la faisant echouer : l'integration retiree de site-pki-01, P36 tire et le
harnais passe NON CONFORME. Une garde qui ne peut pas echouer ne garde rien — cette
semaine en a deja produit deux.
Ce qui reste ouvert, et qu'il faut savoir
Personne ne verifie les sauvegardes du site. serveur_backup rapporte l'etat reel des
instantanes a Icinga ; le site n'a pas de supervision, et son depot tourne avec
serveur_backup_verification_locale: false — pose pour les locataires, dont il ne peut pas
ouvrir les depots. Pour SES propres depots il le pourrait, mais il n'a personne a qui le
dire. La sauvegarde existe et se restaure ; c'est son SILENCE qui n'alerte pas encore.
2026-09-02 (2) — Les machines du site sont durcies : le commentaire disait vrai, le code n'en faisait que la moitie
56 preuves. Les six machines du SITE — racine de l'AC, forge du genome, cache
d'artefacts, resolveur, depot de sauvegarde, runner — ne recevaient QUE serveur_debian.
serveur_durci ne leur etait jamais applique : ni auditd, ni fail2ban, ni apparmor, ni
sysctl, ni pare-feu. Un inventaire de tenant met chaque machine dans les DEUX groupes
(inventory_host.py) ; celui du site n'en mettait aucune dans le second.
Le commentaire au-dessus de GROUPE_SOCLE affirmait pourtant : « elle veut le meme
durcissement SSH, les memes horloges, LE MEME PARE-FEU que n'importe quelle machine de la
flotte ». Personne ne pouvait voir l'ecart, puisque la documentation disait le contraire
de ce que le code faisait. Deuxieme fois en deux jours qu'un commentaire juste couvre une
implementation qui ne l'est pas.
Trouve en cherchant autre chose : un depot de sauvegarde refusait une cle valide, et le fichier de durcissement SSH fautif datait d'une version abandonnee le 2026-08-09. Rien ne l'avait jamais remplace parce que rien n'appliquait le role qui le remplace.
Etat obtenu, mesure sur les machines : auditd, fail2ban, apparmor et nftables actifs sur les six, chacune avec son jeu de regles resolu, aucun service en echec.
Le pare-feu du site n'avait aucune des trois pieces qui le rendent applicable
1. Aucune regle n'etait generee pour le site. resoudre_flux.py nftables lit un
hosts.yml ; le site a un inventaire DYNAMIQUE. Ses machines etaient donc invisibles pour
la generation. Deux absences se cachaient l'une l'autre : pas de regles, et pas de
pare-feu pour s'en apercevoir. D'ou nftables-site, qui traduit l'inventaire dynamique
vers la forme attendue plutot que de dupliquer la generation, et ecrit a cote du plan du
site. Branche sur make flux, pour que ca ne se demode pas.
2. Le chemin du jeu de regles sortait du depot. nftables_baseline derive son chemin
de inventory_dir — juste pour un tenant, faux pour un inventaire dynamique dont le
inventory_dir est scripts/. Le role serait tombe sur son repli : une politique drop
sans les regles resolues.
3. Aucun plan d'administration. nftables_admin_ssh est la seule chose qui empeche un
deploiement de couper la main qui le lance. Pour le site, il fallait comprendre que
l'exploitant administre PAR REBOND : la connexion est ouverte par la frontiere et arrive
avec l'adresse de sa patte DANS LA ZONE DE LA MACHINE VISEE, pas avec celle du poste.
Oublier une seule de ces adresses rend une zone entiere inadministrable. Le generateur
REFUSE desormais de produire des regles si cet intrant manque.
Le defaut que la lecture a attrape avant l'application
Le jeu de regles produit pour site-dns-01 autorisait 10.23.0.0/16 et 10.29.0.0/16 sur
le port 53 — et PAS 10.17.0.0/16. _supernets_voisins() retire l'instance montee : juste
chez un tenant, ou nul n'est son propre voisin ; faux du cote du site, ou ca retire le
locataire qu'on pilote au moment ou l'on genere, c'est-a-dire presque toujours celui a qui
l'on tient le plus.
Chezlepro resout encore sur le resolveur du site. L'appliquer aurait prive de DNS les
quinze machines reconstruites la veille. C'est le meme piege que site_inventaire
documente deja pour les ACL du resolveur : il s'est retendu ici parce que la regle est
portee par la FONCTION, pas par le lieu qui l'appelle.
Une valeur qui dependait de l'ordre des plays
serveur_backup_site desserrait MaxStartups a 60:30:200 — un depot de site voit la
somme des locataires. La valeur vivait dans son meta, donc ne portait que dans SON play.
Le jour ou serveur_durci a apporte ssh_hardening a toutes les machines du site, son
passage — le dernier — a remis le depot au defaut, sans rien signaler. Passee en host_var :
tout play qui applique le role la voit. Un reglage qui depend de l'ordre des plays est un
reglage qui reviendra en arriere.
Verifie apres coup, pas seulement dans le journal
Depuis un locataire, a travers le nouveau pare-feu : le cache repond, la forge repond, le
depot rend 2 snapshots, le resolveur du site resout. L'AC du site, elle, refuse — et
c'est correct : son flux declare pair: flotte, un locataire a la sienne.
2026-09-02 — Reconstruction de Chezlepro depuis zero : quatre defauts que seule une flotte rasee pouvait montrer
56 preuves. make valider : 0 echec sur 13 hotes, test de restitution compris.
Les quinze VM de Chezlepro ont ete detruites, disques compris, puis refaites depuis le
gabarit minimal — make reconstruire : creation des VM, AC et DNS montes completement
d'abord, puis toute la flotte par couches. Resultat : 15/15 hotes, 0 echec, 0
injoignable, et les neuf detenteurs d'etat redeposent sur le depot du site.
Aucun des quatre defauts rencontres ne venait de la flotte ni du gabarit. Tous venaient de gardes ou de derivations que rien n'avait jamais eprouvees, faute d'avoir jamais tout reconstruit.
1. Un avertissement n'est pas un echec
Cinq VM sur quinze declarees perdues. Le journal Proxmox disait pourtant
transferred 16.0 GiB of 16.0 GiB (100.00%), suivi de :
can't deactivate LV 'vm-9006-disk-1': Logical volume in use.
WARN: volume deactivation failed
Proxmox desactive le volume DU GABARIT apres un clonage, et n'y arrive pas tant qu'un
autre clonage parallele s'en sert — la consequence NORMALE de cloner un meme gabarit en
parallele, ce que flotte-creer fait justement pour aller vite. La garde n'acceptait que
exitstatus == 'OK', or Proxmox rend trois formes : OK, WARNINGS: n pour une tache
ABOUTIE qui signale quelque chose, et un texte d'erreur pour un vrai echec.
Son message trompait en plus : « etat stopped » designe l'etat de la TACHE (terminee), pas celui de la VM. On cherche un probleme de demarrage qui n'existe pas. Le message le dit desormais. Et l'avertissement est AFFICHE au lieu d'etre avale.
2. Une garde qui se contredisait elle-meme
_amorcer-socle monte l'autorite en premier, comme il se doit. infra-pki-01 est donc la
premiere machine debout — a un moment ou le cache du locataire, qui vit DANS la flotte
qu'on reconstruit, n'existe pas encore. client_artefacts arretait tout la.
Le plus parlant : le commentaire du role decrivait deja le bon comportement — « en
laissant le plancher en place, elles restent servies par le site : degrade, mais debout,
et reparable par un deploiement ». Le code faisait une assert qui arretait. Or ce que la
garde protege, c'est le plancher d'amorcage : il suffit de NE PAS l'ecraser. Arreter en
plus ne protege rien et rend la reconstruction impossible — aucun ordre de deploiement ne
pouvait la satisfaire, la contradiction etait dans la garde.
Elle degrade desormais, et le journal le DIT. La convergence a fonctionne dans la meme passe : les quinze machines ont fini sur le cache de leur ecosysteme.
3. La racine nie notre TLD, et Unbound etend ce « non » — la vraie cause
internal. n'est pas delegue dans la racine, qui est SIGNEE : elle rend une preuve
NXDOMAIN validee pour ce TLD. harden-below-nxdomain (actif par defaut) tient ce « non »
pour prouve et repond NXDOMAIN pour TOUT nom sous internal. DEPUIS SON CACHE — sans
jamais interroger la stub-zone ni la forward-zone.
Le declencheur : n'importe quelle question sur un nom inexistant sous internal., y
compris la zone d'UN AUTRE ecosysteme, que ce resolveur ne sert pas et va donc chercher a
la racine. Sur un resolveur partage par plusieurs locataires, ca arrive en permanence.
Pourquoi ca a pris deux jours. La panne parait intermittente : au redemarrage le cache est vide, tout fonctionne, on conclut que c'est regle. Une seule requete l'eteint ensuite pour des heures. Pire, le cache contenait EN MEME TEMPS la bonne reponse et un message negatif pour le meme nom — c'est le message qui etait servi. Il a fallu lire le cache.
Le remede a d'abord ete mal identifie : aggressive-nsec: no avait semble marcher, parce
que le REDEMARRAGE qu'il imposait vidait le cache. C'est le vidage qui soignait. Mesure
qui tranche, cache vide puis une requete empoisonnante :
harden-below-nxdomain: no seul -> repond
aggressive-nsec: no seul -> ne repond pas
Ce qu'on perd : une protection anti-usurpation qui suppose que la racine dit vrai sur nos noms. Elle ne le peut pas — nos zones n'y sont pas.
4. Un locataire doit savoir a qui demander la zone de son hebergeur
Un locataire depend de services du SITE par leur NOM : cache, forge, depot de sauvegarde,
autorite. Tant que ses machines pointaient sur le resolveur du site, ca marchait — par
accident. Reconstruites proprement, elles utilisent LEUR resolveur, qui ignorait cette
zone : make valider a echoue sur la RESTITUTION d'une sauvegarde. La sauvegarde etait
intacte ; c'est le chemin pour la NOMMER qui manquait.
D'ou serveur_resolveur_zones_deleguees, derive par instancier. La premiere version
prenait dns_amorcage pour l'adresse du resolveur du site — vrai chez Chezlepro, FAUX
chez Technolibre, dont l'amorcage pointe sur 9.9.9.9. On aurait delegue la zone
souveraine de l'hebergeur a Quad9. P03 l'a attrape avant tout deploiement, en
comparant l'inventaire de CHAQUE instance a son plan. L'adresse vient desormais du plan du
site : la machine qui y porte serveur_resolveur.
Vide par defaut, et c'est le comportement d'un EMANCIPE : sans la carte de l'hebergeur, on ne delegue plus rien, donc on ne nomme plus ses services. C'est ce qu'on veut CONSTATER d'une emancipation, pas une panne a reparer.
Au passage
instancier tentait encore ~/.config/setops-vault-pass, le mot de passe UNIQUE d'avant
la separation des voutes du 2026-08-28 : comparer echouait en exit 4 sur un message qui
ne parlait que d'ansible-inventory. On ne pouvait donc plus voir ce qu'on changeait
avant de l'appliquer. Il derive desormais ses identites de voutes.py.
2026-09-01 — Le depot de sauvegarde du SITE : l'etat d'un locataire quitte enfin sa propre flotte
56 preuves. Chezlepro rangeait ses instantanes sur backup-01, une VM DE SA PROPRE
FLOTTE. Une sauvegarde rangee dans ce qu'elle protege ne protege rien : raser
l'ecosysteme pour le reconstruire, c'etait raser le filet avec. La reconstruction ne
pouvait donc pas etre tentee — le blocage n'etait pas technique, il etait structurel.
Le site a desormais son propre depot (site-backup-01, VLAN 35), et les neuf detenteurs
d'etat de Chezlepro y deposent. Une restitution est sortie : l'annuaire LDAP est
revenu lisible, dc=chezlepro,dc=internal, depuis un depot hors de la flotte.
L'isolation entre locataires est celle du noyau, pas une convention
serveur_backup a un compte et une cle : juste pour un depot d'ecosysteme, faux pour un
depot de site. Une cle partagee aurait donne a chaque locataire la lecture — et
l'effacement — des instantanes des autres.
Un compte Unix par locataire, home en 0700, sa cle et rien qu'elle (exclusive: true :
le site est autorite sur qui entre chez lui). La racine partagee est a root en 0711 :
traversable, non listable — un locataire ne peut pas apprendre QUI sont ses voisins. Les
deux refus ont ete constates, pas supposes.
Ce que le site voit : des octets. restic chiffre CHEZ LE CLIENT, avec un mot de passe qui
reste dans la voute du locataire. Le site ne peut ni lire, ni ouvrir, ni reconstituer —
c'est ce qui rend la mutualisation acceptable, la meme frontiere que pour les voutes.
D'ou serveur_backup_verification_locale: false : la verification n'est pas supprimee,
elle est DEPLACEE chez celui qui detient la cle et sait ce qu'il a envoye.
Quatre defauts que seul un deuxieme usage pouvait reveler
1. La racine des depots ne peut etre le home de personne. /srv/restic en 0700 au
compte restic : sshd a refuse restic-chez par StrictModes, qui exige que chaque
repertoire menant au home appartienne a root ou a l'utilisateur. Le message rendu etait
Permission denied (publickey) — celui d'une mauvaise cle. La cle etait la bonne ; c'est
le CHEMIN qui etait refuse, et rien dans ce message ne pouvait le dire.
2. La racine nie notre TLD, et Unbound etend ce non a tout ce qui est dessous.
internal. n'existe pas dans la racine, qui rend un NXDOMAIN signe (drapeau ad).
harden-below-nxdomain tient ce non pour prouve et fabrique un NXDOMAIN pour tout nom
sous internal., sans jamais interroger la stub-zone. La delegation etait correcte,
l'autoritatif repondait juste, et pas une requete ne lui parvenait.
Ce que ca coutait : aucun locataire ne pouvait nommer un service du site. Chacun
gravait donc des ADRESSES dans sa configuration — le cache, le resolveur, la forge — et le
site devenait indeplacable un intrant a la fois. Le defaut ne s'est pas vu parce que les
machines du site ont le plancher /etc/hosts : le nom marchait partout ou on le testait,
et nulle part ou on en avait besoin. Remede : local-zone transparent sur notre zone —
le meme que pour les zones inverses, la meme cause.
3. Le gabarit transporte des fichiers de durcissement perimes. Dont
20-chezlepro-hardening.conf, celui-la meme que ssh_hardening_fichiers_perimes existe
pour retirer : fige dans l'image, donc herite par chaque VM clonee. Et les machines du
SITE ne recoivent jamais ssh_hardening — leur socle est serveur_debian seul, la ou
un locataire recoit aussi serveur_durci. Rien ne venait donc jamais le corriger.
4. MaxStartups compte les connexions NON AUTHENTIFIEES. Un depot d'ecosysteme en
voit une poignee ; celui d'un SITE en voit la somme de tous ses locataires, dont les
minuteurs se reveillent a la meme heure. Au-dela du seuil, sshd coupe AVANT la banniere :
le client rapporte kex_exchange_identification: Connection reset by peer, qui accuse le
reseau pour un refus applicatif. Douze sauvegardes simultanees, une perdue. Portee a
60:30:200 sur le depot ; les douze repassent ensemble.
Une patte de plus a la frontiere, et ce qu'elle a appris
Ajouter un segment au site n'avait aucune procedure ecrite. Il a fallu, dans l'ordre : le
declarer dans underlay.yml, creer le VLAN et l'interface sur OPNsense, ajouter le VLAN
au trunk du commutateur, une route sur le poste — et inscrire la nouvelle patte dans
opnsense_if_zones. Sans cette derniere ligne, le devis posait les regles du site sur
une patte ou son trafic ne passe pas : 16 regles creees, aucune ne correspondant jamais,
et rien ne le signalant.
Constat non corrige, a decider
Les machines du site ne sont pas durcies. Pas d'auditd, pas de fail2ban, pas de
nftables_baseline, pas de sysctl_hardening, pas d'unattended_upgrades — alors
qu'elles portent la racine de l'AC, la forge du genome, et desormais les sauvegardes de
tous les locataires. ssh_hardening a ete pose sur le depot seul, la ou il bloquait.
Etendre serveur_durci a tout le site est un changement a mener pour lui-meme.
2026-09-01 — q35 n'est pas un reglage : c'est la raison de la procedure manuelle
56 preuves. L'exploitant a explique pourquoi Set-OPS n'utilise pas l'image cloud officielle de Debian — et c'est un constat paye en anomalies, qui n'etait ecrit nulle part.
genericcloud est livree configuree pour i440fx, le defaut de Proxmox. La convertir
en q35 apres coup ne change pas un parametre : ça remplace le materiel virtuel sous un
systeme qui croit connaitre le sien.
i440fx = PCI q35 = PCIe
la topologie des bus change, donc :
les noms d'interfaces predictibles suivent le chemin PCI et changent
les chemins de disques bougent
l'ordre d'enumeration des peripheriques n'est plus le meme
La conversion n'est pas une correction — c'est une transplantation. D'ou la regle : une
machine nait q35, ou elle ne le sera jamais proprement, et c'est l'installation depuis
l'ISO qui le garantit.
Ces deux lignes n'existaient que comme une ligne de tableau dans la procedure. Elles portent desormais leur pourquoi, et le SITE les declare comme donnees — plus seulement comme prose.
make gabarit-etat
Il compare le gabarit reel a ce que le site declare de lui. Controle negatif verifie :
declarer i440fx le fait echouer.
On verifie la SOURCE, pas chaque copie. Ma premiere version gardait le clonage — mais la propriete s'herite : verifier chaque clone coute a chaque creation sans rien dire de plus que verifier le gabarit une fois.
Et trois fois j'ai devine la forme de la reponse au lieu de la regarder — regex_search a
groupe qui rend None, proxmox_vm_info sans config: current qui ne rend que l'etat. La
garde a declare « ? » sur une VM parfaitement conforme. Une garde qui crie toujours est
pire qu'aucune : on apprend a l'ignorer, et le jour ou elle a raison plus personne ne la
lit.
2026-09-01 — Le gabarit refabrique : minimal, et fabrique chez le SITE
56 preuves. Le gabarit dore est refait — VMID 9006, modeleSetOPS-minimal, quatre
roles au lieu de dix-sept.
Il se fabriquait dehors
« Les ressources du SITE font autorite pour tous ses artefacts ; elles servent les tenants jusqu'a ce qu'ils s'emancipent » (decision de l'exploitant). Le gabarit en est un — c'est de lui que descend chaque VM de chaque tenant.
Or -i "<ip>," ne porte aucun group_vars : la fabrication n'avait ni mandataire ni
resolveur. L'ancien gabarit allait chercher ses paquets chez Debian et resolvait chez
l'ancien LAN (192.168.10.10, lu sur la VM 99998). Le site avait son cache et son
resolveur, et son propre artefact les ignorait.
La fabrication derive desormais ses ressources du plan du site et tourne sur le reseau
du genome. Prouve : dix requetes de 10.0.33.31 servies par site-cache-01.
L'identite du gabarit vient du site, plus du tenant
proxmox_clone_vmid_modele vivait dans les group_vars de l'ecosysteme. Deux tenants
pouvaient donc cloner deux gabarits differents sans que rien ne le dise, et un tenant
decidait d'un objet qui n'est pas a lui.
Meme mouvement que l'INDEX, tranche le 2026-08-25 : le site ALLOUE, le tenant RECOIT.
Deux pieges fermes en chemin
Une valeur vide qui ecrasait la derivation. creer-vm passait
VMID_MODELE="$SETOPS_VMID_MODELE" a cloner-vm — or inventory_host.py n'emet pas
cette variable. Elle valait le VIDE, et ce vide reprenait la main sur le site sans bruit.
Le chemin du controleur contient une espace, et lookup('pipe', …) le decoupait. Ce
depot l'a paye une troisieme fois — ansible-galaxy le 08-23, git bundle le 08-26, ici
le 09-01.
Eprouve de bout en bout
VM clonee du gabarit minimal nom, adresse, machine-id neuf,
cle d'hote regeneree, agent invite actif
socle applique dessus changed=5, 0 echec, auditd compris
VM d'essai retiree. L'ancien gabarit (99998) est garde et declare comme precedent : le
retirer est un geste, pas un effet, et il attend une reconstruction complete sur le neuf.
2026-09-01 — Le gabarit etait un cache du socle, et il perimait sans le dire
56 preuves. Question de l'exploitant : le gabarit dore et cloud-init gagnent-ils leur place, vu tout ce que Set-OPS fixe par ailleurs ? La mesure a repondu avant le raisonnement.
Le gabarit portait dix-sept roles — exactement ceux du socle et du durcissement, que le deploiement rejoue ensuite a l'identique. C'etait un cache, et comme tout cache il perimait :
derniere recapture 2026-08-09
roles changes depuis common_packages · cloud_init · ssh_baseline
ssh_hardening · auditd · nftables_baseline
Rien ne le signalait. Aucune preuve du harnais ne regardait sa fraicheur, et le deploiement masquait la derive en reappliquant tout — donc personne ne pouvait la voir. C'est le defaut que D-81 a corrige pour la forge du genome, jamais traite ici.
Il ne garde que ce qui doit exister avant qu'Ansible puisse agir
qemu_guest_agent l'agent repond AVANT SSH — c'est par lui que P52 confirme
cloud_init le seul chemin vers la premiere seconde : adresse, nom, cles
sudo_ansible le compte technique et son sudo — la porte par ou tout entre
ssh_baseline le serveur SSH, meme raison un cran plus bas
Ce ne sont pas des choix d'efficacite, ce sont des conditions d'existence.
cloud-init, lui, ne peut pas etre retire : sans lui une VM neuve n'a ni adresse ni cle,
donc personne ne peut l'atteindre pour lui en donner une. Il reste le seul chemin vers la
premiere seconde — au prix connu de reecrire /etc/hosts a chaque demarrage, ce que le
depot a deja du contourner.
Ce que la reduction coute, et qui le couvre
Une VM neuve n'est plus durcie a la naissance. Elle nait cependant derriere le pare-feu
de l'hyperviseur — policy_in=REJECT, arme au clonage (verifie sur obs-01) : seuls
les flux declares l'atteignent, avant meme son premier paquet. La fenetre d'exposition est
fermee par la fabric, pas par le gabarit — mon objection initiale tombait devant la mesure.
P56 garde les deux moities
Qu'il ne regrossisse pas : un role ajoute recree le cache, donc la peremption invisible. Et que rien de retire ne soit perdu : un role absent du gabarit et du socle disparaitrait de toutes les machines neuves — sans erreur, sans trace, et la panne arriverait des mois plus tard sur une machine qu'on croyait durcie.
Verifie : 14 retires, 14 repris, zero orphelin. Deux controles negatifs.
2026-09-01 — make emancipation-prouver : couper, pas sonder
55 preuves. docs/filiation-emancipation.md decrivait quatre temps et n'en outillait
que trois. Le quatrieme est celui qu'on oublie — « une emancipation non prouvee est une
emancipation non faite ».
Sonder ne prouve rien. Verifier que le service local repond ne dit pas si l'amont sert
encore ; le depot le disait deja du cache : « tant qu'internet repond, un apt update qui
reussit ne dit pas d'ou vient l'octet ». L'instrument coupe donc l'amont, et refait
marcher la chose.
Le meme essai rend les deux verdicts
coupe, la fonction marche -> ÉMANCIPÉ, et c'est prouvé
coupe, la fonction casse -> PAS ÉMANCIPÉ, dépendance prouvée RÉELLE
Le second n'est pas un echec de l'outil : c'est son controle negatif, rendu par la meme commande. Une preuve d'emancipation incapable de montrer la dependance qu'elle mesure ne prouverait rien le jour ou elle passerait au vert.
Un temoin precede la coupure — la fonction marchait-elle seulement avant ? Sans lui, une panne preexistante se lirait comme une dependance.
La coupure est garantie reversible
Une table nftables dediee, jamais une regle glissee dans une table existante : elle se
retire d'un geste et ne peut pas laisser d'etat partiel. Le bloc always la retire meme si
la mesure echoue ou si le play est interrompu, et une tache verifie ensuite qu'elle a bien
disparu.
Mesure le jour de sa naissance
obs-01 / resolveur PAS ÉMANCIPÉ — plus aucune resolution des la coupure
forge-01 / artefacts ÉMANCIPÉ — apt installe, cache du site coupe
Ce second verdict a ete doute avant d'etre cru : apt aurait pu reussir en rejouant des
listes deja fraiches. Refait avec un dossier de listes neuf, amont coupe — il reussit
quand meme. Le cache sert vraiment son contenu.
2026-08-31 — D-82 : patient 0 n'est le parent de personne
55 preuves. Le dilemme ouvert le 2026-08-28 est referme, et ce sont les faits qui l'ont tranche plus que le raisonnement. Trois lui ont retire ce role un par un — il fallait les regarder ensemble pour le voir :
- D-81 a donne l'autorite du genome a la forge du SITE. Son dernier lecteur, Chezlepro, a ete corrige le 08-26 ; Technolibre le 08-31. Il ne sert donc le genome a personne.
- Le denominateur commun qu'il portait vit dans les modeles depuis le 08-24, sous le
nom
origine. Ce n'est plus lui qu'on copie. - L'ancetre etait locataire de son enfant : index 29 sur la fabric de
SITE-Chezlepro, qui descend de lui. Il ne peut pas etre le chemin de reprise de son propre hote.
Des trois issues posees mercredi, la deuxieme l'emporte — non parce qu'elle etait la plus
elegante, mais parce que les deux autres avaient cesse d'etre disponibles. Et le depot
l'avait deja suivie sans le declarer : serveur_forge_site, puis serveur_cache_site,
puis serveur_resolveur_site. Trois services pretes, un seul patron.
Ce que ca ne regle pas, et qui est ecrit
Patient 0 existait pour eliminer un point unique de defaillance — eregion, hors
flotte, que Set-OPS ne deploie ni ne prouve. Il ne l'a pas elimine : il a ete promu. Le
poste y pousse, la forge du site en tire.
La dette a change de proprietaire, pas de nature ; elle appartient au SITE. Un objectif qu'on abandonne sans le dire devient un objectif qu'on croit atteint.
Ce qu'il garde : sa place de pair dans la famille du genome, et sa forge de travail — un ecosysteme garde la sienne meme quand il ne detient plus le genome de personne. Ce qu'il perd : le rang.
Une panne dormante, trouvee en reglant le cas
Technolibre chainait encore son cache sur patient 0. Le meme defaut a coute quinze machines
sans apt chez Chezlepro le jour ou ses VM ont ete eteintes ; chez Technolibre, qui n'a pas
encore de VM, il etait dormant et se serait reveille au premier montage.
2026-08-31 — La recette dependait de l'heure : un depot occupe n'est pas une sauvegarde cassee
55 preuves. make valider : rc=0. La reconstruction de Chezlepro est validee de bout
en bout — 16 cibles Prometheus UP, 6 vhosts HTTPS, un courriel reellement remis (envoi
→ LMTP → Maildir), et six depots restic restaures et verifies, dont l'annuaire dont on
prouve qu'il se rejoue (slapadd -u).
Un seul verdict rouge au premier passage : data-sql-01, la base. Et la meme commande,
rejouee a la main, restaurait ses cinq fichiers.
restic verrouille son depot pendant qu'il ecrit. Une recette qui croise la fenetre de
sauvegarde lit donc un echec de RESTAURATION la ou il n'y a qu'une attente — verdict juste
sur l'instant, faux sur le fond, et dependant de l'heure a laquelle on l'a lancee.
On reessaie, mais on ne masque pas. Trois tentatives espacees couvrent un verrou
d'ecriture ; au-dela l'echec est reel, et la recette rend desormais la raison que restic
a donnee au lieu d'un -1 muet. Controle sur la machine — cle de dechiffrement retiree :
ECHEC — la RESTAURATION a échoué après 3 tentatives (snapshots=0)
— restic dit : Fatal: Resolving password failed
Deux fautes a moi, en chemin
[ "$f" -lt 0 ] && printf ... : la derniere commande d'un script decide de son code de
sortie, et un test faux rend 1. La tache echouait donc exactement sur les machines ou la
restauration avait REUSSI — trois hotes verts declares en echec.
Puis la ligne raison= n'etait emise qu'en cas d'echec : le gabarit cherchait un champ
absent, rendait None, et explosait sur les dix machines saines. Elle est desormais
toujours emise, vide en cas de succes. Un champ toujours present coute un octet et
supprime un cas.
2026-08-31 — Deux filets : un cache mort ne rend plus un tenant irreparable
55 preuves. Le cache d'un tenant est un maillon dont depend sa propre
reconstruction. Toute la flotte y prend ses paquets ; s'il pointe vers un amont mort,
apt echoue sur les quinze machines, donc le socle echoue, donc le deploiement n'atteint
jamais la couche qui poserait le bon amont.
Le correctif se retrouve dans la couche que la panne empeche d'atteindre — et il faut une main pour en sortir. C'est exactement ce qui s'est passe : l'amont pointait sur le cache de patient 0, eteint la veille pour liberer de la RAM.
apt rendait 503 Connection timeout en citant l'adresse du cache LOCAL, jamais
celle de l'amont manquant. La panne accusait le maillon visible.
Deux filets, symetriques
serveur_artefacts sonde l'amont avant d'ecrire, et refuse s'il est muet : mieux vaut
un cache qui garde sa configuration precedente qu'un cache qu'on vient de rendre
inutilisable pour toute la flotte.
client_artefacts sonde la source avant de detourner apt vers elle. Ce fichier
ecrase le plancher d'amorcage pose par le socle — le poser sur un cache mort prive la
machine du cache du SITE, qui lui fonctionnait. En refusant, le plancher reste : la flotte
est degradee mais debout, et reparable par un deploiement.
ignore_errors et non failed_when: false — la difference est toute la garde
failed_when: false reecrit le verdict : la tache n'est plus jamais failed, donc
is not failed est toujours vrai, donc l'assertion qui suit ne peut pas tirer. Ecrite
ainsi, ma premiere version a declare « joignable » un amont mesure muet la seconde
d'avant.
Une garde qui ne peut pas echouer ne garde rien. Deux controles verifies sur la machine : amont eteint → refuse ; amont reel → pose.
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
2026-08-31 — Trois unites en echec sur chaque machine, et personne ne les voyait
55 preuves. Le deploiement rendait 15/15, failed=0. Les machines portaient chacune
trois unites systemd en echec. Le rapport d'Ansible n'est pas l'etat d'une machine.
auditd : onze regles armees, personne pour collecter
Deux fichiers de regles identiques cohabitaient — 99-chezlepro.rules et
99-setops.rules, vestige du renommage du role. augenrules concatene tout
rules.d/, et le noyau refuse la seconde occurrence :
Error sending add rule data request (Rule exists)
There was an error in line 16 of /etc/audit/audit.rules
audit-rules echoue, et auditd ne demarre pas — c'est sa dependance. auditctl -l
affichait pourtant onze regles, ce qui donne toutes les apparences d'un audit qui
fonctionne. L'audit etait arme dans le noyau et personne ne l'enregistrait.
Meme mue que ssh_baseline, meme registre — auditd_fichiers_perimes. Et
failed_when: false cachait le reste : un service de securite qui ne demarre pas doit
se voir.
Les timers apt-daily : un etat d'echec residuel
Masquer le service pendant que son timer tourne lui fait perdre sa cible ; systemd le note
et le garde. state: stopped n'efface pas un etat failed — seul reset-failed le
fait.
Ce n'est pas cosmetique. Une supervision qui compte les unites en echec compte ces deux-la pour toujours, et la vraie panne s'y noiera. Meme defaut que le journal de la frontiere noye sous 982 000 entrees : ce qui ment le plus n'est pas ce qui se tait, c'est ce qui crie sans raison.
Un diagnostic faux, annule avant d'etre livre
J'avais lu masked enabled dans list-unit-files comme un etat contradictoire, conclu que
masquer empechait de desactiver, et bati une reparation pour le defaire. Ces colonnes
sont ETAT puis PRESET : masque, avec un prereglage constructeur active, est parfaitement
normal. Mesure sans ambiguite ensuite : is-enabled=masked, is-active=failed. Le
masquage etait juste ; seule la trace restait.
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
2026-08-30 — Vingt-et-un plays : le resolveur ouvre la chaine, deux defauts tombent
55 preuves. Le deploiement depuis ops-01 passe de 3 plays a 21. Douze machines
sur quinze sont deployees completement (ok=91 a 137, failed=0). Le resolveur du site
a debloque tout ce qui suivait.
Trois echecs restants, deux causes — et les deux etaient de vrais defauts du moteur.
Un port symbolique ecrit tel quel dans le pare-feu
/etc/nftables.conf:36: Error: Could not resolve service: Servname not supported
ip saddr { ... } udp dport derive accept # serveur_powerdns
port: derive dit « ce port depend du deploiement » — Forgejo ecoute 3000 derriere un
edge et 443 quand il sert son propre TLS ; PowerDNS 53 seul sur son hote et 5300 en
loopback derriere le resolveur. Seul le plan sait lequel. Le devis de la frontiere le
resout depuis le 2026-08-25 ; le generateur nftables, lui, ecrivait le mot.
Il resout desormais par le plan, et n'emet rien quand il ne peut pas — en le DISANT :
un flux tu en silence est une porte qu'on croit ouverte. Verifie que ca ne ferme rien :
infra-dns-01 garde son 53, ouvert par serveur_resolveur ; l'omission de PowerDNS est
juste, puisqu'il ecoute en loopback derriere lui.
Et la validation ne pose pas la meme question que l'emission. Ma premiere garde
refusait tout port non numerique — elle a fait echouer P09, qui valide les roles hors
instance, la ou derive est parfaitement legitime. PORTS_SYMBOLIQUES nomme le
vocabulaire : le mot passe a la validation, jamais dans un fichier, et un mot inconnu reste
refuse des deux cotes. Confondre les deux questions coute des deux cotes : un fichier
casse d'un cote, un role correct declare fautif de l'autre.
Recharger n'applique pas un changement d'ecoute — deuxieme fois
warning: ignoring inet_protocols parameter value change
warning: to change inet_protocols, stop and start Postfix
fatal: :::submission: Address family for hostname not supported
main.cf porte inet_protocols, que Postfix refuse de changer a chaud. Le master garde
all, tente d'ouvrir :::submission en IPv6, et meurt — apres avoir accepte une
configuration valide. postfix check ne dit rien, parce que la configuration EST valide :
c'est la TRANSITION qui ne l'est pas.
Meme famille que nginx : restart si changement d'ecoute. Ce qui vit dans le processus
maitre — protocoles, adresses, ports — exige qu'il reparte. main.cf notifie desormais le
redemarrage.
Diagnostic corrige en chemin : j'ai d'abord accuse postfix@-.service, dont l'assertion
echouait sur /etc/postfix-/main.cf. C'etait MOI qui l'avais declenche en tentant un
demarrage manuel — postfix.service le declare en Conflicts. Le vrai journal etait
ailleurs.
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
2026-08-30 — La cle de signature est versionnee : une lignee qui se reproduit sans Internet
55 preuves. Le deploiement de Chezlepro depuis son propre runner a franchi le socle et le durcissement sur les quinze machines, et s'est arrete sur une seule :
infra-pki-01 : Request failed: <urlopen error [Errno -3]
Echec temporaire dans la resolution du nom>
url: https://packages.smallstep.com/keys/apt/repo-signing-key.gpg
Un tenant n'a pas de DNS sortant, et c'est voulu. Il resout chez lui, sa requete ne
traverse jamais la frontiere. apt s'en sort par le mandataire du cache — qui resout a sa
place ; get_url n'a pas de mandataire. Mesure sur la machine :
resolv.conf 9.9.9.9, 149.112.112.112
DNS BLOQUE
443 sortant OK (par adresse)
apt via le cache OK
Les cinq reprises du role ne pouvaient rien : elles etaient ecrites pour un serveur intermittent (mesure du 2026-08-23), pas pour une resolution fermee. Une reprise ne repare que ce qui est passager.
Une dette nommee, appelee
C'etait ecrit dans client_artefacts : « les depots tiers en HTTPS continuent d'aller en
direct […] la seconde voie est propre et reste a faire — et le dire vaut mieux que le
laisser croire ». Elle a ete appelee le jour ou un ecosysteme s'est reconstruit derriere
une frontiere qui fait son travail.
La cle est desormais versionnee, avec son empreinte et sa procedure de rafraichissement. Meme idiome que les collections Ansible et les roues Python : le depot porte, la cible n'ouvre rien. C'est ce qui rend une lignee reproductible sans Internet.
Ce que ca coute, et qui est ecrit : une rotation amont n'est plus recuperee toute seule.
Elle ne passe pas inapercue pour autant — apt refuse alors le depot, bruyamment. Mieux
vaut un refus lisible qu'un telechargement qui reussit depuis n'importe ou.
Trouve en la recuperant : l'URL publique redirige vers pkgs.infra.smallstep.com.
Sans suivre les redirections, on obtient un fichier VIDE que rien ne signale — un curl -f
sans -L rend zero octet et sort en succes. La procedure de mise a jour le dit, parce que
ce piege-la ne se voit qu'au deploiement suivant.
Le correctif du pare-feu a tenu
Plus une seule suspension : les quinze machines ont franchi le durcissement (ok=66
chacune, 0 unreachable) et repris la main apres s'etre armees. C'est la vraie nouvelle
de ce passage — le defaut d'hier soir ne se reproduit plus.
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
2026-08-30 — Le terrain etait inoccupable : la cle du tenant nait avec ses machines
55 preuves. Le deploiement lance depuis ops-01 s'est arrete au premier geste, sur les
quinze machines a la fois :
ops-01 -> toutes : Permission denied (publickey)
Les VM neuves n'acceptaient qu'une cle : celle de l'exploitant, posee par cloud-init. La
cle du runner est declaree au plan (ssh_baseline_cles_admin) — mais c'est le SOCLE
qui la depose, et le socle doit etre applique par quelqu'un qui peut deja entrer. Boucle
fermee : le tenant recevait un terrain qu'il ne pouvait pas occuper.
Deux cles, deux portees, et la difference est toute l'architecture
la cle du SITE -> sur le SEUL runner du tenant l'insemination
la cle du TENANT -> sur TOUTES ses machines il va les configurer
Poser une cle au clonage n'est pas entrer chez le tenant. C'est un parametre de creation, au meme titre que l'adresse ou le disque : le site ecrit les conditions de NAISSANCE, il n'ouvre aucune session. La distinction n'est pas rhetorique — le site n'obtient aucun acces sur ces machines, seul le runner du tenant en obtient un.
C'est la forme que l'exploitant a tranchee : le site renseigne le seul runner, qui se
charge ensuite de toute sa flotte. Le plancher /etc/hosts suit le meme chemin — ops-01
a le sien depuis son insemination, il resout ses quinze voisines par leur nom (verifie :
obs-01.chezlepro.internal:22 ouvert), et c'est lui qui posera le leur en appliquant le
socle. Le SITE n'a jamais a toucher une machine de tenant.
La revocation est honoree a la naissance
Une entree passee a etat: absent n'est pas reposee sur les VM neuves. Sans cette lecture,
une cle retiree de toute la flotte serait ressuscitee sur chaque machine creee ensuite —
une panne lente, silencieuse, et invisible au plan.
P55 — le pouvoir se lit dans les cles, pas dans les intentions
Une VM recoit ses cles a la naissance, et personne ne relit un authorized_keys pose il y
a six mois. Si celle du site partait sur toute une flotte, l'hebergeur y gagnerait un acces
que rien ne declare. P55 verifie les deux moities : la cle du site ne nait que sur un
porteur de serveur_ops_tenant, et aucune machine ne reste sans celle de son tenant.
C'est le pendant exact du flux d'insemination — une frontiere tenue a une seule couche
n'est pas tenue. Deux controles negatifs verifies.
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
2026-08-30 — Le demarrage declarait en panne ce qui fonctionnait
54 preuves. Quinze VM de Chezlepro creees trois a la fois. L'une d'elles a depasse le delai du module pendant que Proxmox generait encore son ISO cloud-init :
fatal: Reached timeout while waiting for starting VM.
Last line in task before timeout: 'generating cloud-init ISO'
Elle a demarre juste apres. Le module avait renonce, pas l'hyperviseur — et
flotte-creer a rendu « au moins une VM n'a pas ete creee » alors que les quinze
tournaient.
Ce faux echec arrete une reconstruction. make reconstruire enchaine flotte-creer
puis deployer-tout : la sequence se serait arretee la, sur une flotte complete et saine.
Un delai reste un pari — on n'en fait plus un verdict
Deux corrections, et la seconde est la vraie. Le delai devient genereux, parce que la generation d'ISO sur un stockage partage se met en file quand trois clonages tombent ensemble. Mais son expiration ne conclut plus rien : c'est l'hyperviseur qui juge, en repondant ce qu'il fait tourner.
C'est exactement le principe de P52, applique un cran plus tot — la, l'agent invite jugeait la materialisation a la place d'une reponse SSH ; ici, l'API juge le demarrage a la place d'un chronometre.
Logique verifiee sur quatre cas : seul running passe ; VM arretee, VM introuvable et
reponse malformee echouent toutes. Une reponse vide ne passe pas en silence. Rejoue sur
une VM deja en marche : idempotent.
Ce que la creation de cette nuit n'a PAS prouve
Les quinze VM ont ete materialisees depuis le poste, pas depuis le runner du SITE. Le
chemin existait — make inseminer etait ecrit la veille — et il n'a pas servi. Rien de
cette nuit ne demontre donc que le runner du site sait materialiser une flotte : il a cree
une VM l'avant-veille, pas celles-ci.
Ce n'est pas une faute de pouvoir : le poste detient legitimement la voute du site. C'est une demonstration qui manque, et il faut le dire plutot que de laisser croire le contraire.
make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.
2026-08-28 — Le puits avalait le plan d'administration
54 preuves. Le poste de l'exploitant ne joignait aucune machine de tenant. La chaîne, mesurée saut par saut :
1. le poste 10.17.0.17 -> 10.17.19.41:22 route et source correctes
2. la frontiere rule 31/0(match): pass out vlan040 ELLE LAISSE PASSER
3. asgard vlan40 In ... 10.17.19.41.22 IL RECOIT
4. la VM (rien) IL N'ARRIVE JAMAIS
Et le noyau dit pourquoi — la source est le discriminant, pas l'interface :
ip route get 10.17.19.41 from 10.0.31.11 iif vlan40 -> dev vrf_t17
ip route get 10.17.19.41 from 10.17.0.17 iif vlan40 -> Invalid cross-device link
Parce que le retour est impossible : depuis le VRF du tenant, 10.17.0.17 tombe dans
blackhole 10.17.0.0/16.
Le puits a ses raisons — c'est sa largeur qui était fausse
Sans lui, une adresse non attribuée du supernet sort par le défaut, revient par la frontière dans la table principale et repart vers le réseau de gestion (mesuré le 2026-08-09). On n'y touche pas.
Mais il couvre la bande basse, là où D-77 place justement l'underlay d'un site. Deux règles justes séparément, contradictoires ensemble.
La correction est dérivée, pas écrite
Pour chaque tenant, les réseaux d'administration qu'il déclare (nftables_admin_ssh)
et qui tombent dans son propre supernet reçoivent une route vers la frontière, plus
spécifique que le puits. Ceux qui vivent dehors n'en ont pas besoin.
vrf_t17 ip route 10.17.0.0/24 ... l'admin de Chezlepro
vrf_t23 (rien) le sien est hors de son supernet
vrf_t29 ip route 10.29.19.41/32 ... l'admin de patient 0 : son propre ops-01
Ce que ça répare au-delà de l'accès
Les règles administration → tenant de la frontière étaient vraies et inapplicables à
la fois. Elles correspondaient, elles laissaient passer, et le paquet mourait un saut
plus loin. Un devis vert sur un chemin qui ne pouvait pas aboutir — exactement le
« périmètre vide » que ce dépôt traque partout ailleurs.
Trois instruments m'ont menti avant d'y arriver : une capture qui ne tournait pas, un
ping vers un ICMP que rien n'autorise, et un test TCP depuis la frontière dont la source
n'est pas dans l'IPSet de la VM. La mesure qui a tranché est ip route get ... iif,
comparée entre une source qui marche et une qui échoue.
make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.
2026-08-28 — make inseminer : le geste sort de mes mains et entre dans le depot
54 preuves. L'insemination a d'abord ete conduite A LA MAIN depuis le runner du site — donc hors du depot, donc sans preuve, ce que ce projet refuse. Elle a maintenant sa cible :
make inseminer TENANT=OPS-Chezlepro # l'hote se DERIVE
TENANT= plutot que le symlink instance. Le runner du SITE materialise et amorce
PLUSIEURS locataires ; pointer un lien global sur l'un d'eux le ferait se prendre pour ce
tenant. Il en nomme un par commande. C'est aussi ce qui borne le couplage que
creer-vm imposait en silence : rien ne disait sur quels tenants ce lien avait le droit de
pointer, ni jusqu'a quand.
L'hote se derive, il ne se tape pas : c'est celui qui porte serveur_ops_tenant. Meme
critere que le flux d'insemination et que la cle SSH du runner — le meme mot borne les
trois pouvoirs. Un ecosysteme qui n'en declare aucun est refuse, avec la raison :
« l'insemination vise LE RUNNER d'un tenant, jamais une machine ordinaire ».
P54 — la ligne de partage, gardee la ou elle glisserait sans bruit
COUCHES_INSEMINATION vaut serveur_debian serveur_ops, et ce n'est pas une commodite :
ce sont les deux seules couches qui ne reclament aucun secret. Le site ne detient pas
la voute d'un tenant, et ne doit jamais la detenir.
Le jour ou l'on ajouterait une couche « pour aller un peu plus loin », le deploiement
echouerait chez le tenant sur une valeur vide — un message qui ne dit pas qu'un pouvoir a
ete franchi. P54 lit les roles reellement appliques et refuse toute citation de vault_*.
Controle negatif eloquent : ajouter client_pki, la couche suivante, la fait echouer,
parce qu'elle reclame le secret de l'autorite du tenant.
Un garde-fou existant a intercepte une insemination mal dirigee
Premier essai sur un second tenant :
REFUS : SETOPS_INVENTAIRE designe un inventaire HORS de l'instance demandee
Le make parent exporte SETOPS_INVENTAIRE, derive de l'instance montee ; ma
resolution en heritait et visait l'inventaire d'un AUTRE ecosysteme. Le refus vient
d'inventory_rules, pas de la cible — et c'est exactement l'erreur qu'un runner de site,
qui sert plusieurs locataires, commettrait en silence. env -u SETOPS_INVENTAIRE ferme la
fuite.
La cible dit aussi ce qu'elle ne fait pas : la machine amorcee n'est PAS armee, ni voute ni cle, et l'armer est le geste d'un humain.
make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.
2026-08-28 — make sonder : rapporter ce qui distingue, au lieu d'« échec »
53 preuves. Trois faux diagnostics en une journée, tous dus à l'instrument, aucun au
composant. curl, bash /dev/tcp et un ping nu écrasent quatre causes incompatibles
dans le même mot. L'information était là chaque fois ; l'outil la jetait.
ouvert, ~0 s le flux est déclaré et le service écoute
refusé (RST) une POLITIQUE interne refuse — immédiat
EHOSTUNREACH, ~3 s LA MACHINE N'EXISTE PAS (l'ARP a abandonné)
silence jusqu'au délai la FRONTIÈRE, muette par conception
Validé contre le réel depuis ops-01 — trois des quatre cas, le quatrième attendant deux
machines vivantes dans un même tenant.
Le même code d'erreur dit deux choses selon le délai
C'est la distinction qui a coûté le plus cher : EHOSTUNREACH immédiat veut dire « pas
de route » ; le même code après trois secondes veut dire « il n'y a pas de machine » —
c'est l'ARP qui renonce. Les confondre a fait appliquer un pare-feu pour réparer un vide.
interpreter est une fonction pure, donc gardée par un test qui exige que les deux ne se
lisent jamais pareil.
La garde qui échouait du côté silencieux
L'outil dit par quelle source le paquet part, et refuse de conclure quand c'est une
passerelle anycast — le piège qui m'a fait déclarer muet un REJECT qui émettait bien ses
RST.
Ma première version rendait « pas anycast » quand inventory_rules était introuvable.
C'est-à-dire exactement sur un hyperviseur où l'on a copié le seul fichier pour
diagnostiquer — là où le piège se produit. Constaté à l'essai : l'avertissement s'est tu
sur 10.17.21.1, qui est une passerelle anycast. Une garde qui échoue doit crier, pas
se taire : sans le moteur, elle retombe désormais sur une heuristique franchement
étiquetée PASSERELLE PROBABLE (module absent).
Et « non concluant » est un résultat
Quand la source est anycast et que la mesure est un silence, l'outil ne tranche pas — il dit quoi faire à la place : compter les paquets du côté qui refuse, ou sonder depuis une VM. C'est ce compteur qui a prouvé, lui, que le refus était instantané.
TCP seulement, et c'est dit : UDP n'a pas de poignée, un silence y confond « ouvert » et « filtré », et prétendre trancher serait le défaut qu'on corrige.
make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.
2026-08-28 — P53 : l'interne refuse à voix haute, la bordure se tait
53 preuves. Décision de l'exploitant : block vers l'Internet, reject à l'intérieur.
« Parce que c'est prudent. »
Ce n'est pas le refus qui informe, c'est ce qu'il fait au silence. Sous drop partout,
un timeout voulait dire trois choses incompatibles — aucune machine, aucune route, ou une
politique. Quand la politique parle, il n'en reste qu'une.
nftables par hôte policy drop + reject with icmpx type admin-prohibited
pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique)
frontière OPNsense block — INCHANGÉ, et c'est la condition
admin-prohibited et non tcp reset : un RST est indiscernable d'un port fermé sans
service. Ce message-ci dit qu'une politique a refusé — la seule forme qui distingue « on
ne veut pas de toi » de « il n'y a rien ».
La chaîne forward reste muette, et ce n'est pas un oubli : elle porte le trafic qui
traverse l'hôte. Y répondre ferait parler cette machine au nom d'une destination qui
n'est pas elle — le défaut qu'on corrige dans input, déplacé d'un cran.
Pourquoi c'est prudent, et pas seulement commode
L'obscurité était déjà nulle à l'intérieur. Chaque machine porte un /etc/hosts
généré qui liste toutes ses voisines avec leurs adresses. Se cacher de pairs qui ont déjà
notre adresse ne protège de rien — le drop coûtait du diagnostic sans rien acheter.
Et la bordure protège le reject. Rien d'indéclaré ne franchit le périmètre : ce reject ne répond donc jamais à l'Internet. Mesure du 2026-08-27 à la frontière, qui explique l'autre moitié du choix : 982 000 entrées par jour, dont 82 % un balayage contre le port VNC. Y répondre serait un vecteur d'amplification, source usurpée comprise.
P53 garde les deux moitiés ensemble, parce que l'une sans l'autre est fausse : une bordure bavarde s'expose, un interne muet ment. Trois contrôles négatifs vérifiés.
La mesure a corrigé la mesure — deux fois
Ma preuve était trop grossière. Elle interdisait le littéral DROP dans le pare-feu
est-ouest, et a fait échouer un code juste :
dc["politique"].upper() in ("DROP", "REJECT") détecte une politique posée au
datacenter, où elle vaudrait pour tout le parc — un garde-fou, pas un réglage. Une preuve
qui interdit un mot au lieu de mesurer une propriété finit par accuser ce qu'elle devrait
protéger. Elle cherche désormais une assignation en dur, pas une occurrence.
Et l'absence parlait déjà. Mesuré depuis ops-01 après application :
cache du site, déclaré OUVERT 0,00 s
machine INEXISTANTE OSError errno 113 EHOSTUNREACH 3,05 s
autre tenant, frontière TimeoutError 6,01 s
La passerelle anycast dit EHOSTUNREACH ; seule la frontière se tait. Mes deux erreurs
de diagnostic de la journée ne venaient donc pas du drop, mais de ma sonde — curl et
bash /dev/tcp écrasent EHOSTUNREACH et timeout dans un même « échec ». L'information
était là ; l'instrument la jetait. Encore une fois, vérifier d'où l'on mesure avant
d'accuser ce qu'on mesure.
Le reject garde toute sa valeur pour ce qu'aucun errno ne dit : la politique d'hôte
et est-ouest. Sa démonstration attend deux machines vivantes dans un même tenant —
Chezlepro n'en a qu'une.
Appliqué : 6 VM passées en REJECT (Chezlepro ops-01, les cinq de patient 0), 0
créée, 0 retirée. Le changement ne ferme rien — REJECT et DROP refusent tous deux,
l'un répond.
Le REJECT est instantané — et c'est mon instrument qui disait le contraire
J'avais présenté « 3,05 s » comme le signal d'un refus. C'était faux, et l'exploitant a eu raison de le contester : un REJECT répond en microsecondes. Ces 3 secondes étaient l'ARP qui abandonne pour une machine inexistante — aucun pare-feu n'était impliqué.
Puis, sondé depuis l'hyperviseur, un port non déclaré a expiré à 8 s. La mesure qui tranche n'est pas le délai mais le compteur :
compteur tcp-reset AVANT : 17
sonde vers ops-01:9999 (non déclaré) → TimeoutError en 4,001 s
compteur tcp-reset APRÈS : 21
Quatre RST émis en quatre secondes, un par retransmission du SYN. Le refus part immédiatement, chaque fois. Ce qui manquait, c'était le retour :
ip route get 10.17.19.41 -> dev vrf_t17 src 10.17.21.1
10.17.21.1 est une passerelle anycast. Le RST revient au nœud qui porte cette adresse
dans le VRF, pas à la socket émettrice. Troisième instrument fautif de la journée, et
celui-ci était déjà documenté — « anycast n'est pas une source de test ».
Entre deux VM d'un tenant — le cas réel — la source est une adresse propre, et le routage inter-zone ne la réécrit pas : le refus arrive instantanément. L'artefact ne vaut que depuis un hyperviseur.
Ce qui manque n'est donc pas dans le pare-feu, c'est une sonde qui rapporte l'errno et le délai au lieu de les écraser dans un « échec ».
make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.
2026-08-28 — L'insémination aboutit : un runner de tenant, né d'un runner de site
52 preuves. ops-01 existe, configurée de bout en bout par le runner du SITE, qui
n'a détenu aucun secret de Chezlepro :
/opt/setops Set-OPS-public OPS-Chezlepro SITE-Chezlepro venv
symlinks (D-80) instance -> OPS-Chezlepro underlay.yml -> SITE-Chezlepro/…
moteur au commit courant
sa paire SSH setops@ops-01.chezlepro.internal
voûte / clé AUCUNE
Il s'arrête exactement là où il n'a pas la clé. Ce n'est pas une règle qu'on lui impose, c'est un fait : la voûte de Chezlepro ne vit dans aucun dépôt, et son mot de passe n'est sur aucune de ses machines. L'armer reste le geste d'un humain, depuis son poste, par son chemin — le site ne peut pas le faire à sa place, même s'il le voulait.
Trois murs, et chacun a nommé une pièce manquante du terrain
Le socle bloquait sur apt, puis serveur_ops sur pip, puis sur le clonage du génome.
Trois échecs, trois couches du terrain que le SITE doit préparer et qui n'étaient pas
déclarées.
apt → la source d'artefacts d'amorçage. Le DNS sortant d'un tenant est fermé par
conception ; un mandataire résout ce que le client ne peut pas résoudre. Aucun flux à
ouvrir : le cache du site acceptait déjà les supernets des tenants.
pip → la roue hors-ligne, dernière dépendance qui sortait par elle-même. Les
collections suivaient l'idiome du dépôt depuis longtemps — le contrôleur télécharge, la
cible n'ouvre rien — pip non. --no-index est le point : sans lui, la réussite
dépendrait un jour d'un flux que ce tenant n'a pas le droit d'avoir, et une lignée
autonome deviendrait une lignée qui a l'air autonome.
Le génome → le marqueur serveur_forge_site, qui manquait. Le site rendait deux
services à ses locataires, les paquets et le génome ; seul le premier était déclaré. Une
dépendance écrite chez le consommateur, sans flux chez le fournisseur — invisible, parce
que la garde de matrice ne vérifie que les paires rôle→rôle.
Les clés SSH suivent la même règle que les voûtes
Chaque runner a son jeu de clés ; sa publique vit dans l'authorized_keys des machines de
son tenant, et d'aucun autre. Le runner du SITE n'entre que sur ops-01, par le flux
d'insémination. Le pouvoir d'entrer se borne au périmètre du runner qui le détient, comme
le pouvoir d'ouvrir se borne à sa voûte.
Le véhicule existait déjà — ssh_baseline_cles_admin, avec son etat par entrée, donc
révocable sur toute la flotte. Chezlepro n'en déclarait aucune parce que son runner
n'existait pas encore.
Ce que la mesure a corrigé, deux fois
ops-01 est la seule VM de Chezlepro qui existe — les quatorze autres ne sont sur
aucun hyperviseur. Mes sondes vers le résolveur, le cache et la PKI du tenant mesuraient
donc une absence, que j'ai d'abord lue comme un pare-feu. Un port muet ressemble à un
refus.
Et l'accès que je croyais avoir cassé en appliquant le pare-feu est-ouest : aucune machine
de Chezlepro n'était joignable depuis le poste par ce rebond. ops-01 l'était parce
qu'elle n'avait aucun groupe de pare-feu. Je n'ai pas créé une panne, j'ai retiré une
exception.
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute.
2026-08-28 — Nourrir la première machine : le cache du site pendant la jeunesse
52 preuves. ops-01 est la première machine de la reconstruction de Chezlepro, et le
socle y restait bloqué sur son premier apt update, sans message qui parle de réseau. La
mesure, depuis la machine :
sortie Internet TCP 443 OK
DNS UDP 53 -> 9.9.9.9 MUET
plancher /etc/hosts 15 machines du tenant, AUCUNE du site
Le DNS sortant est fermé par conception — un tenant résout chez lui, son DNS ne
traverse jamais la frontière. Or apt vise deb.debian.org par son nom, et le cache
de l'écosystème n'existe pas encore.
C'est le cas étroit que le plan du SITE nommait déjà, descendu d'un niveau : la première machine d'un écosystème n'a ni résolveur ni cache, et c'est elle qui doit les construire.
Un mandataire résout ce que le client ne peut pas résoudre
apt envoie au mandataire l'URL absolue ; c'est lui qui traduit le nom. La machine
n'a donc besoin d'aucun DNS pour installer ses paquets — seulement d'une adresse. D'où
artefacts_amorcage, symétrique exact de dns_amorcage, à la même place dans le socle.
Aucun flux à ouvrir, et c'est la bonne surprise. serveur_cache_site accepte déjà le
3142 depuis voisins_site, qui résout les supernets des tenants — donc toute machine
d'un locataire, pas seulement son cache. Vérifié depuis ops-01 : HTTP 200 en 0,158 s,
la résolution faite par le cache.
Ce n'est pas une rustine, c'est de la filiation
L'hébergeur nourrit la première machine de son locataire le temps qu'il monte son propre
cache. L'emprunt est déclaré — l'intrant nomme l'amont — et il s'efface :
client_artefacts écrit 00-setops-artefacts, qui trie après ce fichier-ci, donc
gagne dès que l'écosystème a sa source. Le plancher reste dessous, comme /etc/hosts
reste sous le DNS.
Retirer cette ligne le jour où le cache du tenant est debout est une émancipation — un geste, pas un effet. C'est le premier service à recevoir cette forme après la forge, et le patron vaudra pour les sauvegardes, l'observabilité, le relais courriel.
Deux diagnostics faux, corrigés par la mesure
J'ai lu « BLOQUÉ » comme un pare-feu. Les sondes vers le résolveur, le cache et la PKI
du tenant échouaient toutes — j'y ai vu un filtre, puis j'ai appliqué le pare-feu est-ouest
en croyant corriger. La vraie cause était une absence : ops-01 est la seule VM de
Chezlepro qui existe, les quatorze autres ne sont sur aucun hyperviseur. Un port muet
ressemble à un refus.
Et j'ai cru avoir cassé mon propre accès. Après l'application du pare-feu, ops-01 est
devenue injoignable depuis le poste — j'y ai vu une régression que j'aurais causée. Mesure :
aucune machine de Chezlepro n'est joignable depuis le poste par ce rebond. ops-01
l'était parce qu'elle n'avait aucun groupe de pare-feu. Je n'ai pas créé une panne, j'ai
retiré une exception — et le seul chemin qui reste vers elle est le chemin déclaré,
celui de l'insémination.
Le nœud 192.168.11.42 est injoignable, à traiter séparément.
Le garde-fou du génome a refusé une histoire réécrite
--amend sur un commit déjà poussé : le runner a refusé l'avance non rapide, exactement
comme il doit. Les arbres étant identiques et seul le message différant, c'est la version
de la forge qui fait foi (D-81) — et l'explication vit ici, où elle a de toute façon sa
place.
make verifier : vert.
2026-08-28 — Armer un runner est un acte, pas un réglage
52 preuves. La séparation faite, la distribution devient sûre. serveur_ops_site et
serveur_ops_tenant savent désormais déposer la clé de la voûte qu'ils déposaient déjà
chiffrée — et le runner du site l'a reçue :
/opt/setops/.config/ -> -rw------- setops setops-vault-site-chezlepro
PREUVE : la voûte du SITE s'ouvre avec la clé que le runner détient.
Une seule clé chez lui, et c'est tout ce qu'il peut ouvrir. La fabric, pas un tenant.
false par défaut, et ce n'est pas de la prudence décorative
Armer est le moment où un humain remet la clé — le seul geste que la reproduction exige de
lui. Un défaut à true armerait des machines sans que personne ne l'ait décidé. Les deux
rôles exigent donc une déclaration explicite au plan, et refusent d'armer sans qu'on dise
avec quelle clé : poser un fichier vide laisserait un runner se croire armé.
Écrire, puis relire — comme pour la voûte, mais contre la faute symétrique. Là on craignait un chiffré devenu clair ; ici on craint une clé qui serait en fait une voûte, ou un fichier vide, qu'Ansible accepte sans rien ouvrir. Le rôle mesure existence, taille et mode après avoir écrit.
Ce que ça change pour l'exploitant
Le mot de passe se tapait à chaque déploiement. Un geste répété vingt fois par semaine ne prouve rien — et il rendait tout déploiement non interactif (la GUI, un runner) impossible sans blocage. Il se remet désormais une fois, et c'est un événement daté.
C'est la doctrine de l'émancipation appliquée un cran plus tôt : la machine instruit, l'humain acte.
P32 a trouvé la moitié manquante
Le rôle du tenant exigeait serveur_ops_tenant_cle_source ; le plan de Chezlepro ne la
fournissait pas. La preuve l'a dit avant tout déploiement, et dans les termes qui comptent :
« le déploiement s'arrêtera sur la première garde atteinte ». ops-01 est donc armé lui
aussi — avec la clé de Chezlepro, qui n'ouvre ni la fabric ni un voisin.
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute.
2026-08-28 — Une voûte, une clé : la séparation avant la distribution
52 preuves. Décision de l'exploitant : chaque runner est maître de sa voûte et en détient la clé. C'est ce qui rend un runner autonome — et donc ce qui rend l'émancipation atteignable. Mais elle exigeait un préalable, mesuré ce matin :
UN SEUL mot de passe ouvrait les SIX voûtes de la flotte
SITE-Chezlepro (la fabric) · OPS-Chezlepro · OPS-Chezlepro-lab (×2)
OPS-Patient0 · OPS-Technolibre
Distribuer avant de séparer aurait été strictement pire que le statu quo. Poser « la » clé sur chaque runner, c'était rendre chaque runner capable d'ouvrir les autres : compromettre le plus petit locataire donnait les secrets de l'hébergeur. L'ordre n'est pas un détail de mise en œuvre, c'est ce qui décide si la mesure protège ou expose.
Cinq clés, une par écosystème, sous ~/.config/setops-vault-<dépôt-en-minuscules>.
Vérification croisée après rechiffrement — la diagonale, et rien qu'elle :
voûte \ clé chezlepro lab patient0 site technolibre ANCIENNE
ops-chezlepro OUVRE . . . . refusée
ops-chezlepro-lab . OUVRE . . . refusée
ops-patient0 . . OUVRE . . refusée
site-chezlepro . . . OUVRE . refusée
ops-technolibre . . . . OUVRE refusée
Un seul mot de passe ne pouvait plus suffire, et pas pour la raison qu'on croit
cloner_vm_debian.yml charge deux voûtes dans la même exécution : celle du tenant,
puis celle de l'underlay — les identifiants Proxmox appartiennent à l'hébergeur, le reste
au locataire. ANSIBLE_VAULT_PASSWORD_FILE n'en porte qu'une.
ANSIBLE_VAULT_IDENTITY_LIST en porte plusieurs et les essaie toutes sur un bloc chiffré
sans étiquette. Un seul export dans le Makefile suffit donc pour les 28 appels à
ansible-playbook, sans en toucher un seul. La liste se dérive de l'instance montée, de
son site et des dépôts frères (scripts/voutes.py).
Ce qui borne le pouvoir n'est pas cette liste mais la présence des fichiers. Sur le poste de l'exploitant, toutes les clés sont là — c'est l'humain qui les détient toutes, et des preuves comme P03 lisent les inventaires de tous les écosystèmes frères. Sur un runner, une seule clé existe, et la même fonction n'en nomme qu'une. Le code est identique, le pouvoir ne l'est pas.
Deux preuves ont immédiatement dit ce qui manquait
P03 (diff-vide de toutes les instances) est tombée dès la séparation : elle lit les inventaires des écosystèmes voisins, donc il lui faut leurs clés. C'est elle qui a établi que la liste devait couvrir le voisinage sur le poste — je l'avais d'abord limitée à l'instance montée et à son site.
P16 s'est mise à sauter : sa garde ne connaissait que ANSIBLE_VAULT_PASSWORD_FILE.
Une preuve sautée se lit beaucoup trop facilement comme une preuve passée — c'est le
même défaut que le vert sur un périmètre vide, en plus discret.
Ce que ça coûte, et qui est assumé
Le chiffré et sa clé cohabiteront sur la même machine dès que les runners recevront la
leur. Voler le disque d'un runner suffira, là où ça ne donnait rien. Le pari tient parce
que le périmètre est borné : le runner d'un tenant ne peut ouvrir que ce tenant. Trois
contreparties en découlent — mode 0600, la clé hors du chemin de sauvegarde ordinaire, et
un runner durci comme un détenteur d'état.
Les six voûtes ont été sauvegardées telles quelles avant rechiffrement. L'ancienne clé maîtresse n'ouvre plus rien et reste sur le poste : la retirer est une décision, pas un nettoyage.
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute — sans
ANSIBLE_VAULT_PASSWORD_FILE.
2026-08-28 — L'insémination aboutit, et trois tests qui ne gardaient rien
52 preuves. Le flux était déclaré et appliqué ; il manquait l'identité. Le runner
du SITE pouvait atteindre la porte de ops-01 et n'avait pas de clé :
ansible@10.17.19.41: Permission denied (publickey)
Le piège, et pourquoi il compte plus que le correctif. La clé s'injecte par cloud-init,
au clonage — or creer-vm crée toutes les machines d'un tenant. L'injecter à chaque
clonage aurait donné à l'hébergeur un accès SSH à la flotte entière de chaque
locataire, en silence, sans qu'aucune règle ne le dise. Ça aurait défait à la couche
identité ce que le pare-feu venait de borner à la couche réseau — et deux couches qui ne
déclarent pas la même politique, c'est une politique qu'on ne peut plus lire.
Le même critère gouverne les deux : porter serveur_ops_tenant. inventory_host.py
émet SETOPS_CLES_AMORCAGE pour cet hôte, et une chaîne vide partout ailleurs. La clé
vient du plan du SITE, pas du disque local : on matérialise depuis le runner comme
depuis le poste, et un lookup local rendrait deux clés différentes selon qui agit.
Elle s'ajoute, elle ne remplace pas : l'exploitant garde son accès à la machine qu'il vient de créer. C'est l'humain qui arme, pas le site.
Deux couches mangeaient les espaces d'une clé publique, en silence
Une clé porte des espaces — type, matériel, commentaire. Proxmox répondait :
500 Internal Server Error: SSH public key validation error
Un message qui ne parle ni de make, ni de shlex, ni d'espaces. Ce qui l'a isolé n'est
pas la lecture du code mais un contrôle : rejouer cloner-vm sans la clé — la tâche
passe. Puis la mesure de ce qui arrivait vraiment au module : "sshkeys": "ssh-ed25519".
Le premier mot, rien d'autre.
Deux causes, et j'ai accusé la mauvaise d'abord. Une variable make passée à un sous-make
est un chemin fragile pour une valeur à espaces — vrai, mais ce n'était pas ça. Le
coupable est ansible-playbook -e clé=valeur, qui découpe la chaîne au shlex : tout ce
qui suit le premier espace devient d'autres paires. D'où la forme retenue :
l'environnement pour le transport, et -e '{"…": "…"}' en JSON pour l'entrée dans
Ansible — le seul mode de -e qui ne découpe rien.
Un scalaire YAML plié (>-) ne produit pas non plus de saut de ligne : '\n' y reste
deux caractères. Vérifié en base64 sur les trois formes candidates.
Trois tests qui ne gardaient rien
test_inventory_host déclare ses tests dans une liste explicite. Les deux que j'ai
écrits pour la nouvelle garde y étaient absents : définis, verts en apparence, jamais
joués. J'ai donc ajouté la garde qui manquait — la liste doit couvrir tout ce que le
fichier définit.
Elle a trouvé un troisième cas le jour même : test_etiquette_vlan_repli_et_vide_explicite,
jamais inscrit depuis sa création. Inscrit, il a levé un KeyError — il cherchait
hotes_actifs dans une fixture qui range ses hôtes sous hotes_planifies. Il gardait le
comportement SDN de l'étiquette VLAN, et il ne gardait rien. Un test non inscrit est pire
qu'un test absent : on croit l'avoir.
La preuve, avec son contrôle négatif
runner du SITE -> ops-01 ops-01 10.17.19.41/24 ✔ entré
runner du SITE -> 10.17.19.21 Connection timed out ✘ refusé
Le chemin est large d'exactement une machine — et la première ligne est une livraison,
pas une poignée de main : ops-01 s'est nommée.
ops-01 est née avant ce mécanisme ; sa clé a donc été posée une fois à la main. Toute VM
matérialisée à partir d'ici la reçoit de cloud-init, ou ne la reçoit pas — selon ce que le
plan dit d'elle.
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec.
2026-08-28 — Le lien d'insémination, déclaré — et un test rouge depuis trois jours
52 preuves. L'insémination avait un nom depuis ce matin ; elle n'avait pas de flux. Le runner du SITE doit joindre le runner d'un tenant pour l'amorcer — socle, moteur, plan, plancher de résolution — et ce chemin s'exerçait jusqu'ici sans être déclaré nulle part.
Deux déclarations, aux deux bouts, et rien d'autre.
serveur_ops_site egress 22/tcp -> serveur_ops_tenant
serveur_ops_tenant ingress 22/tcp <- runner_site partage: true
Étroit par construction : il vise le groupe serveur_ops_tenant, qu'un écosystème ne
pose que sur une machine. Déclaré au socle, il aurait ouvert le SSH du site vers toute la
flotte du tenant ; déclaré sur ce rôle, sa portée est celle du groupe — et le groupe est
écrit au plan du tenant.
L'en-tête de roles/serveur_ops_site/meta/flux.yml disait « ce rôle n'entre JAMAIS chez
un tenant ». C'était une frontière qu'on ne pouvait pas tenir : creer-vm exige
_instance-requise, et le runner du site avait déjà dû basculer son symlink instance
sur OPS-Chezlepro pour matérialiser ses VM. Déclarer ne crée pas ce pouvoir — ça rend
limitable un pouvoir qui s'exerçait sans borne.
Ce qui reste interdit n'est pas une règle mais un fait : le runner du SITE ne détient pas la voûte d'un tenant — elle vit hors dépôt — et n'en connaît pas le mot de passe. Il pose tout ce qui est public ; l'humain arme. Le pouvoir du site s'arrête là où il n'a pas la clé.
La règle est émise d'un seul côté, et ce n'est pas celui qu'on croit
Le paquet part du réseau de l'hébergeur vers la zone du tenant : il pénètre le pare-feu
par la patte du site, pas par le lien de transit. La règle appartient donc au côté
SITE du devis. L'émettre aussi depuis la déclaration ingress du tenant aurait produit
une seconde règle attachée à la mauvaise interface — jamais évaluée, impossible à
distinguer d'une règle utile, et comptée comme telle par tout ce qui audite ce devis.
La déclaration du tenant n'est pas perdue : c'est elle qui pose la règle nftables sur la
machine visée, et elle seule. Une ligne, sur un seul hôte, dont la source dérive du plan
du site :
ip saddr { 10.0.31.11 } tcp dport 22 accept # serveur_ops_tenant: Insémination…
Écrire cette adresse à la main aurait survécu au prochain déplacement du runner sans que rien ne le dise — le site a déjà déplacé ses machines une fois, le 2026-08-25.
Plan de la frontière : 2 objets à créer, 0 à retirer, 126 inchangés. Rien n'est appliqué.
P41 appliquée au plan du site
resoudre_flux avait besoin du plan du SITE à son tour, que devis_opnsense portait en
privé. Les trois lecteurs déménagent dans underlay — le module qui sait déjà où vit
l'hébergeur — et l'appelant délègue. Deux recensements du même objet finissent toujours
par diverger, et la divergence se lit « tout va bien ».
Deux gardes ont fait leur travail au passage, et méritent d'être nommées : P33 a refusé
ingress 22 sur un hôte qui porte déjà le sshd du socle — la réponse était partage: true,
le mot qui distingue ouvrir une écoute d'autoriser une source de plus sur celle d'un
autre, exactement comme serveur_backup. Et le devis a refusé d'émettre une règle vers un
alias vide plutôt que d'en poser une sans destination.
Un test rouge depuis trois jours, que personne ne pouvait voir
test_adressage_derive construisait un site avec un index. Or un SITE n'a pas
d'index — add94f2 l'a retiré le 2026-08-25, remplacé par bande_basse_de, porté par
chaque réseau et non par le site. Le test est resté rouge du 25 au 28.
Personne ne l'a vu parce que le geste quotidien est make prouver, qui ne joue pas les
tests — make verifier le fait, et il n'avait pas été rejoué. Un test rouge que rien ne
lit ne garde plus rien. Remis sur le contrat actuel, et sa contrepartie ajoutée : un
réseau qui ne dit pas de quel tenant il occupe la bande basse n'a droit à aucun
chevauchement — ne pas déclarer ne peut pas valoir permission.
make verifier : vert de bout en bout. make prouver : CONFORME, 52 OK, 0 echec.
2026-08-28 — L'insémination : la parenté est la seule exception au zéro-confiance
52 preuves. Entre tenants, la frontière refuse tout ce qui n'est pas déclaré. Mais un écosystème neuf ne peut pas s'amorcer lui-même : quelqu'un doit poser sa première machine. Ce geste n'avait pas de nom, et un geste sans nom ne se borne pas.
le SITE matérialise VNets, VM, routes, flux — le terrain
le SITE amorce ops-01 la première machine, et elle seule
le TENANT achève ses autres machines, ses rôles, ses intégrations
Le partage d'intelligence qui le rend possible, et qui est plus net que « le SITE ignore
les rôles » : les deux runners portent le même moteur, et n'en consomment pas la même face.
Le SITE lit roles/*/meta/flux.yml — ce que les rôles exigent du réseau — et en tire le
SDN, le pare-feu Proxmox, la frontière. Le tenant lit tasks/, templates/,
meta/integration.yml — ce qu'ils exigent de la machine. C'est ce partage qui permet
au SITE de poser les bonnes règles sans jamais lire la configuration d'un tenant ni détenir
sa voûte.
Le couplage existait déjà, sans être déclaré
make creer-vm exige _instance-requise. Pour matérialiser les VM de Chezlepro, le runner
du SITE a donc dû basculer son symlink instance sur le dépôt de Chezlepro — alors que son
plan pose serveur_ops_instance: "" et que la doctrine dit qu'un SITE n'a pas de plan monté
par ce lien. Mesuré sur la machine :
/opt/setops/Set-OPS-public/instance -> ../OPS-Chezlepro (27 août 21:52)
Rien ne borne sur quels tenants ce lien porte, ni jusqu'à quand. Le déclarer, c'est le rendre limitable ; le taire ne l'a jamais empêché.
L'émancipation exige la gouverne d'un humain
J'avais proposé une condition d'extinction mesurée : le lien se ferme quand la machine constate que le tenant se reproduit sans son parent. C'était l'erreur symétrique de celle qu'on corrigeait, et ce document disait déjà pourquoi — « chaque émancipation est une décision du client, jamais une rupture technique ».
Le fruit de l'émancipation est livré à quelqu'un. Sans quelqu'un pour le recevoir, il n'y a pas de livraison : seulement un lien qui tombe. Une émancipation qui se déclencherait toute seule ne serait pas une émancipation, ce serait une expulsion.
D'où quatre lignes qui ne se confondent pas : le moteur porte le lien, la machine
produit le constat d'aptitude — elle instruit, elle n'agit pas —, l'humain pose
l'acte sous CONFIRMER=true, et la machine prouve ensuite que le lien est coupé.
C'est la forme des autres gestes lourds d'ici : raser exige son nom d'instance, le mot de
passe de la voûte se tape à l'exécution. L'émancipation est le deuxième objet de cette
liste.
Deux limites nommées plutôt que tues
Qui est le parent ? D-81 donne l'autorité du génome à la forge du SITE. Conséquence non écrite : patient 0, dont la raison d'être est « porter les dépôts qui fabriquent les écosystèmes, et les servir à ses enfants », n'a plus un seul lecteur — Chezlepro était le dernier, corrigé le 26. Et la machine hors flotte qu'il existe pour remplacer alimente désormais la forge qui fait autorité : le point unique de défaillance n'a pas été éliminé, il a été promu. Trois issues sont posées dans le document ; aucune n'est tranchée, et aucune ne bloque la reconstruction en cours.
La séparation des voûtes est organisationnelle, pas cryptographique. Les fichiers sont
bien séparés — vérifié sur site-ops-01, qui ne détient que underlay.vault.yml. Mais un
seul mot de passe ouvre la voûte du site et celles des tenants. « Deux pouvoirs, aucun
omnipotent » décrit la répartition des fichiers, pas celle des clés.
Un document qui nomme ce qu'il n'a pas encore résolu vaut mieux qu'un document dont on découvrira le trou en s'appuyant dessus.
2026-08-27 — P52 : matérialiser n'exige pas d'entrer chez le tenant
52 preuves. creer-vm confirmait son succès en attendant une réponse SSH. Le runner du
SITE matérialise le terrain de tous les tenants, mais la frontière lui refuse d'entrer
chez eux — c'est le sens même de leur isolation. La première VM qu'il a créée a donc été
déclarée en échec après 600 secondes alors qu'elle tournait, avec l'adresse exacte que le
plan lui destinait :
Attente de SSH sur ops-01 ............ ECHEC: injoignable apres 600s.
La tentation était d'ouvrir le SSH du runner vers tous les tenants. Ça aurait réparé la mesure en détruisant ce qu'elle protège : une machine capable d'entrer chez chaque locataire est précisément ce que cette architecture refuse d'avoir.
L'agent invité répond sans rien ouvrir — l'API des hyperviseurs est déjà le flux par lequel
la VM vient d'être créée, donc qui peut la créer peut la voir naître — et il prouve
davantage. « Quelque chose écoute sur le port 22 » ne dit ni quel système a démarré, ni si
cloud-init a posé la bonne adresse. Sur ops-01 :
10.17.19.41 portee sur asgard — Debian GNU/Linux 13 (trixie) 6.12.101
Ses échecs distinguent deux causes très différentes : « la machine démarre mais rapporte une autre adresse — cloud-init, ou le pont sur lequel elle est posée », et « introuvable sur la fabric ».
Attendre la disponibilité a changé de main. Guetter cloud-init et la libération de dpkg
appartient à qui va configurer : deployer le fait désormais, là où il se contentait
d'un ping unique. Il y gagne ce qu'il n'avait pas — attendre le verrou APT, faute de quoi
la première couche échouait dessus. Contrepartie assumée : un nom d'hôte erroné patiente au
lieu d'échouer vite ; échouer vite interdirait de chaîner création et déploiement, le geste
central d'une reconstruction.
Une preuve qui exige un pouvoir que l'architecture refuse ne mesure pas le système : elle mesure ce qu'il faudrait casser pour la satisfaire.
2026-08-27 — P51 : quatre défauts de portabilité, révélés par le runner
51 preuves. Première matérialisation d'une VM de tenant depuis le runner du SITE. Elle a échoué quatre fois d'affilée, sur quatre dépendances que le moteur ne déclarait nulle part. Chacune fonctionnait chez le mainteneur pour une raison différente, et aucune de ces raisons n'existe sur une autre machine.
1. Versions des collections. requirements.yml les nommait sans les épingler. Le runner
a reçu community.general 13.3.0 quand le poste porte la 10.3.0 — et la 11 a retiré les
modules Proxmox de cette collection (ils vivent désormais dans community.proxmox).
ERROR! couldn't resolve module/action 'community.general.proxmox_pool'
Une dépendance non épinglée n'est pas une dépendance, c'est un pari sur l'état d'Internet à la date du déploiement.
2. Installation en avant. ansible-galaxy ne rétrograde pas : déclarer la 10.3.0 ne
suffisait pas à défaire une 13.3.0 déjà posée. --force. Un poste peut être en avance,
pas seulement en retard, et le dépôt doit faire autorité dans les deux sens.
3. Emplacement. Le Makefile pose ANSIBLE_HOME ?= $(CURDIR)/.ansible : sous make,
Ansible ne lit que <moteur>/.ansible/collections. Le rôle déposait dans
<racine>/.ansible/collections — à côté, jamais lu. Invisible chez le mainteneur, où les
collections viennent du paquet système, toujours dans le chemin quel que soit ANSIBLE_HOME.
Et le garde-fou d'installation suivait le dépôt des archives : changer la destination ne
le déclenchait pas. Il mesure désormais la destination — même piège qu'au 2026-08-24, déplacé
d'un cran.
4. Bibliothèques Python. Le venv recevait ansible-core et pyyaml, écrits en dur. Les
modules Proxmox tournent delegate_to: localhost et exigent proxmoxer sur le
contrôleur.
La bibliotheque Python proxmoxer est absente
Né d'ici : requirements-python.txt, avec sa frontière écrite — il ne déclare que ce qui
tourne sur le contrôleur. python-ldap et psycopg2 s'exécutent sur leurs cibles ; le poste
du mainteneur ne les a pas et la flotte se déploie, ce qui prouve que la ligne est au bon
endroit.
P51 garde les trois faiblesses de cette famille : une dépendance utilisée sans être
déclarée (le défaut du 2026-08-24, ansible.posix), une dépendance déclarée sans version, et
proxmoxer absent alors que des playbooks Proxmox existent. La preuve ne lit que les fichiers
de tâches : un defaults/main.yml porte net.ipv4.ip_forward, qu'un motif trop large
prendrait pour un module.
Résultat : ops-01 matérialisée par le runner du site. L'agent invité rapporte
eth0 10.17.19.41/24, Debian 13 trixie — l'adressage dérivé du plan, appliqué.
Migration connue, pas faite : passer les quatre modules Proxmox à community.proxmox
permettra de suivre community.general au-delà de la 11.
Un seul exécutant masque ces écarts indéfiniment ; un second les révèle tous en une soirée. Même mécanique que le second tenant en août.
2026-08-27 — P50 : le devis de la frontière sait refuser SANS consigner
50 preuves. L'outil ne savait qu'autoriser — "action": "pass" était en dur dans
l'émetteur. Une règle de silence ne pouvait donc pas naître du dépôt, et j'en avais posé deux
à la main sur le boîtier : exactement ce que ce projet refuse.
Ce qui l'a motivé. Le journal de la frontière écrivait 982 000 entrées par jour, dont 82 % un balayage Internet contre le port VNC et le reste du bavardage de découverte du réseau local. Sa fenêtre utile était tombée à quarante-quatre secondes. J'y ai cherché la trace d'un flux du site vers les hyperviseurs, je n'ai rien trouvé, et j'en ai conclu à tort qu'aucune règle ne bloquait. Un journal noyé ment aussi sûrement qu'un journal mort. Mesuré après déclaration : ~20 700/jour.
Une règle de silence ne change aucun comportement : ce qu'elle vise était déjà refusé par le défaut. Elle ne supprime qu'une trace que personne ne lira.
Trois pièces. cle_regle accepte une action sans changer d'un octet la clé des règles
pass déjà posées — l'ajout naïf d'un champ les aurait toutes détruites pour les recréer à
l'identique, sur la frontière, en production ; le plan l'a confirmé : 7 à créer, 0 à retirer,
121 inchangées. _corps_regle lit l'action, la consignation et la séquence depuis le
devis : la séquence est ce qui rend un block sûr, puisque OPNsense évalue en quick et
qu'un blocage large émis avant les pass fermerait courrier, web et accès distant.
devis_opnsense lit opnsense_silences et en fabrique règles et alias, motif compris —
une règle block muette sans raison écrite est indiscernable d'un oubli.
P50 garde ces deux dangers : un silence en séquence 1 échoue, un silence sans motif échoue.
2026-08-26 — P49 : le registre des flux avait dérivé sans bruit
48 preuves. docs/registre-flux.md est généré depuis les roles/*/meta/flux.yml,
et c'est le document qu'un humain lit pour savoir ce que le pare-feu laisse passer. Son
existence était vérifiée depuis longtemps ; sa fraîcheur ne l'était pas.
Il avait dérivé : la garde d'administration y portait encore 10.0.0.0/24 alors que le
réseau d'administration vaut 10.17.0.0/24, deux flux client_resolveur ajoutés depuis
n'y figuraient pas, et un hôte manquait des listes de sources. Un lecteur y aurait lu un
pare-feu qui n'existe plus.
L'inventaire avait déjà sa garde — P03, le diff-vide du plan. Le registre des flux est
le même genre d'artefact : généré, versionné, lu par un humain. Il lui manquait la même.
generer_registre étant une fonction pure, P49 la rejoue en mémoire et compare — une
preuve qui répare ce qu'elle mesure ne mesure plus rien.
Contrôle négatif idéal, et il ne s'invente pas : la version commitée elle-même. Restaurée, la preuve échoue ; régénérée, elle passe.
Ce que cette découverte corrige aussi dans ma tête
J'avais d'abord décrit le symptôme comme « le runner salit ses propres clones » — une contradiction structurelle entre un dépôt-clone et un répertoire de travail. C'était faux, et la question de l'exploitant l'a mis au jour. Régénérer un artefact doit produire un diff quand les sources ont changé ; ce qui manquait n'était pas une architecture, c'était une garde. Un symptôme observé depuis un seul endroit ressemble toujours à une propriété de cet endroit.
2026-08-26 — serveur_ops_tenant : le runner d'un tenant reçoit enfin sa voûte
47 preuves. La doctrine des runners décrit trois portées depuis le 2026-08-22 :
calculer plan → inventaire aucune voûte serveur_ops
configurer rôles sur ses machines voûte du TENANT ← revendiquée, jamais reçue
matérialiser créer/détruire des VM voûte du SITE serveur_ops_site
La deuxième ligne était un trou. Rien ne déposait jamais la voûte d'un tenant sur son runner. Un runner pouvait dériver son inventaire et ne rien pouvoir en faire : chaque rôle qui demande un secret échouait sur son assertion, et l'échec ne disait pas qu'il manquait un fichier, seulement que les valeurs étaient vides.
Découvert en préparant la reconstruction de Chezlepro. Le runner du site peut matérialiser
ses quinze machines ; il ne peut pas les configurer, parce que Chezlepro a sa propre
autorité de certification — donc client_pki y réclame un secret de Chezlepro. Le runner
du site ne l'a pas, et ne doit pas l'avoir : c'est la ligne qui rend l'hébergement
mutualisé défendable.
serveur_ops_tenant est le symétrique exact de serveur_ops_site : un marqueur, pas
un installateur. Il dépose la voûte decrypt: false, puis relit l'en-tête du fichier
déposé — une voûte déchiffrée par accident est une fuite silencieuse. Le dossier
d'inventaire (principal ou production) se découvre sur la machine.
Pourquoi un rôle à part et non une option : donner sa voûte à un runner est un pouvoir, pas un réglage. Le déclarer au plan force à répondre à « cette machine a-t-elle le droit de configurer cet écosystème ? » — une option activée par défaut y répondrait à notre place.
Chezlepro lisait encore son génome chez patient 0
Trouvé au passage, et bloquant pour la reconstruction : serveur_ops_forge_amont pointait
sur 10.29.16.11 — la forge de patient 0, l'amont d'avant que le site ait la sienne.
D-81 a tranché depuis. Laissé tel quel, Chezlepro se serait reconstruit depuis un
moteur périmé, sur un réseau que le découpage en zones a de toute façon déplacé.
Corrigé vers la forge du site, par son IP — forge.genese.internal appartient à la
zone souveraine du site, et un tenant ne résout que la sienne ; le certificat servi porte
bien IP Address:10.0.33.11. La racine de l'AC du site est désormais versionnée à côté de
la carte de la fabric à laquelle elle appartient, et son chemin se dérive du symlink
underlay.yml — il vaut donc depuis le poste comme depuis un runner.
2026-08-26 — Le runner du site travaille, et le génome remonte chez lui
46 preuves. serveur_ops et serveur_ops_site sont déployés sur site-ops-01 : le
moteur, les plans des trois tenants, les collections hors ligne, et la voûte de l'underlay
déposée chiffrée. Le site a son runner.
Deux formes de runner, et une seule était prévue
serveur_ops exigeait un dépôt role: instance et un serveur_ops_instance. C'est la
forme d'un runner de tenant : il pilote un écosystème, monte son plan par le lien
instance, détient sa voûte.
Un runner de site ne pilote rien — il matérialise le terrain que d'autres
occuperont, et son inventaire est dynamique. Son plan déclarait donc ses dépôts de tenants
en role: tenant, à dessein et avec ses raisons écrites, et le rôle le refusait au nom
d'une exigence qui ne le concernait pas. Il exige désormais que le runner pilote
quelque chose, sans prescrire quoi : un instance, ou un hebergeur.
Et il posait le lien quand même : serveur_ops_instance: "" donnait instance -> /opt/setops/, un répertoire qui n'est l'écosystème de personne. Un lien qui existe et ne
désigne rien est pire qu'un lien absent : tout ce qui le suit croit avoir trouvé une
instance.
Le certificat couvre les noms du service, pas seulement ceux de la machine
client_pki ne mettait dans ses SAN que le FQDN, le nom court et l'IP. Le clonage du
génome a buté dessus : « certificate subject name (site-forge-01.genese.internal) does not
match target hostname 'forge.genese.internal' ». Le nom déclaré expose: était publié
partout — plancher, zone DNS — et couvert nulle part.
Les expositions que cet hôte sert réellement entrent maintenant dans le certificat. Un certificat qui revendiquerait le nom d'un service rendu ailleurs serait une usurpation, pas une commodité.
make genome-pousser — le runner pousse, le poste ne route pas
La forge du génome vit dans le site, sur un réseau que le poste de l'exploitant ne
route pas. On ne perce pas un chemin pour lui : on lui retire le rôle. Le poste emballe les
commits dans un git bundle — un fichier, vérifiable, qui ne demande aucun réseau — et le
runner vérifie, avance en avance rapide seulement, puis pousse avec sa clé.
Ce qui est poussé se dérive de serveur_ops_depots : les dépôts que le runner porte et
dont une copie existe à côté du moteur. Six, ici. DEPOT=<nom> en cible un seul.
Trois pièges rencontrés, tous inscrits dans le code :
- l'outil ne peut pas être supposé présent sur le runner — il ne recevrait cette version qu'après la poussée qu'elle sert à faire. Le contrôleur le porte ;
- la branche n'est pas toujours
main— deux dépôts vivent surmaster, et le plan le déclarait déjà ; - le dossier des dépôts frères contient un espace :
argvet noncmd.
Le juge n'est pas le journal du playbook mais la forge : on lui demande son API ce qu'elle porte, et on refuse si ça diffère de ce que porte le runner.
Pourquoi ça comptait : la forge du site était restée quatre commits derrière le poste, sans que rien ne le signale — dont le correctif qui désarme le pare-feu Proxmox. Un écosystème qui se reproduit depuis une forge en retard reproduit ses défauts.
P48 — la carte d'orientation ne peut plus mentir
docs/carte-set-ops.md est l'index du mainteneur : l'ordre de lecture du corpus, et
surtout le catalogue des mécanismes transverses avec, pour chacun, où il vit dans le
code. Son but est écrit en toutes lettres : « ne plus re-déterrer ce qui existe ».
Elle n'avait aucune garde, alors que catalogue-services.md a la sienne depuis P38. Ses
sept chiffres étaient faux — 54 rôles annoncés contre 60, 34 documents contre 38, 15
pièces d'audit contre 27, 70 décisions contre 78.
Le défaut coûteux n'est pourtant pas là. C'est le pointeur mort : la carte dit où vit un mécanisme, quelqu'un ne l'y trouve pas, et le réimplémente à côté — exactement la panne qu'elle existe pour prévenir. Aucun de ces nombres ne fait travailler personne ; mais un document dont les faits vérifiables sont faux cesse d'être consulté, et c'est alors ses pointeurs qu'on perd.
P48 vérifie les deux : 84 chemins cités existent, et 7 chiffres correspondent à la
mesure. Quatre distinctions ont dû être écrites pour qu'elle ne soit pas fausse dans
l'autre sens — un gabarit de nom (preuve-<date>.md) décrit une forme, pas un fichier ; un
chemin hors dépôt (~/.config/setops-vault-pass) vit sur le poste de l'exploitant ; un
fragment (meta/acces.yml) vaut comme suffixe ; et un artefact généré (hosts.yml) n'a
pas à exister dans le moteur. Les quatre sont nommées dans le code plutôt que sautées en
silence.
Le tableau « Le dépôt en chiffres » remplace les comptes en prose : ce qu'on n'entretient pas, on ne l'affirme pas — ou bien on le fait recompter.
D-81 — la forge du site fait autorité, et make genome-etat le vérifie
Décision de l'exploitant : la forge du SITE fait autorité pour le génome. Toute autre copie — y compris celle d'où le moteur a été poussé jusqu'ici — est un miroir.
Une autorité qu'on ne vérifie pas est une autorité qu'on suppose. make genome-etat
confronte donc, dépôt par dépôt, ce que le poste porte à ce que la forge porte — et
refuse en cas d'écart plutôt que de le signaler. Un écart connu et toléré redevient un
écart oublié, et la commande qui le corrige tient en trois mots. Il dit aussi quand la
copie locale n'est pas propre : des commits pas encore faits sont une autre forme de
retard.
Mesuré au passage, et traité plutôt qu'ignoré : le premier contact avec la forge échoue une fois sur six — poignée TLS expirée, puis cinq réponses de suite. Ce n'est pas le chemin, qui est prouvé ; c'est l'acceptation TLS après un temps d'inactivité. Les deux cibles réessaient.
2026-08-25 — Quatre zones inverses, et rien de plus
47 preuves. Le site ne servait aucun PTR. serveur_powerdns_zone_inverse dérivait
d'un supernet /16 — la forme d'un tenant, qui tire tout de son index. Un site ne
dérive pas : il déclare plusieurs /24 et n'a pas de supernet unique, si bien que la
dérivation rendait une chaîne vide et qu'aucune zone n'était générée.
Le site revendique désormais exactement ce qu'il occupe :
31.0.10.in-addr.arpa 32.0.10.in-addr.arpa
33.0.10.in-addr.arpa 34.0.10.in-addr.arpa
Revendiquer 0.10.in-addr.arpa d'un seul geste aurait été plus simple et faux : cette
zone couvre aussi la frontière, le transit et les hyperviseurs, qui ne sont pas à lui.
Une autorité qu'on s'attribue sans l'exercer est une panne différée.
La dérivation vit dans un filtre (zones_inverses) parce qu'elle a deux appelants :
serveur_powerdns écrit ces zones, serveur_resolveur les délègue à l'autoritatif. Deux
calculs séparés finiraient par diverger, et la divergence ne se verrait qu'au premier PTR
interrogé. Le nom d'une zone dit sa profondeur — trois étiquettes numériques valent un
/24, deux valent un /16 — et le modèle en déduit seul la forme du PTR.
Deux chemins morts trouvés en route
Les zones étaient servies, et personne ne les demandait. serveur_resolveur déléguait la
zone directe par une stub-zone mais pas les inverses : dig -x rendait vide depuis les
cinq machines alors que la même requête posée directement à l'autoritatif répondait juste.
Un service correct derrière un chemin que rien n'emprunte.
Puis, les stubs posés, Unbound répondait toujours NXDOMAIN avec le drapeau aa — une
réponse autoritaire, sans jamais consulter le stub. Il embarque des local-zone pour
tout l'espace RFC1918 inversé. nodefault n'y change rien : ce mode n'agit que si le nom
correspond exactement à une zone par défaut, et la sienne est 10.in-addr.arpa, le /8
entier. C'est transparent qui laisse la requête suivre son cours — pour nos quatre zones
seulement, là où unblock-lan-zones aurait ouvert tout l'espace privé.
Rien ne distinguait ce blocage d'une absence : le même NXDOMAIN qu'un nom qui n'existe pas.
Vérification
Les cinq PTR résolvent depuis les cinq hôtes par le résolveur, les huit noms directs par
le plancher et par le DNS, et les deux rôles sont idempotents (changed=0).
P47 évalue la dérivation sur cinq cas — un site à quatre zones, un tenant à une, deux
machines d'un même /24 qui n'en font qu'une, aucune adresse, une adresse illisible.
2026-08-25 — Le plancher survit au redémarrage, et la zone dit les vraies adresses
46 preuves. Le découpage du site en quatre zones a déplacé cinq machines ; ni le
plancher /etc/hosts ni la zone DNS n'avaient suivi. Quatre défauts, tous dans le moteur.
Le plancher était effacé à chaque démarrage — et le correctif d'hier n'en était pas un
hosts_statiques posait /etc/cloud/cloud.cfg.d/99-setops-hosts.cfg avec
manage_etc_hosts: false, pendant que cloud_init posait 99_setops.cfg avec true.
Dans cloud.cfg.d l'ordre est lexical et le dernier gagne : - vaut 0x2D, _ vaut
0x5F — le true l'emportait. On a donc d'abord retiré la clé de cloud_init (le rôle qui
possède le fichier décide), puis renommé notre fragment zz- pour passer après le
99_chezlepro.cfg du gabarit doré.
Et ça ne suffisait toujours pas. Redémarrage d'épreuve : le plancher, encore effacé.
La cause réelle est ailleurs — Proxmox inscrit manage_etc_hosts: true dans la
user-data de son lecteur cloud-init, et la user-data prime sur cloud.cfg.d tout
entier. Aucun fragment ne pouvait gagner ; renommer pour parler en dernier ne servait à
rien, le dernier mot n'appartenant pas à ce répertoire.
Ce que cloud-init régénère, il le régénère depuis hosts.debian.tmpl — le gabarit le
documente lui-même. hosts_statiques le pose donc désormais avec le même contenu que
/etc/hosts, et une garde compare les deux à chaque passage. Redémarrage d'épreuve : les
neuf entrées sont là.
La garde précédente affirmait « conforme » en mesurant l'ordre lexical — vrai, et sans rapport avec ce qui se passait. Une garde qui mesure la mauvaise chose est pire qu'aucune.
La zone DNS ne publiait pas les noms de service
forge.genese.internal et pki.genese.internal — des noms que les certificats portent et
que les clients appellent — n'avaient aucun enregistrement. Seul le plancher savait
les résoudre. Trois causes empilées :
- le plan du site coupait
serveur_powerdns_publier_expositions, au motif que « le site n'a pas d'edge » — ce qui confondait public et exposé ; expositions_des_applicationsrendaitdomaine: Nonefaute dedomaines.yml, et le modèle de zone écarte les expositions dont le domaine n'est pas la zone. Le repli existe désormais : sans domaine public déclaré, le domaine est celui que porte le FQDN — symétrique du repli déjà écrit pouredge;serveur_powerdnsexigeait les deux registres et échouait sidomaines.ymlmanquait — le même tout-ou-rien quehosts_statiquesavait corrigé le même jour.
Puis named-checkzone a refusé la zone : dns.genese.internal héritait d'un CNAME par
défaut du rôle et d'un A par exposition. La garde a bien joué son rôle — elle a
arrêté une zone cassée avant qu'elle soit servie. Le plan l'emporte désormais sur le
défaut du rôle.
Un service ne doit pas dépendre d'un plancher pour être joignable : le plancher est un filet, pas le sol.
Vérification
Les huit noms — cinq machines et trois services — résolvent vers les bonnes adresses depuis les cinq hôtes, par le plancher et par le DNS, et le plancher survit au redémarrage.
Reste nommé, pas corrigé : la zone INVERSE. serveur_powerdns_zone_inverse dérive
d'un supernet /16 — la forme d'un tenant. Un site déclare plusieurs /24 et n'a pas de
supernet unique : aucune zone inverse n'est générée, et les machines du site restent
anonymes à l'envers.
2026-08-25 — Le pare-feu Proxmox ne s'arme que dans le SDN
45 preuves. Le découpage du site en quatre zones a révélé un défaut qui dormait dans
le moteur : firewall=1 sur l'interface d'une VM, posé sur un pont classique, jette
le retour des flux en épingle.
Le mécanisme. firewall=1 fait passer tout le trafic ponté par conntrack. Sur un VNet
SDN c'est sans conséquence : en EVPN le routage inter-VNet se fait dans le VRF, sur le
nœud, et le flux ne quitte jamais l'hyperviseur. Sur un pont classique routé par une
frontière externe, deux VM du même nœud dans deux VLAN différents ne se parlent qu'en
épingle : la trame sort par le lien physique, la frontière la route, elle revient sur le
même pont. La même table conntrack voit alors les deux moitiés de la connexion, classe le
retour INVALID, et PVEFW-FORWARD le jette.
La mesure, et c'est elle qui a tranché :
ops(asgard) -> pki(asgard) 0/8 dns(gandalf) -> cache(gandalf) 0/8
ops(asgard) -> forge(vishnu) 6/8 dns(gandalf) -> pki(asgard) 6/8
ops(asgard) -> cache(gandalf) 8/8 dns(gandalf) -> forge(vishnu) 8/8
Toutes les paires intra-nœud échouent, toutes les paires inter-nœuds passent.
Douze tentatives faisaient monter le compteur ctstate INVALID de +112 sur asgard et
+116 sur gandalf ; firewall=0 posé, il ne bouge plus — 0 sur les deux.
Ce qui rend le défaut coûteux, c'est son déguisement : la poignée TCP aboutit (SYN et
SYN-ACK créent l'état), et ce sont les paquets de données qui disparaissent. L'AC le
disait dans son propre journal — TLS handshake error … i/o timeout, elle accepte la
connexion et attend un ClientHello qui n'arrive jamais. On accuse le MTU, la frontière,
la zone, le port. Écartés un par un, par la mesure : règles identiques champ par champ,
alias corrects, assignation des interfaces confirmée, ARP et routes saines, IPS désactivé,
shaper vide, NAT source limité à wan, MTU à 1500 de bout en bout et la taille sans effet
(100 octets se perdent comme 1460).
La règle, désormais dans le moteur : le pare-feu Proxmox ne s'arme que sur un VNet
SDN. L'intention déclarée ne suffit pas — un tenant déclare
proxmox_clone_parefeu_interface: true avec proxmox_clone_pont: vmbr1 comme valeur par
défaut, chaque hôte la remplaçant par son VNet dérivé ; l'hôte qui retombe sur vmbr1
naîtrait armé sur un pont classique. cloner_vm_debian.yml croise donc l'intention avec
le pont réellement utilisé, et le dit quand il désarme.
P45 évalue l'expression du playbook sur quatre cas plutôt que d'en lire le texte, dont un qui doit rendre vrai — sans lui, une expression constamment fausse passerait la preuve sans rien garantir.
Le site a révélé ce défaut parce qu'il a été le premier à porter plusieurs zones. Ce n'est pas une particularité du site : un tenant dérive ses zones du même principe.
2026-08-25 — Le SITE en quatre zones, et l'ordre inscrit dans les intégrations
44 preuves. Le site n'est plus un /24 plat : une zone par nature d'autorité,
chacune son VLAN et sa patte sur la frontière.
opt3 vlan031 10.0.31.0/24 pilotage site-ops-01 voûte underlay, API Proxmox + OPNsense
opt4 vlan032 10.0.32.0/24 autorite site-pki-01 racine de confiance
opt5 vlan033 10.0.33.0/24 genome site-forge-01, site-cache-01
opt6 vlan034 10.0.34.0/24 service site-dns-01
L'inversion que ça corrige, mesurée : les cinq VM du site n'avaient aucun filtrage
est-ouest — enable=None, policy_in=None, zéro règle — contre enable=1 policy_in=DROP
sur une machine de tenant. La machine la plus autoritaire du système était la moins
protégée. Le filtrage est nord-sud, par choix de l'exploitant : un seul point de police,
un seul langage, un seul devis — plutôt que deux politiques à tenir d'accord.
Le devis passe de 90 à 117 règles, et c'est tout le sens du découpage : ce qui était
gratuit devient policé. Chaque flux flotte produit une règle par zone source, avec
une destination nommée — le rôle visé, jamais la zone entière.
L'ordre fait partie de l'intégration
client_pki s'exécutait sur tous les hôtes en parallèle. Sur celui qui porte l'autorité,
le rôle réémet le certificat de step-ca et recharge le service ; les quatre autres
l'interrogeaient dans cette fenêtre et échouaient ensemble sur TLS handshake timeout —
un message qui accuse le réseau alors que la cause est une course de dix secondes.
Ce n'est pas propre à la PKI. Chaque intégration déclare désormais sa dépendance dans
meta/integration.yml, les playbooks de groupe en sont le miroir généré, et P44
refuse l'écart. Quatre contrôles négatifs : play unique, second play qui n'exclut pas le
serveur, any_errors_fatal retiré, déclaration absente.
serveur: ~ est une réponse, pas un oubli — client_metrique pose un exportateur que
Prometheus vient lire. Toutes les intégrations ne dépendent pas d'un service debout.
Le cas dangereux n'est pas le certificat mais le résolveur : un hôte basculé sur un résolveur pas encore prêt devient muet, et le runner qui devrait le réparer tombe avec lui.
Le plancher avant le premier apt
hosts_statiques venait après common_packages. Or apt vise le cache par son nom —
ce qui est voulu. Le jour où les adresses changent, la boucle se referme : apt échoue faute
de résoudre, donc le socle n'atteint jamais le plancher, donc le plancher reste périmé.
Cinq machines bloquées, /etc/hosts vide. Le plancher ne s'installe pas, il rend
installable — il passe donc avant.
dns_amorcage se dérive
Écrit en dur, il a eu tort deux fois : la frontière d'abord, puis une adresse que le
découpage a déplacée. Il se lit maintenant sur l'hôte qui porte serveur_resolveur.
Et l'ACL du résolveur dérivait de setops_supernet — juste tant que le site était plat.
Découpé, il refusait trois zones sur quatre, et la panne ressemble à un DNS mort alors
que c'est une autorisation.
Ce que je me suis trompé à croire
J'ai diagnostiqué un trou noir de MTU et déclaré 1450 sur les quatre zones. C'était
faux. Il n'y a pas de VXLAN sur ce chemin — il sert aux zones EVPN des tenants — et tout
est à 1500 de bout en bout, vérifié sur igb1, les vlan03x, bond3, vmbr3 et les
cartes. Ma mesure (1500 → échec, 1300 → 200) était réelle, mon interprétation ne
l'était pas : je venais de débrancher la carte à chaud en modifiant net0, et chaque
ip link set mtu reconfigurait l'interface au passage. C'est la reconfiguration qui
débloquait, pas la valeur. Les VM sont en mtu=1, héritant du pont, à 1500.
2026-08-25 — P43 : la frontière voit-elle les machines du site ?
43 preuves. Celle-ci couvre ce qui a failli coûter 36 objets ce soir : le devis de la
frontière avait cessé de voir le site, et proposait de retirer tous ses alias et toutes
ses règles. La frontière aurait laissé tomber la forge du génome, le cache racine et le
runner du site, d'un seul CONFIRMER=true.
Rien ne l'avait signalé — harnais vert, ansible-lint vert. Seule la lecture manuelle du
plan avant application l'a attrapé.
La première version était inutile, et c'est instructif
Elle lisait le plan par devis_opnsense._machines_du_plan_site() — la fonction même
dont la panne était à détecter. Éprouvée sur la régression réelle, elle ne criait pas :
elle se taisait. Les deux voyaient le vide, et la preuve concluait « rien à prouver ».
Une preuve qui partage la source de ce qu'elle vérifie ne vérifie rien.
C'est la même erreur d'instrument qui a coûté quatre faux diagnostics cette session : le
VPN pris pour la frontière, ping pour du TCP, l'overview d'OPNsense pour ses
assignations. Ici elle était logée dans la preuve elle-même.
La version retenue lit plan/serveurs.yml et plan/applications.yml directement, et
confronte au devis produit. Éprouvée sur la régression réelle : elle refuse, et nomme la
cause — « SETOPS_SITE est absent ou vide, alors que le plan déclare 5 machines ».
2026-08-25 — Le site résout chez lui, et le socle cesse de le défaire
Mise au point de l'exploitant : la frontière OPNsense n'a pas de service DNS actif et géré. Un Unbound y tourne — il répondait, ce qui m'a induit en erreur — mais un processus n'est pas un service. La délégation de zone que j'y avais posée est retirée : on ne règle pas ce que rien ne déclare ni ne prouve.
Et surtout, le site a son propre DNS. dns_amorcage pointait encore sur la frontière
(10.0.3.1), valeur d'un moment où site-dns-01 n'existait pas. Elle a survécu à sa
raison d'être. Il vaut désormais 10.0.3.51.
Reste un cas étroit, nommé plutôt que résolu : la machine qui PORTE le DNS ne peut pas
résoudre chez elle avant de l'avoir installé. Un site bâti depuis zéro doit créer et
déployer site-dns-01 en premier.
Le devis de la frontière ne voyait plus le site
En déplaçant le plan hors de underlay.yml, j'ai vidé machines() sans rebrancher
devis_opnsense. Il proposait de retirer 36 objets — tous les alias et toutes les
règles du site. Aucune preuve ne couvre le devis de la frontière du site, donc
make prouver restait vert : c'est le plan avant application qui l'a attrapé, et rien
d'autre ne l'aurait fait.
Le socle défaisait la bascule du résolveur
Sa garde ne protégeait que l'hôte du résolveur (127.0.0.1). Partout ailleurs il
réécrivait /etc/resolv.conf avec dns_amorcage, annulant client_resolveur à chaque
passage.
Invisible chez un tenant : dns_amorcage y vaut l'adresse du résolveur du tenant, donc
réécrire remettait la même valeur. La coïncidence masquait le défaut. Sur le site les
deux ont divergé quelques heures — et ça ne s'est vu qu'en retirant la règle de pare-feu
vers la frontière : les cinq machines ont perdu la résolution d'un coup.
L'amorçage ne s'applique plus que si rien de sensé n'est en place : ni la loopback, ni un
résolveur déclaré de l'écosystème. Vérifié — le socle repasse changed=0 et les cinq
machines restent sur 10.0.3.51.
2026-08-25 — Le génome vit sur la forge du SITE
Six dépôts, 606 commits, chaque tête confrontée entre le poste et la forge : identique. Le génome ne dépend plus d'un écosystème pour se reproduire.
L'ancienne forge de patient 0 devient un second remote, nommé patient0 — elle n'est pas
remplacée. Le README de patient 0 exige que le génome vive en au moins trois endroits
sans coordination : « n'importe quel survivant réamorce les autres. Une famille, pas un
maître. » La forge du site en fait un quatrième.
On vérifie l'état, on ne fait pas confiance au drapeau
Toute l'API de la forge rendait 403. Ni 401, ni un message de droits : jeton valide,
identité reconnue, requête rejetée. On cherche un scope, une politique, une capacité — et
la cause est un drapeau sur le compte, must-change-password.
Le rôle passait pourtant déjà --must-change-password=false, corrigé le 2026-08-23
sur la première forge de patient 0. Forgejo 16 a ignoré l'option : le compte est né
avec le drapeau posé malgré elle. La correction d'il y a deux jours ne protégeait plus
rien, et rien ne le disait.
D'où la forme du remède : le rôle ne demande plus à la création de bien se comporter, il
constate l'état voulu et le rétablit — une tâche idempotente, sans effet quand la
création a tenu parole. C'est la même leçon que le changed= d'Ansible qui ne prouve pas
un service : vérifier l'état, pas l'intention.
Le jeton d'amorçage est créé localement par la CLI : un mot de passe d'administrateur n'a pas à circuler pour créer six dépôts.
2026-08-25 — Un SITE a bien un plan
J'ai passé la journée à répéter qu'un SITE n'est pas un plan. C'était faux, et le
dépôt le démontrait à mesure : j'ai reconstruit un plan morceau par morceau dans
underlay.yml — machines, services, variables, intégrations — sans jamais le nommer.
Ce qui est vrai, c'est qu'un site ne dérive pas. Un tenant tire tout de son index ;
un site déclare ses adresses, parce qu'il est le terrain. Il ne passe donc pas par
instancier et ne partage pas la superclasse du tenant. Mais « ne pas dériver » n'est
pas « ne pas avoir de plan » — et faute d'avoir fait la distinction, chaque manque est
arrivé par surprise au lieu d'être prévisible.
SITE-Chezlepro/
plan/10-intrants.yml domaine, organisation, rebond, clé, amorçage, exemptions
plan/serveurs.yml 5 machines — adressage DÉCLARÉ, pas dérivé
plan/applications.yml 7 services, leur placement, et leurs `expose:`
underlay.yml la fabric seule : ce qui est MESURÉ
underlay.yml décrit ce qui est mesuré, le plan ce qui est voulu. Les groupes
viennent du registre des applications, comme chez un tenant. Les gardes ont suivi le plan
— une garde loin de ce qu'elle garde finit par garder autre chose — et quatre contrôles
négatifs les éprouvent, dont une collision d'IP avec la frontière.
Ce que le site a fait tomber, et qu'aucun tenant ne pouvait révéler
Le site est le premier écosystème qui n'a que le strict nécessaire : pas de plan (jusqu'à aujourd'hui), pas de Keycloak, pas de PostgreSQL, pas d'edge, pas de rôles colocalisés.
| défaut | pourquoi il était invisible |
|---|---|
client_pki codait infra-pki-01 en dur |
la nomenclature d'un tenant nomme toujours sa PKI ainsi — le littéral avait raison par coïncidence |
| aucun moyen pour un service non-root de lire la clé | nginx, Postfix, Dovecot lisent en root avant de déprivilégier ; Forgejo tourne en git d'emblée |
le certificat — public par nature — restait en 0600 |
même cause |
l'unité Forgejo n'avait pas d'ExecReload |
chez un tenant c'est nginx qui consomme le cert, et il sait se recharger |
| le rôle Forgejo ne savait pas servir TLS | il y avait toujours un edge devant |
| le certificat ne couvrait pas le nom de service | expose: n'existait pas, faute de plan |
| chargement des registres en tout-ou-rien | domaines.yml existe toujours chez un tenant |
le flux déclarait 3000 en dur |
vrai tant qu'il y avait toujours un edge |
Le message d'erreur accusait presque toujours autre chose : le DNS quand c'était un nom faux, une permission de fichier quand c'était un port privilégié, rien du tout quand le plancher s'écrivait vide.
ROOT_URL est gravée dans les URL de clonage
La forge servait son TLS sur 3000 — le port qu'elle écoute derrière un edge. Coût
réel : chaque écosystème descendant aurait porté :3000 dans son origin, pour toujours.
Elle sert désormais sur 443, ROOT_URL = https://forge.genese.internal/, avec
CAP_NET_BIND_SERVICE dans son unité — Forgejo tournant en git ne peut pas se lier à un
port privilégié autrement, et l'échec parlait de « permission denied » sur le port.
Sans edge déclaré, le service se sert lui-même
expositions_des_applications résolvait l'edge par le domaine. Sans domaines.yml,
elle rendait edge: None, le gabarit cherchait groups[None] et écrivait un plancher
sans alias, sans erreur. Le repli dit une vérité générale, et corrige aussi un silence
chez les tenants dont un domaine ne déclare pas d'edge.
Chaîne complète, vérifiée depuis le réseau : expose: → /etc/hosts → SAN du certificat
→ Verify return code: 0 (ok) → https://forge.genese.internal/ → 200.
2026-08-25 — Un SITE n'a pas d'index : le vestige est retiré
Le site portait index: 17. Ça n'a jamais voulu dire « voici mon adressage » — un site
ne dérive rien, ses machines vivent sur un réseau de fabric (10.0.3.0/24), hors de toute
dérivation. Ça voulait dire une seule chose : quel supernet de tenant est le mien,
pour autoriser un réseau du site à en occuper la bande basse (D-77).
C'était un reste de la coïncidence hébergeur↔tenant chez Chezlepro, qui est les deux à la fois. Ailleurs, ça aurait induit en erreur : un hébergeur qui n'héberge pas son propre écosystème n'a aucun supernet à lui, et devait quand même inventer un index.
L'exception se déclare désormais sur le réseau qui en a besoin, et elle nomme le tenant dont elle occupe la bande basse :
- nom: management
segment_physique: true
bande_basse_de: OPS-Chezlepro
sous_reseau: 10.17.0.0/24
Trois gains. C'est plus vrai — un seul réseau est concerné, pas le site entier. C'est
plus lisible — le lecteur voit pourquoi ce /24 habite un /16 qui n'est pas le sien.
Et c'est vérifiable : le nom se résout dans le registre tenants:, donc une faute de
frappe est refusée au lieu de passer silencieusement.
Quatre contrôles négatifs, tous refusés : un tenant inconnu, l'exception absente alors que le chevauchement existe, un débordement sur la bande des zones, et — le cas subtil — le mauvais tenant nommé.
2026-08-25 — Le SITE devient un écosystème complet : PKI, DNS, forge
Le site portait trois services sur les sept que le modèle origine appelle le plus
petit écosystème complet. Ce n'était pas un choix : j'ai bâti le strict minimum pour y
déplacer la forge, signalé une fois que « le site n'a ni PKI ni DNS », puis continué. La
décision était celle de l'exploitant, et je l'avais tranchée par omission.
Ce que ça coûtait : la forge servait le génome en clair — le code qui fabrique tous les écosystèmes — et aucune machine du site n'avait de certificat, donc aucun chiffrement est-ouest. La doctrine zéro-confiance l'interdit frontalement.
site-pki-01 (step-ca) et site-dns-01 (PowerDNS + résolveur, colocalisés) rejoignent
les trois autres. Cinq machines, réparties sur les trois nœuds.
Quatre défauts que le site a fait tomber
Le site est le premier endroit qui n'a que le strict nécessaire — pas de plan, pas de Keycloak, pas de PostgreSQL, pas de rôles colocalisés. Chaque manque a révélé une dépendance implicite qu'aucun tenant ne pouvait exposer :
| défaut | où il se cachait |
|---|---|
| DNS bloqué par notre propre default-deny | un tenant a son résolveur dans son réseau et ne traverse jamais la frontière |
serveur_cache_site sans dépendance déclarée |
colocalisé avec serveur_artefacts chez patient 0 |
| OIDC résolu même désactivé | il y a toujours un Keycloak chez un tenant |
plancher /etc/hosts vide |
hotes_actifs n'existait pas dans l'inventaire du site |
Le DNS du site est dérivé, pas écrit à la main : le devis lit site.dns_amorcage et
émet SETOPS_SITE → SETOPS_RESOLVEUR_SITE en 53/udp et 53/tcp. Destination déclarée,
jamais any — ouvrir le 53 vers le monde aurait été une sortie DNS non policée.
La panne ne ressemblait pas à un pare-feu : resolv.conf correct, Unbound qui écoute sur
toutes les interfaces, le port qui « répondait » à un test TCP naïf. On a cherché du côté
du résolveur pendant que c'était le filtre.
serveur_cache_site déclare enfin sa dépendance. Il n'installe rien — il marque un
cache comme racine de chaîne et lit pour ça les variables de serveur_artefacts. Or les
défauts d'un rôle ne sont en portée que dans le play qui l'inclut, et chaque groupe est
appliqué par son propre playbook. Déclarer les deux rôles sur la machine ne suffisait pas ;
une dépendance de rôle règle l'ordre et la portée.
On ne résout pas un fournisseur d'identité qu'on n'utilisera pas. resoudre_idp
partait inconditionnellement et exigeait un plan. serveur_forgejo_oidc_actif dérivait
déjà de la présence de Keycloak : la résolution suit désormais l'usage.
Une machine du site peut se configurer
Un tenant configure ses services par son plan. Le site n'en a pas — c'est tout le sens de
« un SITE n'est pas un plan » — mais ses machines ont quand même des choix à faire. D'où
une section variables: par machine, appliquée en dernier : ce qu'une machine déclare
d'elle-même prime sur ce que le site déclare pour toutes. La forge y déclare
serveur_forgejo_bd: sqlite, le DNS serveur_powerdns_publier_expositions: false.
Vérifié, pas supposé
systemd disait active et la racine existait — mais rien n'écoutait sur 443 :
step-ca sert sur 8443. Le changed= d'Ansible n'est pas une preuve de service. La
zone souveraine résout (site-forge-01.genese.internal → 10.0.3.31) et la récursion
marche, mesurées depuis la machine.
2026-08-25 — C'est le SITE qui détermine l'index d'un tenant
SITE et OPS sont deux classes distinctes. Le site décide de la fabric et du réseau et produit les intrants ; le tenant les consomme et n'a d'intelligence que sur ses applications, leur configuration et leurs intégrations. Une valeur réseau qu'un OPS décide est une valeur mal placée.
Or l'index est l'intrant réseau par excellence : de lui descendent le supernet
10.<index>.0.0/16, les sous-réseaux de zone, les VLAN, les VMID, les noms de VNet SDN.
Un tenant qui le décide décide du plan d'adressage de la fabric.
Ce qui n'allait pas
Il était déclaré deux fois — dans plan/nomenclature.yml du tenant et dans
l'underlay du site. Deux sources de vérité pour le même nombre, dans deux classes
différentes. Et le site ne déclarait même pas qui il héberge : la clé tenants:
existait dans le code, avec une docstring qui décrivait précisément le danger, mais
n'était écrite nulle part — la découverte se faisait par balayage des dossiers frères,
c'est-à-dire par accident du système de fichiers.
Le sens de la flèche change, pas les valeurs
tenants: devient un registre d'allocation : OPS-Chezlepro: 17,
OPS-Technolibre: 23, OPS-Patient0: 29. Le site alloue, le tenant reçoit, et la
nomenclature du tenant n'est plus que la copie vérifiable d'une décision prise ailleurs.
Quatre gardes neuves, chacune éprouvée par un contrôle négatif : une allocation qui
contredit le plan du tenant, deux tenants sur le même index, une allocation qui n'est pas
un entier, un tenant nommé qui n'existe pas. Elles vivent dans underlay.valider(), donc
sous P23 — aucune preuve à ajouter.
La forme héritée (simple liste de noms) reste acceptée : elle dit « ces tenants sont ici » sans rien allouer, pour un site qui n'a pas encore migré.
Et l'émancipation
Un tenant qui part chez un autre hébergeur reçoit un index neuf de son nouveau site. L'index n'est pas une propriété du tenant : c'est une place sur une fabric.
2026-08-25 — La vue Réseau du panneau était vide : un KeyError deux couches plus bas
segment_physique: true — introduit le matin même pour dire qu'un réseau n'a pas
d'étiquette VLAN — retire la clé vlan. Or devis_reseau.py écrivait r['vlan'] en une
vingtaine d'endroits.
Le premier segment non étiqueté a levé un KeyError, /api/devis-reseau a rendu une 500,
et la vue Réseau de l'interface est restée vide. Une panne qui ne ressemble à rien, à
deux couches de sa cause — c'est le genre qu'on cherche partout sauf là où elle est.
Filtré à la source, une seule fois. Colmater les vingt appels aurait laissé le
vingt-et-unième. devis_reseau définit désormais son propre reseaux_de_fabric qui écarte
les réseaux sans étiquette, et les dix appels y passent : ce fichier ne configure que des
commutateurs, et un commutateur n'a rien à dire d'un segment qui ne l'atteint pas.
Le bloc de gestion d'un switch échappait au filtre — il lit son réseau directement. Quand
ce réseau n'est pas étiqueté, il n'existe aucune interface VlanN : l'adresse appartient à
l'interface de gestion native du boîtier, dont le nom dépend du modèle. Le devis le dit
plutôt que d'inventer une syntaxe — un devis qui promet une commande fausse est pire qu'un
devis qui se tait.
Au passage, ça expose une incohérence de la carte : bifrost-3 et bifrost-4 déclarent
leur adresse de gestion sur management, un segment qui ne les traverse pas. Ces deux
adresses sont d'ailleurs celles de l'ancien plan et n'ont jamais répondu.
Les quatre devis du panneau — switches, frontière, SDN, pare-feu est-ouest — repassent.
2026-08-25 — Un renommage de rôle laissait son ancien fichier aux commandes
Mesuré sur forge-01, par l'agent invité — donc sans dépendre du réseau. Quatre
drop-ins SSH coexistent là où il devrait y en avoir deux :
10-chezlepro.conf 10-setops.conf
20-chezlepro-hardening.conf 20-setops-hardening.conf
Les rôles se sont appelés chezlepro avant de porter le nom du moteur. Le renommage a
changé le fichier déposé sans retirer le précédent. Et les paires ne disent pas la même
chose : MaxSessions 2 contre 10, MaxStartups 5:30:20 contre 10:30:60.
C'est l'ancien qui gagne. sshd retient la première valeur rencontrée pour chaque
mot-clé, et lit les drop-ins dans l'ordre lexical : 20-chezlepro-hardening.conf passe
avant 20-setops-hardening.conf. Une configuration qu'on croit avoir remplacée reste donc
aux commandes, en silence — et sa trace se lirait un jour comme une lenteur inexplicable
pendant un déploiement parallèle, cherchée partout sauf là.
ssh_baseline et ssh_hardening retirent désormais chacun le fichier de la nomenclature
qu'il a abandonnée, avant de déposer le sien, et notifient la même validation. La liste
est déclarative (ssh_baseline_fichiers_perimes, ssh_hardening_fichiers_perimes) et ne
contient que des noms qu'on a réellement déposés un jour : supprimer un fichier qu'on n'a
jamais écrit serait effacer la configuration de quelqu'un d'autre.
Pas encore éprouvé. Les seules machines portant les anciens fichiers sont celles de patient 0, actuellement hors d'atteinte depuis le poste — leur écosystème est sain, c'est le chemin d'entrée qui est coupé. Le correctif est écrit et validé, il reste à le voir retirer un fichier pour de vrai.
2026-08-25 — Le clonage inter-nœuds : quatre défauts que la première VM ailleurs a révélés
Toutes les VM naissaient jusqu'ici sur le nœud du gabarit, puis migraient. site-cache-01
est la première à être placée ailleurs — et elle a fait tomber quatre défauts enchaînés du
moteur de clonage. Aucun n'était visible avant, et aucun ne disait son nom.
1. Le clonage visait le nœud de destination. L'URL était
nodes/{{ proxmox_clone_noeud }}/qemu/<gabarit>/clone, alors que l'API veut le nœud qui
détient le modèle ; la destination se dit par target. Le gabarit vit sur asgard,
la VM allait sur gandalf → 500 Configuration file 'nodes/gandalf/qemu-server/99998.conf' does not exist. Le nœud du gabarit se découvre désormais dans l'inventaire du cluster.
2. Le refus de l'API était avalé. failed_when: false + no_log: true protégeaient
l'en-tête d'authentification, mais masquaient aussi le 500. Une assertion le relève
maintenant, message de l'API compris — le masque reste, l'erreur sort.
3. L'attente cherchait la tâche au mauvais endroit. Elle interrogeait
nodes/<destination>/tasks/<UPID> ; un clonage inter-nœuds s'exécute sur le nœud du
gabarit. L'UPID n'existait pas là, l'API répondait en erreur, until n'était jamais
satisfait : 60 tentatives × 10 s pour un clonage terminé en 87 s, puis la suite qui
reprend sans un mot. Un UPID s'écrit UPID:<nœud>:<pid>:… — le nœud y est déjà, on le lit
là plutôt que de le redemander à une variable.
4. La garde d'après-attente ne pouvait pas échouer. exitstatus | default('OK')
faisait passer pour un succès une attente qui n'avait rien observé : sans json.data,
pas d'exitstatus, donc « OK ». Elle exige désormais d'avoir vu la tâche s'arrêter, et
bien s'arrêter.
La taille des disques porte toujours son unité
disque: 40 produisait un resize à « 40 » que Proxmox lisait comme un rétrécissement
(shrinking disks is not supported). Le playbook tolère cette erreur — à raison, un disque
déjà assez grand n'est pas une panne — donc la machine naissait avec les 16 Go du gabarit
au lieu de 40, sans que rien ne le dise. Les plans des tenants écrivent 40G depuis
toujours ; le site écrit pareil, et un entier nu se voit rattacher son unité.
Le site déclare ce qu'il matérialise
Le playbook prenait le gabarit et le stockage dans les group_vars du tenant actif :
make site-creer aurait cloné depuis un modèle différent selon le symlink instance,
sans rien dire. materialisation: (vmid du gabarit, stockage, clone_complet) vit
désormais dans l'underlay du site, et site-creer les passe explicitement.
Ce que le chronomètre a dit
Le clonage réel : 59 s et 87 s pour 3,3 Gio effectivement alloués (le gabarit en
déclare 16). Ce n'était donc pas le disque du modèle qui coûtait — c'était les dix minutes
d'attente aveugle du défaut n° 3. Le clone lié reste le vrai levier (rbd clone depuis
@__base__, instantané), mais il exige CephNVMe en destination : TrueNAS est du LVM
épais, sans copy-on-write.
2026-08-25 — La frontière voit le site : fabric, alias et règles
Un mot manquait au vocabulaire des flux
Le runner de SITE déclarait son API Proxmox en pair: externe. Or « externe » se rend
par !SETOPS_INTERNES — tout sauf les espaces privés — et les hyperviseurs sont en
RFC 1918. La règle sortante les aurait exclus tout en ayant l'air d'ouvrir le flux.
Un flux qui a l'air ouvert et qui ne l'est pas est pire qu'un flux fermé : il ne se
cherche pas.
D'où fabric : le matériel de l'hébergeur — hyperviseurs, frontière, commutateurs. Ce
n'est ni flotte (les machines d'un écosystème) ni voisins_site (les tenants d'à côté),
c'est le socle sur lequel les uns et les autres reposent. Le seul à s'y adresser est
le runner du site, et c'est tout son objet.
Deux flux qui n'existaient pas
Le cache racine ne pouvait rien aller chercher. serveur_cache_site ne déclarait
qu'un ingress 3142 : la chaîne entière se terminait sur un cache vide. Ça ne se serait
vu qu'au premier apt update d'un écosystème neuf — c'est-à-dire au pire moment.
Le runner de site n'avait que l'API. Déplacer un disque, poser un pont, lire une configuration réseau passe par le shell du nœud ; ouvrir les flux du tenant qu'on matérialise passe par l'API de la frontière. Trois pouvoirs distincts, déclarés à part.
Ce que le devis ignorait
Les machines du site ne sont dans aucun plan de tenant : la boucle du devis ne pouvait pas les voir, et il rendait zéro règle pour elles sans rien signaler. Un devis muet sur une machine qui existe n'est pas un devis.
Le devis émet désormais SETOPS_SITE (le réseau), SETOPS_FABRIC (ce que le runner
pilote), SETOPS_ADMIN_SITE (seule source du SSH) et un alias par rôle du site. Leur
trafic arrive par opt2, pas par le lien de transit — une règle posée sur la mauvaise
interface ne correspond jamais, et c'est la panne la plus silencieuse de cette couche. Le
SSH de gestion, lui, arrive par lan. Plus le NAT sortant du site : son réseau est
directement attaché, mais le mode automatique d'OPNsense a été quitté en déclarant les
tenants à la main — compter sur un mode qu'on a soi-même quitté se paierait au premier
téléchargement.
Le socle s'applique aussi au site. site_inventaire.py range ses machines dans
serveur_debian : même durcissement SSH, mêmes horloges, mêmes règles que n'importe
quelle machine de la flotte. L'oublier aurait donné à la machine la plus puissante du
site la protection la plus faible.
Patient 0 rend ses responsabilités de site
Corrigé le 2026-08-25. Cette section affirmait que « patient 0 est un tenant comme les autres : c'est même tout ce qu'il prouve ». C'est faux, et le texte est corrigé plutôt qu'effacé. Un tenant ordinaire existe pour ses gens ; patient 0 n'héberge que la lignée. Il est l'écosystème d'origine et le détenteur du génome, et il le reste. Le geste était juste, la raison ne l'était pas — et une doctrine juste appuyée sur une raison fausse finit toujours par se retourner.
Son plan déclarait encore serveur_ops_site et serveur_cache_site. Il les tenait non
par nature mais faute d'un SITE capable de les porter : à l'époque, un site n'était
qu'un fichier de carte, sans machines à lui.
Le SITE existe désormais comme objet à part entière — machines déclarées dans son underlay, sur leur propre réseau de fabric, avec leur voûte et leur runner. Matérialiser et servir le cache aux voisins lui reviennent. Porter la lignée reste chez patient 0.
Bilan au devis : 26 règles à créer, 5 à retirer (dont les trois périmées de patient 0).
2026-08-24 — Un SITE n'est pas un plan : ses machines vivent dans l'underlay
J'avais d'abord fait entrer le site dans le générateur des tenants, avec une branche « si l'adresse est déclarée, elle gagne ». Cette branche est retirée.
Le symptôme était visible tout de suite. Un tenant se dérive : de son seul index
descendent son supernet, ses VLAN, ses VMID, ses VNet SDN. Un site ne dérive de rien —
il n'a pas d'index, il est le terrain sur lequel les tenants dérivent. Les faire
passer par la même moulinette donnait un nomenclature.yml de site réduit à une coquille
vide, et un site exclu des devis par absence d'index plutôt que par nature. Une
exclusion fondée sur un manque casse au premier ajout innocent.
Les machines de l'hébergeur se déclarent donc dans underlay.yml, à côté des switches et
des hyperviseurs qui les portent : même carte, même fichier, mêmes secrets. Ce qui se
partage entre un site et un tenant, ce sont les rôles, pas la forme du plan.
Six gardes neuves, chacune éprouvée par un contrôle négatif : réseau inconnu, réseau sans pont, nœud qui n'est pas un hyperviseur déclaré, IP hors sous-réseau, IP déjà prise, vmid absent ou en double, machine sans service.
Ce que la mesure a corrigé
Le réseau d'administration 10.17.0.0/24 n'a pas d'étiquette VLAN, et n'en a jamais
eu. vlan: 10 était une supposition héritée, et elle était fausse : la frontière le porte
sur igb0, un port physique à elle. Il existe bien un VLAN 10 sur vmbr1, mais c'est
l'ancien plan 10.0.0.0/24, aujourd'hui vide — deux choses différentes que le même
chiffre confondait. D'où segment_physique: true.
Aucun pont d'hyperviseur ne touche ce segment : trois sondes non persistantes depuis
asgard (bond0 étiqueté 10, bond3 étiqueté 10, vmbr1 non étiqueté) sont restées muettes,
témoin positif réussi dans le même script. Une VM y naîtrait sourde. Le validateur refuse
désormais toute machine déclarée sur un réseau sans pont:.
Le premier jeu de sondes avait pour cible la frontière, et concluait faux : elle ne répond pas à une source qu'elle ne connaît pas — son default-deny travaillait. C'est le témoin qui l'a révélé, pas la sonde.
Les machines du site vivent sur site-services — VLAN 30, 10.0.3.0/24, pont
vmbr3 (bond3, créé sur les trois nœuds), passerelle 10.0.3.1 portée par la frontière
sur son interface OPT2. site-ops-01 en .11, site-cache-01 en .21.
Le repli intermédiaire était grappe-controle (192.168.11.0/24) : porté par un vrai
pont, mais de l'hérité — des adresses sans avenir, derrière une passerelle qui n'est même
pas la frontière. Ancrer le site là aurait figé les deux défauts dans la carte. Le VLAN 30
lève les deux : adressage de fabric cohérent avec le transit (vlan 40 → 10.0.4.0/24), et
sortie par la frontière, donc policée.
Le chemin qui matérialise un site
site_machines.py traduit une déclaration d'underlay en les mêmes SETOPS_* que
make cloner-vm consomme déjà : le clone reste le seul chemin éprouvé, il n'y en a pas
deux. site_inventaire.py est un inventaire dynamique — un site ne dérivant de rien,
sa déclaration est déjà sa forme finale, et un hosts.yml généré ne rendrait rien de
plus inspectable. Les groupes sont les services : playbooks/groupes/<rôle>.yml trouve
ses hôtes sans qu'on câble quoi que ce soit.
Quatre cibles, indépendantes de toute instance montée — le site existe avant tout tenant
et doit pouvoir naître quand aucun n'est encore là : site-decrire, site-inventaire,
site-creer (CONFIRMER=true), site-appliquer GROUPE=….
Le premier garde-fou de site-appliquer refusait un GROUPE vide — il ne pouvait
jamais se déclencher, GROUPE ayant un défaut global. Le vrai risque, observé en le
testant : un playbook de tenant lancé contre l'inventaire du site n'y trouve aucun hôte,
n'a rien à faire, et sort avec 0 — un succès qui n'a rien fait. La cible refuse désormais
tout groupe absent de cet inventaire-ci.
passerelle_amont, rôle d'hôte neuf : un routeur qui existe mais que nous
n'administrons pas. Le déclarer n'est pas l'adopter — c'est distinguer une passerelle
étrangère d'une passerelle fantôme.
2026-08-24 — Quinze invites pour un seul mot de passe
flotte-creer appelait make creer-vm une fois par hôte, et chaque appel ajoutait
--ask-vault-pass. Quinze machines, donc quinze invites — quatre en parallèle, avec la
sortie redirigée vers des journaux : l'exploitant est harcelé par des invites qu'il ne voit
même pas.
Mesuré en reconstruisant Chezlepro. Ce n'est pas une fatalité de l'outil, c'est une faute de l'outil.
L'idiome existait déjà — je ne l'avais pas cherché
deployer-tout demande une seule fois depuis longtemps, et refuse proprement quand
personne n'est au clavier. Ma première correction inventait une seconde façon de
manipuler un secret, à côté de la première.
C'est exactement la faute que P41 combat : deux résolutions d'une même question finissent par diverger, et pour un secret la divergence ne se remarque qu'après.
Les deux cibles partagent désormais un seul bloc, VAULT_UNE_FOIS : lecture unique,
fichier mktemp en 0600, trap au retrait, et refus explicite en entrée non
interactive.
Une garde que ma première version n'avait pas
-t 0 : ne demander que si un humain est au clavier. Sans elle, un appel non interactif —
CI, ordonnanceur, agent — resterait bloqué sur une invite que personne ne lit. Je m'en
suis aperçu en vérifiant ma propre correction : la commande a pendu deux minutes.
Vérifié : flotte-creer sans mot de passe et sans terminal refuse maintenant en une
ligne, au lieu d'attendre indéfiniment.
2026-08-24 — Le dénominateur extrait : origine devient un modèle
La hiérarchie se lit maintenant sans ambiguïté :
Set-OPS le moteur — méthodes, rôles, preuves
SITE-xxx le terrain — UN par hébergeur
Set-OPS-Modeles les plans-types, dont `origine` est le dénominateur commun
OPS-xxx les écosystèmes réels : un modèle, posé sur un SITE
Chezlepro et Technolibre sont frères — deux OPS au même niveau.
Patient 0 conflait trois rôles
La mesure l'a montré : il portait le dénominateur commun et un index: 29, une voûte
réelle, une parenté, cinq VM en marche — quatre choses qu'un modèle n'a pas.
socle public pki · edge · mail · dns
patient 0 pki · edge · dns · forge · ops
+ artefacts + cache_site + ops_site + resolveur
Trois rôles, dont un seul est générique :
| rôle | où il vit désormais |
|---|---|
| le dénominateur commun | le modèle origine |
l'écosystème de l'hébergeur de SITE-Chezlepro |
reste dans OPS-Patient0 |
| le détenteur du génome | reste dans OPS-Patient0 |
Ce que origine ne porte pas
serveur_ops_site et serveur_cache_site — les rôles de l'hébergeur. Un modèle qui
les porterait donnerait à chaque client un pouvoir sur ses voisins.
Ni index réel, ni voûte, ni parenté. Un modèle est une recette : index: 1 est un
gabarit que l'instanciation remplace par le seed dont tout l'adressage dérive.
C'est la même séparation que pour les runners et pour les voûtes — distinguer la recette de la machine qui l'applique.
Et le même défaut, trouvé une fois de plus
Les six modèles privés portaient encore setops_plan_dir: instance/plan — le lien du
moteur, en dur. Corrigé chez les instances et le modèle public il y a deux jours, il avait
survécu ici. Un déploiement lancé par SETOPS_INSTANCE y aurait lu le plan d'une autre
instance, comme l'edge de patient 0 avait publié les noms de Chezlepro.
Les sept modèles génèrent leur inventaire.
2026-08-24 — Les deux pare-feu appliqués, et un derive qui n'était compris que d'un côté
frontiere a creer : 0 | a retirer : 0 | inchange : 65 + 15 routes
est-ouest a creer : 0 | a mettre a jour : 0 | a retirer : 0
Flotte vérifiée après coup, sur les cinq hôtes : DNS interne, Internet, apt sans erreur,
et le cache d'artefacts joignable (HTTP 200).
Un port derive que seul un générateur sur deux savait lire
J'avais déclaré le port de PowerDNS derive — il vaut 53 seul sur son hôte, 5300 derrière
le résolveur. verifier_ports traite depuis toujours un port non numérique comme « pas une
écoute fixe ». Le générateur est-ouest, lui, l'envoyait tel quel à l'API Proxmox :
dport: derive -> 400 « invalid format - invalid port 'derive' » six règles refusées
Une même notion, comprise d'un côté et pas de l'autre. Elle l'est désormais des deux.
Et sauter est la bonne réponse, pas un contournement : depuis que le résolveur du
tenant est la seule porte, l'autoritatif n'écoute que sur 127.0.0.1:5300. Aucune règle
est-ouest n'a d'objet pour lui — les trois groupes t*-srv-powerdns sont devenus périmés
et ont été retirés.
Mais un flux qu'on n'applique pas doit se voir. Le devis recense et affiche les ports sautés, avec la raison. Sans cette note, sauter proprement serait devenu un trou silencieux — la faute que ce dépôt traque sous tous ses déguisements.
Le même piège qu'à la frontière, une heure plus tôt
Là aussi le premier essai a signalé des échecs, et là aussi une partie du travail était déjà écrite : le devis suivant est passé de « 6 à créer, 10 à mettre à jour » à « 0 à créer, 1 à mettre à jour ». Un refus partiel n'est pas un refus.
2026-08-24 — La frontière appliquée, et une contrainte qui n'était écrite nulle part
a creer : 0 | a retirer : 0 | inchange : 65 + 15 routes — le devis est clos. La flotte de
patient 0 vérifiée après coup : DNS interne, Internet et apt sans erreur sur les cinq.
OPNsense refuse un nom d'alias de 32 caractères ou plus
Le schéma SETOPS_<tenant>_<ROLE> ne laisse que 17 caractères au rôle :
SETOPS_PATI29_SERVEUR_ARTEFACTS_SITE 36 refusé
SETOPS_PATI29_SRV_ARTEFACTS_SITE 32 refusé encore, d'un caractère
SETOPS_PATI29_SRV_CACHE_SITE 28 passe
La contrainte n'était écrite nulle part, et elle s'est manifestée à l'application,
pas au devis. Un devis qui promet un objet que la cible rejettera n'est pas un devis :
nom_alias abrège désormais (SERVEUR_ → SRV_, comme le pare-feu est-ouest) et refuse
bruyamment si le nom déborde encore. Le rôle est renommé serveur_cache_site.
« RIEN N'EST APPLIQUÉ » voulait dire autre chose que ce que j'ai lu
Le premier essai a signalé cinq échecs et affiché « RIEN N'EST APPLIQUÉ, la config reste en attente ». J'ai d'abord compris que le boîtier n'avait pas été touché. Le devis suivant disait autre chose : 62 objets inchangés au lieu de 49.
Les objets étaient bien écrits dans la configuration ; c'est le rechargement qui n'avait pas eu lieu — « en attente » au sens d'OPNsense. Une différence qui compte : entre les deux, la configuration contenait des objets que le pare-feu en marche ignorait encore.
Et la garde du boîtier injoignable a servi
Une tentative a échoué sur une poignée TLS expirée — le premier paquet après une pause, mesuré tout au long de la journée. Le code a refusé de lire un boîtier injoignable comme un boîtier vide, au lieu de proposer de tout recréer. C'est exactement ce pour quoi cette garde avait été écrite.
2026-08-24 — Le chaînage des caches, et la règle qui appartient au SITE
Le runner de SITE est le seul à toucher la configuration système du matériel. Sa responsabilité est de préparer le terrain — VNets, routes, flux — pour que le plan d'un OPS puisse tenir. Ensuite le runner du tenant fait la suite.
Cette mise au point tranche une question restée ouverte : une règle inter-tenant n'appartient ni à l'émetteur ni au récepteur, mais au site. Ce n'est pas « Chezlepro ouvre une porte chez patient 0 » — c'est le site qui autorise un flux entre deux de ses tenants.
Ce que je croyais, et qui était faux
J'avais annoncé que le générateur construisait « tenant par tenant, sur les interfaces de ce tenant », et que des règles croisées changeraient sa forme. Faux : il fait une seule passe sur tous les tenants du site, et il n'y a qu'une interface de transit. Les tenants s'y distinguent par leur alias source, pas par leur interface.
Deux silences fermés dans le générateur
flux_frontiere() ne retenait que les flux externe. Or deux tenants d'une même fabric
vivent sur des VLAN distincts, routés par la frontière : leur trafic la traverse, donc
elle doit le porter. Sans ça, le mot voisins_site était accepté par la validation et
rendait zéro règle — un flux déclaré que personne n'applique.
Et un egress vers un voisin recevait !SETOPS_INTERNES en destination, comme tout flux
sortant — c'est-à-dire le port ouvert vers l'Internet. La destination est désormais
nommée.
Deux rôles, parce que les flux sont statiques
Un rôle unique aurait dû déclarer l'ingress inter-tenant pour tous les caches. Le devis l'a montré avant toute application :
+ TENANT_PATI29 -> CHEZ17_SERVEUR_ARTEFACTS un maillage complet,
+ TENANT_TECH23 -> CHEZ17_SERVEUR_ARTEFACTS chaque cache acceptant chaque autre
serveur_artefacts_site porte donc l'ingress, serveur_artefacts l'egress vers son amont.
Résultat au devis : l'ingress ne vise plus que le cache du site.
Ce que ça donne
VM du tenant -> cache du tenant -> cache du SITE -> Debian
Debian téléchargé une fois pour toute la fabric. Un seul flux nouveau par écosystème, entre deux caches — jamais d'une VM vers le cache d'un voisin. Et le cache du site ne voit que des requêtes agrégées : jamais quelle machine installe quoi.
Vider serveur_artefacts_amont, c'est s'émanciper.
Ce qui reste large, et que je ne cache pas
L'egress autorise chaque cache de tenant à joindre le 3142 de tous ses voisins, alors
qu'un seul lui sert d'amont. La déclaration étant statique, le rôle ne sait pas lequel de
ses voisins est le cache du site. La portée effective reste juste — seul le cache du site
accepte — mais la règle sortante est plus permissive que nécessaire.
Rien n'a été appliqué. Le devis se lit avant.
2026-08-24 — voisins_site : le vocabulaire d'un flux entre tenants, et le mur qu'il révèle
Le registre des flux connaissait flotte (mon écosystème), edge, admin et externe.
Il ne savait pas dire « les autres tenants de ma fabric » — et ingress + externe
signifie depuis l'Internet : déclarer ainsi un cache partagé l'aurait publié au monde.
voisins_site existe maintenant dans resoudre_flux. Il rend des CIDR — les supernets
des tenants que ce site héberge — et réutilise devis_reseau.decouvrir_du_site()
plutôt que d'en écrire un second recensement. Deux listes de tenants finiraient par
diverger, et la divergence se lirait « tout va bien ».
Vérifié : depuis patient 0, il rend 10.17.0.0/16 et 10.23.0.0/16. Le lab, federe: false, est correctement exclu.
Une faute évitée de justesse. Ma première version lisait un federe absent comme
« non fédéré » et excluait Chezlepro et Technolibre — leur nomenclature est antérieure à
cette clé. devis_reseau dit n.get("federe", True) : l'absence vaut fédéré. Réutiliser
sa découverte plutôt que d'en réécrire une a fermé le piège au passage.
Ce qui n'est PAS fait, et pourquoi
Le chaînage des caches — chaque écosystème garde le sien, qui prend celui de l'hébergeur comme amont, pour que Debian ne soit téléchargé qu'une fois par site — n'est pas livré.
Le vocabulaire est accepté, mais aucun générateur ne le rend : make frontiere-plan
produit zéro règle pour le port 3142 entre tenants. Un flux déclaré que personne
n'applique est précisément le piège que ce dépôt traque, alors les déclarations ont été
retirées plutôt que laissées à moitié.
La difficulté est structurelle, pas cosmétique. Les règles d'OPNsense s'évaluent sur l'interface d'arrivée : un paquet venant de Chezlepro vers le cache de patient 0 arrive sur l'interface de Chezlepro, donc la règle doit être posée là. Or le générateur construit ses règles tenant par tenant, sur les interfaces de ce tenant. Émettre des règles croisées change la forme de ce qu'il produit — et ses propres commentaires répètent qu'une règle posée sur la mauvaise interface ne correspond jamais à un paquet.
Ce qui reste en place : le mot voisins_site, éprouvé ; et serveur_artefacts_amont, la
capacité de chaînage côté rôle. Il manque le rendu, dans les deux générateurs de pare-feu.
2026-08-24 — Un résolveur par tenant, et non plus un par machine
client_unbound posait un Unbound sur chaque VM. C'était étanche, et c'était N démons
identiques pour un service unique.
Mesure prise avant de décider : ~21 Mo de RSS par VM, et 28 à 189 requêtes servies depuis le démarrage. Le gain de cache mutualisé est donc négligeable ; ce qu'on récupère, c'est un démon au lieu de cinq — et un endroit à regarder au lieu de cinq.
Pourquoi pas sur la frontière
C'était la proposition, et elle aurait été la plus économe. Mais un résolveur partagé par tout le SITE devrait connaître la zone interne de chaque tenant : sans vues par réseau soigneusement réglées, le tenant A résoudrait les noms du tenant B. Et un tenant dont le résolveur vit chez l'hébergeur ne peut plus s'émanciper avec.
La récursion est générique ; la zone interne ne l'est pas. C'est ce qui fait du résolveur un service de tenant, et non de fabric.
La cohabitation avec l'autoritatif
Les deux vivent sur infra-dns-01 et voudraient le port 53. Le partage est dérivé, pas
déclaré — l'exploitant n'a pas à se souvenir de déplacer un port parce qu'il a ajouté un
rôle :
résolveur (Unbound) 10.29.19.11:53 + 127.0.0.1:53 la seule porte
autoritatif (PowerDNS) 127.0.0.1:5300 derrière lui
Le port de PowerDNS est déclaré derive dans son meta/flux.yml — verifier_ports traite
un port non numérique comme « pas une écoute fixe », ce qui est exactement le cas : les
deux lient le port 53, mais sur des adresses différentes.
Deux pièges, dont un que le harnais seul a vu
Unbound refuse d'interroger une loopback. do-not-query-localhost vaut yes d'origine
— une protection contre les boucles. Or l'autoritatif vit désormais sur 127.0.0.1:5300,
derrière le résolveur. Sans la lever, toute la zone souveraine rendait SERVFAIL.
Et la panne était masquée par le plancher. getent hosts forge.genese.internal
répondait 10.29.16.11 sur les cinq machines — c'était /etc/hosts, pas le DNS. J'ai
d'abord conclu que la résolution fonctionnait. Seul un dig explicite a montré le
SERVFAIL. Le plancher fait son travail — mais il rend une panne DNS invisible à qui
mesure avec le mauvais instrument.
C'est la tâche de validation du rôle qui a refusé de se dire satisfaite, et elle avait raison contre moi.
Le client sait se retirer
client_resolveur désinstalle l'Unbound local après avoir basculé — jamais avant,
sinon l'hôte perdrait toute résolution au milieu du play. L'hôte qui porte le résolveur est
épargné : c'est le même paquet.
Sans cette tâche, le rôle aurait changé le resolv.conf sans rien économiser, et la raison
même du changement aurait été perdue.
Le renommage
client_unbound n'installant plus Unbound, son nom mentait. Il devient client_resolveur
— une vingtaine de fichiers, quatre plans d'instance et six modèles. Le harnais a rattrapé
chaque oubli : playbook homonyme manquant, inventaires en écart, plan de recette périmé,
README absent.
2026-08-24 — Collabora en natif : la dernière exception conteneurisée tombe
serveur_collabora lançait collabora/code dans Docker — seule exception de la
flotte au principe fondateur : logiciel libre en natif, systemd + paquets + nginx. Elle
traînait une dépendance entière, community.docker, qui n'était même pas déclarée dans
requirements.yml et donc absente de tout runner.
Plus aucun rôle du dépôt n'a besoin de Docker. La collection est retirée.
Éprouver l'outil avant le rôle
Sur une Debian 13.6 réelle, sans rien installer :
apt-get install --simulate coolwsd -> résout jusqu'à « Conf coolwsd (26.04.3.1-1) »
libgcc1 (exigé par le paquet) -> fourni par libgcc-s1 sur trixie
le dépôt CODE-deb -> PLAT : Packages et Release à la racine, pas de dists/
Le paquet livre aussi /etc/nginx/snippets/coolwsd.conf et un profil AppArmor — deux
choses que cette flotte sait exploiter, contrairement à une image opaque.
La configuration par surcharge
Le coolwsd.xml livré fait 439 lignes richement commentées. Le remplacer par un
gabarit maison obligerait à suivre son évolution amont et ferait perdre ses explications.
coolwsd acceptant des surcharges --o:<clé>=<valeur>, le rôle pose un fragment
systemd : la configuration de Set-OPS tient en un fichier, celle du paquet reste intacte.
La console d'administration est fermée
Elle expose un formulaire hors de tout SSO, avec un secret à créer, faire tourner et
surveiller — pour une fonction dont personne n'a besoin. Même raisonnement que
serveur_forgejo_connexion_locale.
Trois outils du harnais ont eu raison, et l'un avait tort
voute.py lisait les commentaires. Expliquer en commentaire « pour ouvrir cette
option, fournir vault_x » suffisait à rendre vault_x obligatoire dans le gabarit ET
dans la voûte réelle. Documenter le nom d'une clef ne doit pas l'exiger : le scanner
ignore désormais ce qui suit un #. Contrôle négatif fait — une référence réelle, dans du
code, reste exigée.
Le message d'erreur d'un assert comptait comme une référence. Il nommait la clef de
voûte ; il nomme maintenant la variable que l'exploitant règle, ce qui est plus utile de
toute façon.
Et verifier_intrants.py avait raison. Un assert de la forme var | length > 0
déclare un contrat — et l'outil ignore délibérément les when:, parce qu'un intrant
exigé sous condition reste un intrant exigé. Écrite en deux morceaux, ma garde promettait
un secret pour une console fermée. Réécrite en implication — si ouverte, alors un mot
de passe — elle dit ce qu'elle veut vraiment dire.
Ce qui n'est pas prouvé
L'installabilité est établie. Le fonctionnement ne l'est pas : aucun écosystème de la flotte ne fait tourner Collabora aujourd'hui. La première mise en service devra vérifier l'édition d'un document de bout en bout, depuis Nextcloud.
2026-08-24 — La clé du runner, et trois silences fermés en chemin
Le poste d'exploitation fabriquait sa propre paire SSH depuis son premier déploiement, et
n'avait jamais pu joindre un seul hôte. Deux verrous, pas un : la clé n'était autorisée
nulle part, et nftables_admin_ssh ne listait pas son adresse. Les deux vont ensemble —
c'est tout le sens de P24, un seul intrant pour la flotte et la frontière.
ssh_baseline gère désormais les clés d'administration déclarées au plan. Chaque entrée
porte son etat : passer à absent révoque sur toute la flotte au prochain passage. Une
liste purement additive ne sait pas retirer, et « révoquer » voudrait alors dire se
connecter à la main sur chaque machine.
Pas d'exclusive: true : il effacerait la clé posée par cloud-init si le plan ne la
reprenait pas, et fermerait la flotte à tout le monde d'un seul déploiement. Le prix
assumé : une clé posée hors du plan n'est pas détectée ici.
Vérifié depuis ops-01 : les cinq hôtes répondent leur FQDN.
Cloud-init effaçait le plancher de résolution à chaque démarrage
manage_etc_hosts: true traîne dans le gabarit doré. À chaque boot, cloud-init régénère
/etc/hosts depuis son propre modèle et efface le plancher dérivé de l'inventaire —
la seule façon qu'a une machine de nommer ses voisines sans DNS.
Constaté sur infra-dns-01, redémarré lors d'un déplacement de disque : six entrées de
flotte perdues. Le défaut s'est manifesté deux jours plus tard sous la forme d'un
apt update qui ne résolvait plus le cache d'artefacts — un message qui ne parlait pas du
tout du vrai problème. infra-edge-01 avait subi le même sort et s'était fait réparer
sans qu'on le sache, par un redéploiement.
hosts_statiques dépose maintenant un fragment cloud.cfg.d qui neutralise cette
réécriture. Les cinq hôtes : six entrées, manage_etc_hosts: false, apt sans erreur.
Trois collections utilisées, aucune déclarée
requirements.yml ne nommait que community.postgresql et community.general. Or les
rôles utilisent aussi ansible.posix (serveur_backup) et community.docker
(serveur_collabora). Ça marchait chez le mainteneur, où un gros lot est installé — et
échouait partout ailleurs.
Le runner n'a que ce que ce fichier déclare : il n'aurait pas pu déployer les sauvegardes. C'est le genre d'écart qu'on ne voit qu'en portant le moteur sur une autre machine.
À trancher : community.docker contredit le principe « zéro conteneur ». La dépendance
est déclarée parce qu'elle existe — mieux vaut une contradiction visible qu'un rôle qui
échoue en silence — mais la question reste ouverte : Collabora en natif, ou l'exception
assumée ?
Et le cache suivait la mauvaise chose
Le téléchargement des collections était gardé par creates: <cache>/requirements.yml :
une collection ajoutée au fichier n'aurait jamais été récupérée, le témoin existant
déjà. Le cache est désormais indexé sur l'empreinte du fichier — changer une ligne
crée un cache neuf, donc un téléchargement.
Un when en écrase un autre
En ajoutant une condition, j'en ai laissé une seconde juste en dessous. YAML garde la
dernière clé et jette l'autre, en silence : not ansible_check_mode avait disparu.
ansible-lint l'a vu ; personne d'autre ne l'aurait vu. Les conditions vont dans une liste.
2026-08-24 — Deux runners, deux pouvoirs, aucun omnipotent
Le travail d'un runner se divise en trois, et la ligne de partage est celle des voûtes :
calculer plan -> inventaire aucune voûte portée TENANT
configurer rôles sur ses machines voûte du TENANT portée TENANT
matérialiser créer/détruire des VM voûte du SITE portée FABRIC
Un runner par tenant qui matérialiserait mettrait la voûte du SITE en N exemplaires — le secret le plus dangereux du système, recopié autant de fois qu'il y a de locataires.
Un runner unique qui ferait tout devrait entrer en SSH chez tous les tenants, donc traverser le default-deny inter-tenant — et rendrait l'émancipation impossible : un écosystème dont le runner appartient à l'hébergeur ne peut plus se rebâtir sans lui.
D'où serveur_ops_site, additif : il crée des VM vides et n'entre jamais chez un
tenant ; serveur_ops habille des machines et ne touche jamais la fabric. Réservé à
l'écosystème de l'hébergeur — un tenant ordinaire qui le déclarerait s'arrogerait un
pouvoir sur ses voisins.
La voûte, de droit plutôt que par emprunt
La cérémonie du prêt que j'avais bricolée en ligne de commande masquait une pièce
manquante. Le rôle dépose maintenant la voûte du SITE, chiffrée, en 0600 — et le mot
de passe n'est toujours pas stocké : le Makefile ajoute --ask-vault-pass quand aucun
fichier n'est défini.
Une garde née d'une faute
decrypt: false est obligatoire sur la copie : sans lui, Ansible déchiffre la source
quand il détient le mot de passe. Mesuré le jour même, en la déposant à la main — 776
octets en clair au lieu de 3465 chiffrés, sur une machine où ils n'avaient rien à faire.
Le rôle relit l'en-tête après avoir écrit et refuse si le fichier n'est pas chiffré.
Contrôle négatif fait : pointé sur un fichier en clair, il échoue ; rétabli ensuite, la
voûte déposée est bien $ANSIBLE_VAULT, 3465 octets, 0600.
Une fuite silencieuse aurait été le pire des cas : le fichier existe, le rôle se dit satisfait, et les clés du cluster dorment en clair.
2026-08-24 — Filiation, mutualisation, émancipation : nommer un motif que le moteur avait déjà
Un écosystème ne naît pas autoportant. Il lui faut une forge pour lire son génome, une source d'artefacts pour ses paquets, un dépôt pour ses sauvegardes — et rien de tout cela n'existe la première seconde. Il emprunte donc à son hôte.
Le moteur avait rencontré ce besoin trois fois sans le reconnaître :
client_artefacts_actif dérivé : « une source existe-t-elle chez moi ? »
serveur_ops_forge_externe « je lis mon génome ailleurs »
client_backup_cible patient 0 sauvegarde chez eregion — mutualisé, en production
Trois astuces, une seule notion — désormais déclarée dans docs/filiation-emancipation.md.
Ce que ça change pour les modèles
Quatre modèles sur six n'ont pas de forge : collaboration, identite, observabilite,
presence-web. On pouvait lire ça comme une lacune — leur ops-01 n'aurait rien à cloner.
C'en est une autre : ce sont des écosystèmes au premier âge, qui lisent leur génome
chez leur hôte. L'ajout d'une forge n'est pas une correction, c'est une émancipation.
Les quatre le déclarent maintenant explicitement. forge et integral restent émancipés
par défaut.
Et personne ne l'aurait vu : le harnais ne regarde pas les modèles privés — P17 n'en découvre qu'un seul, celui du dépôt public. Encore un vert sur un périmètre vide.
Une promesse sans substance
serveur_ops_forge_externe était citée par docs/dependances-groupes.yml et par le
README du rôle, et définie nulle part. L'exemption qu'elle portait ne pouvait donc
jamais s'appliquer : le registre refusait un écosystème sans forge tout en documentant
comment l'autoriser. La variable existe maintenant, avec son amont obligatoire — lever le
drapeau sans nommer chez qui l'on emprunte, c'est ne rien déclarer du tout.
Ce que l'émancipation devra prouver
Trois temps, dont le dernier est celui qu'on oublie :
- 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.