# CHANGELOG — Set-OPS ## 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-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-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. ## 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.` : Keycloak n'écoute qu'en clair derrière lui, et le canal serveur-à-serveur de l'OIDC (Grafana, oauth2-proxy) en dépend. Nouveau champ de `domaines.yml` : `resolution_interne: service | edge` (absent = `edge`). Le site déclare `service`. - `expositions_des_applications` rend `resout_vers` ; une exposition qui se sert elle-même (sans port, ou sans edge) mène toujours au service. - Le plancher (`hosts.j2`) et la zone (`zone.db.j2`, A et empreinte `_plancher`) suivent la même règle ; `test_empreinte_plancher.py` couvre un nom résolu vers son service. - `client_pki` ajoute au certificat du service le nom qui mène à lui : sans cela, une reconstruction de `site-forge-01` aurait rendu un certificat sans `forge.genese.internal`, que le runner du site appelle en direct. - Validation (`valider_domaines`) et schéma : valeur hors `edge`/`service` refusée. Pas de `defaut` au schéma : le GUI l'aurait écrit à chaque sauvegarde (`test_rendu_gui` l'a vu). **Posé au site, après simulation.** Zone : `console`, `forge`, `observatoire`, `vigie` → leurs services (10.37.31.11, .33.11, .36.11, .36.11). Planchers des 9 machines : `console` et `site-dnspub-01` ajoutés ; `site-dnspub-01` ramené aux services. Cache d'Unbound vidé. Le runner du site joint la forge par son nom, en direct (`ls-remote` : tête publiée) ; les deux locataires ouvrent leur session SFTP de sauvegarde. Sonde `plancher` : 35 machines sur 35 conformes (13 + 13 + 9). Chez les locataires, rien ne change (simulation : 0 enregistrement, 13 planchers sur 13 intacts). Les runners des locataires joignaient déjà la forge du site en direct, par son adresse. **À savoir.** Au prochain passage de `client_pki` au site, `site-ops-01` recevra `console.genese.internal` dans son certificat (le nom mène désormais à lui) : réémission normale. ## 2026-09-29 (31) — Sonde `plancher` ; elle a trouvé une régression que j'avais causée au site, corrigée à la source **La sonde `plancher` (rôle `client_sante`, sur toute machine qui rapporte).** Le plancher `/etc/hosts` et la zone PowerDNS suivent le même plan, chacun quand son rôle est rejoué (voir (30)). La zone publie désormais `_plancher. 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.` ne se résolvait pas, alors que l'edge la sert. Les autres expositions se résolvaient. **Première cause : le plancher `/etc/hosts` n'avait pas été rejoué.** Il dérive du plan (inventaire + `expose`), mais n'est posé que par le socle (`serveur_debian`). La console a été ajoutée au plan après le dernier passage du socle ; seul `ops-01`, redéployé depuis, la portait. Rejoué seul (`hosts_statiques`, sans `common_packages` et sa mise à jour complète) sur les 26 machines des deux locataires, après simulation : `console` ajoutée, `forge-01` et `forge` retirées (l'ancienne forge des locataires, sortie du plan). **Seconde cause, plus grave : PowerDNS servait une zone antérieure à son fichier.** Le fichier de zone, réécrit le 16 septembre, portait `console` ; PowerDNS servait la version chargée à son démarrage (13 septembre chez Chezlepro, 14 chez Technolibre). Le rechargement ne passait que par un handler, et un handler en attente est abandonné quand une tâche échoue plus loin dans le même passage ; aux passages suivants, le fichier ne change plus et rien ne le redemande. La sonde `zones` comptait les zones servies, pas leur fraîcheur : verte. - **Rôle `serveur_powerdns`** : à chaque passage, chaque zone (interne et publique) dont le fichier est plus récent que son chargement (`bind-domain-status`) est rechargée (`bind-reload-now`). Ce qui est à jour n'est pas touché. - **Sonde `zones`** : critique si une zone servie est plus ancienne que son fichier au-delà de `serveur_powerdns_sonde_tolerance` (900 s), en nommant les zones. Mise en défaut par ce paramètre : critique. - **Posé sur les trois** (Chezlepro, Technolibre, site) : deux zones rechargées chez chaque locataire ; au site, la zone et une zone inverse réécrites (le DNS public `site-dnspub-01` n'y était pas encore). `zones` au vert partout, « chacune conforme à son fichier ». `console` se résout depuis les deux edges, par le plancher et par PowerDNS. **Une erreur de ma part, rattrapée par la garde de syntaxe.** `${#tableau[@]}` dans une sonde ouvre un commentaire Jinja. `test_sondes_syntaxe.py` l'a vu ; mais j'avais enchaîné le déploiement de Technolibre sans attendre le test, et la pose de la sonde y a échoué (la réconciliation, elle, était passée). Corrigé et redéployé ; la configuration publique réécrite pendant ce passage raté ne différait que par des commentaires. **Ce qui n'est pas traité ici.** Les machines des locataires interrogent le résolveur du site, qui répond NXDOMAIN pour leurs noms internes : leur résolution repose entièrement sur le plancher. C'est l'état voulu tant que le basculement vers leur résolveur (`client_resolveur_apply`) n'est pas confirmé. Et rien ne signale encore qu'un plancher est en retard sur le plan : il suit le plan au déploiement, pas en continu. ## 2026-09-29 (29) — `passerelle` fait le trajet d'un visiteur jusqu'à la page de connexion ; fin de la revue des sondes Cinquième et dernière des sondes « répond » de la revue (25). **Avant.** `check_http` sur `/ping` : vert tant que le processus oauth2-proxy vit, même quand plus personne ne peut entrer — client supprimé ou renommé dans Keycloak, URL de retour retirée du client, IdP injoignable depuis la machine de la passerelle. **Maintenant.** Après `/ping`, la sonde fait le trajet d'un visiteur : `/oauth2/start` doit renvoyer vers l'émetteur configuré (lu dans la configuration déployée), pour CE client ; puis l'IdP, joint depuis cette machine avec le même magasin de confiance que la passerelle, doit servir sa page de connexion (200). C'est aussi le chemin de l'échange du code au retour. Un refus de Keycloak est nommé (`Invalid parameter: redirect_uri`, `Client not found`…) ; un IdP injoignable rend le code d'erreur de curl. **Ce qu'elle ne teste pas : le secret du client.** Le vérifier demande un échange de jeton raté, que Keycloak écrit dans le journal de sécurité du realm — toutes les 15 minutes, par passerelle, ce serait noyer le journal où l'on cherche les vraies tentatives. Le cas sain n'y écrit rien. **Éprouvé.** Au vert sur les quatre passerelles (vigie et console, chez les deux locataires), 35 ms ; le site n'en a pas. Mises en défaut chez Chezlepro : URL de retour non autorisée (`serveur_oauth2_proxy_sonde_retour`) → 400 nommé ; configuration illisible ; port fermé ; magasin de confiance inutilisable → « n'atteint pas l'IdP (curl 77) ». Toutes critiques. **À savoir en lisant le journal du realm `chezlepro`** : ces épreuves y ont laissé cinq `LOGIN_ERROR` (`redirect_uri` vers `exemple.invalid`), les 28 et 29 septembre. **Au passage.** Le port de la sonde était écrit en dur (4180) : il dérive de l'écoute. Un échec de `/ping` sortait sur deux lignes : une seule. Et l'archive oauth2-proxy repartait du contrôleur à chaque déploiement, pour la même raison que Keycloak en (26) — le repère était dans `/tmp` ; c'est désormais le binaire installé. Plus aucun rôle ne prend `/tmp` pour repère. Limite qui demeure, dans les deux rôles : un changement de VERSION ne réinstalle pas (l'extraction s'arrête sur `creates`) ; à traiter le jour d'une montée de version. **Bilan de la revue.** Les cinq sondes qui disaient « répond » disent désormais « sert » : `filtrage` (GTUBE analysé), `edge` (chaque exposition servie), `identite` (Keycloak authentifié auprès de l'annuaire), `vigie` (base, annuaire ou comptes, Redis), `passerelle` (trajet jusqu'à la connexion). ## 2026-09-28 (28) — `vigie` vérifie ce que la vigie lit, pas seulement qu'elle répond Quatrième des cinq sondes « répond » de la revue (25). **Avant.** `check_http` sur la boucle locale : un 302 vers le formulaire de connexion suffisait. Une vigie dont la base est injoignable (mot de passe tourné d'un seul côté, `pg_hba`, certificat), dont l'annuaire refuse la liaison, ou dont le Redis est tombé, sert très bien ce formulaire — puis une erreur, des tableaux vides, ou un compte connecté sans aucun droit parce que ses groupes ne se lisent plus. **Maintenant.** Après `check_http`, la sonde lit la configuration DÉPLOYÉE de la vigie (`resources.ini`, `authentication.ini`, `groups.ini`, `modules/icingadb/`) et éprouve chaque ressource qu'elle utilise, avec SES identifiants et le même pilote PHP : la base du moteur (compte des hôtes, zéro = critique), l'annuaire (liaison + lecture de la racine) ou la base des comptes, et Redis (`PING`). Un tenant (annuaire) et le site (base locale) passent par le même code. Les secrets restent dans les fichiers (0640), rien n'est affiché. **Éprouvé.** Au vert sur les trois : Chezlepro et Technolibre (13 hôtes, soit ceux du plan ; annuaire ; Redis), site (23 hôtes, base des comptes et groupes, Redis) ; 51 ms. Mises en défaut sur Technolibre, sur une copie de la configuration : table absente (`serveur_icingaweb2_sonde_table`), Redis sur un port fermé, liaison à l'annuaire refusée, mot de passe de la base faux — les quatre critiques, chacune nommant sa cause. Au site, codes HTTP attendus changés : critique, « ne sert plus ses pages ». Reste de la revue : `passerelle` (oauth2-proxy atteint-il Keycloak ?). ## 2026-09-28 (27) — La sonde d'Icinga Web 2 s'appelle `vigie`, plus `console` Le vocabulaire des plans dit **vigie** pour Icinga Web 2 (`vigie.`) et **console** pour la console d'exploitation Set-OPS (`serveur_ops`, `console.`, 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-.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-` : 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://:3100` — le nom qui est dans le certificat, `localhost` n'y est pas ; l'autorité interne est dans le magasin de confiance du système, Grafana vérifie. Sans TLS (le site), rien ne change. Vérifié par l'API de Grafana chez les deux locataires : Loki et Prometheus sains, 13 hôtes rendus pour `host`, plus aucune erreur Loki au journal. ## 2026-09-28 (22) — Pairs WireGuard de Technolibre appliqués ; un renommage n'est plus une création **Appliqué** (`vpn_admin.py appliquer --portee OPS-Technolibre`, à la demande de l'exploitant) : `technolibre-mathieu-portable` devient `technolibre-responsable-portable`. `fabric` au vert : les quatre plans de la fabric sont conformes. **Ce que l'application a révélé.** Le « nouveau » pair portait la MÊME clé publique que l'ancien : le même appareil, renommé. Le script créait avant de retirer ; OPNsense a refusé la création (« Public keys should be unique ») — mais le retrait, lui, était passé. La configuration n'avait plus AUCUN pair pour ce tunnel ; seul le fait que l'échec empêche le rechargement a gardé l'ancien en service. Rejoué aussitôt : le nouveau pair est créé, relu en service. **Corrigé** : `rapprocher` reconnaît un renommage à la clé (à créer ET à retirer, même clé) et le fait en MODIFICATION sur place (`set_client`) — l'accès n'est jamais coupé, la frontière ne voit jamais deux fois la même clé. Un vrai remplacement (autre clé) reste une création suivie d'un retrait. Éprouvé sur un état simulé, dans les deux cas. ## 2026-09-28 (21) — La sonde « genome-a-jour » : le filet sous `make publier` `make publier` tient la forge du site à jour dans le geste courant. Reste le jour où l'on publie autrement — un `git push` fait à la main, un runner injoignable au moment de publier. La sonde `genome-a-jour` (runner du site) compare la tête de chaque dépôt de la forge du site à celle d'eregion, où chaque commit du poste arrive en premier — comme REPÈRE seulement : eregion reste hors du chemin du génome. **Seulement ce qu'eregion laisse lire sans identifiant** (aujourd'hui : le moteur, celui qui avait quatre commits de retard le 2026-08-26). Les dépôts privés sont NOMMÉS comme non comparés : donner au site un jeton sur une forge héritée l'y lierait. **Un écart neuf n'est pas un retard** : `make publier` pousse eregion une minute avant la forge du site. La sonde retient depuis quand chaque écart dure et ne parle qu'au-delà d'une heure. Éprouvée en vrai : commit poussé sur eregion SEULEMENT → « écart récent, toléré » ; écart vieilli de deux heures → avertissement « FORGE DU SITE EN RETARD… `make publier` » ; `make publier` → à jour, écart oublié. Reçue par l'Icinga du site. ## 2026-09-28 (20) — `make publier` : eregion ET la forge du site, en un geste **Le défaut.** La forge du site fait autorité (D-81), mais les commits naissent sur le poste et n'y arrivent que par `make genome-pousser` — un geste à part, que rien ne force ni ne signale. Un `git push` vers eregion la laisse en retard. Le 2026-08-26 : quatre commits, dont le correctif du pare-feu Proxmox. Aujourd'hui même, `make genome-etat` la trouvait en retard d'un commit. **`make publier`** (`scripts/publier.py`), pour chaque dépôt que le runner du site porte — la liste de `genome-pousser` (`serveur_ops_depots`), lue et non recopiée : refuse si eregion porte des commits absents du poste (une fusion acceptée serait écrasée sur la forge du site), signale ce qui n'est pas committé, pousse sur eregion ; puis `genome-pousser`, puis `genome-etat`, et dit si la forge du site porte bien la tête du poste. Un refus arrête tout AVANT la forge du site : mieux vaut la laisser en retard que la nourrir d'un dépôt incomplet. Ce commit est le premier publié ainsi. ## 2026-09-28 (19) — Chaque semaine, chaque sauvegarde est rouverte pour de vrai **Le trou.** `sauvegarde` disait qu'un instantané existe, récent et non vide ; rien ne disait qu'il se rouvre. Aujourd'hui, c'est à la main qu'on a restauré les dumps PostgreSQL et LDAP — et découvert que deux locataires sur deux ne déposaient rien hors de leur flotte. **Le contrôle** (`client_backup`, minuteur du dimanche 04:00 ± 1 h), joué par le nœud lui-même, seul détenteur de la clé : `restic check --read-data-subset=10%` (l'intégrité, en relisant des données, pas seulement l'index), puis restauration RÉELLE du dernier instantané avec `--verify` (contenu recomparé), puis comptage des fichiers face à l'instantané. Répertoire temporaire détruit quoi qu'il arrive ; `nice` et E/S au repos. Rapporté au service `restauration` (fraîcheur 8 jours, liée au `ttl` par `test_fraicheur_icinga.py`). **Le piège évité** : le compte d'API des nœuds n'écrit que sur des services NOMMÉS ; sans l'ajout de `restauration` au filtre, chaque rapport aurait reçu un 404 « No objects found » sur un objet présent — le défaut du 2026-09-02. **Premier passage, déclenché au déploiement** : 19 nœuds, 3 à 6 s chacun. Tout se restaure, contenu recomparé — Nextcloud (≈ 140 Mo), la base, le courriel, l'annuaire, la PKI chez chaque locataire, la forge du génome (1723 fichiers) au site. Avertissement sur les instantanés VIDES (courriel et web sans données encore), comme `sauvegarde` le dit déjà. ## 2026-09-28 (18) — La sonde « fabric » : la fabric dit-elle encore ce que le plan dit ? **Le trou.** Le pare-feu Proxmox, le SDN, la frontière et les accès WireGuard ont chacun un plan de lecture (`*-plan`) ; personne ne les jouait sans raison. Aujourd'hui même : 26 VM sans pare-feu, sans signal. **Sur le runner du site** (`serveur_ops_site`) : un minuteur horaire joue les quatre plans (4 s au total), additionne ce qui reste à créer, retirer, mettre à jour ou corriger, et consigne le constat dans `/var/lib/setops/conformite-fabric.json`. La sonde `fabric` le lit — une sonde n'a que quelques secondes : écart → avertissement (avec le premier détail), plan illisible → inconnu, constat de plus de 3 h → CRITIQUE (un minuteur mort ne doit pas laisser la dernière bonne nouvelle au vert). Éprouvée sur quatre constats fabriqués, puis en vrai : `site-ops-01!fabric` en avertissement sur un écart RÉEL — les pairs WireGuard de Technolibre, en attente de décision. **Ce qu'elle a attrapé avant même d'exister.** En mesurant la durée des plans, `frontiere-plan` voulait 4 créations : des alias et des règles « SSH depuis le tunnel du locataire, sur le WAN ». Cause : le tunnel ajouté à `admin_de` plus tôt dans la journée ; la frontière range chaque réseau d'administration par interface et l'avait classé WAN — une règle qui ne correspondrait jamais. `admin_de` redevient l'intrant (la frontière) ; `admin_avec_tunnel` sert l'est-ouest (Proxmox, épreuve). Les deux plans : 0 écart. ## 2026-09-28 (17) — La sonde « disque » : espace et inodes, sur les 35 machines **Le trou.** Seuls le cache apt et les dépôts de sauvegarde avaient une sonde d'espace. Rien ne regardait le disque de la base, des boîtes, de Nextcloud, ni de Loki qui grossit chaque jour ; Prometheus collecte l'occupation, mais aucune règle n'en fait une alerte. Un disque plein aurait arrêté la base ou le courriel sans prévenir. **La sonde.** Pour chaque système de fichiers réel (pas `tmpfs`, `overlay`…), l'espace ET les inodes — une file de petits fichiers épuise les inodes avec la moitié de l'espace libre, et l'erreur parle alors d'un disque « plein » qu'un `df` dit à moitié vide. Seuils 80 / 90 % (`client_sante_disque_avert` / `_crit`) ; relevé du jour : au plus 22 % d'espace et 7 % d'inodes. Une ligne, le pire système de fichiers, les métriques de tous. **Où elle vit, et pourquoi.** Déclarée ET posée par `client_sante`, dont le groupe est exactement « tout hôte qui rapporte ». Pas par `common_packages`, qui porte `correctifs` : il fait un `upgrade: full` à chaque passage, et poser un script aurait mis à jour toute la flotte. Une première version la déclarait dans `serveur_debian` : P64 l'a refusée — il suit le playbook du groupe déclarant pour trouver le dépôt, et `client_sante` n'y est pas. La garde avait raison ; la déclaration a suivi le rôle qui dépose. Déployé (Icinga d'abord, pour que les premiers rapports aient un destinataire) : 35 services `disque`, tous alimentés et OK — 13, 13 et 9. `make verifier` : CONFORME. ## 2026-09-28 (16) — La procédure d'activation du pare-feu Proxmox entre au dépôt `scripts/eprouver_parefeu.py`, par `make proxmox-fw-eprouver TENANT= HOTE=` (aucune écriture) et `make proxmox-fw-activer-vm TENANT= HOTE= CONFIRMER=true`. Ce qui a servi à filtrer les 26 VM, sous une forme qui resservira : une VM reconstruite perd ses options de pare-feu. Pour UNE VM : matrice tirée du devis (chaque membre de chaque source), connexions entrantes établies confrontées aux règles, matrice avant, activation par le runner du site (seul à joindre l'API du cluster), matrice après, sondes relancées, critiques d'Icinga. Refus d'activer si un flux réel échappe aux règles ou si un flux échoue déjà. **Plus d'exclusions à la main** : un échec vers un port que rien n'écoute hors de `127.0.0.1` est classé « sans objet » (nginx lié en local derrière la passerelle SSO, frontal sans site) — c'est ce que la procédure manuelle tranchait au cas par cas. **Le VMID se dérive du devis**, et du bloc du BON locataire : au premier essai, le script visait `ops-01` de Chezlepro pour une demande sur Technolibre — les noms d'hôtes se répètent d'un locataire à l'autre. Éprouvé : `--plan` sur `ops-01` (8090 reconnu sans objet), `--activer` de bout en bout sur `web-frontal-01` (runner : « Rien à faire », après = avant, Icinga : 0 critique). Limite écrite : un flux UDP est testé en TCP sur le même port. ## 2026-09-28 (15) — Les 26 VM des locataires filtrées par Proxmox ; `proxmox-fw-plan` : « Rien à faire » **Chezlepro, 13/13**, dans le même ordre que Technolibre et par la même procédure, VM par VM : matrice complète tirée du devis, flux entrants établis confrontés aux règles, matrice avant, activation, matrice après, sondes relancées sur les 13 nœuds, Icinga. Toutes : avant = après, 0 critique. Au-delà : DNS UDP réel à travers `infra-dns-01` ; les 13 rapports `sante` reçus après l'activation de `mon-01` ; et de bout en bout, `courriel-plan` (Postfix → LDAP → Dovecot → IMAP) et `identite-plan` : CONFORME. **Une régression introduite, puis fermée.** nftables admet le SSH depuis l'intrant `nftables_admin_ssh` PLUS le tunnel du locataire (`10.x.29.0/24`) ; le devis Proxmox ne lisait que l'intrant. Filtré, Technolibre refusait donc le SSH venu de son propre tunnel (de 13:27 à ~15:40, SSH seulement, aucun service). Une seule dérivation désormais, `inventory_rules.tunnel_admin_de`, pour les deux ; IPSets `admin` à trois membres ; `make flux` identique octet pour octet. **Deux devis cassés depuis le 2026-09-13, trouvés en vérifiant.** `courriel-plan` et `identite-plan` appelaient `resoudre_annuaire` sans nommer de compte de service : ils échouaient sur une assertion, avant toute connexion. Ils empruntent maintenant le compte du service qu'ils vérifient (`postfix`, `keycloak`). **Reste à faire** : une règle Proxmox pour les flux venus d'Internet (`externe`) avant la première exposition d'un locataire. ## 2026-09-28 (14) — Technolibre entièrement filtré par Proxmox, VM par VM **Les quatre dernières, une à la fois** : `infra-dns-01`, `obs-01`, `ops-01`, `mon-01`. Pour chacune : matrice complète tirée du devis, flux entrants établis observés et confrontés aux règles, matrice avant, activation, matrice après, sondes relancées sur les 13 nœuds, Icinga. Toutes : avant = après, 0 critique. Technolibre : 13/13 VM filtrées. **Ce que la procédure a arrêté, à raison, et pourquoi c'était sans défaut :** - `obs-01` se parle à lui-même par sa propre adresse (Prometheus, Alloy→Loki) : ce trafic passe par `lo`, jamais par le pont de Proxmox — l'analyse l'écarte désormais ; - `infra-edge-01 → ops-01:8090` et `→ mon-01:8080` échouaient DÉJÀ avant : en mode `oidc`, nginx n'écoute qu'en local (le défaut le documente — une passerelle SSO qu'on ne peut pas contourner) ; c'est `oauth2-proxy` (4180) qui est publié, et la matrice le teste. La règle du port nginx reste pour le mode `locale`, sans objet ici. **Vérifié au-delà de la matrice** : résolution DNS réelle en UDP à travers `infra-dns-01` (noms internes et externes, depuis deux nœuds) ; pour `mon-01`, les 13 rapports `sante` reçus APRÈS l'activation — le flux passif vers 5665 traverse le filtre. ## 2026-09-28 (13) — Technolibre, deuxième lot : l'edge, l'identité, la base, la PKI **Activées** : `infra-edge-01`, `idm-01`, `data-sql-01`, `infra-pki-01`. **Deux vérifications, parce qu'une seule ne suffit pas.** La matrice tirée du devis ne teste que ce qui est DÉCLARÉ ; un flux réellement utilisé mais non déclaré la passerait, puis serait coupé. Donc, en plus : relever sur chaque VM les connexions entrantes ÉTABLIES (trois relevés), et vérifier que chaque couple source→port est couvert par une règle. 11 flux observés, 0 hors des règles (SSH d'administration et forme IPv4-mappée d'`obs-01` comprises). Matrice étendue à TOUS les membres de chaque source : 100/100 avant, 100/100 après. Sondes de santé relancées sur les 13 nœuds : 133 services revérifiés, aucun critique. ## 2026-09-28 (12) — Technolibre, premier lot : quatre VM de plus filtrées, preuve comprise **Activées** : `web-dorsal-01`, `collab-01`, `edge-mta-01`, `infra-mail-01`. Méthode : une matrice tirée du DEVIS lui-même — pour chaque règle des groupes de ces VM, un hôte réel de la source autorisée → VM:port —, jouée AVANT (19/19) puis APRÈS (19/19, aucun changement). Icinga : aucun critique. **Le filtrage mord, et c'est Proxmox qui mord.** Un contrôle négatif doit distinguer les deux couches : nftables, dans la VM, accepte TOUT l'ICMP ; Proxmox ne l'accepte que de `srv-icinga`. Depuis `data-sql-01` : pas de réponse des VM filtrées (`collab-01`, `web-frontal-01`), réponse des non filtrées (`idm-01`, `infra-pki-01`). **Un trou à fermer AVANT d'exposer un locataire.** Le devis saute les flux dont la seule source est `externe` (« la frontière s'en charge »). Mais un flux redirigé par la frontière arrive sur la VM avec sa source Internet : sous `REJECT`, sans règle, il serait refusé. Sans effet aujourd'hui — la frontière ne redirige vers aucune VM de locataire (relu : seul le DNS public du site) —, bloquant le jour d'une exposition. ## 2026-09-28 (11) — Première VM filtrée par Proxmox, et le ping de supervision qu'aucune règle ne portait **Activée : `web-frontal-01` de Technolibre** (`--vm 123603101`), pare-feu `enable=1`, `policy_in=REJECT`, groupes `cli-metrique`, `srv-debian`, `srv-web-frontal`. Vérifié flux par flux : SSH du poste, rapports de santé et de sauvegarde acceptés, `node_exporter` joint par `obs-01`, SSH depuis la flotte (autorisé par `srv-debian`, déclaré), port 80 ouvert à l'edge seul (rien n'y écoute encore : aucun site). Icinga : tout vert — sauf un critique NOUVEAU. **`ping4` : 100 % de pertes.** Le ping de supervision EST déclaré (`serveur_debian`, `echo-request` depuis `serveur_icinga`), mais `_ports()` ne garde que les nombres : le type ICMP était rangé parmi les « ports dérivés » et le flux sauté. Aucune règle, donc `REJECT`. `_cibles()` rend désormais un flux ICMP en règle `proto: icmp` + `icmp_type`, que l'applicateur transmet (`icmp-type`) et compare. Appliqué : `srv-debian` des deux locataires accepte `echo-request` depuis `srv-icinga` ; `ping4` revenu OK (0,20 ms). **Vérifié avant d'aller plus loin** : le « sans source » `serveur_icinga:5665` que recense le devis est le flux de `serveur_backup`, qu'aucun locataire ne porte — les rapports passifs, eux, ont leurs règles (`cli-backup`, `srv-debian`). Et le recensement ne déclare plus sauté un flux ICMP qu'il rend. ## 2026-09-28 (10) — Le pare-feu est-ouest de Proxmox ne filtrait aucune VM des locataires **Mesuré.** Pare-feu du datacenter actif, `firewall=1` sur les cartes des 26 VM de Chezlepro et Technolibre — mais AUCUNE n'avait son propre pare-feu activé ni un seul groupe affecté. Proxmox ne filtrait rien entre elles ; seul nftables, dans chaque VM, tenait l'est-ouest. Le plan de `proxmox-fw-appliquer` le rattrapait d'un bloc : 64 changements, dont l'activation en `REJECT` des 26 VM à la fois — autant de pannes possibles si une règle manquait. **Par étapes.** `appliquer_proxmox_fw.py` prend `--objets-seulement` (IPSets et groupes, aucune VM) et `--vm ` (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 ` : 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 --vers ` : la configuration COMPLÈTE, écrite en 0600 ; - `plan|appliquer --portee site --portee OPS-X` : sans filtre, `appliquer` réconcilie TOUS les tunnels — la rotation aurait emporté l'écart en attente de Technolibre (un pair retiré que personne n'a encore décidé de retirer). Et `service : ?` après un rechargement réussi : OPNsense répond `result`, pas `status`. **Fait.** Deux paires tirées dans `~/wireguard-rotation/` (hors dépôt), `cle_publique` remplacée au plan du site et de Chezlepro — nom et adresse inchangés —, appliqué sur ces deux seuls tunnels. Relu EN SERVICE sur la frontière : les deux nouvelles clés actives, les deux anciennes absentes. Technolibre intact. ## 2026-09-28 (6) — Les voûtes sortent du poste, à côté des clés qui les ouvrent **Ce qui manquait.** La clé USB portait les CLÉS des voûtes, pas les voûtes. Or elles sont gitignorées : sur aucune forge, seulement sur le poste et sur le runner de chaque écosystème. Poste et runner perdus ensemble, les clés restaurées n'ouvraient rien, et chaque secret était à régénérer. La runbook `cles` affirmait pourtant « les voûtes sortent chiffrées ». **`scripts/exporter_voutes.py`** (`make voutes-recenser`, `make voutes-exporter VERS=…`). Une archive à part, `setops-voutes--.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 `. 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: ` 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 à `!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: ` — au vert, et donc crus complets. Le gabarit choisissait l'un OU l'autre témoin ; il crée désormais les deux quand un dépôt existe. Déployé sur `site-mon-01`, vérification relancée sur les trois nœuds : six services au vert, et les deux témoins disent la même chose (1723 fichiers, 40 Mo pour la forge). Chez les tenants, sans dépôt, rien ne change. ## 2026-09-27 (1) — Patient 0 effacé, l'index 29 libéré Ses machines n'existaient plus depuis le 2026-09-06 (D-83), mais son plan restait sur disque en `federe: true` : la fédération lui réservait l'index 29, et quatre machines du site ouvraient SSH, apt, DNS et HTTPS à `10.29.0.0/16` — un périmètre vide. **Ce qui est fait :** - `SITE-Chezlepro/underlay.yml` : `OPS-Patient0: 29` retiré de `tenants`. - `SITE-Chezlepro/plan/serveurs.yml` : le runner du site ne clone plus `ops-patient0`. - `make flux` : `10.29.0.0/16` sort des règles de `site-backup-01`, `site-cache-01`, `site-dns-01`, `site-dnspub-01` et `site-forge-01` — et rien d'autre ne change. - Le dépôt local `OPS-Patient0/` est supprimé, puis, le 2026-09-28, ses deux copies distantes : `genome/ops-patient0` sur la forge du site (API, 404 relu) et `Chezlepro/OPS-Patient0` sur `eregion` (interface web — le jeton du poste n'a pas `write:repository` ; 404 relu). La clé de sa voûte est détruite (`shred`). - Les commentaires et documents vivants qui le nommaient gardent leur leçon sans le nommer (moteur, modèles, tenants, sites). Le CHANGELOG, les rapports de preuve datés et la chronique restent tels quels : ce sont des archives. - `filiation-emancipation.md` §« Qui est le parent ? » ramené à la décision et au chemin réel du génome. **Corrigé le 2026-09-28 : il n'y a pas de « SPOF eregion ».** D-82, D-83 et ce paragraphe affirmaient que `eregion` alimente la forge qui fait autorité (« le poste y pousse, la forge du site en tire »). Le chemin mesuré dit autre chose : `make genome-pousser` porte les commits du poste en bundle au runner du site, qui pousse sur sa forge — `eregion` n'y figure pas. C'est une forge héritée (`forge.alliance-boreale.ca`), porte publique des contributions, qui ne fera jamais partie de Set-OPS ; la redondance vient d'une forge par site, chacune inséminée du génome. La phrase avait été recopiée trois fois sans que personne ne suive le chemin — moi compris, la veille. **Ce qui n'est pas encore sur le réseau.** Les `.nft` régénérés doivent être posés par le runner du site. Le SDN (zone `t29`, VLAN 1291-1296), la frontière et le pare-feu Proxmox n'ont pas pu être relus depuis le poste : le cluster ne répondait pas (22 et 8006). **Mesuré en passant, et corrigé le 2026-09-28.** `make sdn-plan`, cluster injoignable, annonçait « à créer : 33 » — TOUTES les zones, Chezlepro comprise. Une lecture en échec rend `{'_erreur': …}`, et `rep or []` parcourait ce dict comme une liste vide. `appliquer_sdn.py` s'arrête désormais, comme `frontiere-plan`. Et `t29` rejoint `ANCIEN_NOMMAGE` : sortie du devis sans cela, la zone aurait été lue comme étrangère et laissée en place, avec ses VLAN 1291-1296. `devis_proxmox_fw.py` et `devis_proxmox_pools.py` balayaient encore TOUS les dossiers frères (`decouvrir()`), alors que la frontière et le SDN s'en tiennent à `underlay.tenants` (`decouvrir_du_site()`, dont la docstring le demandait déjà pour « les devis qui équipent un site »). Le runner du site, qui gardait un clone de patient 0, proposait donc de recréer ses groupes ; le poste comptait un dossier de CI comme tenant. Les deux devis suivent maintenant la même liste : 2 tenants, et non 3. Filtré ainsi, le devis ne voyait plus du tout `t29` — ni à créer, ni à RETIRER : son périmètre ne retient que les préfixes des tenants présents. 3 IPSets et 6 groupes `t29-` restaient posés sur le cluster. `appliquer_proxmox_fw.py` ajoute à son périmètre les étiquettes des zones de `ANCIEN_NOMMAGE` (une liste, pas deux), et reçoit le même garde que le SDN contre un cluster muet. **Posé le 2026-09-28, depuis le runner du site.** SDN : zone `t29`, 3 VNets, 3 sous-réseaux retirés, le plan relu dit « Rien à faire ». Frontière : 44 objets `PATI29` et 3 routes `10.29.x` retirés, 283 règles inchangées, rien d'autre au plan relu. Pare-feu Proxmox : les 6 groupes et 3 IPSets `t29-` retirés un par un par l'API — PAS par `proxmox-fw-appliquer`, dont le plan mêle 64 changements préexistants sur `t17`/`t23` qui demandent leur propre revue. Ce retrait a révélé un dernier défaut : l'applicateur envoyait `force` dans le CORPS d'un `DELETE`, que Proxmox refuse (501) ; aucun IPSet n'était donc jamais retiré. Corrigé (`?force=1`). La strophe FRR des nœuds de sortie garde `t29` : ils sont injoignables en SSH depuis le runner. Wiki republié (P60) : deux de ses pages nommaient patient 0. `make verifier` : 82 OK, 1 échec — P02, `test_ecriture_plan.py` sur `domaines.yml` (`'list' object has no attribute 'get'`), présent avant ce changement. ## 2026-09-25 — Le registre disait « mesure » d'une cible qui coupe l'amont **21 preuves sur `test_runbooks.py`, `verifier` à 0 écart.** Un outil tiers veut n'offrir de ce moteur que ce qui n'agit pas. Le seul champ qui le lui dise est `nature`. Il fallait donc qu'il ne mente pas — et il mentait une fois. ### Une mesure qui demande confirmation n'en est pas une `filiation → emancipation-prouver` se déclarait `nature: mesure` et portait `fixes: {CONFIRMER: "true"}`. Les deux ne peuvent pas être vrais ensemble : la cible refuse sans confirmation, et son propre refus dit pourquoi — *« cette preuve COUPE l'amont quelques secondes pour mesurer »*. La coupure est retirée quoi qu'il arrive, mais pendant ce temps la fonction éprouvée peut échouer. Le mot « prouver » avait emporté la décision. Un constat rapporté ne rend pas inerte le geste qui l'obtient. L'étape est désormais `nature: ecriture`, et son `pourquoi` dit ce qu'elle coupe. `verifier()` refuse maintenant cette contradiction : rien ne la voyait, et chaque lecteur du registre refaisait l'enquête. Mesuré avant correction : un écart, exactement celui-là. ### Lire le registre sans analyser un arbre fait pour l'œil `runbooks.py lister --json` rend le registre assemblé d'un bloc. Sans lui, un outil tiers n'avait le choix qu'entre analyser la sortie humaine — qui dérive au premier changement de mise en page — et importer ce module, ce qui le lie à nos noms internes. Les deux se paient plus tard. L'affichage humain ne change pas, et une preuve le tient : un `lister` qui rendrait toujours du JSON passerait sinon les épreuves du drapeau sans que personne ne le voie. ### La recette tranche, le registre ne se compare plus à lui-même Le premier invariant comparait `nature` à `fixes` — deux champs écrits par la même main. Le même mensonge repassait en EFFAÇANT la ligne `fixes`, et trois cibles le portaient ainsi : `flotte-creer`, `deployer-tout`, `reconstruire` refusent sans confirmation, le registre ne le déclarait pas, et un assistant les lançait telles quelles — sortie en 2. Le contrôle lit désormais la RECETTE, qui ne ment pas : elle refuse, et c'est ce refus que l'exploitant rencontre. ### Une étape qui agit barre celles qui la suivent Passer `emancipation-prouver` à `ecriture` l'a placée derrière un ménage destructif : la console débloque d'office une étape « mesure », mais fait attendre toute autre que la précédente ait réussi dans la session. Un ménage qu'on peut n'avoir rien à faire rendait donc la preuve injouable. `depots-perimes` est marqué `facultative` — il nettoie ce que la filiation a laissé, il n'est pas son préalable. Mesuré sur le registre réel : 17 runbooks, 127 étapes. **84 sont de nature `mesure`** — c'est la surface qui n'agit pas, et la seule qu'un outil puisse offrir sans confirmation. Les 110 dont les `fixes` ne portent pas de `CONFIRMER` en contiennent 26 d'écriture, dont « déploie TOUTE la flotte » : compter sur ce chiffre pour dire « sûr » serait une erreur de lecture. ## 2026-09-20 (12) — Une décision appliquée à moitié se croit tenue **83 preuves, `make test` à 0 échec.** L'exploitant a demandé : « n'avions-nous pas décidé d'un plan d'adressage dérivé pour chaque flotte, qu'elle soit tenant ou site ? » Oui. La décision est écrite dans l'underlay du site, datée du 12 septembre : > *« sites et locataires se partagent la classe A, chacun avec SON index. Le site prend > 37, le locataire garde 17. `make instances` les compte ensemble depuis ce jour. »* ### Ce que l'entrée (11) affirmait, et qui était faux Elle disait qu'un site *« déclare ses adresses, il n'a aucun seed dont les faire descendre »*. C'était la recopie d'un commentaire en tête de `SITE-Chezlepro/plan/serveurs.yml` — **périmé depuis le jour où le site a reçu son index**, et lu au lieu d'être mesuré. Un commentaire périmé se propage : celui-ci s'est retrouvé le même jour dans la docstring de `ConsoleSite` et dans une entrée de CHANGELOG. C'est exactement ce que `CARTE-DU-SITE.md` décrit chez un locataire — **inerte et crédible** : le moteur ne le lit pas, donc rien ne le corrige ; un humain le lit, et le croit. ### La mesure Les sept zones du site suivaient `10...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 `