Le devis OPNsense ne rendait le chemin du poste chez un tenant qu au travers des flux externe ; il traduit desormais les flux admin (gestion, VPN). Applique : 4 retraits (WAN -> edges), 18 ajouts, 0 ecart. sonde_tcp : une fermeture propre apres la requete est une reponse de la destination ; la mesure de la frontiere est conforme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20020 lines
1.1 MiB
20020 lines
1.1 MiB
# CHANGELOG — Set-OPS
|
||
|
||
## 2026-09-29 (34) — La frontière appliquée : l'edge hors de l'Internet, l'administration traduite chez les tenants
|
||
|
||
**Un premier plan refusé.** Retirer `externe` de l'edge faisait aussi disparaître, à la
|
||
frontière, les chemins gestion → edge et VPN → edge des locataires : chez un locataire, le
|
||
devis ne rendait le chemin du poste qu'au travers des flux `externe` (option `poste`). Les
|
||
flux `admin`, appliqués par Proxmox et nftables, n'y étaient pas traduits — ils l'étaient au
|
||
site. Le devis les traduit désormais chez les locataires aussi, par les mêmes portes que le
|
||
SSH de gestion (gestion, WAN d'administration s'il est déclaré, VPN) ; un port non
|
||
numérique (`derive`) est sauté avec une note plutôt que rendu en règle invalide.
|
||
|
||
**Appliqué** (plan relu avant) : 4 règles retirées — « WAN, n'importe qui → edge 80/443 »
|
||
des deux locataires ; 18 ajouts — gestion/VPN → edge du site en 80, et gestion/VPN → Grafana
|
||
3000, Icinga Web 8080, console 8090 des locataires (déclarés `admin` par leurs rôles, déjà
|
||
permis par Proxmox et nftables ; Icinga Web et la console n'écoutent qu'en local chez les
|
||
locataires). Convergé : 0 écart, 293 règles. Depuis le poste : edges des deux locataires et du
|
||
site en 443 (302) et 80 (301), Dovecot 993 en TLS 1.3. La route du poste vers le site
|
||
(`10.37.0.0/16 via 10.37.0.1`), ajoutée par l'exploitant, ferme la régression notée en (33).
|
||
|
||
**La mesure de la frontière accusait l'edge à tort.** `frontiere-mesurer` rendait
|
||
`infra-edge-01:80` « rien n'est livré : l'hôte refuse ». C'était le serveur par défaut de
|
||
nginx, qui ferme sans rien dire un nom inconnu (`return 444`) — la sonde envoie `Host:
|
||
sonde`. Mesuré : l'edge ferme en 0,00 s (FIN, lecture vide) ; le contrôle et un port bloqué
|
||
n'aboutissent pas (délai). `sonde_tcp.py` compte désormais une fermeture propre après la
|
||
requête comme une réponse de la destination ; le silence reste AMBIGU, et le contrôle garde
|
||
l'instrument (`test_sonde_tcp.py` : parle, lit-puis-ferme, muet). Mesure conforme chez les
|
||
deux locataires. Au passage : la mesure écrit toujours dans `instance/` — mesurer un autre
|
||
locataire que celui monté écrase le relevé du premier.
|
||
|
||
**Architecture précisée par l'exploitant**, pour la suite : l'edge expose les services
|
||
internes à l'administration ; les services publics d'un locataire le seront par son web
|
||
frontal, avec des certificats Let's Encrypt ; le site attribue à chaque locataire une IP
|
||
publique de son pool.
|
||
|
||
## 2026-09-29 (33) — L'edge fermé à l'Internet ; le pare-feu Proxmox reçoit ce que la frontière laisse entrer
|
||
|
||
**La règle, mise au point par l'exploitant** : les URL `*.internal` des edges ne s'ouvrent
|
||
qu'à la zone d'administration de leur écosystème ; ce qui sera publié sur l'Internet le sera
|
||
par le **web frontal**, pas par l'edge.
|
||
|
||
**L'edge déclarait pourtant 80/443 `externe`.** Conséquences, dérivées du même registre :
|
||
la frontière portait « WAN, n'importe qui → edge 80/443 » chez les deux locataires, et le
|
||
pare-feu de la machine (`nftables`) de chaque edge — site compris — acceptait 80 et 443 de
|
||
toute source. Seul le pare-feu Proxmox, qui sautait tout `externe`, fermait la porte.
|
||
Désormais `serveur_nginx` ne déclare plus d'`externe` : 443 depuis `flotte` et `admin`, 80
|
||
(redirection) depuis `admin`. Vérifié avant : le poste joint les edges par le réseau de
|
||
gestion (10.37.0.17, règle `admin`), et les journaux d'accès des trois edges ne montrent
|
||
aucun client hors flotte et administration.
|
||
|
||
**Le pare-feu Proxmox et les flux `externe`.** Il les sautait tous (« la frontière s'en
|
||
charge ») ; depuis que les VM rejettent par défaut, un service publié aurait été refusé par
|
||
sa propre VM — et le poste ne joignait pas Dovecot 993, que la frontière lui ouvre. Même
|
||
partage que la frontière : un flux entrant `externe` est reçu depuis `+t<i>-internet`
|
||
(`0.0.0.0/1`, `128.0.0.0/1`, les trois blocs RFC 1918 exclus en `nomatch` — jamais une source
|
||
vide, et un IPSet refuse `/0`), et depuis `+t<i>-admin` quand le poste en est client
|
||
(`poste`, vrai par défaut ; faux pour le 25 de Postfix). Le SSH `externe` reste réservé à
|
||
l'administration. Rendu : Postfix 25 (Internet), Dovecot 993 (Internet, admin), ICMP
|
||
« fragmentation nécessaire » (découverte de MTU). `appliquer_proxmox_fw` pose et relit les
|
||
exclusions. `test_proxmox_fw_externe.py` garde les invariants : edge jamais ouvert à
|
||
l'Internet, tout `externe` reçu, aucune source vide, exclusions en `nomatch`.
|
||
|
||
**Le web frontal** ne déclare encore que le 80 depuis l'edge : le rendre public (son propre
|
||
TLS, sa redirection à la frontière) est l'étape suivante, pas celle-ci.
|
||
|
||
**Posé.** Pare-feu Proxmox (depuis le runner du site) : 2 IPSets `internet`, 8 groupes mis à
|
||
jour, convergé (0 écart). Premier passage : Proxmox a refusé `frag-needed` — nom nftables ;
|
||
l'API attend `fragmentation-needed` (`ICMP_PROXMOX` dans le devis). Les autres règles des
|
||
groupes `srv-debian` étaient restées en place (SSH 13/13 chez chaque locataire, ping de
|
||
supervision). Nftables des trois edges, après simulation : les deux « toute source »
|
||
retirées, 80 réservé à l'administration. Depuis le poste : Dovecot 993 répond (TLS 1.3) chez
|
||
les deux locataires — il était rejeté par la VM — ; `vigie`/`auth` en 443 (302) et 80 (301
|
||
vers HTTPS) par leurs noms.
|
||
|
||
**Une régression, de ma part : le poste ne joint plus l'edge du SITE.** Il a une route par
|
||
son lien de gestion (10.37.0.17) vers `10.17.0.0/16` et `10.23.0.0/16`, mais aucune vers les
|
||
zones du site (`10.37.x`) : il y va par le wifi (`192.168.13.254`) et arrive à l'edge avec une
|
||
adresse hors administration. Seule la règle « toute source » le laissait passer ; je ne l'ai
|
||
vu qu'après l'avoir retirée (le contrôle d'avant, sur les journaux d'accès, ne montrait que
|
||
des adresses internes — la traduction en route cachait le chemin). Remède sur le poste,
|
||
comme pour les tenants : `10.37.0.0/16 via 10.37.0.1` sur la connexion filaire.
|
||
|
||
## 2026-09-29 (32) — L'edge expose aux personnes ; entre machines du site, on va au service
|
||
|
||
**La règle, énoncée par l'exploitant** : l'edge sert à exposer des interfaces web ; les
|
||
connexions entre les VM du site ne passent pas par lui. Les anciens planchers du site la
|
||
respectaient (`forge`, `observatoire`, `vigie` → les services eux-mêmes) ; c'est la
|
||
dérivation qui avait tort, en envoyant à l'edge tout nom d'un domaine qui en a un. Ma
|
||
recommandation de (31) — rejouer ces planchers par l'edge — tombe.
|
||
|
||
**Déclarée par domaine, pas imposée partout.** Chez les locataires, les machines passent
|
||
encore par l'edge pour `auth.<domaine>` : Keycloak n'écoute qu'en clair derrière lui, et le
|
||
canal serveur-à-serveur de l'OIDC (Grafana, oauth2-proxy) en dépend. Nouveau champ de
|
||
`domaines.yml` : `resolution_interne: service | edge` (absent = `edge`). Le site déclare
|
||
`service`.
|
||
|
||
- `expositions_des_applications` rend `resout_vers` ; une exposition qui se sert elle-même
|
||
(sans port, ou sans edge) mène toujours au service.
|
||
- Le plancher (`hosts.j2`) et la zone (`zone.db.j2`, A et empreinte `_plancher`) suivent
|
||
la même règle ; `test_empreinte_plancher.py` couvre un nom résolu vers son service.
|
||
- `client_pki` ajoute au certificat du service le nom qui mène à lui : sans cela, une
|
||
reconstruction de `site-forge-01` aurait rendu un certificat sans `forge.genese.internal`,
|
||
que le runner du site appelle en direct.
|
||
- Validation (`valider_domaines`) et schéma : valeur hors `edge`/`service` refusée. Pas de
|
||
`defaut` au schéma : le GUI l'aurait écrit à chaque sauvegarde (`test_rendu_gui` l'a vu).
|
||
|
||
**Posé au site, après simulation.** Zone : `console`, `forge`, `observatoire`, `vigie` →
|
||
leurs services (10.37.31.11, .33.11, .36.11, .36.11). Planchers des 9 machines : `console`
|
||
et `site-dnspub-01` ajoutés ; `site-dnspub-01` ramené aux services. Cache d'Unbound vidé.
|
||
Le runner du site joint la forge par son nom, en direct (`ls-remote` : tête publiée) ; les
|
||
deux locataires ouvrent leur session SFTP de sauvegarde. Sonde `plancher` : 35 machines sur
|
||
35 conformes (13 + 13 + 9). Chez les locataires, rien ne change (simulation : 0 enregistrement,
|
||
13 planchers sur 13 intacts). Les runners des locataires joignaient déjà la forge du site en
|
||
direct, par son adresse.
|
||
|
||
**À savoir.** Au prochain passage de `client_pki` au site, `site-ops-01` recevra
|
||
`console.genese.internal` dans son certificat (le nom mène désormais à lui) : réémission
|
||
normale.
|
||
|
||
## 2026-09-29 (31) — Sonde `plancher` ; elle a trouvé une régression que j'avais causée au site, corrigée à la source
|
||
|
||
**La sonde `plancher` (rôle `client_sante`, sur toute machine qui rapporte).** Le plancher
|
||
`/etc/hosts` et la zone PowerDNS suivent le même plan, chacun quand son rôle est rejoué
|
||
(voir (30)). La zone publie désormais `_plancher.<domaine> TXT "n=… sha256=…"` : le nombre et
|
||
l'empreinte des paires « nom adresse » que le plancher DOIT porter — flotte active ET
|
||
planifiée, expositions du domaine. La sonde calcule la même chose sur `/etc/hosts`, en
|
||
interrogeant l'autoritatif directement, sans transfert de zone (AXFR reste fermé). En cas
|
||
d'écart elle nomme ce qu'elle peut : noms publiés absents (nombre), adresses différentes,
|
||
noms inconnus de l'autoritatif. Manquant ou réadressé : critique ; en trop seulement :
|
||
avertissement. `seulement_si: hosts_statiques_actif`. `test_empreinte_plancher.py` rend les
|
||
deux gabarits sur un même inventaire et exige la même empreinte.
|
||
|
||
Chez les deux locataires : 26 machines sur 26 conformes (19 noms chacun). Mises en défaut
|
||
sur `mon-01` (Technolibre), sur des copies du plancher : vide, sans `console` → critique,
|
||
« 1 nom publié absent » ; `forge-01` en trop → avertissement nommé ; `mon-01` réadressé →
|
||
critique, les deux adresses données.
|
||
|
||
**Ce qu'elle a trouvé au site — et ce que j'y avais cassé.** En (30), j'ai redéployé la zone
|
||
du site sans simulation préalable, en attribuant la réécriture au seul `site-dnspub-01`. La
|
||
zone faisait en réalité pointer `sauvegarde`, `pki` et `dns.genese.internal` vers l'edge du
|
||
site (10.37.37.11), qui ne les sert pas. Les locataires sauvegardent vers
|
||
`sauvegarde.genese.internal`, résolu par le DNS du site : de 05 h 23 à 11 h 51, ce nom menait
|
||
à l'edge, où aucun SFTP n'attend. Aucune sauvegarde n'est tombée dans la fenêtre (elles
|
||
tournent vers 02 h 30), mais celles de la nuit auraient toutes échoué.
|
||
|
||
**La cause est dans la dérivation.** `expositions_des_applications` attribuait tout FQDN
|
||
d'un domaine à l'edge de ce domaine. Or un edge ne publie que ce qui a un port : nginx n'écrit
|
||
un vhost que pour une exposition qui en porte un. Depuis que `genese.internal` a un edge
|
||
(14 septembre), les trois expositions sans port lui étaient attribuées ; les anciens
|
||
planchers du site, antérieurs, les envoyaient encore aux bonnes machines, et la zone chargée
|
||
avant (30) aussi. **Corrigé** : une exposition sans port se sert elle-même
|
||
(`test_expositions_sans_port.py`) ; la preuve de `prouver.py` qui refuse qu'une exposition
|
||
retombe sur son propre groupe dans un écosystème à edge excepte désormais celles sans port.
|
||
Zone du site redéployée après simulation (trois A, vers 10.37.35.11, .32.11, .34.11), cache
|
||
d'Unbound vidé pour `genese.internal`. Depuis les deux locataires, `sauvegarde` rend
|
||
10.37.35.11 et une session SFTP s'ouvre dans le bon dépôt. `site-dnspub-01`, dont le plancher
|
||
avait été généré avec la dérivation fautive, rejoué seul : conforme.
|
||
|
||
**Correction de (30)** : la zone du site n'y avait pas seulement reçu `site-dnspub-01` —
|
||
elle avait aussi envoyé ces trois noms vers l'edge.
|
||
|
||
**Ce qui reste, et qui est une décision.** Les 8 autres machines du site ont un plancher
|
||
antérieur à l'edge : `forge`, `observatoire` et `vigie` y mènent directement aux services,
|
||
et `console`, `site-dnspub-01` y manquent (le DNS du site les résout). La sonde les montre
|
||
critiques. Les rejouer ferait passer le runner du site par l'edge pour joindre la forge en
|
||
HTTPS : la lecture y a été éprouvée (`ls-remote` identique), mais l'edge limite une requête à
|
||
16 Mo et `Set-OPS-public` en pèse 171 — un envoi de rattrapage n'y passerait pas. Et
|
||
`client_pki` retiendra, à son prochain passage au site, `pki.genese.internal` sur
|
||
`site-pki-01` plutôt que sur l'edge — le nom qu'il sert vraiment.
|
||
|
||
## 2026-09-29 (30) — `console` ne se résolvait pas : un plancher périmé et une zone jamais rechargée
|
||
|
||
**Constat.** Depuis l'edge de chaque locataire, `console.<domaine>` ne se résolvait pas,
|
||
alors que l'edge la sert. Les autres expositions se résolvaient.
|
||
|
||
**Première cause : le plancher `/etc/hosts` n'avait pas été rejoué.** Il dérive du plan
|
||
(inventaire + `expose`), mais n'est posé que par le socle (`serveur_debian`). La console a
|
||
été ajoutée au plan après le dernier passage du socle ; seul `ops-01`, redéployé depuis,
|
||
la portait. Rejoué seul (`hosts_statiques`, sans `common_packages` et sa mise à jour
|
||
complète) sur les 26 machines des deux locataires, après simulation : `console` ajoutée,
|
||
`forge-01` et `forge` retirées (l'ancienne forge des locataires, sortie du plan).
|
||
|
||
**Seconde cause, plus grave : PowerDNS servait une zone antérieure à son fichier.** Le
|
||
fichier de zone, réécrit le 16 septembre, portait `console` ; PowerDNS servait la version
|
||
chargée à son démarrage (13 septembre chez Chezlepro, 14 chez Technolibre). Le rechargement
|
||
ne passait que par un handler, et un handler en attente est abandonné quand une tâche échoue
|
||
plus loin dans le même passage ; aux passages suivants, le fichier ne change plus et rien ne
|
||
le redemande. La sonde `zones` comptait les zones servies, pas leur fraîcheur : verte.
|
||
|
||
- **Rôle `serveur_powerdns`** : à chaque passage, chaque zone (interne et publique) dont le
|
||
fichier est plus récent que son chargement (`bind-domain-status`) est rechargée
|
||
(`bind-reload-now`). Ce qui est à jour n'est pas touché.
|
||
- **Sonde `zones`** : critique si une zone servie est plus ancienne que son fichier
|
||
au-delà de `serveur_powerdns_sonde_tolerance` (900 s), en nommant les zones. Mise en
|
||
défaut par ce paramètre : critique.
|
||
- **Posé sur les trois** (Chezlepro, Technolibre, site) : deux zones rechargées chez chaque
|
||
locataire ; au site, la zone et une zone inverse réécrites (le DNS public `site-dnspub-01`
|
||
n'y était pas encore). `zones` au vert partout, « chacune conforme à son fichier ».
|
||
`console` se résout depuis les deux edges, par le plancher et par PowerDNS.
|
||
|
||
**Une erreur de ma part, rattrapée par la garde de syntaxe.** `${#tableau[@]}` dans une sonde
|
||
ouvre un commentaire Jinja. `test_sondes_syntaxe.py` l'a vu ; mais j'avais enchaîné le
|
||
déploiement de Technolibre sans attendre le test, et la pose de la sonde y a échoué (la
|
||
réconciliation, elle, était passée). Corrigé et redéployé ; la configuration publique
|
||
réécrite pendant ce passage raté ne différait que par des commentaires.
|
||
|
||
**Ce qui n'est pas traité ici.** Les machines des locataires interrogent le résolveur du
|
||
site, qui répond NXDOMAIN pour leurs noms internes : leur résolution repose entièrement sur
|
||
le plancher. C'est l'état voulu tant que le basculement vers leur résolveur
|
||
(`client_resolveur_apply`) n'est pas confirmé. Et rien ne signale encore qu'un plancher est
|
||
en retard sur le plan : il suit le plan au déploiement, pas en continu.
|
||
|
||
## 2026-09-29 (29) — `passerelle` fait le trajet d'un visiteur jusqu'à la page de connexion ; fin de la revue des sondes
|
||
|
||
Cinquième et dernière des sondes « répond » de la revue (25).
|
||
|
||
**Avant.** `check_http` sur `/ping` : vert tant que le processus oauth2-proxy vit, même
|
||
quand plus personne ne peut entrer — client supprimé ou renommé dans Keycloak, URL de
|
||
retour retirée du client, IdP injoignable depuis la machine de la passerelle.
|
||
|
||
**Maintenant.** Après `/ping`, la sonde fait le trajet d'un visiteur : `/oauth2/start` doit
|
||
renvoyer vers l'émetteur configuré (lu dans la configuration déployée), pour CE client ; puis
|
||
l'IdP, joint depuis cette machine avec le même magasin de confiance que la passerelle, doit
|
||
servir sa page de connexion (200). C'est aussi le chemin de l'échange du code au retour.
|
||
Un refus de Keycloak est nommé (`Invalid parameter: redirect_uri`, `Client not found`…) ;
|
||
un IdP injoignable rend le code d'erreur de curl.
|
||
|
||
**Ce qu'elle ne teste pas : le secret du client.** Le vérifier demande un échange de jeton
|
||
raté, que Keycloak écrit dans le journal de sécurité du realm — toutes les 15 minutes, par
|
||
passerelle, ce serait noyer le journal où l'on cherche les vraies tentatives. Le cas sain
|
||
n'y écrit rien.
|
||
|
||
**Éprouvé.** Au vert sur les quatre passerelles (vigie et console, chez les deux
|
||
locataires), 35 ms ; le site n'en a pas. Mises en défaut chez Chezlepro : URL de retour non
|
||
autorisée (`serveur_oauth2_proxy_sonde_retour`) → 400 nommé ; configuration illisible ; port
|
||
fermé ; magasin de confiance inutilisable → « n'atteint pas l'IdP (curl 77) ». Toutes
|
||
critiques. **À savoir en lisant le journal du realm `chezlepro`** : ces épreuves y ont laissé
|
||
cinq `LOGIN_ERROR` (`redirect_uri` vers `exemple.invalid`), les 28 et 29 septembre.
|
||
|
||
**Au passage.** Le port de la sonde était écrit en dur (4180) : il dérive de l'écoute. Un
|
||
échec de `/ping` sortait sur deux lignes : une seule. Et l'archive oauth2-proxy repartait du
|
||
contrôleur à chaque déploiement, pour la même raison que Keycloak en (26) — le repère était
|
||
dans `/tmp` ; c'est désormais le binaire installé. Plus aucun rôle ne prend `/tmp` pour
|
||
repère. Limite qui demeure, dans les deux rôles : un changement de VERSION ne réinstalle pas
|
||
(l'extraction s'arrête sur `creates`) ; à traiter le jour d'une montée de version.
|
||
|
||
**Bilan de la revue.** Les cinq sondes qui disaient « répond » disent désormais « sert » :
|
||
`filtrage` (GTUBE analysé), `edge` (chaque exposition servie), `identite` (Keycloak
|
||
authentifié auprès de l'annuaire), `vigie` (base, annuaire ou comptes, Redis), `passerelle`
|
||
(trajet jusqu'à la connexion).
|
||
|
||
## 2026-09-28 (28) — `vigie` vérifie ce que la vigie lit, pas seulement qu'elle répond
|
||
|
||
Quatrième des cinq sondes « répond » de la revue (25).
|
||
|
||
**Avant.** `check_http` sur la boucle locale : un 302 vers le formulaire de connexion
|
||
suffisait. Une vigie dont la base est injoignable (mot de passe tourné d'un seul côté,
|
||
`pg_hba`, certificat), dont l'annuaire refuse la liaison, ou dont le Redis est tombé, sert
|
||
très bien ce formulaire — puis une erreur, des tableaux vides, ou un compte connecté sans
|
||
aucun droit parce que ses groupes ne se lisent plus.
|
||
|
||
**Maintenant.** Après `check_http`, la sonde lit la configuration DÉPLOYÉE de la vigie
|
||
(`resources.ini`, `authentication.ini`, `groups.ini`, `modules/icingadb/`) et éprouve chaque
|
||
ressource qu'elle utilise, avec SES identifiants et le même pilote PHP : la base du moteur
|
||
(compte des hôtes, zéro = critique), l'annuaire (liaison + lecture de la racine) ou la base
|
||
des comptes, et Redis (`PING`). Un tenant (annuaire) et le site (base locale) passent par
|
||
le même code. Les secrets restent dans les fichiers (0640), rien n'est affiché.
|
||
|
||
**Éprouvé.** Au vert sur les trois : Chezlepro et Technolibre (13 hôtes, soit ceux du plan ;
|
||
annuaire ; Redis), site (23 hôtes, base des comptes et groupes, Redis) ; 51 ms. Mises en
|
||
défaut sur Technolibre, sur une copie de la configuration : table absente
|
||
(`serveur_icingaweb2_sonde_table`), Redis sur un port fermé, liaison à l'annuaire refusée,
|
||
mot de passe de la base faux — les quatre critiques, chacune nommant sa cause. Au site,
|
||
codes HTTP attendus changés : critique, « ne sert plus ses pages ».
|
||
|
||
Reste de la revue : `passerelle` (oauth2-proxy atteint-il Keycloak ?).
|
||
|
||
## 2026-09-28 (27) — La sonde d'Icinga Web 2 s'appelle `vigie`, plus `console`
|
||
|
||
Le vocabulaire des plans dit **vigie** pour Icinga Web 2 (`vigie.<domaine>`) et **console**
|
||
pour la console d'exploitation Set-OPS (`serveur_ops`, `console.<domaine>`, sonde
|
||
`console-ops`). La sonde d'Icinga Web 2 s'appelait pourtant `console` : dans Icinga,
|
||
`mon-01!console` parlait de la vigie, et les entrées (25) et (26) ci-dessous en ont hérité
|
||
(« `console` : Icinga Web lit-il sa base ? »). Renommée `vigie` : déclaration, gabarit
|
||
(`sonde-vigie.sh.j2`), tâche, et le catalogue des services. `console-ops` garde son nom :
|
||
reprendre tout de suite le nom libéré aurait donné deux sens à « console » dans l'historique.
|
||
|
||
Posé sur les trois supervisions (Chezlepro, Technolibre, site) dans l'ordre : `serveur_icinga`
|
||
(service et filtre du compte de dépôt, dérivés des déclarations), `serveur_icingaweb2` (la
|
||
sonde), `client_sante` (qui a retiré l'orphelin `console.sh` sur les trois). `vigie` est au
|
||
vert partout ; le service `console` n'existe plus. Son historique Icinga repart à zéro sous
|
||
le nouveau nom.
|
||
|
||
## 2026-09-28 (26) — `identite` vérifie que Keycloak atteint l'annuaire ; l'archive Keycloak cesse de voyager pour rien
|
||
|
||
Troisième des cinq sondes « répond » de la revue (25).
|
||
|
||
**`identite` (Keycloak).** S'arrêtait à la découverte OIDC du realm. Or les comptes vivent dans
|
||
LDAP : un lien rompu vers l'annuaire (certificat, mot de passe de liaison tourné d'un seul côté,
|
||
slapd tombé) laisse la découverte parfaite et refuse chaque connexion. La sonde demande
|
||
désormais à Keycloak de s'authentifier auprès de l'annuaire avec SA PROPRE configuration
|
||
(`testLDAPConnection`, `testAuthentication`, `componentId` de la fédération enregistrée, secret
|
||
masqué : Keycloak substitue le secret stocké — la sonde ne manipule jamais le secret LDAP). Le
|
||
jeton admin est pris avec les identifiants de `/etc/keycloak/keycloak.env` (0640, lus en root,
|
||
jamais affichés ni passés en argument). Éprouvé avant d'écrire : 204 sur la vraie fédération,
|
||
400 `AuthenticationFailure` avec un faux bindDn. Chez les deux locataires : fédération
|
||
`openldap` authentifiée, reçue par Icinga (0,12 s). Mise en défaut par paramètre
|
||
(`serveur_keycloak_sonde_federation`) : critique, « la fédération n'existe pas » ; fichier
|
||
d'identifiants illisible : avertissement, « fédération non vérifiée ». Sans fédération
|
||
(`serveur_keycloak_ldap_federation: false`), la sonde s'en tient à la découverte.
|
||
|
||
**Au passage : l'archive Keycloak repartait à chaque déploiement.** « Déjà posée ? » regardait
|
||
`/tmp/keycloak-<version>.tar.gz`, que le système vide : l'archive repartait du contrôleur à
|
||
chaque passage (`changed` sur les deux locataires). Le repère est désormais le marqueur
|
||
`.kc-built-<version>` : cette version est en place, rien à apporter. Redéployé : `changed=0`
|
||
sur les deux.
|
||
|
||
Reste de la revue : `console` (Icinga Web lit-il sa base ?), `passerelle` (oauth2-proxy
|
||
atteint-il Keycloak ?).
|
||
|
||
## 2026-09-28 (25) — Deux sondes qui disaient « répond » apprennent à dire « sert » ; l'edge refuse les noms inconnus
|
||
|
||
Revue de toutes les sondes, à la recherche de celles qui resteraient vertes pendant que le
|
||
service ne rend plus rien — le cas de `tableaux`. La plupart vont déjà au fond (vraie requête
|
||
SQL, recherche LDAP, résolution réelle, cibles collectées…). Cinq ne le font pas :
|
||
`filtrage`, `edge`, `identite`, `console`, `passerelle`. Les deux premières sont faites.
|
||
|
||
**`filtrage` (rspamd).** S'arrêtait à `/ping`. Soumet désormais GTUBE, le message de test
|
||
que tout filtre doit rejeter : rien n'est envoyé ni livré, `rspamc` lit le verdict. Chez les
|
||
deux locataires : rejeté, 15/15. Mise en défaut par paramètre : critique, « n'analyse plus ».
|
||
|
||
**`edge` (nginx).** Vérifiait la configuration et les ports. Interroge désormais chaque
|
||
exposition EN LOCAL (son nom, son SNI, vers 127.0.0.1) : un 5xx ou un silence la fait passer
|
||
critique, en la nommant. Même filtre que les vhosts (hôte, adresse, port) : au site, `dns`,
|
||
`pki` et `sauvegarde` n'ont pas de vhost et ne sont pas attendus.
|
||
|
||
**Ce que l'épreuve de `edge` a révélé : un nom inconnu recevait Keycloak.** Sans serveur par
|
||
défaut sur 443, nginx confiait tout nom inconnu au premier vhost — `absente.chezlepro.internal`
|
||
obtenait un 302 vers `/admin/`. Une porte qui ne devrait pas exister, et une sonde aveugle :
|
||
une exposition dont le vhost aurait disparu restait « servie »… par Keycloak. Et le site par
|
||
défaut de Debian restait actif sur 80 (`serveur_nginx_desactiver_defaut: false`, sans raison
|
||
écrite). Désormais `000-defaut.conf` : 444 sur 80, `ssl_reject_handshake` sur 443 ; le site
|
||
Debian est retiré. Posé sur les trois edges : expositions toujours servies (6, 6, 4), noms
|
||
inconnus et IP nue refusés, et la sonde voit une exposition fictive (critique).
|
||
|
||
**Une erreur de ma part, en direct, et sa garde.** `sonde-edge.sh.j2` redéfinit la syntaxe des
|
||
commentaires Jinja (`{=# … #=}`) ; j'y ai écrit un commentaire ordinaire, sorti tel quel dans le
|
||
script : la sonde a rendu une erreur de syntaxe sur les trois edges (les edges servaient). Corrigé
|
||
aussitôt. `test_sondes_syntaxe.py` (dans `make test`) rend chaque gabarit de sonde — en
|
||
respectant l'en-tête `#jinja2:` — et exige que `bash -n` l'accepte : 44 gabarits valides, et
|
||
l'erreur recréée est refusée.
|
||
|
||
**Relevé en passant, non réglé** : `console.chezlepro.internal` ne se résout pas DEPUIS l'edge
|
||
(ni dans son `/etc/hosts`, ni au DNS) ; le poste le résout par le sien. L'edge le sert bien.
|
||
|
||
## 2026-09-28 (24) — La sonde « tableaux » vérifie aussi les sources de données
|
||
|
||
Elle était verte pendant que les tableaux des locataires n'affichaient rien : elle
|
||
demandait si Grafana répond et si sa base tient — pas s'il peut montrer quelque chose.
|
||
Elle interroge désormais la santé de CHAQUE source de données (`/api/datasources/uid/…/health`)
|
||
et passe CRITIQUE en nommant celle qui est en défaut. Le mot de passe d'administration est
|
||
lu là où il est, dans le drop-in systemd de Grafana (0600 root), pas recopié ; illisible, la
|
||
sonde le dit (avertissement « sources non vérifiées ») au lieu de se taire.
|
||
|
||
Éprouvée sur `obs-01` de Technolibre : saine ; puis une source Loki temporaire recréant le
|
||
défaut du jour (`http://localhost:3100` face à un Loki en HTTPS) → CRITIQUE, source nommée ;
|
||
source retirée → saine. Déployée sur les trois Grafana : `tableaux` au vert, « sources
|
||
saines (Loki, Prometheus) ».
|
||
|
||
## 2026-09-28 (23) — Grafana des locataires : aucune donnée, parce que l'URL de Loki ne suivait pas son TLS
|
||
|
||
**Signalé par l'exploitant** : une erreur de plugin et aucun panneau rempli sur les Grafana
|
||
d'`obs-01`, chez les deux locataires.
|
||
|
||
**Cause.** Les locataires activent `serveur_loki_tls_actif` : Loki sert en HTTPS. La source
|
||
Loki de Grafana était écrite en dur, `http://localhost:3100` : Loki coupait la connexion
|
||
(« connection reset by peer »). Les tableaux construisent leur variable `host` depuis Loki
|
||
— 21 appels en échec sur `label/host/values` —, elle restait vide, et AUCUN panneau
|
||
n'affichait rien, ceux de Prometheus compris. Le même défaut que la sonde de Loki, corrigé
|
||
pour elle seule le 2026-09-10 : un paramètre qui ne suit pas l'interrupteur dont il dépend.
|
||
|
||
**Correctif.** `serveur_grafana_loki_url` se DÉRIVE de `serveur_loki_tls_actif` de l'hôte
|
||
Loki : en TLS, `https://<fqdn>:3100` — le nom qui est dans le certificat, `localhost` n'y est
|
||
pas ; l'autorité interne est dans le magasin de confiance du système, Grafana vérifie. Sans
|
||
TLS (le site), rien ne change. Vérifié par l'API de Grafana chez les deux locataires : Loki
|
||
et Prometheus sains, 13 hôtes rendus pour `host`, plus aucune erreur Loki au journal.
|
||
|
||
## 2026-09-28 (22) — Pairs WireGuard de Technolibre appliqués ; un renommage n'est plus une création
|
||
|
||
**Appliqué** (`vpn_admin.py appliquer --portee OPS-Technolibre`, à la demande de l'exploitant) :
|
||
`technolibre-mathieu-portable` devient `technolibre-responsable-portable`. `fabric` au vert :
|
||
les quatre plans de la fabric sont conformes.
|
||
|
||
**Ce que l'application a révélé.** Le « nouveau » pair portait la MÊME clé publique que
|
||
l'ancien : le même appareil, renommé. Le script créait avant de retirer ; OPNsense a refusé
|
||
la création (« Public keys should be unique ») — mais le retrait, lui, était passé. La
|
||
configuration n'avait plus AUCUN pair pour ce tunnel ; seul le fait que l'échec empêche le
|
||
rechargement a gardé l'ancien en service. Rejoué aussitôt : le nouveau pair est créé, relu
|
||
en service.
|
||
|
||
**Corrigé** : `rapprocher` reconnaît un renommage à la clé (à créer ET à retirer, même clé)
|
||
et le fait en MODIFICATION sur place (`set_client`) — l'accès n'est jamais coupé, la
|
||
frontière ne voit jamais deux fois la même clé. Un vrai remplacement (autre clé) reste une
|
||
création suivie d'un retrait. Éprouvé sur un état simulé, dans les deux cas.
|
||
|
||
## 2026-09-28 (21) — La sonde « genome-a-jour » : le filet sous `make publier`
|
||
|
||
`make publier` tient la forge du site à jour dans le geste courant. Reste le jour où l'on
|
||
publie autrement — un `git push` fait à la main, un runner injoignable au moment de publier.
|
||
La sonde `genome-a-jour` (runner du site) compare la tête de chaque dépôt de la forge du site
|
||
à celle d'eregion, où chaque commit du poste arrive en premier — comme REPÈRE seulement :
|
||
eregion reste hors du chemin du génome.
|
||
|
||
**Seulement ce qu'eregion laisse lire sans identifiant** (aujourd'hui : le moteur, celui qui
|
||
avait quatre commits de retard le 2026-08-26). Les dépôts privés sont NOMMÉS comme non
|
||
comparés : donner au site un jeton sur une forge héritée l'y lierait.
|
||
|
||
**Un écart neuf n'est pas un retard** : `make publier` pousse eregion une minute avant la
|
||
forge du site. La sonde retient depuis quand chaque écart dure et ne parle qu'au-delà d'une
|
||
heure. Éprouvée en vrai : commit poussé sur eregion SEULEMENT → « écart récent, toléré » ;
|
||
écart vieilli de deux heures → avertissement « FORGE DU SITE EN RETARD… `make publier` » ;
|
||
`make publier` → à jour, écart oublié. Reçue par l'Icinga du site.
|
||
|
||
## 2026-09-28 (20) — `make publier` : eregion ET la forge du site, en un geste
|
||
|
||
**Le défaut.** La forge du site fait autorité (D-81), mais les commits naissent sur le poste
|
||
et n'y arrivent que par `make genome-pousser` — un geste à part, que rien ne force ni ne
|
||
signale. Un `git push` vers eregion la laisse en retard. Le 2026-08-26 : quatre commits,
|
||
dont le correctif du pare-feu Proxmox. Aujourd'hui même, `make genome-etat` la trouvait en
|
||
retard d'un commit.
|
||
|
||
**`make publier`** (`scripts/publier.py`), pour chaque dépôt que le runner du site porte —
|
||
la liste de `genome-pousser` (`serveur_ops_depots`), lue et non recopiée : refuse si
|
||
eregion porte des commits absents du poste (une fusion acceptée serait écrasée sur la forge
|
||
du site), signale ce qui n'est pas committé, pousse sur eregion ; puis `genome-pousser`,
|
||
puis `genome-etat`, et dit si la forge du site porte bien la tête du poste. Un refus arrête
|
||
tout AVANT la forge du site : mieux vaut la laisser en retard que la nourrir d'un dépôt
|
||
incomplet. Ce commit est le premier publié ainsi.
|
||
|
||
## 2026-09-28 (19) — Chaque semaine, chaque sauvegarde est rouverte pour de vrai
|
||
|
||
**Le trou.** `sauvegarde` disait qu'un instantané existe, récent et non vide ; rien ne disait
|
||
qu'il se rouvre. Aujourd'hui, c'est à la main qu'on a restauré les dumps PostgreSQL et LDAP
|
||
— et découvert que deux locataires sur deux ne déposaient rien hors de leur flotte.
|
||
|
||
**Le contrôle** (`client_backup`, minuteur du dimanche 04:00 ± 1 h), joué par le nœud lui-même,
|
||
seul détenteur de la clé : `restic check --read-data-subset=10%` (l'intégrité, en relisant
|
||
des données, pas seulement l'index), puis restauration RÉELLE du dernier instantané avec
|
||
`--verify` (contenu recomparé), puis comptage des fichiers face à l'instantané. Répertoire
|
||
temporaire détruit quoi qu'il arrive ; `nice` et E/S au repos. Rapporté au service
|
||
`restauration` (fraîcheur 8 jours, liée au `ttl` par `test_fraicheur_icinga.py`).
|
||
|
||
**Le piège évité** : le compte d'API des nœuds n'écrit que sur des services NOMMÉS ; sans
|
||
l'ajout de `restauration` au filtre, chaque rapport aurait reçu un 404 « No objects found »
|
||
sur un objet présent — le défaut du 2026-09-02.
|
||
|
||
**Premier passage, déclenché au déploiement** : 19 nœuds, 3 à 6 s chacun. Tout se restaure,
|
||
contenu recomparé — Nextcloud (≈ 140 Mo), la base, le courriel, l'annuaire, la PKI chez
|
||
chaque locataire, la forge du génome (1723 fichiers) au site. Avertissement sur les
|
||
instantanés VIDES (courriel et web sans données encore), comme `sauvegarde` le dit déjà.
|
||
|
||
## 2026-09-28 (18) — La sonde « fabric » : la fabric dit-elle encore ce que le plan dit ?
|
||
|
||
**Le trou.** Le pare-feu Proxmox, le SDN, la frontière et les accès WireGuard ont chacun un
|
||
plan de lecture (`*-plan`) ; personne ne les jouait sans raison. Aujourd'hui même : 26 VM
|
||
sans pare-feu, sans signal.
|
||
|
||
**Sur le runner du site** (`serveur_ops_site`) : un minuteur horaire joue les quatre plans
|
||
(4 s au total), additionne ce qui reste à créer, retirer, mettre à jour ou corriger, et
|
||
consigne le constat dans `/var/lib/setops/conformite-fabric.json`. La sonde `fabric` le lit
|
||
— une sonde n'a que quelques secondes : écart → avertissement (avec le premier détail),
|
||
plan illisible → inconnu, constat de plus de 3 h → CRITIQUE (un minuteur mort ne doit pas
|
||
laisser la dernière bonne nouvelle au vert). Éprouvée sur quatre constats fabriqués, puis
|
||
en vrai : `site-ops-01!fabric` en avertissement sur un écart RÉEL — les pairs WireGuard de
|
||
Technolibre, en attente de décision.
|
||
|
||
**Ce qu'elle a attrapé avant même d'exister.** En mesurant la durée des plans,
|
||
`frontiere-plan` voulait 4 créations : des alias et des règles « SSH depuis le tunnel du
|
||
locataire, sur le WAN ». Cause : le tunnel ajouté à `admin_de` plus tôt dans la journée ; la
|
||
frontière range chaque réseau d'administration par interface et l'avait classé WAN — une
|
||
règle qui ne correspondrait jamais. `admin_de` redevient l'intrant (la frontière) ;
|
||
`admin_avec_tunnel` sert l'est-ouest (Proxmox, épreuve). Les deux plans : 0 écart.
|
||
|
||
## 2026-09-28 (17) — La sonde « disque » : espace et inodes, sur les 35 machines
|
||
|
||
**Le trou.** Seuls le cache apt et les dépôts de sauvegarde avaient une sonde d'espace.
|
||
Rien ne regardait le disque de la base, des boîtes, de Nextcloud, ni de Loki qui grossit
|
||
chaque jour ; Prometheus collecte l'occupation, mais aucune règle n'en fait une alerte.
|
||
Un disque plein aurait arrêté la base ou le courriel sans prévenir.
|
||
|
||
**La sonde.** Pour chaque système de fichiers réel (pas `tmpfs`, `overlay`…), l'espace ET
|
||
les inodes — une file de petits fichiers épuise les inodes avec la moitié de l'espace
|
||
libre, et l'erreur parle alors d'un disque « plein » qu'un `df` dit à moitié vide. Seuils
|
||
80 / 90 % (`client_sante_disque_avert` / `_crit`) ; relevé du jour : au plus 22 % d'espace
|
||
et 7 % d'inodes. Une ligne, le pire système de fichiers, les métriques de tous.
|
||
|
||
**Où elle vit, et pourquoi.** Déclarée ET posée par `client_sante`, dont le groupe est
|
||
exactement « tout hôte qui rapporte ». Pas par `common_packages`, qui porte `correctifs` :
|
||
il fait un `upgrade: full` à chaque passage, et poser un script aurait mis à jour toute la
|
||
flotte. Une première version la déclarait dans `serveur_debian` : P64 l'a refusée — il
|
||
suit le playbook du groupe déclarant pour trouver le dépôt, et `client_sante` n'y est pas.
|
||
La garde avait raison ; la déclaration a suivi le rôle qui dépose.
|
||
|
||
Déployé (Icinga d'abord, pour que les premiers rapports aient un destinataire) :
|
||
35 services `disque`, tous alimentés et OK — 13, 13 et 9. `make verifier` : CONFORME.
|
||
|
||
## 2026-09-28 (16) — La procédure d'activation du pare-feu Proxmox entre au dépôt
|
||
|
||
`scripts/eprouver_parefeu.py`, par `make proxmox-fw-eprouver TENANT= HOTE=` (aucune écriture)
|
||
et `make proxmox-fw-activer-vm TENANT= HOTE= CONFIRMER=true`. Ce qui a servi à filtrer les 26
|
||
VM, sous une forme qui resservira : une VM reconstruite perd ses options de pare-feu.
|
||
|
||
Pour UNE VM : matrice tirée du devis (chaque membre de chaque source), connexions entrantes
|
||
établies confrontées aux règles, matrice avant, activation par le runner du site (seul à
|
||
joindre l'API du cluster), matrice après, sondes relancées, critiques d'Icinga. Refus
|
||
d'activer si un flux réel échappe aux règles ou si un flux échoue déjà.
|
||
|
||
**Plus d'exclusions à la main** : un échec vers un port que rien n'écoute hors de
|
||
`127.0.0.1` est classé « sans objet » (nginx lié en local derrière la passerelle SSO,
|
||
frontal sans site) — c'est ce que la procédure manuelle tranchait au cas par cas.
|
||
**Le VMID se dérive du devis**, et du bloc du BON locataire : au premier essai, le script
|
||
visait `ops-01` de Chezlepro pour une demande sur Technolibre — les noms d'hôtes se
|
||
répètent d'un locataire à l'autre.
|
||
|
||
Éprouvé : `--plan` sur `ops-01` (8090 reconnu sans objet), `--activer` de bout en bout sur
|
||
`web-frontal-01` (runner : « Rien à faire », après = avant, Icinga : 0 critique).
|
||
Limite écrite : un flux UDP est testé en TCP sur le même port.
|
||
|
||
## 2026-09-28 (15) — Les 26 VM des locataires filtrées par Proxmox ; `proxmox-fw-plan` : « Rien à faire »
|
||
|
||
**Chezlepro, 13/13**, dans le même ordre que Technolibre et par la même procédure, VM par
|
||
VM : matrice complète tirée du devis, flux entrants établis confrontés aux règles, matrice
|
||
avant, activation, matrice après, sondes relancées sur les 13 nœuds, Icinga. Toutes :
|
||
avant = après, 0 critique. Au-delà : DNS UDP réel à travers `infra-dns-01` ; les 13
|
||
rapports `sante` reçus après l'activation de `mon-01` ; et de bout en bout,
|
||
`courriel-plan` (Postfix → LDAP → Dovecot → IMAP) et `identite-plan` : CONFORME.
|
||
|
||
**Une régression introduite, puis fermée.** nftables admet le SSH depuis l'intrant
|
||
`nftables_admin_ssh` PLUS le tunnel du locataire (`10.x.29.0/24`) ; le devis Proxmox ne lisait
|
||
que l'intrant. Filtré, Technolibre refusait donc le SSH venu de son propre tunnel (de 13:27
|
||
à ~15:40, SSH seulement, aucun service). Une seule dérivation désormais,
|
||
`inventory_rules.tunnel_admin_de`, pour les deux ; IPSets `admin` à trois membres ;
|
||
`make flux` identique octet pour octet.
|
||
|
||
**Deux devis cassés depuis le 2026-09-13, trouvés en vérifiant.** `courriel-plan` et
|
||
`identite-plan` appelaient `resoudre_annuaire` sans nommer de compte de service : ils
|
||
échouaient sur une assertion, avant toute connexion. Ils empruntent maintenant le compte du
|
||
service qu'ils vérifient (`postfix`, `keycloak`).
|
||
|
||
**Reste à faire** : une règle Proxmox pour les flux venus d'Internet (`externe`) avant la
|
||
première exposition d'un locataire.
|
||
|
||
## 2026-09-28 (14) — Technolibre entièrement filtré par Proxmox, VM par VM
|
||
|
||
**Les quatre dernières, une à la fois** : `infra-dns-01`, `obs-01`, `ops-01`, `mon-01`. Pour
|
||
chacune : matrice complète tirée du devis, flux entrants établis observés et confrontés aux
|
||
règles, matrice avant, activation, matrice après, sondes relancées sur les 13 nœuds, Icinga.
|
||
Toutes : avant = après, 0 critique. Technolibre : 13/13 VM filtrées.
|
||
|
||
**Ce que la procédure a arrêté, à raison, et pourquoi c'était sans défaut :**
|
||
- `obs-01` se parle à lui-même par sa propre adresse (Prometheus, Alloy→Loki) : ce trafic
|
||
passe par `lo`, jamais par le pont de Proxmox — l'analyse l'écarte désormais ;
|
||
- `infra-edge-01 → ops-01:8090` et `→ mon-01:8080` échouaient DÉJÀ avant : en mode `oidc`,
|
||
nginx n'écoute qu'en local (le défaut le documente — une passerelle SSO qu'on ne peut pas
|
||
contourner) ; c'est `oauth2-proxy` (4180) qui est publié, et la matrice le teste. La règle
|
||
du port nginx reste pour le mode `locale`, sans objet ici.
|
||
|
||
**Vérifié au-delà de la matrice** : résolution DNS réelle en UDP à travers `infra-dns-01`
|
||
(noms internes et externes, depuis deux nœuds) ; pour `mon-01`, les 13 rapports `sante`
|
||
reçus APRÈS l'activation — le flux passif vers 5665 traverse le filtre.
|
||
|
||
## 2026-09-28 (13) — Technolibre, deuxième lot : l'edge, l'identité, la base, la PKI
|
||
|
||
**Activées** : `infra-edge-01`, `idm-01`, `data-sql-01`, `infra-pki-01`.
|
||
|
||
**Deux vérifications, parce qu'une seule ne suffit pas.** La matrice tirée du devis ne teste
|
||
que ce qui est DÉCLARÉ ; un flux réellement utilisé mais non déclaré la passerait, puis
|
||
serait coupé. Donc, en plus : relever sur chaque VM les connexions entrantes ÉTABLIES (trois
|
||
relevés), et vérifier que chaque couple source→port est couvert par une règle. 11 flux
|
||
observés, 0 hors des règles (SSH d'administration et forme IPv4-mappée d'`obs-01` comprises).
|
||
Matrice étendue à TOUS les membres de chaque source : 100/100 avant, 100/100 après.
|
||
Sondes de santé relancées sur les 13 nœuds : 133 services revérifiés, aucun critique.
|
||
|
||
## 2026-09-28 (12) — Technolibre, premier lot : quatre VM de plus filtrées, preuve comprise
|
||
|
||
**Activées** : `web-dorsal-01`, `collab-01`, `edge-mta-01`, `infra-mail-01`. Méthode : une
|
||
matrice tirée du DEVIS lui-même — pour chaque règle des groupes de ces VM, un hôte réel de
|
||
la source autorisée → VM:port —, jouée AVANT (19/19) puis APRÈS (19/19, aucun changement).
|
||
Icinga : aucun critique.
|
||
|
||
**Le filtrage mord, et c'est Proxmox qui mord.** Un contrôle négatif doit distinguer les
|
||
deux couches : nftables, dans la VM, accepte TOUT l'ICMP ; Proxmox ne l'accepte que de
|
||
`srv-icinga`. Depuis `data-sql-01` : pas de réponse des VM filtrées (`collab-01`,
|
||
`web-frontal-01`), réponse des non filtrées (`idm-01`, `infra-pki-01`).
|
||
|
||
**Un trou à fermer AVANT d'exposer un locataire.** Le devis saute les flux dont la seule
|
||
source est `externe` (« la frontière s'en charge »). Mais un flux redirigé par la frontière
|
||
arrive sur la VM avec sa source Internet : sous `REJECT`, sans règle, il serait refusé.
|
||
Sans effet aujourd'hui — la frontière ne redirige vers aucune VM de locataire (relu : seul
|
||
le DNS public du site) —, bloquant le jour d'une exposition.
|
||
|
||
## 2026-09-28 (11) — Première VM filtrée par Proxmox, et le ping de supervision qu'aucune règle ne portait
|
||
|
||
**Activée : `web-frontal-01` de Technolibre** (`--vm 123603101`), pare-feu `enable=1`,
|
||
`policy_in=REJECT`, groupes `cli-metrique`, `srv-debian`, `srv-web-frontal`. Vérifié flux par
|
||
flux : SSH du poste, rapports de santé et de sauvegarde acceptés, `node_exporter` joint par
|
||
`obs-01`, SSH depuis la flotte (autorisé par `srv-debian`, déclaré), port 80 ouvert à l'edge
|
||
seul (rien n'y écoute encore : aucun site). Icinga : tout vert — sauf un critique NOUVEAU.
|
||
|
||
**`ping4` : 100 % de pertes.** Le ping de supervision EST déclaré (`serveur_debian`,
|
||
`echo-request` depuis `serveur_icinga`), mais `_ports()` ne garde que les nombres : le type
|
||
ICMP était rangé parmi les « ports dérivés » et le flux sauté. Aucune règle, donc `REJECT`.
|
||
`_cibles()` rend désormais un flux ICMP en règle `proto: icmp` + `icmp_type`, que
|
||
l'applicateur transmet (`icmp-type`) et compare. Appliqué : `srv-debian` des deux locataires
|
||
accepte `echo-request` depuis `srv-icinga` ; `ping4` revenu OK (0,20 ms).
|
||
|
||
**Vérifié avant d'aller plus loin** : le « sans source » `serveur_icinga:5665` que recense le
|
||
devis est le flux de `serveur_backup`, qu'aucun locataire ne porte — les rapports passifs,
|
||
eux, ont leurs règles (`cli-backup`, `srv-debian`). Et le recensement ne déclare plus sauté
|
||
un flux ICMP qu'il rend.
|
||
|
||
## 2026-09-28 (10) — Le pare-feu est-ouest de Proxmox ne filtrait aucune VM des locataires
|
||
|
||
**Mesuré.** Pare-feu du datacenter actif, `firewall=1` sur les cartes des 26 VM de Chezlepro
|
||
et Technolibre — mais AUCUNE n'avait son propre pare-feu activé ni un seul groupe affecté.
|
||
Proxmox ne filtrait rien entre elles ; seul nftables, dans chaque VM, tenait l'est-ouest.
|
||
Le plan de `proxmox-fw-appliquer` le rattrapait d'un bloc : 64 changements, dont
|
||
l'activation en `REJECT` des 26 VM à la fois — autant de pannes possibles si une règle
|
||
manquait.
|
||
|
||
**Par étapes.** `appliquer_proxmox_fw.py` prend `--objets-seulement` (IPSets et groupes, aucune
|
||
VM) et `--vm <vmid>` (répétable) ; le plan affiché est exactement ce qui sera appliqué.
|
||
Posés aujourd'hui, les OBJETS seuls, inertes tant qu'aucune VM ne les porte : IPSets `admin`
|
||
passés aux sources d'administration actuelles (`10.37.0.0/24`, VPN `10.37.29.0/24`),
|
||
membres retirés (ancien `forge-01`, `x.18.21`), objets des rôles disparus (`backup`,
|
||
`forgejo`, `artefacts`) retirés, `srv-debian`, `srv-ops`, `srv-powerdns`, `srv-ops-tenant`
|
||
créés, douze groupes remis au devis. Relu : objets conformes, restent les 26 affectations.
|
||
|
||
## 2026-09-28 (9) — P02 : l'écriture d'une table s'arrêtait au premier commentaire
|
||
|
||
**`make verifier` : CONFORME, 83 OK, 0 échec** — P02 échouait depuis le 2026-09-16.
|
||
|
||
**La cause.** `_fusion_table` (écriture ligne à ligne des registres du plan, par le GUI)
|
||
délimitait une entrée par les lignes plus indentées qui la suivent, et **s'arrêtait à la
|
||
première ligne de commentaire**. Le 2026-09-16, `chezlepro.ca` a reçu des commentaires À
|
||
L'INTÉRIEUR de son entrée (signature DNSSEC, relevé de la zone) : l'entrée était coupée en
|
||
deux, et les lignes suivantes testées comme entrées de la table. ` - {nom: "@", type:
|
||
A, …}` y passait, clef `- {nom`, YAML = une liste → `'list' object has no attribute 'get'`.
|
||
Le GUI ne pouvait plus écrire `domaines.yml`.
|
||
|
||
**Le correctif.** L'étendue d'une entrée traverse commentaires et lignes vides tant que la
|
||
prochaine ligne significative est encore plus indentée ; un commentaire suivi de l'entrée
|
||
SUIVANTE reste avec elle. Et seules les lignes à l'indentation des enfants directs peuvent
|
||
être des entrées. Réécrire le vrai `domaines.yml` sans changement le rend octet pour octet.
|
||
|
||
**Le test aussi avait une prémisse périmée.** Retirer une entité devait garder TOUS les
|
||
commentaires du fichier — juste tant qu'aucune entrée n'en portait à l'intérieur. Un
|
||
commentaire intérieur part désormais avec son entrée (laissé, il expliquerait la voisine) ;
|
||
ceux qui l'entourent restent, et c'est ce que le test exige maintenant.
|
||
|
||
## 2026-09-28 (8) — L'amont du cache des locataires suit le site, au lieu d'une adresse morte
|
||
|
||
`serveur_artefacts_amont` valait `http://10.0.33.21:3142` chez Chezlepro ET Technolibre :
|
||
l'adresse du cache du site avant qu'il prenne son index (`10.37.3x`). Inerte aujourd'hui —
|
||
aucun hôte de ces écosystèmes ne porte `serveur_artefacts`, et `apt` passe par
|
||
`artefacts_amorcage` (mesuré sur les nœuds : `10.37.33.21:3142`) —, mais un piège le jour où
|
||
un locataire reprendrait son propre cache : son amont serait né mort. La preuve d'amorçage
|
||
ne regardait que les intrants, pas ce fichier. La valeur est désormais DÉRIVÉE :
|
||
`"http://{{ artefacts_amorcage }}"`, la seule adresse que cette preuve garde alignée.
|
||
|
||
## 2026-09-28 (7) — Rotation des clés WireGuard de l'exploitant, sans qu'aucune privée ne s'affiche
|
||
|
||
**Pourquoi.** Les deux clés privées de `daniel-portable` (tunnel du site, tunnel de
|
||
Chezlepro) ont fui dans l'historique d'une session d'assistance. On les change.
|
||
|
||
**Ce que `vpn_admin.py` ne savait pas faire.** `pair-nouveau` AFFICHE la privée pour qu'on
|
||
la colle ; lancé par un assistant ou dans un terminal journalisé, cet affichage la dépose
|
||
dans un historique — exactement la fuite qu'on répare. Trois ajouts :
|
||
|
||
- `cle-appareil --vers <fichier>` : la privée va droit dans un fichier en 0600 (refus
|
||
d'écraser), seule la publique s'affiche. Sert à la création comme à la rotation ;
|
||
- `config --cle <fichier> --vers <fichier>` : la configuration COMPLÈTE, écrite en 0600 ;
|
||
- `plan|appliquer --portee site --portee OPS-X` : sans filtre, `appliquer` réconcilie TOUS
|
||
les tunnels — la rotation aurait emporté l'écart en attente de Technolibre (un pair
|
||
retiré que personne n'a encore décidé de retirer).
|
||
|
||
Et `service : ?` après un rechargement réussi : OPNsense répond `result`, pas `status`.
|
||
|
||
**Fait.** Deux paires tirées dans `~/wireguard-rotation/` (hors dépôt), `cle_publique`
|
||
remplacée au plan du site et de Chezlepro — nom et adresse inchangés —, appliqué sur ces
|
||
deux seuls tunnels. Relu EN SERVICE sur la frontière : les deux nouvelles clés actives,
|
||
les deux anciennes absentes. Technolibre intact.
|
||
|
||
## 2026-09-28 (6) — Les voûtes sortent du poste, à côté des clés qui les ouvrent
|
||
|
||
**Ce qui manquait.** La clé USB portait les CLÉS des voûtes, pas les voûtes. Or elles sont
|
||
gitignorées : sur aucune forge, seulement sur le poste et sur le runner de chaque
|
||
écosystème. Poste et runner perdus ensemble, les clés restaurées n'ouvraient rien, et
|
||
chaque secret était à régénérer. La runbook `cles` affirmait pourtant « les voûtes sortent
|
||
chiffrées ».
|
||
|
||
**`scripts/exporter_voutes.py`** (`make voutes-recenser`, `make voutes-exporter VERS=…`).
|
||
Une archive à part, `setops-voutes-<date>-<poste>.tar` : `restaurer_cles.py` range chaque
|
||
clé d'après son seul nom, et les voûtes s'appellent presque toutes `vault.yml` — elles s'y
|
||
seraient écrasées. Chaque voûte garde donc son chemin relatif aux dépôts ; restauration :
|
||
`tar -xf … -C <dossier des dépôts>`. Pas de seconde couche : elles sont déjà chiffrées par
|
||
une clé qui n'est pas dans cette archive, et le script REFUSE tout fichier dont l'en-tête
|
||
n'est pas `$ANSIBLE_VAULT`. Il refuse aussi une destination dans l'infrastructure et
|
||
l'écrasement d'une archive existante, puis relit empreinte par empreinte.
|
||
|
||
**Fait et éprouvé** : six voûtes (Chezlepro, Chezlepro-lab ×2, Technolibre, les deux
|
||
sites) sur la clé USB, 80 Ko ; extraites dans un répertoire temporaire, chacune rouverte
|
||
avec les clés (33, 25, 2, 37, 20 et 14 entrées), puis détruites. Le mode d'emploi de la
|
||
clé, la runbook `cles` et `sortir-les-cles-du-poste.md` le disent désormais. À refaire
|
||
après toute écriture dans une voûte.
|
||
|
||
## 2026-09-28 (5) — L'exportateur PostgreSQL de Chezlepro, et où vivent vraiment les voûtes
|
||
|
||
**Le dernier critique de Chezlepro.** `obs-01!collecte` : Prometheus scrutait
|
||
`data-sql-01:9187`, où rien n'écoutait. `serveur_postgresql` ne pose l'exportateur que si
|
||
`vault_pg_exportateur` existe ; Technolibre l'avait, pas Chezlepro. Ajouté à sa voûte
|
||
selon la procédure (copie, lecture par un tube et comptage : 32 clés, chiffrement vers un
|
||
fichier neuf relu : 33 clés, les 32 d'origine identiques, puis remplacement). Rôle
|
||
redéployé : exportateur actif, `pg_up 1`. Chezlepro n'a plus aucun critique.
|
||
|
||
**La promesse alignée sur Prometheus (même jour).** `vault.yml.example` promettait
|
||
« VIDE = … Prometheus ne dérive aucune cible » ; le gabarit dérive pourtant la cible de
|
||
tout hôte de `serveur_postgresql`, secret ou pas. Le comportement est gardé — une base sans
|
||
métriques doit se voir — et la phrase dit désormais ce qui arrive : sans le secret, la
|
||
collecte d'`obs-01` rougit, et renseigner le secret l'éteint. Corrigé dans les quatre
|
||
exemplaires : `exemples/vault.exemple.yml`, `docs/metriques-conception.md`, et les
|
||
`vault.yml.example` de Chezlepro et de Technolibre.
|
||
|
||
**Où vivent les voûtes.** `sortir-les-cles-du-poste.md` et `exporter_cles.py` disaient les
|
||
voûtes chiffrées répliquées sur les forges. Elles sont gitignorées : chacune vit sur le
|
||
poste et sur le runner de son écosystème (copie chiffrée par `serveur_ops_tenant`). Le
|
||
runner de Chezlepro portait donc la voûte d'avant ce changement ; redéposée, empreintes
|
||
identiques. Les deux phrases sont corrigées.
|
||
|
||
## 2026-09-28 (4) — Une sonde posée sous condition n'est attendue que là où elle est posée
|
||
|
||
**Ce que la fraîcheur a fait voir.** Chez les deux locataires, `obs-01!journaux-frontiere`
|
||
est passé au rouge et y serait resté pour toujours. `serveur_loki` ne pose cette sonde que
|
||
si une étiquette de frontière est déclarée — au SITE, jamais chez un locataire, dont la
|
||
frontière appartient à l'hébergeur — et la RETIRE sinon. Icinga, lui, l'attendait sur tout
|
||
hôte du groupe. Trois rôles posent ainsi leur sonde sous `when:` (`journaux-frontiere`,
|
||
`audit`, `console-ops`) ; les deux autres n'étaient pas rouges par chance, leur condition
|
||
étant vraie partout.
|
||
|
||
**Le correctif.** La déclaration porte la même condition que le dépôt :
|
||
`seulement_si: <variable>` dans `meta/supervision.yml`, lue par `setops-sondes.conf.j2`
|
||
dans l'inventaire de l'hôte, sinon dans les défauts du rôle qui la définit (`defini_par`)
|
||
— la valeur même que ce rôle voit. `test_sondes_conditionnelles.py` (dans `make test`)
|
||
exige qu'un `when:` qui pose une sonde ait son `seulement_si`, sur la même variable, avec
|
||
un défaut littéral ; vérifié en retirant volontairement une condition.
|
||
|
||
Simulé puis appliqué : chez chaque locataire, un seul changement — `journaux-frontiere`
|
||
retiré d'`obs-01`. Technolibre n'a plus aucun critique ; Chezlepro n'en garde qu'un, son
|
||
exportateur PostgreSQL, faute du secret `vault_pg_exportateur`.
|
||
|
||
**Corrigé le même jour : les journaux de la frontière SONT couverts.** Cette entrée
|
||
affirmait le contraire, tirée d'un `--diff` où `journaux-frontiere` n'apparaissait pas —
|
||
un diff ne montre que ce qui CHANGE, pas ce qui existe. Mesuré ensuite : la frontière
|
||
envoie en TCP vers `site-mon-01:1514` (Alloy), qui porte bien `serveur_loki` ; la sonde
|
||
`site-mon-01!journaux-frontiere` est verte, 748 lignes reçues en 10 min.
|
||
|
||
## 2026-09-28 (3) — Un service qui n'a jamais rien reçu passe au rouge
|
||
|
||
**Le trou.** Tous les services passifs (sauvegardes, santé, sondes de rôle) étaient
|
||
déclarés `enable_active_checks = false`, avec une commande qui exécutait `/bin/true`. Le
|
||
`ttl` de chaque envoi couvrait le silence qui SUIT un rapport ; un service qui n'en a
|
||
jamais reçu restait « en attente », gris, pas rouge. C'est ce qui a caché dix nœuds de
|
||
Chezlepro sans sauvegarde pendant seize jours.
|
||
|
||
**Le correctif.** Un gabarit, `setops-rapport-attendu` (dans `setops-sauvegardes.conf.j2`),
|
||
que les trois familles importent : service ACTIF, commande `dummy` en CRITIQUE,
|
||
`check_interval` = le seuil — patron documenté d'Icinga 2 pour la fraîcheur. Chaque rapport
|
||
repousse l'échéance ; seul le silence la laisse arriver, y compris le silence initial.
|
||
Seuils = les `ttl` que les nœuds envoient (6 h, 45 min, 90 min), dans
|
||
`serveur_icinga_fraicheur_*`. Une liste qui en suit une autre : `test_fraicheur_icinga.py`
|
||
(dans `make test`) les compare et refuse tout service passif sans échéance — vérifié en
|
||
cassant volontairement une valeur.
|
||
|
||
**Prouvé en vrai** sur `site-mon-01` : rapport envoyé avec un `ttl` de 60 s → CRITIQUE à
|
||
t+60 s (« AUCUN RAPPORT… ») → vrai rapport de santé → OK. Déployé sur les trois Icinga
|
||
(site, Chezlepro, Technolibre) : 120 + 138 + 138 services, tous avec échéance.
|
||
|
||
**Ce que ça a aussitôt montré.** Chez les deux tenants, dix services n'avaient JAMAIS reçu
|
||
de rapport, et rougissent maintenant : `obs-01!journaux-frontiere`, et neuf sondes de rôle
|
||
(console, passerelle, édition, cache, filtrage, sites/apps servis) — leur `client_sante`
|
||
n'a pas été redéployé depuis que ces sondes existent. Leur Icinga surveillait aussi
|
||
encore `forge-01`, retiré du plan : la configuration est réalignée, mais `obs-01!collecte`
|
||
reste rouge tant que Prometheus scrute l'ancienne adresse (`10.x.21.11:9100`).
|
||
|
||
## 2026-09-28 (2) — Deux locataires sur deux n'avaient pas de sauvegarde hors de leur flotte
|
||
|
||
**Question de l'exploitant : les sauvegardes des tenants passent-elles ?** Non. Mesuré
|
||
côté dépôt (`site-backup-01`, ce que l'hébergeur voit sans la clé) puis côté nœuds.
|
||
|
||
**Technolibre — refusé chaque nuit, `restic-tech` vide.** Ses huit nœuds à état se
|
||
présentaient comme `restic`, le compte du SITE : `Permission denied (publickey)`, plusieurs
|
||
fois par jour. Chezlepro déclare deux lignes (`client_backup_cible` ET
|
||
`client_backup_utilisateur_distant`) ; Technolibre n'avait recopié que la première, et le
|
||
rôle retombait sur son défaut. Ajouté `restic-tech` (OPS-Technolibre `ef8c905`), déployé,
|
||
sauvegarde lancée : huit premiers instantanés, 79 Mo ; le dump PostgreSQL restauré (4,4 Mo,
|
||
435 tables) se lit.
|
||
|
||
**Chezlepro — dix nœuds sur onze sans `client_backup` du tout.** Seul `infra-pki-01`
|
||
déposait (9 instantanés depuis le 2026-09-13). Les autres n'avaient ni script, ni minuteur,
|
||
ni secret : ils ne frappaient même pas à la porte. Les dates de naissance le situent au
|
||
redéploiement du 2026-09-12 au soir — `client_backup` y a abouti sur `infra-pki-01`
|
||
(21:22) et `infra-dns-01` (21:28), les deux nœuds amorcés en premier, jamais sur les
|
||
autres, alors que `client_sante`, plus haut dans `site.yml`, les a tous atteints (21:59).
|
||
Aucun journal n'a été gardé : échec ou interruption, on ne sait pas. Le rôle, relancé sans
|
||
aucune modification, a réussi partout. Sept premiers instantanés ; dump PostgreSQL (6 bases,
|
||
435 tables) et annuaire (12 entrées) restaurés et lus. `/var/vmail`, `/srv/web` et
|
||
`/srv/webapp` sont vides sur le disque aussi : rien de manqué.
|
||
|
||
**Pourquoi personne ne l'a vu.** Un nœud sans `client_backup` n'a pas non plus sa
|
||
vérification : il ne rapporte RIEN. Un service passif qui n'a jamais reçu de résultat reste
|
||
« en attente » dans Icinga — il ne passe jamais au rouge. Le `ttl` n'alerte que sur un
|
||
silence qui SUIT un rapport. C'est le trou à fermer : une fraîcheur qui vaut aussi pour
|
||
le premier rapport.
|
||
|
||
## 2026-09-28 (1) — La sauvegarde de la forge du génome passait ; sa vérification parlait dans le vide
|
||
|
||
**Question de l'exploitant : la sauvegarde de `site-forge-01` passe-t-elle vraiment ?** Oui,
|
||
et c'est maintenant mesuré jusqu'à la restauration : `setops-sauvegarde` réussit chaque nuit
|
||
(dernier instantané 2026-09-28 02:43, 9 conservés, ~45 Mio), et `set-ops-public.git`
|
||
restauré depuis `latest` passe `git fsck`, avec pour tête `859ac07` — exactement ce que la
|
||
forge portait à 02:43, avant les poussées de la journée.
|
||
|
||
**Ce que la mesure a trouvé en chemin.** La vérification que chaque nœud fait de SON dépôt
|
||
(`setops-verification-depot`, celle qui détient la clé) échouait toutes les quatre heures
|
||
depuis le 2026-09-20 sur `site-forge-01`, `site-mon-01` et `site-pki-01` : 48 fois
|
||
« ECHEC du rapport Icinga : 404 No objects found ». Le nœud rapportait à
|
||
`<nœud>!sauvegarde` ; `setops-sauvegardes.conf.j2` ne créait ce service que dans un
|
||
écosystème SANS dépôt. Le site en a un (`site-backup-01`) : seuls existaient les services
|
||
du témoin du dépôt, `site-backup-01!sauvegarde: <nœud>` — au vert, et donc crus complets.
|
||
|
||
Le gabarit choisissait l'un OU l'autre témoin ; il crée désormais les deux quand un dépôt
|
||
existe. Déployé sur `site-mon-01`, vérification relancée sur les trois nœuds : six
|
||
services au vert, et les deux témoins disent la même chose (1723 fichiers, 40 Mo pour la
|
||
forge). Chez les tenants, sans dépôt, rien ne change.
|
||
|
||
## 2026-09-27 (1) — Patient 0 effacé, l'index 29 libéré
|
||
|
||
Ses machines n'existaient plus depuis le 2026-09-06 (D-83), mais son plan restait sur
|
||
disque en `federe: true` : la fédération lui réservait l'index 29, et quatre machines du
|
||
site ouvraient SSH, apt, DNS et HTTPS à `10.29.0.0/16` — un périmètre vide.
|
||
|
||
**Ce qui est fait :**
|
||
|
||
- `SITE-Chezlepro/underlay.yml` : `OPS-Patient0: 29` retiré de `tenants`.
|
||
- `SITE-Chezlepro/plan/serveurs.yml` : le runner du site ne clone plus `ops-patient0`.
|
||
- `make flux` : `10.29.0.0/16` sort des règles de `site-backup-01`, `site-cache-01`,
|
||
`site-dns-01`, `site-dnspub-01` et `site-forge-01` — et rien d'autre ne change.
|
||
- Le dépôt local `OPS-Patient0/` est supprimé, puis, le 2026-09-28, ses deux copies
|
||
distantes : `genome/ops-patient0` sur la forge du site (API, 404 relu) et
|
||
`Chezlepro/OPS-Patient0` sur `eregion` (interface web — le jeton du poste n'a pas
|
||
`write:repository` ; 404 relu). La clé de sa voûte est détruite (`shred`).
|
||
- Les commentaires et documents vivants qui le nommaient gardent leur leçon sans le nommer
|
||
(moteur, modèles, tenants, sites). Le CHANGELOG, les rapports de preuve datés et la
|
||
chronique restent tels quels : ce sont des archives.
|
||
- `filiation-emancipation.md` §« Qui est le parent ? » ramené à la décision et au chemin
|
||
réel du génome.
|
||
|
||
**Corrigé le 2026-09-28 : il n'y a pas de « SPOF eregion ».** D-82, D-83 et ce paragraphe
|
||
affirmaient que `eregion` alimente la forge qui fait autorité (« le poste y pousse, la
|
||
forge du site en tire »). Le chemin mesuré dit autre chose : `make genome-pousser` porte
|
||
les commits du poste en bundle au runner du site, qui pousse sur sa forge — `eregion`
|
||
n'y figure pas. C'est une forge héritée (`forge.alliance-boreale.ca`), porte publique des
|
||
contributions, qui ne fera jamais partie de Set-OPS ; la redondance vient d'une forge par
|
||
site, chacune inséminée du génome. La phrase avait été recopiée trois fois sans que
|
||
personne ne suive le chemin — moi compris, la veille.
|
||
|
||
**Ce qui n'est pas encore sur le réseau.** Les `.nft` régénérés doivent être posés par le
|
||
runner du site. Le SDN (zone `t29`, VLAN 1291-1296), la frontière et le pare-feu Proxmox
|
||
n'ont pas pu être relus depuis le poste : le cluster ne répondait pas (22 et 8006).
|
||
|
||
**Mesuré en passant, et corrigé le 2026-09-28.** `make sdn-plan`, cluster injoignable,
|
||
annonçait « à créer : 33 » — TOUTES les zones, Chezlepro comprise. Une lecture en échec
|
||
rend `{'_erreur': …}`, et `rep or []` parcourait ce dict comme une liste vide.
|
||
`appliquer_sdn.py` s'arrête désormais, comme `frontiere-plan`. Et `t29` rejoint
|
||
`ANCIEN_NOMMAGE` : sortie du devis sans cela, la zone aurait été lue comme étrangère et
|
||
laissée en place, avec ses VLAN 1291-1296.
|
||
|
||
`devis_proxmox_fw.py` et `devis_proxmox_pools.py` balayaient encore TOUS les dossiers
|
||
frères (`decouvrir()`), alors que la frontière et le SDN s'en tiennent à
|
||
`underlay.tenants` (`decouvrir_du_site()`, dont la docstring le demandait déjà pour
|
||
« les devis qui équipent un site »). Le runner du site, qui gardait un clone de patient 0,
|
||
proposait donc de recréer ses groupes ; le poste comptait un dossier de CI comme tenant.
|
||
Les deux devis suivent maintenant la même liste : 2 tenants, et non 3.
|
||
|
||
Filtré ainsi, le devis ne voyait plus du tout `t29` — ni à créer, ni à RETIRER : son
|
||
périmètre ne retient que les préfixes des tenants présents. 3 IPSets et 6 groupes `t29-`
|
||
restaient posés sur le cluster. `appliquer_proxmox_fw.py` ajoute à son périmètre les
|
||
étiquettes des zones de `ANCIEN_NOMMAGE` (une liste, pas deux), et reçoit le même garde
|
||
que le SDN contre un cluster muet.
|
||
|
||
**Posé le 2026-09-28, depuis le runner du site.** SDN : zone `t29`, 3 VNets, 3 sous-réseaux
|
||
retirés, le plan relu dit « Rien à faire ». Frontière : 44 objets `PATI29` et 3 routes
|
||
`10.29.x` retirés, 283 règles inchangées, rien d'autre au plan relu. Pare-feu Proxmox : les
|
||
6 groupes et 3 IPSets `t29-` retirés un par un par l'API — PAS par `proxmox-fw-appliquer`,
|
||
dont le plan mêle 64 changements préexistants sur `t17`/`t23` qui demandent leur propre
|
||
revue. Ce retrait a révélé un dernier défaut : l'applicateur envoyait `force` dans le CORPS
|
||
d'un `DELETE`, que Proxmox refuse (501) ; aucun IPSet n'était donc jamais retiré. Corrigé
|
||
(`?force=1`). La strophe FRR des nœuds de sortie garde `t29` : ils sont injoignables en SSH
|
||
depuis le runner.
|
||
|
||
Wiki republié (P60) : deux de ses pages nommaient patient 0. `make verifier` : 82 OK, 1 échec — P02,
|
||
`test_ecriture_plan.py` sur `domaines.yml` (`'list' object has no attribute 'get'`),
|
||
présent avant ce changement.
|
||
## 2026-09-25 — Le registre disait « mesure » d'une cible qui coupe l'amont
|
||
|
||
**21 preuves sur `test_runbooks.py`, `verifier` à 0 écart.** Un outil tiers veut
|
||
n'offrir de ce moteur que ce qui n'agit pas. Le seul champ qui le lui dise est
|
||
`nature`. Il fallait donc qu'il ne mente pas — et il mentait une fois.
|
||
|
||
### Une mesure qui demande confirmation n'en est pas une
|
||
|
||
`filiation → emancipation-prouver` se déclarait `nature: mesure` et portait
|
||
`fixes: {CONFIRMER: "true"}`. Les deux ne peuvent pas être vrais ensemble : la
|
||
cible refuse sans confirmation, et son propre refus dit pourquoi — *« cette
|
||
preuve COUPE l'amont quelques secondes pour mesurer »*. La coupure est retirée
|
||
quoi qu'il arrive, mais pendant ce temps la fonction éprouvée peut échouer.
|
||
|
||
Le mot « prouver » avait emporté la décision. Un constat rapporté ne rend pas
|
||
inerte le geste qui l'obtient. L'étape est désormais `nature: ecriture`, et son
|
||
`pourquoi` dit ce qu'elle coupe.
|
||
|
||
`verifier()` refuse maintenant cette contradiction : rien ne la voyait, et
|
||
chaque lecteur du registre refaisait l'enquête. Mesuré avant correction : un
|
||
écart, exactement celui-là.
|
||
|
||
### Lire le registre sans analyser un arbre fait pour l'œil
|
||
|
||
`runbooks.py lister --json` rend le registre assemblé d'un bloc. Sans lui, un
|
||
outil tiers n'avait le choix qu'entre analyser la sortie humaine — qui dérive
|
||
au premier changement de mise en page — et importer ce module, ce qui le lie à
|
||
nos noms internes. Les deux se paient plus tard.
|
||
|
||
L'affichage humain ne change pas, et une preuve le tient : un `lister` qui
|
||
rendrait toujours du JSON passerait sinon les épreuves du drapeau sans que
|
||
personne ne le voie.
|
||
|
||
### La recette tranche, le registre ne se compare plus à lui-même
|
||
|
||
Le premier invariant comparait `nature` à `fixes` — deux champs écrits par la
|
||
même main. Le même mensonge repassait en EFFAÇANT la ligne `fixes`, et trois
|
||
cibles le portaient ainsi : `flotte-creer`, `deployer-tout`, `reconstruire`
|
||
refusent sans confirmation, le registre ne le déclarait pas, et un assistant
|
||
les lançait telles quelles — sortie en 2. Le contrôle lit désormais la
|
||
RECETTE, qui ne ment pas : elle refuse, et c'est ce refus que l'exploitant
|
||
rencontre.
|
||
|
||
### Une étape qui agit barre celles qui la suivent
|
||
|
||
Passer `emancipation-prouver` à `ecriture` l'a placée derrière un ménage
|
||
destructif : la console débloque d'office une étape « mesure », mais fait
|
||
attendre toute autre que la précédente ait réussi dans la session. Un ménage
|
||
qu'on peut n'avoir rien à faire rendait donc la preuve injouable.
|
||
`depots-perimes` est marqué `facultative` — il nettoie ce que la filiation a
|
||
laissé, il n'est pas son préalable.
|
||
|
||
Mesuré sur le registre réel : 17 runbooks, 127 étapes. **84 sont de nature
|
||
`mesure`** — c'est la surface qui n'agit pas, et la seule qu'un outil puisse
|
||
offrir sans confirmation. Les 110 dont les `fixes` ne portent pas de
|
||
`CONFIRMER` en contiennent 26 d'écriture, dont « déploie TOUTE la flotte » :
|
||
compter sur ce chiffre pour dire « sûr » serait une erreur de lecture.
|
||
|
||
|
||
## 2026-09-20 (12) — Une décision appliquée à moitié se croit tenue
|
||
|
||
**83 preuves, `make test` à 0 échec.** L'exploitant a demandé : « n'avions-nous pas décidé
|
||
d'un plan d'adressage dérivé pour chaque flotte, qu'elle soit tenant ou site ? » Oui. La
|
||
décision est écrite dans l'underlay du site, datée du 12 septembre :
|
||
|
||
> *« sites et locataires se partagent la classe A, chacun avec SON index. Le site prend
|
||
> 37, le locataire garde 17. `make instances` les compte ensemble depuis ce jour. »*
|
||
|
||
### Ce que l'entrée (11) affirmait, et qui était faux
|
||
|
||
Elle disait qu'un site *« déclare ses adresses, il n'a aucun seed dont les faire
|
||
descendre »*. C'était la recopie d'un commentaire en tête de `SITE-Chezlepro/plan/serveurs.yml`
|
||
— **périmé depuis le jour où le site a reçu son index**, et lu au lieu d'être mesuré.
|
||
|
||
Un commentaire périmé se propage : celui-ci s'est retrouvé le même jour dans la docstring
|
||
de `ConsoleSite` et dans une entrée de CHANGELOG. C'est exactement ce que
|
||
`CARTE-DU-SITE.md` décrit chez un locataire — **inerte et crédible** : le moteur ne le lit
|
||
pas, donc rien ne le corrige ; un humain le lit, et le croit.
|
||
|
||
### La mesure
|
||
|
||
Les sept zones du site suivaient `10.<index>.<vlan>.0/24` **à la lettre** — et étaient
|
||
pourtant **écrites** : `sous_reseau` et `passerelle` stockés pour chacune. C'est
|
||
précisément ce que P20 interdit à un locataire, sous le titre *« Adressage 100 % dérivé du
|
||
seed (aucun stocké) »*. Et la portée de P20 était *« l'instance liée + les modèles »* :
|
||
**elle ne regardait jamais le site**.
|
||
|
||
Les valeurs concordaient parce qu'un humain les avait tapées juste, ce jour-là. Rien ne le
|
||
garantissait : une zone écrite `10.36.x` serait passée sans un mot.
|
||
|
||
### Deux natures dans une seule liste
|
||
|
||
`reseaux:` mélangeait ce qui peut dériver et ce qui ne le peut pas. Chaque entrée déclare
|
||
maintenant sa nature :
|
||
|
||
| `nature: fabric` | grappe, iSCSI, Ceph, transit, VXLAN, management | adressage dicté par les participants d'un lien physique (D-02, D-04) — il reste **écrit** |
|
||
| `nature: site` | les sept zones du site | elles **descendent de son index**, et ne stockent plus rien |
|
||
|
||
Le loader dérive `sous_reseau` et `passerelle` à la lecture, pour que les consommateurs ne
|
||
voient aucune différence. **Quatorze valeurs stockées ont disparu du fichier, et ce que
|
||
les consommateurs lisent est identique — écart mesuré : zéro.** `make underlay`,
|
||
`make devis-sdn` et `make devis-reseau` passent.
|
||
|
||
P20 juge désormais les deux natures, et refuse une entrée qui ne déclare pas la sienne :
|
||
on ne peut pas juger ce qu'on ne sait pas lire. Contrôle négatif joué : une zone remise à
|
||
`10.36.31.0/24` est refusée, en nommant l'index dont elle aurait dû descendre.
|
||
|
||
### Ce qui reste écrit, et qui est une dette nommée
|
||
|
||
Les **machines** du site gardent leurs `ip`, `vmid` et `noeud` dans son plan. Ce n'est plus
|
||
une doctrine — « le site est le terrain, il n'a rien dont faire descendre » — puisque le
|
||
seed existe maintenant pour elles aussi. C'est une dette, et la nommer est le minimum :
|
||
*un objectif qu'on abandonne sans le dire devient un objectif qu'on croit atteint.*
|
||
|
||
**P02 et P60 restent**, pour les raisons déjà consignées.
|
||
|
||
## 2026-09-20 (11) — Deux fonctions qui doivent rendre la même forme finissent par ne plus la rendre
|
||
|
||
**83 preuves, `make test` à 0 échec, 8 tests de rendu.** La console avait deux
|
||
assembleurs de charge — `inventaire_api` pour un locataire, `inventaire_api_du_site` pour
|
||
un site. Ils ont dérivé deux fois le même jour, et ce n'était pas une malchance : c'est
|
||
ce qui arrive toujours à deux copies d'une même obligation.
|
||
|
||
### Ce que la duplication a coûté, mesuré
|
||
|
||
**En forme.** `serveurs` valait `[]` d'un côté et `{}` de l'autre. Les deux « vides », et
|
||
la page ne les lit pas pareil : `charger()` levait, et la console d'un site mourait avant
|
||
sa première vue.
|
||
|
||
**En contenu.** Cinq registres étaient servis vides — « pour ne pas fabriquer un faux plan
|
||
de tenant ». Le site n'a pas de faux plan : il a **le sien**, au même format, que les
|
||
lecteurs du moteur lisent sans broncher. Mesure : **9 serveurs, 21 applications, 2 bases,
|
||
1 domaine**, tous invisibles à l'écran.
|
||
|
||
### Un tronc, deux branches, et le rôle distinct de la portée
|
||
|
||
`Console` assemble — **un seul endroit** où la charge se compose, et c'est lui qui fixe
|
||
les clés et leurs formes. Les branches ne décident que de ce qui leur appartient : d'où
|
||
vient le plan, d'où vient l'inventaire, quels pouvoirs elles portent.
|
||
|
||
| Branche | Ce qu'elle est | Son plan | Son inventaire |
|
||
|---|---|---|---|
|
||
| `ConsoleLocataire` | configure ses services | dérivé d'un seed | artefact généré |
|
||
| `ConsoleSite` | matérialise le terrain | **déclaré** (`ip`, `vmid`, `noeud`) | un script |
|
||
| `ConsolePoste` | l'atelier — hérite du locataire | idem locataire | idem locataire |
|
||
| `Console` | ne pilote rien, et le dit | aucun | vide |
|
||
|
||
**Le rôle et la portée ne se confondent pas.** Le rôle est une propriété de la classe :
|
||
ce pour quoi cette console existe. La portée se **calcule** depuis les pouvoirs : ce
|
||
qu'elle peut vraiment, ici et maintenant. Un poste privé de la voûte du site reste
|
||
l'atelier du mainteneur et n'engendre pourtant rien — figer la portée sur la classe lui
|
||
aurait rendu un pouvoir que la mesure lui refuse.
|
||
|
||
Ce découpage a immédiatement montré un trou : `ConsolePoste` héritait du refus d'un
|
||
locataire — *« elle ne sait pas sur quelle fabric poser »* — alors qu'il **monte** la
|
||
carte. Ce qui lui manque, c'est la voûte. Un message qui accuse la mauvaise absence fait
|
||
chercher au mauvais endroit.
|
||
|
||
### Le jugement des assistants remonte dans le tronc
|
||
|
||
Il était écrit deux fois : dans la route qui **liste** les assistants, et dans celle qui
|
||
**exécute** une étape. Deux copies d'une même règle finissent par ne plus dire la même
|
||
chose — et **celle qui se trompe est toujours celle qui exécute, parce que c'est la moins
|
||
relue**. `Console.peut_conduire()` répond aux deux.
|
||
|
||
Le gestionnaire ne demande plus « est-ce un site ? » pour choisir un assembleur : il
|
||
demande sa charge à sa console, et ignore laquelle lui répond.
|
||
|
||
### Ce qui a été vérifié
|
||
|
||
`make test` à 0 échec, les 8 tests de rendu sous `node` — dont deux neufs : la console de
|
||
site sert bien son plan, et les deux charges portent les mêmes types. La console a été
|
||
lancée pour de vrai : contexte `poste`, 13 serveurs, 24 applications, 17 runbooks,
|
||
127 étapes conduisibles, 0 écart.
|
||
|
||
**Et `make verifier` a trouvé une faute que j'avais laissée passer** : le playbook
|
||
`depots_perimes.yml` livré une heure plus tôt violait `risky-shell-pipe`. J'avais passé
|
||
`--syntax-check` dessus et `ansible-lint` seulement sur le rôle voisin. Corrigé — les
|
||
tubes retirés, `pipefail` armé sans `-e` pour qu'un `git` qui échoue laisse le script
|
||
répondre au lieu de faire tomber la tâche.
|
||
|
||
**Deux échecs restent.** P02, antérieur, consigné à l'entrée `(6)`. Et **P60** : le wiki
|
||
publié est en retard d'un commit depuis que l'entrée `(6)` a documenté les Assistants —
|
||
`make wiki-publier WIKI_REMOTE=…` le republie, et l'adresse publique n'est celle d'aucun
|
||
plan, donc elle ne se devine pas.
|
||
|
||
## 2026-09-20 (10) — Un vide doit avoir la forme de ce qu'il remplace
|
||
|
||
**83 preuves, `make test` à 0 échec, 7 tests de rendu.** La console de
|
||
`console.genese.internal` — celle du runner de SITE-Chezlepro — ne montrait rien. Elle a
|
||
douze machines.
|
||
|
||
### `charger()` levait, et la page mourait avant de dessiner
|
||
|
||
`inventaire_api` sert `serveurs` en **liste** ; `inventaire_api_du_site` le servait en
|
||
**table**. Les deux sont « vides », et la page ne les lit pas pareil :
|
||
`(data.serveurs || []).map(...)` trouve `{}` — *truthy*, sans `.map` — et `charger()`
|
||
lève. Toute la console d'un site s'arrêtait là, avant la première vue, et le message
|
||
accusait une méthode manquante plutôt qu'une forme qui ment.
|
||
|
||
Un vide doit avoir la **forme** de ce qu'il remplace. Sinon ce n'est pas un vide, c'est un
|
||
autre objet qui se fait passer pour lui — et le malentendu ne se voit qu'au moment où
|
||
quelqu'un appelle une méthode dessus.
|
||
|
||
Un test compare désormais les deux charges **type par type** pour les clés qu'elles
|
||
partagent, avec deux écarts admis et motivés. Il ne demande pas les mêmes valeurs — un
|
||
site n'a pas de plan de locataire — seulement les mêmes formes.
|
||
|
||
### Et la vue regardait au mauvais endroit
|
||
|
||
Le registre vide était **voulu** : « on ne fabrique pas un faux plan de tenant pour
|
||
remplir l'écran ». C'est juste — un site déclare ses machines dans son propre plan, avec
|
||
`ip`, `vmid` et `noeud` **écrits**, là où un locataire les dérive de son seed ; il est le
|
||
terrain, il n'a rien dont les faire descendre.
|
||
|
||
Mais la vue d'accueil tirait de ce registre vide le message d'un locataire sans serveurs —
|
||
« + Serveur », `make serveurs-bootstrap` — et les douze machines, servies dans `hotes`,
|
||
n'étaient dessinées nulle part. Une console de site montre maintenant **ses** machines :
|
||
mêmes tuiles que les serveurs d'un locataire, en lecture seule, avec leur adresse, leur
|
||
VMID, leurs rôles et leur point de vie ; le panneau droit dit ce que cette console peut —
|
||
matérialiser le terrain, n'entrer chez aucun locataire — et renvoie aux Assistants.
|
||
|
||
**C'est le défaut du 2026-09-16 par une autre porte.** La première fois, l'inventaire était
|
||
vide et la page dessinait ce vide comme un plan vide. Cette fois l'inventaire est **plein**
|
||
et c'est la vue qui regarde ailleurs. Le premier correctif a donné à la console de quoi
|
||
répondre ; il ne lui a pas appris où regarder.
|
||
|
||
### Ce qui a trouvé le défaut
|
||
|
||
Pas une lecture : **le banc**. `test_rendu_gui.py` dessine les vues sous `node` dans un
|
||
DOM simulé. En lui donnant pour la première fois la charge d'une console de SITE, il a
|
||
levé `(data.serveurs || []).map is not a function` — c'est-à-dire la cause réelle, sous
|
||
le symptôme que je m'apprêtais à corriger. Sans lui, j'aurais livré une vue juste par
|
||
dessus un `charger()` qui lève, et la console serait restée vide.
|
||
|
||
### Ce qui a été vérifié
|
||
|
||
`node --check` sur le bloc `<script>` (147 707 caractères), les 7 tests de rendu,
|
||
`make test` à 0 échec, `runbooks.py verifier` à 0 écart, P81 et P83 vertes. Le test neuf
|
||
nomme chaque machine du site attendue à l'écran, refuse le message d'amorçage d'un
|
||
locataire, et vérifie que choisir une machine montre ses rôles.
|
||
|
||
**P02 reste en échec**, pour la raison antérieure consignée à l'entrée `(6)`.
|
||
|
||
## 2026-09-20 (9) — Ce qui n'existe qu'ici ne se détruit pas depuis ici
|
||
|
||
**83 preuves, `make test` à 0 échec, 133 cibles documentées.** Le runner de
|
||
Chezlepro-locataire portait encore le clone de `SITE-Chezlepro` — la carte de son
|
||
hébergeur — longtemps après que son plan ait cessé de la déclarer. `serveur_ops` clone ce
|
||
qui est déclaré ; il ne retirait rien.
|
||
|
||
### Pourquoi ce retrait n'est pas dans le rôle
|
||
|
||
Un rôle qui efface des dossiers à chaque passage est une grenade dégoupillée : une faute
|
||
de frappe dans `serveur_ops_depots` suffirait à perdre du travail local. Le retrait est
|
||
donc un geste **séparé**, qui **regarde** par défaut et n'efface que sur `CONFIRMER=true`
|
||
— comme `raser` et `site-raser`.
|
||
|
||
`make depots-perimes` nomme ce qu'un runner porte et que son plan ne déclare plus, puis
|
||
dit ce qu'une confirmation emporterait. **Trois choses ne sont jamais retirées**, même
|
||
confirmées : ce qui n'est pas un dépôt git, ce qui porte des modifications non validées,
|
||
et ce qui porte des commits qu'aucun distant ne porte.
|
||
|
||
### La garde a servi au premier essai
|
||
|
||
Le relevé a nommé **deux** dossiers non déclarés : `SITE-Chezlepro`, et `venv` —
|
||
l'environnement Python du runner. `venv` a été écarté parce que ce n'est pas un dépôt
|
||
git. **Une version naïve de ce geste aurait effacé l'environnement d'exécution du
|
||
runner**, et l'aurait fait en se disant satisfaite.
|
||
|
||
C'est la raison d'être de la première des trois règles : un dossier qu'on ne sait pas
|
||
lire n'est pas un dossier périmé, c'est un dossier qu'on ne comprend pas. Le doute se
|
||
résout en s'abstenant, pas en effaçant.
|
||
|
||
### Écrire, puis relire — ici : regarder, puis vérifier
|
||
|
||
Le geste relit après avoir écrit : un `state: absent` qui se dit satisfait sans que le
|
||
dossier ait disparu est exactement le genre de succès qui ment. Mesure après coup — le
|
||
runner du locataire ne porte plus que `Set-OPS-public` et `OPS-Chezlepro`, ce que son plan
|
||
déclare, et sa console rend `portee=tenant`, `configurer=True`, `materialiser=False`,
|
||
`fabric=False` : même le pouvoir de LIRE la fabric est tombé avec la carte, ce qui est
|
||
juste — il n'y a plus de miroir à consulter.
|
||
|
||
### Ce qui a été vérifié
|
||
|
||
`--syntax-check` sur le playbook, `python3 scripts/runbooks.py verifier` (0 écart,
|
||
la cible neuve est portée par le runbook « Filiation »), `make test` à 0 échec. Le geste
|
||
a été passé **deux fois** sur le runner réel : une fois pour regarder — rien touché,
|
||
`changed=0` — puis une fois confirmé, `changed=1`, avec sa relecture.
|
||
|
||
**P02 reste en échec**, pour la raison antérieure consignée à l'entrée `(6)`.
|
||
|
||
## 2026-09-20 (8) — Un pouvoir se mesure sur ce qu'on peut ouvrir, pas sur ce qu'on peut lire
|
||
|
||
**83 preuves, `make test` à 0 échec (21 tests neufs aujourd'hui).** La vue Assistants a
|
||
rendu visible un écart que les quatre boutons d'avant cachaient : le runner de
|
||
Chezlepro-**locataire** se déclarait `poste` et offrait 126 étapes sur 126, création et
|
||
destruction de machines comprises.
|
||
|
||
### Ce que la console lisait, et ce que le document exigeait
|
||
|
||
`docs/responsabilites-locataire-hebergeur.md` §2 attache chaque pouvoir à une **voûte** :
|
||
calculer ne demande aucun secret, configurer demande celle du tenant, matérialiser celle
|
||
du site. Le code, lui, lisait les **symlinks** — c'est-à-dire les cartes.
|
||
|
||
Le runner de Chezlepro montait la carte du site sans en avoir jamais eu la voûte. Les
|
||
gestes de fabric qu'il proposait seraient **partis puis tombés sur un secret vide** :
|
||
un échec au milieu du chemin, là où un refus net aurait dit la vérité avant de commencer.
|
||
Et un refus qui arrive trop tard envoie chercher la panne dans la fabric, pas dans le
|
||
pouvoir.
|
||
|
||
`contexte()` dérive désormais les pouvoirs des voûtes présentes, et la **portée découle
|
||
des pouvoirs** au lieu de les précéder. On ne prouve pas qu'une voûte s'ouvre — le mot de
|
||
passe se tape à l'exécution — mais son absence, elle, est décisive et se mesure sans rien
|
||
ouvrir : **une voûte absente ne s'ouvrira jamais**. Lire une carte reste permis : le
|
||
pouvoir `fabric` continue de suivre le symlink, parce que consulter un miroir n'est pas
|
||
engendrer.
|
||
|
||
### Chezlepro-locataire est un locataire
|
||
|
||
Il vit dans sa coquille et reçoit de l'hébergeur qui le porte — même quand cet hébergeur
|
||
est lui-même. Que le même humain tienne les deux rôles ne fusionne pas les deux pouvoirs :
|
||
ça rend la coupure **plus** nécessaire, puisque plus rien d'extérieur ne la rappelle.
|
||
|
||
Son plan cesse donc de déclarer la fabric (`serveur_ops_underlay: ""`, comme TechnoLibre)
|
||
et de cloner le dépôt de son hébergeur. Il porte maintenant **sa propre copie** de la
|
||
racine d'autorité du site — un certificat public, identique à l'octet près à celui que
|
||
TechnoLibre porte déjà — au lieu de la dériver du symlink qu'il n'a plus.
|
||
|
||
### Retirer une déclaration doit retirer l'artefact
|
||
|
||
`serveur_ops_underlay` vide voulait dire « ce poste exploite sans engendrer », et le rôle
|
||
se contentait de **ne pas poser** le lien. Un runner qui l'avait déjà le gardait — avec le
|
||
pouvoir que son plan ne lui donnait plus. C'est la même forme que la liste qui suit une
|
||
autre : une déclaration qui ne vaut que dans un sens ne décrit plus rien.
|
||
|
||
Le rôle retire maintenant le lien quand la fabric n'est plus déclarée. **Il ne retire
|
||
qu'un lien, jamais un fichier** : si une vraie carte occupe la place, elle n'a pas été
|
||
écrite par ce rôle, et il le dit plutôt que de détruire un contenu qui n'est pas le sien.
|
||
Le chemin de cette place est dérivé une seule fois, en défaut du rôle — trois copies d'un
|
||
même chemin finissent par ne plus désigner le même endroit.
|
||
|
||
### Ce qui a été vérifié
|
||
|
||
`--syntax-check` sur `playbooks/groupes/serveur_ops.yml`, `ansible-lint` sur le rôle
|
||
(profil `production`, 0 échec, 0 avertissement), `make test` à 0 échec, P81 et P83 vertes.
|
||
Six tests neufs montent quatre faux disques et exigent la portée qui leur revient, dont
|
||
**le défaut lui-même, nommé** : carte présente, voûte absente → `tenant`, jamais `poste`.
|
||
|
||
L'état des trois runners est mesuré avant la correction — site : fabric + voûte du site ;
|
||
Chezlepro : instance + voûte du tenant + carte **sans** voûte du site ; TechnoLibre :
|
||
instance + voûte du tenant, aucune fabric. **Le premier relevé était faux** : `readlink -f`
|
||
rend un chemin même quand la cible n'existe pas, et faisait voir une fabric à TechnoLibre
|
||
qui n'en a pas. Refait avec un test d'existence.
|
||
|
||
**P02 reste en échec**, pour la raison antérieure consignée à l'entrée `(6)`. Le clone
|
||
`SITE-Chezlepro` subsiste sur le runner du locataire : il ne donne plus aucun pouvoir une
|
||
fois le lien retiré, et le retirer serait détruire un dossier que personne n'a demandé de
|
||
détruire.
|
||
|
||
## 2026-09-20 (7) — Une portée jugée trop haut ferme une séquence à qui elle appartient
|
||
|
||
**83 preuves, `make test` à 0 échec (15 tests de runbooks).** Les assistants livrés à
|
||
l'entrée `(6)` pesaient la portée **au runbook**. Une console de locataire le montre en
|
||
une lecture : `locataire-deployer` et `machine-une` y étaient hors de portée — donc un
|
||
locataire ne pouvait pas déployer sa propre flotte, ce qui est exactement son métier.
|
||
|
||
### Une seule étape qui matérialise fermait tout le reste
|
||
|
||
`flotte-creer` et `creer-vm` engendrent des VM : ils exigent la fabric, que le locataire
|
||
n'a pas. Déclarés dans une séquence dont la portée valait pour l'ensemble, ils faisaient
|
||
basculer en `poste` les étapes voisines — `deployer-tout`, `valider`, `verifier-hote` —
|
||
qui ne demandent pourtant que ce que le locataire possède déjà.
|
||
|
||
Mesuré sur la console de TechnoLibre, portée `tenant` : **6 runbooks conduisibles sur 17**,
|
||
et parmi les onze fermés, les deux qui décrivent son travail quotidien.
|
||
|
||
La portée se pèse désormais **à l'étape**. Le runbook n'en donne que le défaut ; l'étape
|
||
qui exige davantage le déclare. Le locataire conduit donc sa séquence et **bute
|
||
précisément là où il faut** : sur la machine à engendrer, pas sur le déploiement qui suit.
|
||
La page ferme l'étape seule, en disant sa raison sur cette étape ; l'inspecteur compte ce
|
||
qui est hors de portée plutôt que de barrer l'ensemble.
|
||
|
||
Côté serveur, la garde de `/api/runbook-etape` lit la portée de **l'étape visée par son
|
||
index**, et non celle du runbook : juger sur le runbook aurait refusé un geste que la
|
||
console avait le droit de faire, ou accepté l'inverse.
|
||
|
||
### Ce que cette correction dit du reste
|
||
|
||
**Un droit qui se calcule sur l'ensemble se trompe toujours dans le même sens** : il
|
||
refuse à quelqu'un ce qu'il a le droit de faire, et le refus paraît fondé puisqu'il
|
||
nomme un vrai manque. Il a fallu une console de locataire réelle pour le voir — sur le
|
||
poste, qui porte les deux liens, les dix-sept séquences s'affichaient conduisibles et
|
||
rien ne clochait.
|
||
|
||
Quatre tests neufs refusent le retour du défaut, dont un qui nomme le cas exact :
|
||
`deployer-tout` et `valider` restent à portée d'un locataire, `flotte-creer` non.
|
||
|
||
### Ce qui a été vérifié
|
||
|
||
`python3 scripts/runbooks.py verifier` (0 écart), `make test` à 0 échec, P83 verte. La
|
||
correction est mesurée sur les trois consoles après déploiement. Aucun rôle, playbook ou
|
||
réglage d'infrastructure modifié. **P02 reste en échec, pour la raison antérieure déjà
|
||
consignée à l'entrée `(6)`.**
|
||
|
||
## 2026-09-20 (6) — Cent trente-deux cibles, et aucune ne dit dans quel ordre
|
||
|
||
**83 preuves (P83 est neuve), `make test` à 0 échec.** Le `Makefile` porte 132 cibles
|
||
documentées. Chacune dit ce qu'elle fait ; aucune ne dit quand, ni après quoi, ni ce
|
||
qu'il faut avoir mesuré avant. La console offrait donc des boutons sans séquence, et
|
||
l'ordre des gestes vivait en prose dans des documents que la console ne porte pas.
|
||
|
||
### Un exploitant devant un site neuf n'avait pas de quoi commencer
|
||
|
||
Rien dans l'interface n'apprenait que `site-creer` précède `forge-amorcer`, que le premier
|
||
passage de `site-deployer-tout` **s'arrête** sur une forge vide — et que ce n'est pas un
|
||
échec mais le maillon suivant — ni que rien n'est « prêt » avant `valider`. C'est
|
||
exactement ce que le principe fondateur exige : *un opérateur exploite Set-OPS sans IA*.
|
||
Sans la séquence, la console ne tenait cette promesse qu'à moitié.
|
||
|
||
La vue **Assistants** conduit **17 runbooks, 126 étapes**, du premier jour d'un site à la
|
||
remise au client. Les 132 cibles y sont : chacune portée par un assistant, ou **exemptée
|
||
avec son motif**. Une exemption muette est refusée — c'est un oubli déguisé.
|
||
|
||
### Le registre ne recopie pas le Makefile, et la garde est écrite avec lui
|
||
|
||
`docs/runbooks-construction.yml` déclare seulement ce que le `Makefile` ne peut pas
|
||
porter : l'ordre, la nature du geste, la portée, le pourquoi. **Le libellé de chaque étape
|
||
est lu dans le `Makefile` au moment de servir** — jamais recopié. Une cible renommée se
|
||
voit donc à l'écran, elle ne s'invente pas.
|
||
|
||
Ce fichier est malgré tout une seconde liste à côté de la première, et une liste qui suit
|
||
une autre prend du retard : vu quatre fois dans ce dépôt en une seule journée. **P83 est
|
||
donc écrite en même temps que la liste**, pas après. Elle refuse une étape qui vise une
|
||
cible absente du `Makefile`, une cible documentée que nul runbook ne porte et que nul
|
||
motif n'exempte, une cible à la fois portée et exemptée, et une portée que la console ne
|
||
saurait pas traduire en pouvoir. Onze tests unitaires lui présentent des registres faux —
|
||
un par forme de retard — et exigent qu'elle les refuse : une garde qui rendrait toujours
|
||
« rien à signaler » passerait P83 tous les jours sans rien garder.
|
||
|
||
### Le navigateur ne nomme pas une commande, il nomme une place
|
||
|
||
`/api/runbook-etape` ne lance jamais la cible que la page demande : il lance ce que le
|
||
registre déclare **à cet index-là**, avec les seules variables déclarées, et refuse tout
|
||
le reste en disant pourquoi. Une page périmée — ou compromise — ne peut pas réclamer
|
||
`raser` depuis un assistant de mesure. **L'index compte** : « Le premier jour d'un site »
|
||
joue `site-deployer-tout` deux fois, et les deux places n'ont pas le même sens ; chercher
|
||
par nom seul les confondrait.
|
||
|
||
Les valeurs imposées par une étape (`CONFIRMER=true` et compagnie) ne viennent jamais du
|
||
corps de la requête : une confirmation qu'on peut s'envoyer à soi-même n'en est pas une.
|
||
Ce qui protège, côté page, c'est d'**écrire `DETRUIRE`** — le seul garde-fou qui résiste à
|
||
un clic distrait.
|
||
|
||
**La portée se dérive, elle ne se déclare pas.** Un assistant de site exige
|
||
`materialiser`, un de locataire `configurer`, un de poste les deux. La garde de cette
|
||
route est plus fine que celle des autres, et c'est voulu : un seul mot y serait soit trop
|
||
laxiste, soit trop sévère. Un assistant hors de portée reste **visible et lisible** —
|
||
savoir que le geste existe, et chez qui il se fait, fait partie du métier. Ce qui est
|
||
refusé, c'est de le lancer.
|
||
|
||
### La séquence est un garde-fou, pas une décoration
|
||
|
||
Une étape qui **écrit** attend que la précédente non facultative ait réussi : sinon on
|
||
bâtit sur un terrain qu'on n'a pas vérifié. Une étape qui **mesure** reste toujours
|
||
offerte, même après un échec — mesurer pour comprendre est exactement ce qu'on fait
|
||
ensuite. Cette asymétrie est la seule chose que l'interface impose d'elle-même.
|
||
|
||
### Ce qui a été vérifié, et ce qui ne l'a pas été
|
||
|
||
`python3 scripts/runbooks.py verifier` (0 écart), `make test` à 0 échec — 11 tests neufs
|
||
compris — et les 83 preuves rejouées. La console a été lancée pour de vrai : `GET /`
|
||
rend 200, `/api/runbooks` sert 17 runbooks avec les libellés lus au `Makefile`, six
|
||
requêtes malformées sont refusées une à une (cible absente du runbook, runbook inconnu,
|
||
bon nom au mauvais index, variable requise absente, valeur commençant par un tiret,
|
||
valeur hors du vocabulaire déclaré), et une étape de mesure s'exécute de bout en bout
|
||
avec son journal daté.
|
||
|
||
**`make verifier` n'est pas vert, et ce n'est pas de ce changement.** P02
|
||
(`scripts/tests/test_ecriture_plan.py`) échoue sur `domaines.yml` — trois erreurs dans
|
||
`_fusion_table`, où un bloc réindenté se relit comme une liste là où le code attend une
|
||
table. Mesuré sur une copie de `HEAD` avec la même instance montée : **l'échec est
|
||
identique avant ce travail**. Il reste ouvert, et il a une conséquence pour l'exploitant :
|
||
ajouter ou retirer un domaine public depuis la vue Domaines lèverait au moment
|
||
d'enregistrer. L'aller-retour sans modification, lui, fonctionne.
|
||
|
||
Aucun rôle, playbook ou réglage n'est modifié ; aucune VM touchée, aucune voûte ouverte.
|
||
|
||
## 2026-09-20 (5) — Celui qui pousse avance son propre clone
|
||
|
||
**82 preuves dans le rapport du 17 septembre, non rejouées ici.** Le correctif de
|
||
l'entrée `(4)` attendait sa preuve : il l'a eue en conditions réelles, et en la donnant
|
||
il a montré le cas qu'il ne couvrait pas.
|
||
|
||
### Le déclenchement, mesuré deux fois
|
||
|
||
Le commit `f9a20b0` poussé sur la forge du site, rejouer `serveur_ops` chez les deux
|
||
locataires a fait avancer leur clone de `6367d85` à `f9a20b0`. La tâche
|
||
« Redémarrer la console quand le moteur a avancé » est passée en `changed` sur les deux :
|
||
console de TechnoLibre repartie à 15h33m33 contre 15h16m49, celle de Chezlepro à 15h38m02
|
||
contre 15h16m47. Les deux sondes rendent 0. **Le mécanisme a été vu fonctionner, pas
|
||
simulé** — et la tâche s'était abstenue plus tôt le même jour, quand les clones étaient
|
||
déjà au niveau de la forge.
|
||
|
||
### Le site ne peut pas se voir avancer
|
||
|
||
`genome_pousser.yml` fait avancer le clone du runner du site **sans qu'aucun rôle ne
|
||
passe** : c'est lui qui pousse, donc il doit d'abord recevoir. Mesure du même jour — son
|
||
clone est passé à `f9a20b0` pendant la poussée, sa console est restée démarrée à 15h16.
|
||
Le correctif de `serveur_ops` ne peut rien pour lui : quand le rôle passera, le clone sera
|
||
déjà à jour, et la tâche s'abstiendra à juste titre. L'hébergeur gardait donc précisément
|
||
le défaut corrigé chez ses locataires.
|
||
|
||
Le remède tient là où vit la cause. Le playbook nomme déjà la transition dans son verdict
|
||
(`6367d85 → f9a20b0`) : il en tire maintenant la conséquence et redémarre la console du
|
||
site quand c'est **le moteur** qui a bougé. Un plan poussé ne coupe pas les pages
|
||
ouvertes, et un runner sans console n'est pas touché — l'unité systemd est vérifiée avant.
|
||
|
||
**Personne d'autre que ce playbook ne sait que ce clone a avancé.** Une information qui
|
||
n'existe qu'à un endroit doit y être utilisée, sinon elle se perd et le défaut se
|
||
reconstitue au passage suivant.
|
||
|
||
### Ce qui a été vérifié, et ce qui ne l'a pas été
|
||
|
||
`--syntax-check` sur `playbooks/maintenance/genome_pousser.yml`, contre l'inventaire du
|
||
site et contre celui d'un locataire ; `ansible-lint` sur le playbook (profil `production`,
|
||
0 échec, 0 avertissement). La condition de déclenchement est éprouvée sur quatre cas :
|
||
moteur avancé, moteur immobile, `DEPOT=` visant un plan seulement, et la boucle sans
|
||
résultat du mode `--check`.
|
||
|
||
**Le redémarrage de la console du site a été observé à la première poussée suivante.**
|
||
`f9a20b0 → f958739` sur la forge, la tâche est passée en `changed`, et la console du site
|
||
est repartie à 15h46m27 contre 15h16m45 — sonde à 0, vestibule `locale` rendant 401 à une
|
||
requête anonyme. Les cinq autres dépôts, reconnus immobiles, n'ont rien redémarré : la
|
||
condition ne réagit qu'au moteur. `make genome-etat` rend `failed=0` sur les six.
|
||
|
||
Les trois correctifs de la journée ont donc été vus fonctionner sur la machine que chacun
|
||
concerne : le rôle chez les deux locataires, le playbook de poussée chez l'hébergeur.
|
||
Aucune preuve du harnais rejouée, aucune voûte ouverte, aucune VM touchée.
|
||
|
||
## 2026-09-20 (4) — Le code sur le disque n'est pas le code servi
|
||
|
||
**82 preuves dans le rapport du 17 septembre, non rejouées ici.** Les trois runners
|
||
portaient un moteur périmé, et leurs consoles servaient un moteur plus vieux encore.
|
||
Deux causes distinctes, l'une dans la chaîne de distribution, l'autre dans le rôle.
|
||
|
||
### Une forge en retard, et trois runners qui ne tirent que lorsqu'on les rejoue
|
||
|
||
Mesure du 20 septembre : la forge du site portait `19bbadf` (16 septembre) pour les six
|
||
dépôts, le poste `6367d85`. Le runner du site était à `504840f` — vingt-trois commits en
|
||
arrière — et les deux runners de locataire à `8456d29`, quinze en arrière. Aucun écart de
|
||
contenu : chaque révision trouvée était un ancêtre du poste, donc du retard seul.
|
||
`make genome-etat` le disait déjà, et nommait le remède. Les commits manquants touchaient
|
||
`scripts/inventory_gui.py`, dont *« la console dit sa portée »* et *« la page cesse de
|
||
proposer ce que le serveur refuse »* — ce que l'exploitant voyait à l'écran.
|
||
|
||
`make genome-pousser` remet la forge au niveau du poste ; rejouer `serveur_ops` remet les
|
||
clones au niveau de la forge. **Rien ne tire tout seul** : aucune minuterie ne rafraîchit
|
||
un runner, et c'est assumé, mais l'écart ne se signalait nulle part entre deux rejeux.
|
||
|
||
### Le handler existait, il n'était branché que sur le fichier d'unité
|
||
|
||
Après les déploiements, les trois consoles portaient `6367d85` sur leur disque et
|
||
servaient toujours le code chargé le 16 septembre à 16h36. La cause tient en deux lignes :
|
||
le clone du génome n'était enregistré que pour ses tentatives, sans `notify`, et la tâche
|
||
de service demande `state: started`, qui ne fait rien quand le service tourne déjà.
|
||
`python3 scripts/inventory_gui.py` lit son code au démarrage, et seulement là — le même
|
||
piège que le certificat renouvelé qu'un nginx non rechargé continue de servir périmé.
|
||
|
||
Le rôle retient désormais si le **moteur** a avancé — `serveur_ops_depot_moteur`, jamais
|
||
un chemin recopié — et redémarre la console dans ce seul cas. Un plan qui avance ne coupe
|
||
pas les pages ouvertes. L'expression du `when` est éprouvée sur cinq cas, dont le mode
|
||
`--check` où la boucle ne rend aucun résultat.
|
||
|
||
### Une sonde qui ignore son mode fabrique une panne permanente
|
||
|
||
`console-ops` rendait `exit 2` sur les deux runners de locataire — *« LE VESTIBULE EST
|
||
OUVERT : deployer, creer et raser sont a la portee de qui trouve l'adresse »* — sur des
|
||
consoles parfaitement fermées. Elle frappait `127.0.0.1:8090`, le seul endroit d'où, en
|
||
mode `oidc`, un 200 est le résultat **attendu** : nginx y est réduit à la boucle locale
|
||
précisément pour qu'on ne puisse pas contourner la passerelle SSO. Mesure du jour :
|
||
`ss` et la configuration déployée concordent, `oauth2-proxy` écoute sur `*:4180` et rend
|
||
302 vers Keycloak à une requête anonyme.
|
||
|
||
La sonde connaît maintenant son mode. En `locale`, nginx **est** le vestibule et doit
|
||
refuser : le verdict ne change pas. En `oidc`, la serrure n'est pas un code HTTP mais une
|
||
adresse d'écoute — toute écoute du port public hors de la boucle locale est la panne, et
|
||
le refus de la passerelle reste mesuré par la sonde `passerelle` de
|
||
`serveur_oauth2_proxy`, posée sur le même hôte. Deux sondes, deux serrures, aucune qui
|
||
devine le port de l'autre.
|
||
|
||
**Une panne permanente finit par ne plus être lue.** Un instrument qui ne sait pas d'où
|
||
il mesure invente des défauts, et use la confiance qu'on met dans les vrais.
|
||
|
||
### Ce qui a été vérifié, et ce qui ne l'a pas été
|
||
|
||
`--syntax-check` sur `playbooks/groupes/serveur_ops.yml` et `ansible-lint` sur le rôle
|
||
(profil `production`, 0 échec, 0 avertissement, 14 fichiers). Le gabarit de la sonde est
|
||
rendu dans ses deux modes, sans reste Jinja, et passe `bash -n` ; son filtre d'écoute est
|
||
éprouvé sur cinq formes d'adresses réelles. Le rôle est appliqué aux trois runners et la
|
||
sonde rejouée sur chacun.
|
||
|
||
**Le redémarrage automatique n'était pas observé au moment d'écrire ces lignes** : les
|
||
clones étaient déjà au niveau de la forge, la tâche s'est donc correctement abstenue.
|
||
La preuve est venue le jour même, une fois ce commit poussé sur la forge du site — elle
|
||
est mesurée dans l'entrée `(5)` ci-dessus. Les trois consoles ont été redémarrées
|
||
à la main ce jour-là pour servir le code courant. Aucune preuve du harnais n'a été
|
||
rejouée, aucune voûte ouverte, aucune VM créée ou détruite.
|
||
|
||
## 2026-09-20 (3) — La capacité du système ne se déduit pas de la méthode
|
||
|
||
**82 preuves dans le rapport du 17 septembre, non rejouées ici.** Le premier comparatif
|
||
SOC 2 expliquait la différence entre méthode et attestation. Il répondait à côté de la
|
||
question : le système construit peut-il satisfaire les critères, et que lui manque-t-il ?
|
||
|
||
### Nommer les contrôles disponibles, puis les réglages qui restent à relire
|
||
|
||
Le dossier HTML/PDF `2026-09-20-comparatif-setops-soc2`, **hors dépôt dans `livraisons/`**,
|
||
est recentré sur cette capacité : oui sous conditions, pas une conformité acquise.
|
||
Les identités, flux, certificats, traces, sauvegardes et moyens de reprise sont rapprochés
|
||
de critères identifiés, sans déclarer une couverture exhaustive ni un résultat d'audit.
|
||
|
||
La lecture relève des points concrets : vérification des clés d'hôtes SSH désactivée,
|
||
interfaces Prometheus/Loki déclarées sans authentification interne, TLS optionnel dans
|
||
les défauts de certains rôles et limite du VPN sans MFA. **Les défauts de rôle ne sont
|
||
pas présentés comme les réglages réels d'une instance.** La conservation des traces,
|
||
la résistance des sauvegardes, les objectifs de reprise et les traitements applicatifs
|
||
restent à vérifier selon les engagements. Le dossier propose une recette, il ne l'exécute pas.
|
||
|
||
### Corriger la réponse sans modifier l'infrastructure
|
||
|
||
Le générateur et le README de livraison sont mis à jour ; les quatre fichiers précédents
|
||
sont archivés avant remplacement. Les huit pages PDF, leur texte, leurs liens et leur
|
||
pagination sont contrôlés ; la page HTML est vérifiée de 320 à 1440 pixels et sans
|
||
JavaScript. Les références officielles AICPA et les constats de code sont relus.
|
||
Les dossiers clients, le kit VPN et l'habillage partagé restent inchangés.
|
||
|
||
Aucun rôle, playbook ou réglage SSH/TLS n'est modifié. Aucun sondage de VM, aucune
|
||
ouverture de voûte, aucun test d'intrusion : la syntaxe Ansible et les preuves ne sont
|
||
pas rejouées pour cette modification strictement documentaire.
|
||
|
||
## 2026-09-20 (2) — Un contrôle rejouable n'est pas une attestation indépendante
|
||
|
||
**82 preuves dans le rapport du 17 septembre, non rejouées ici.** Comparer Set-OPS à un
|
||
service couvert par SOC 2 exige de distinguer l'architecture, son exploitation et
|
||
l'assurance indépendante : aucun compteur du harnais ne donne un taux de conformité SOC 2.
|
||
|
||
### Comparer sans faire passer une capacité pour un statut
|
||
|
||
Un nouveau dossier HTML/PDF de huit pages, `2026-09-20-comparatif-setops-soc2`, est ajouté
|
||
à `livraisons/`, **hors dépôt**, avec son générateur et sa documentation. Il reprend
|
||
l'habillage partagé sans modifier les dossiers clients ni le kit VPN. Les références
|
||
officielles AICPA, consultées le 20 septembre, accompagnent la distinction entre rapport
|
||
et certification du logiciel, les types 1 et 2 et les catégories des Trust Services Criteria.
|
||
|
||
Le rapprochement nomme les mécanismes de Set-OPS, les limites de leurs preuves, les
|
||
questions d'exploitation et la frontière hébergeur/locataire. Les écarts proposés restent
|
||
des questions de préparation, pas un audit de l'entreprise ; aucun rapport SOC 2 d'un
|
||
fournisseur ou de Chezlepro n'a été examiné. Ni conformité ni supériorité de sécurité
|
||
ne sont attribuées au projet.
|
||
|
||
Structure, sources, liens, rendu de 320 à 1440 pixels, lecture sans JavaScript et PDF de
|
||
huit pages sont vérifiés. Aucun sondage des VM, aucune ouverture de voûte et aucune
|
||
modification du moteur d'exploitation.
|
||
|
||
## 2026-09-20 — Une nouvelle couverture ne rajeunit pas une mesure
|
||
|
||
**82 preuves dans le rapport du 17 septembre, non rejouées ici.** Les dossiers de
|
||
livraison datés du 16 septembre présentaient des réponses HTTP anciennes comme un état
|
||
de livraison et ne décrivaient pas encore les tunnels d'administration nominatifs.
|
||
Leur révision distingue les capacités, les faits déclarés au plan et les usages à recevoir.
|
||
|
||
### Donner envie sans transformer le dossier en attestation
|
||
|
||
Les quatre HTML et leurs quatre PDF de `livraisons/`, **hors de ce dépôt**, sont revus
|
||
pour Chezlepro et TechnoLibre : bénéfices, services reliés, prise en main, autonomie,
|
||
remise des clés et responsabilités. Les noms du 16 septembre restent stables ; la
|
||
révision du 20 septembre est visible. Les réponses 200/302 restent des mesures anciennes,
|
||
pas une preuve actuelle de SSO. La séparation des runners n'est plus présentée comme
|
||
une suppression des pouvoirs de l'hyperviseur.
|
||
|
||
### Une source pour les deux formats, et des responsabilités qui ne disparaissent pas
|
||
|
||
Les générateurs lisent les machines, applications et expositions dans les plans ; la
|
||
charte conserve ses vingt lignes, lues dans le tableau canonique. L'habillage commun
|
||
est distinct de celui du kit VPN confidentiel, laissé intact. Un README décrit la
|
||
régénération et les originaux sont archivés avant remplacement.
|
||
|
||
Les quatre pages sont vérifiées de 320 à 1440 pixels ; chaque PDF compte sept pages.
|
||
Les liens, les titres, les cellules de la charte, les adresses dérivées et la pagination
|
||
sont contrôlés. Aucun déploiement, aucune lecture de voûte et aucun sondage de VM :
|
||
ces vérifications portent sur les documents, pas sur l'état actuel des services.
|
||
|
||
## 2026-09-19 — L’histoire commence là où les traces commencent
|
||
|
||
**82 preuves dans le dernier rapport versionné, non rejouées pour cette page.** Le premier
|
||
commit contient déjà 308 fichiers : raconter la naissance de Set-OPS comme une succession
|
||
d'étapes antérieures aurait inventé ce que Git ne montre pas.
|
||
|
||
### Les décisions se racontent, leurs sources restent consultables
|
||
|
||
`promo/histoire.html` raconte huit étapes à partir des 641 commits accessibles depuis
|
||
`05b85eb`, du 24 juin au 17 septembre 2026. Les reconstructions, les limites des preuves
|
||
statiques et les changements de direction gardent leur date et leur périmètre. Le récit
|
||
renvoie aux commits charnières ; les 641 titres originaux, leurs dates et leurs identifiants
|
||
sont intégrés dans une archive recherchable, avec un graphique d'activité issu du même
|
||
corpus. Aucun service externe, aucune police distante, aucun moteur Ansible modifié.
|
||
|
||
La page Capacités donne accès à cette chronique. Le README de `promo/` précise son
|
||
caractère figé, son fonctionnement hors ligne et les éléments à actualiser ensemble
|
||
pour prolonger le récit. Les vérifications de cette livraison portent sur la page et
|
||
ses interactions, pas sur les playbooks ni sur la flotte.
|
||
|
||
## 2026-09-17 (6) — TechnoLibre a son tunnel, et son kit de mise en service
|
||
|
||
- Instance `admins-technolibre` (`wg23`, port 52023, `10.23.29.0/24`), pair declare dans le
|
||
plan de TechnoLibre ; deux regles bornees a `SETOPS_TENANT_TECH23` ; 13 machines
|
||
redeployees (0 failed).
|
||
- **Defaut d'ergonomie corrige avant de servir** : `pair-nouveau --locataire` refusait de
|
||
proposer une adresse a un ecosysteme qui n'a encore AUCUN pair — c'est-a-dire exactement le
|
||
premier. L'oeuf et la poule, sur le geste d'ouverture.
|
||
- **Kit remis au client** (`livraisons/generer-kit-vpn.py`, hors depot) : reseau, port, cle
|
||
publique du serveur et adresses des machines sont DERIVES ou LUS, jamais recopies. Le
|
||
document dit lui-meme que la premiere cle privee a voyage et comment la remplacer par une
|
||
cle tiree sur l'appareil.
|
||
|
||
`make prouver` : CONFORME, 82 OK.
|
||
|
||
## 2026-09-17 (5) — Un tunnel d'administration par locataire : il declare, le site sert
|
||
|
||
- **Nouveau registre de plan** : `plan/acces.yml` (`acces_admin_vpn`), une entree par personne
|
||
ET par appareil, cle PUBLIQUE seule. Decrit au schema (donc editable a la console), valide
|
||
par `valider_acces` : nom `personne-appareil`, cle WireGuard bien formee, adresse en /32
|
||
dans le tunnel derive, jamais celle de la frontiere, jamais en double.
|
||
- **Tout derive de l'index** : reseau `10.<index>.29.0/24`, port `52000+index`, instance
|
||
`wg<index>`. Rien a allouer, aucune collision (P21 garde l'unicite des index).
|
||
- **`scripts/vpn_admin.py`** tient desormais le tunnel du site ET celui de chaque locataire ;
|
||
`--locataire` vise le bon. Le nom du pair sur le boitier porte l'ecosysteme : deux
|
||
locataires peuvent avoir chacun leur `daniel-portable`.
|
||
- **L'isolement est GARDE** : `verifier` refuse une regle dont la source est le tunnel d'un
|
||
locataire et la destination autre chose que SON supernet — sans quoi un ecosysteme
|
||
obtiendrait un acces chez un voisin en ajoutant un pair dans son propre plan. Mise en
|
||
defaut eprouvee ; P24 l'execute avant toute ecriture.
|
||
- **Pare-feu des machines** : `resoudre_flux` derive le reseau du tunnel du plan du locataire,
|
||
et seulement s'il declare un pair ACTIF.
|
||
|
||
Mesure : `wg17` actif en 10.17.29.1 avec son pair, port 52017 ouvert sur le WAN, deux regles
|
||
bornees a `SETOPS_TENANT_CHEZ17`, 13 machines de Chezlepro redeployees (0 failed).
|
||
`make prouver` : CONFORME, 82 OK.
|
||
|
||
## 2026-09-17 (4) — Le tunnel d'administration, eprouve en production : deux trous refermes
|
||
|
||
Le tunnel montait, la poignee de main se faisait, une machine de LOCATAIRE repondait — et
|
||
aucune machine du SITE, ni la console de la frontiere.
|
||
|
||
- **SSH des machines du site** : le socle declare son 22 en `pair: [flotte, externe]`, jamais
|
||
`admin`. L'acces vivait du REBOND par la frontiere (la connexion part alors du boitier, que
|
||
pf laisse sortir sans regle) ; par le tunnel, la source est exterieure et rien ne
|
||
l'autorisait. Les locataires marchaient deja : leur regle SSH nait de `nftables_admin_ssh`.
|
||
- **Console et API de la frontiere** : aucun flux ne la designait comme destination
|
||
d'administration. Monter le tunnel faisait perdre le moyen de reparer la regle manquante —
|
||
il a fallu remettre l'adresse d'avant pour appliquer le correctif.
|
||
- **Une regle par port** : OPNsense refuse « 22,443 » (« Please specify a valid portnumber,
|
||
name, alias or range »). Aucun flux jusqu'ici n'en portait deux.
|
||
|
||
Mesure, tunnel monte et adresse d'avant retiree : site-mon-01, site-ops-01, site-dnspub-01,
|
||
les deux `infra-dns-01` des locataires et la console OPNsense (HTTP 200) repondent tous, vus
|
||
depuis `10.37.29.2`. `make prouver` : CONFORME, 82 OK.
|
||
|
||
## 2026-09-17 (3) — L'administration entre par un tunnel nominatif, pas par le runner
|
||
|
||
Question posee : « le runner ne devrait-il pas etre le rebond SSH des admins ? » Non — il
|
||
detient la cle de la voute du site et les cles SSH de toutes les machines. Une session humaine
|
||
compromise deviendrait le plan de controle, et reconstruire le runner couperait l'acces.
|
||
|
||
- **Instance WireGuard `admins`** (port 51821, `10.37.29.0/24`), creee par
|
||
`scripts/vpn_admin.py` — a COTE du tunnel site-a-site vers le site pair, jamais melee a lui.
|
||
Perimetre strict : un pair attache a une autre instance n'est jamais touche.
|
||
- **Un pair = une personne ET un appareil.** Le plan ne porte que des cles PUBLIQUES ;
|
||
`pair-nouveau` tire la paire et affiche la privee une fois. `etat: absent` revoque, et tout
|
||
pair de notre instance que le plan ne declare plus est RETIRE.
|
||
- **Le reseau du tunnel est un reseau d'administration, et tout en derive** : pare-feux des
|
||
machines du site (`resoudre_flux`), contrat vers les locataires (`site_intrants`, donc leurs
|
||
machines aussi), regles de la frontiere (`devis_opnsense`).
|
||
- **`devis_opnsense` connait une troisieme voie d'arrivee.** Un CIDR d'administration etait
|
||
soit « gestion » soit « WAN » ; celui d'un tunnel n'est ni l'un ni l'autre, et sa regle
|
||
aurait ete posee sur une patte que ce trafic n'emprunte jamais.
|
||
- **Un seul port ouvert sur l'Internet** : 51821/udp vers l'adresse publique de la frontiere.
|
||
|
||
Mesure : `wg1` active en `10.37.29.1` ; `pfctl` porte la regle du port et les 15 acces du
|
||
tunnel ; les 31 machines (site + deux locataires) acceptent `10.37.29.0/24` en SSH,
|
||
0 failed. `make prouver` : CONFORME, 82 OK. Document : `docs/acces-administration.md`.
|
||
|
||
## 2026-09-17 (2) — La frontiere rejoint la supervision du materiel
|
||
|
||
- **Exportateur** : greffon officiel `os-node_exporter` installe et configure par l'API
|
||
d'OPNsense, n'ecoutant que sur la patte de supervision (`10.37.36.1:9100`), collecteurs
|
||
utiles seulement. Declare dans `opnsense.yml` (`opnsense_exportateur_metriques`) : c'est ce
|
||
fait du boitier qui fait deriver la cible Prometheus (`job="frontiere"`, `hote="bifrost-1"`)
|
||
et l'hote Icinga — sans lui, rien n'est derive.
|
||
- **Flux** `serveur_prometheus -> frontiere:9100`. Le bloc des flux vers la frontiere ouvrait
|
||
TOUT flux a chaque locataire et chaque zone : juste pour l'heure du socle, beaucoup trop
|
||
large pour une scrutation. Un role autre que le socle ne part desormais que de SES machines
|
||
du site. Et le saut des flux `frontiere` chez les locataires arrive AVANT la creation de
|
||
l'alias du role : le plan montrait deux alias `SETOPS_*_SRV_PROMETHEUS` que rien ne
|
||
referencait.
|
||
- Plan de la frontiere : 1 regle (opt9, tcp/9100, `SETOPS_SITE_SERVEUR_PROMETHEUS ->
|
||
SETOPS_FRONTIERE`), appliquee et chargee.
|
||
- **Carte d'orientation** : les equipements ne sont plus « sans supervision » (hyperviseurs et
|
||
frontiere) ; les commutateurs le restent.
|
||
|
||
Mesure : `up{job="frontiere"}` = 1 ; `check_materiel.py --hote bifrost-1` : OK, 4 coeurs a
|
||
41 °C. `make prouver` : CONFORME, 82 OK.
|
||
|
||
## 2026-09-17 (1) — Le materiel se regarde et se juge : hyperviseurs
|
||
|
||
Les hyperviseurs exposaient deja 68 temperatures, leurs ventilateurs, SMART et l'usure NVMe,
|
||
collectes par Prometheus depuis le 2026-09-10 — et regardes par personne.
|
||
|
||
- **`scripts/materiel.py`** : un catalogue de capteurs et de seuils, lu par Grafana ET Icinga.
|
||
Releve sur les trois cartes ASUS TUF X670E / Ryzen 9 7900X : `AUXTIN*` non cables, `PCH_*` a 0,
|
||
DDR5 et cartes reseau exposees seulement par le noyau 6.14 de vishnu, SSD Samsung qui portent
|
||
leur temperature dans l'attribut 190.
|
||
- **Prometheus** : chaque cible de la fabric porte `hote="asgard"`.
|
||
- **Grafana** : tableau « Set-OPS — Materiel » (27 panneaux) genere depuis le catalogue.
|
||
- **Icinga** : un hote par hyperviseur, juge par `up` ; service `materiel` (`check_materiel.py`).
|
||
|
||
TROUVE EN LE CONSTRUISANT : le disque `sda` de gandalf (WD Red Plus 10 To, OSD HDD `osd.4` de
|
||
Ceph, 26 225 h) porte 56 secteurs en attente, 17 irreparables et 2 realloues — stables sur une
|
||
semaine, maximum a vie 63 °C. Ceph `HEALTH_OK`. Avertissement dans Icinga ; a remplacer.
|
||
|
||
Deux fautes eprouvees avant d'etre laissees : jointures refusees apres le reetiquetage
|
||
(doublons de series — cote droit desormais agrege), et un 422 rapporte comme « Prometheus
|
||
injoignable » (la sonde dit maintenant ce que Prometheus refuse).
|
||
|
||
Mesure : asgard OK (6 capteurs), vishnu OK (13), gandalf AVERTISSEMENT (secteurs sda). Toutes
|
||
les requetes du tableau repondent. `make prouver` : CONFORME, 82 OK.
|
||
|
||
## 2026-09-16 (10) — Publier un service du site sur l'Internet : la redirection devient une capacite
|
||
|
||
Phase 2, quatrieme volet. Le devis du site sautait tout flux entrant `externe` (« rien d'autre
|
||
n'entre chez le site depuis l'exterieur ») : aucun service du site ne pouvait etre publie
|
||
autrement qu'a la main.
|
||
|
||
- **Un flux porte deux ports** : `port`, ce que la machine ecoute, et `port_public`, ce que
|
||
l'Internet frappe. `serveur_dns_public` declare `WAN:53 -> 1053` (UDP et TCP) : le 53 public
|
||
aboutit au frontal dnsdist, jamais a PowerDNS.
|
||
- **`devis_opnsense`** emet une `redirection` et sa regle WAN (source `any`, destination la
|
||
machine, port local). Une cible unique ou rien ; pas de `port_public`, pas de publication
|
||
(une note le dit). La garde refuse une redirection sans regle, ou vers une cible publique.
|
||
- **`appliquer_opnsense`** reconcilie les redirections (`d_nat`, identite `setopsrdr:`), sans
|
||
regle associee creee par le boitier, reflexion NAT desactivee, et les charge
|
||
(`d_nat/apply`). Le formulaire encode desormais les champs imbriques
|
||
(`rule[destination][network]`) — un seul niveau aurait envoye une destination vide.
|
||
- **Pare-feu d'hote** de `site-dnspub-01` regenere et pose : `1053` ouvert a tous, `53` reste
|
||
limite aux supernets des locataires.
|
||
|
||
Plan de la frontiere, lu et non applique : 4 objets a creer (2 regles, 2 redirections),
|
||
286 inchanges, rien a retirer. `make prouver` : CONFORME, 82 OK.
|
||
|
||
APPLIQUE PAR L'EXPLOITANT, puis mesure depuis un poste qui sort par une AUTRE adresse publique
|
||
(.50) : SOA des deux zones servis, MX en TCP, 2 RRSIG, ANY UDP tronque, AXFR refuse, recursion
|
||
et zone `.internal` REFUSED. `pfctl` : 2 `rdr` vers 10.37.37.21:1053 ; plan : 0 a creer.
|
||
`dns1.chezlepro.ca` publie chez Namespro, resolu par 9.9.9.9. Retirage horaire du site observe
|
||
(23:04, 23:05). Devis de bascule : 0 perdu ; seul prealable restant, le second serveur de noms.
|
||
|
||
## 2026-09-16 (9) — Un frontal devant le serveur public : dnsdist borne ce qu'on demande
|
||
|
||
Phase 2, troisieme volet. Un autoritatif signe DNSSEC expose sans frontal est un
|
||
amplificateur : 60 octets a adresse usurpee rendent 1 a 3 Ko a la victime.
|
||
|
||
- `serveur_dns_public` installe `dnsdist` sur `<hote>:1053` (la frontiere y redirigera son 53) ;
|
||
PowerDNS garde le 53 de la machine pour les NOTIFY des primaires.
|
||
- Regles eprouvees dans l'unite reelle, avec le vrai PowerDNS derriere : NOTIFY et UPDATE
|
||
refuses, AXFR/IXFR refuses, ANY en UDP tronque, debit par /24 (UDP 20 q/s -> tronque, TCP
|
||
40 q/s -> abandon). Rafale de 300 requetes : 70 reponses, 230 tronquees. Recursion : REFUSED.
|
||
Signatures DNSSEC relayees.
|
||
- Sonde `zones-publiques` : interroge par le frontal, alerte si dnsdist est arrete.
|
||
- Idempotent (second passage : 0 changement). Aucune console de controle ouverte.
|
||
|
||
PIEGE DE MESURE : `dig` envoie ANY en TCP par defaut. Le premier essai « montrait » une regle
|
||
inoperante ; `+notcp` et le compteur de la regle ont montre qu'elle agissait.
|
||
|
||
Rien n'est encore expose : aucun flux `externe`, aucune redirection a la frontiere.
|
||
|
||
## 2026-09-16 (8) — DNSSEC : le locataire signe avec la cle de sa voute
|
||
|
||
Phase 2 du DNS public, deuxieme volet. Eprouve sur les vraies machines AVANT d'etre code :
|
||
cle importee depuis un fichier, transfert signe vers le site, `PRESIGNED` pose tout seul,
|
||
`delv` « fully validated » (reponses negatives comprises).
|
||
|
||
- **La cle vit dans la voute du locataire** (`vault_dnssec_<zone>`), tiree par
|
||
`scripts/dnssec.py generer`. Une cle nee sur la machine mourrait a la reconstruction, et le
|
||
DS publie rendrait le domaine BOGUS. Le DS se calcule depuis la voute seule
|
||
(`make dnssec-ds`) ; le calcul local a ete confronte a `pdnsutil` : identique a l'octet.
|
||
- **`setops-dnssec-zone`** met chaque zone dans l'etat du plan (cle unique, conforme a la
|
||
voute), idempotent (second passage : 0 changement), et REFUSE de changer la cle ou de retirer
|
||
la signature tant qu'un DS est publie — une mesure qui echoue vaut refus.
|
||
- **CAA** `issue letsencrypt.org` sur `chezlepro.ca` (tous ses certificats publics mesures
|
||
viennent de Let's Encrypt). Pas sur `technolibre.ca` : c'est a son responsable de le dire.
|
||
- **Sonde du site** : zone signee servie nue = critique ; signatures sous 6 jours =
|
||
avertissement, sous 3 = critique. Mise en defaut eprouvee.
|
||
- **`valider_domaines`** refuse `dnssec: true` hors `primaire-cache`.
|
||
- **P18 deplie les familles de secrets derivees du plan** : `'vault_dnssec_' ~ zone` exige une
|
||
cle par zone signee, au lieu d'exiger un nom `vault_dnssec_` que personne ne peut fournir.
|
||
Au passage : `vault_pg_exportateur` manquait au gabarit de Chezlepro (P18 ne lit que
|
||
l'instance montee).
|
||
|
||
UNE FAUTE DE CONCEPTION, TROUVEE PAR LE CALCUL APRES LE DEPLOIEMENT. La premiere version
|
||
posait `SOA-EDIT INCEPTION-EPOCH` : serial servi = max(serial, inception). PowerDNS date
|
||
l'inception au debut de la semaine PRECEDENTE ; notre serial suit l'heure du dernier
|
||
changement et la depasse donc pendant deux semaines. Le serial n'aurait bouge qu'a
|
||
l'expiration meme des signatures du site — une fenetre BOGUS a chaque cycle. Passe a
|
||
`SOA-EDIT EPOCH` : serial servi = l'heure, le site retire la zone a chaque rafraichissement.
|
||
|
||
Mesure en production : serial servi par le primaire = l'heure exacte ; le site a retire les
|
||
deux zones par NOTIFY sans intervention ; ancres = DS calcules depuis les voutes, `delv` valide
|
||
MX, CAA, TXT, A et NXDOMAIN sur les deux zones. `make prouver` : CONFORME, 82 OK, 0 echec.
|
||
|
||
LIMITE : le retirage horaire (rafraichissement du SOA) n'est pas encore observe ; le journal
|
||
du site le dira dans l'heure.
|
||
|
||
## 2026-09-16 (7) — Les zones publiques portent le courriel : reproduire avant de basculer
|
||
|
||
Phase 2 du DNS public, premier volet. Une zone publique ne portait que des A d'exposition :
|
||
basculer `chezlepro.ca` aurait fait disparaitre son MX, son SPF et son DMARC — le courriel de
|
||
production — a la minute ou le registraire aurait suivi.
|
||
|
||
- **Enregistrements declares au plan** (`domaines_publics.<zone>.enregistrements`) : A, AAAA,
|
||
CNAME, MX, TXT, CAA. `valider_domaines` les passe au crible ; le schema les decrit (et
|
||
porte desormais les `enum` d'une liste d'entrees) ; `zone-publique.db.j2` les rend, TXT
|
||
coupes en tranches de 255.
|
||
- **Le serveur de noms porte le nom que le site declare** (`dns_public_nom:
|
||
dns1.chezlepro.ca`), transmis par le contrat du site. `ns1.<zone>` aurait deplace
|
||
`ns1.chezlepro.ca`, qui existe en production vers .53. Le role refuse un enregistrement
|
||
redeclare a ce nom.
|
||
- **`chezlepro.ca` reproduit la production** (releve par 9.9.9.9, Namespro refusant le
|
||
transfert) ; **`technolibre.ca` est preparee** (courriel chez Koumbit), sans les marques
|
||
`heritage=external-dns`.
|
||
- **`make dns-bascule-devis`** (`scripts/dns_bascule.py`) : plan contre DNS en service, aucune
|
||
ecriture. Verdict mesure : `chezlepro.ca` 12 identiques, 0 perdu ; `technolibre.ca` 8
|
||
identiques, 0 perdu. Bloquent encore : un seul serveur de noms (le `.ca` en exige deux) et
|
||
`dns1.chezlepro.ca` non publie.
|
||
|
||
Deux fautes trouvees par l'epreuve, avant la production :
|
||
|
||
- une boucle Jinja dans `set_fact` rend une **chaine** : `in` y cherchait une sous-chaine, et
|
||
`lepro.ca` « appartenait » a la liste. La liste passe en JSON.
|
||
- `shlex.split` sur une valeur du plan (non citee) effacait les espaces : `v=spf1 mx` devenait
|
||
`v=spf1mx`, et le devis voyait deux ecarts qui n'existaient pas.
|
||
|
||
Mesure en production : deploiement des deux primaires 0 failed ; le site a tire les nouveaux
|
||
serials par NOTIFY sans intervention ; les 20 couples (nom, type) du plan sont servis a
|
||
l'identique par `site-dnspub-01`. `make prouver` : CONFORME, 82 OK, 0 echec.
|
||
|
||
## 2026-09-16 (6) — Le DNS public en production, et cinq fautes que seule la production montrait
|
||
|
||
La phase 1 tourne. Chaque ligne ci-dessous a ete MESUREE sur les machines, pas deduite d'un
|
||
recapitulatif Ansible :
|
||
|
||
primaires (Chezlepro, TechnoLibre) pdns@public actif ; zone .internal demandee a
|
||
l'instance publique : REFUSED ; AXFR sans cle : refuse
|
||
site-dnspub-01 2 zones sur 2, serials IDENTIQUES aux primaires ;
|
||
tirage AUTOMATIQUE en 80 s, jamais force
|
||
recursion, AXFR sortant, zone interne : REFUSED
|
||
version annoncee : aucune
|
||
AXFR non signe vers un primaire refuse PAR LE PRIMAIRE (pas un delai : le chemin
|
||
existe, seule la cle manque)
|
||
NOTIFY compteur du secondaire 3 -> 5 pour deux envois
|
||
frontiere 8 regles CHARGEES, verifiees dans pf
|
||
|
||
L'epreuve de l'outil avait tourne dans un /tmp, sous l'utilisateur qui la lancait, sans
|
||
unite systemd, sur la boucle locale. Elle ne pouvait voir aucune des cinq fautes suivantes.
|
||
|
||
### 1. Ecrire n'est pas charger — l'applicateur de la frontiere
|
||
|
||
Deux coupures reseau pendant `frontiere-appliquer` : les objets ont ete ECRITS dans la
|
||
configuration d'OPNsense, l'applicateur s'est arrete avant `filter/apply`. Au passage
|
||
suivant, le plan comparait la configuration au devis, les trouvait d'accord, et rendait
|
||
« Rien a faire » — sans jamais charger. `pfctl` montrait zero regle du DNS public pendant que
|
||
le plan disait « a creer : 0 ». Relancer n'aurait JAMAIS repare : le code connaissait ce
|
||
defaut pour les routes seules. Desormais, `CONFIRMER=true` charge meme quand il n'y a rien a
|
||
creer, et l'echec dit que la relance terminera le chargement.
|
||
|
||
### 2. `site-verifier` promettait une verification qu'il ne faisait pas
|
||
|
||
Son aide : « verifie que playbooks/site.yml correspond aux couches declarees ». Son code :
|
||
les couches et le graphe, jamais le fichier. `serveur_dns_public` etait classe, coherent,
|
||
et ABSENT du playbook qui deploie le site. `verifier` rend maintenant le fichier en memoire
|
||
et le compare ; eprouve contre la version committee, il nomme le groupe manquant. P08 appelle
|
||
cette commande : la garde vient sans nouvelle preuve.
|
||
|
||
### 3. SQLite ecrit son journal a cote du fichier
|
||
|
||
`pdns@public` refusait de demarrer : « attempt to write a readonly database ». Le fichier
|
||
appartenait a pdns — le REPERTOIRE, `/var/lib/powerdns`, a root. Reproduit en `pdns` hors
|
||
systemd : ce n'etait pas le confinement, c'etaient les droits. La base vit dans un
|
||
sous-repertoire a pdns, toutes les commandes `pdnsutil` s'executent en pdns, et l'ancienne
|
||
base — qui portait une cle TSIG — est retiree.
|
||
|
||
### 4. La question precede le transfert
|
||
|
||
Le secondaire n'a tire AUCUNE zone. Avant un transfert (TCP), il demande le SOA — EN UDP.
|
||
Seul le TCP etait declare : la frontiere laissait passer l'AXFR et jetait la question.
|
||
Mesure : SOA en UDP, delai ; en TCP, reponse. Un tirage force a la main passait, et aurait
|
||
fait croire que tout marchait. P82 exige desormais les deux protocoles.
|
||
|
||
### 5. `failed_when: false` avalait un socket introuvable
|
||
|
||
`pdns_control --config-name=public` cherchait son socket a l'emplacement par defaut, alors
|
||
que l'unite le cree dans `/run/pdns-public`. La notification echouait a CHAQUE deploiement,
|
||
et le handler le taisait. `socket-dir` est declare, et le `failed_when: false` est retire :
|
||
un canal de controle casse est un defaut, pas un bruit.
|
||
|
||
### Ce qu'elles ont en commun
|
||
|
||
Toutes rendaient un SUCCES : un plan conforme, un orchestrateur coherent, un deploiement
|
||
vert, un tirage force reussi, un handler sans erreur. C'est la famille de P79, et la raison
|
||
pour laquelle chaque verification de cette entree a ete faite sur la machine qui devait
|
||
utiliser le resultat.
|
||
|
||
## 2026-09-16 (5) — Le DNS public, phase 1 : le locataire ecrit, le site sert
|
||
|
||
Les noms publics des locataires etaient chez un tiers, et le mode `autorite: primaire-cache`
|
||
du plan etait valide par le schema et **consomme par rien**. Cette phase le fait exercer, sans
|
||
rien exposer a Internet.
|
||
|
||
LOCATAIRE infra-dns-01 pdns@public primaire cache de chezlepro.ca ─┐ NOTIFY
|
||
│ AXFR signe TSIG
|
||
SITE site-dnspub-01 secondaire public ◄──────────────┘
|
||
10.37.37.21 (zone de publication, pas sur l'edge)
|
||
|
||
### Eprouver l'outil avant le role
|
||
|
||
Deux instances PowerDNS 4.9.17 jetables, sur une machine du site, en repertoire temporaire.
|
||
Sept verifications, et trois enseignements que personne n'aurait devines :
|
||
|
||
- **`allow-axfr-ips` et TSIG sont ALTERNATIFS.** Une adresse listee obtient la zone *sans
|
||
signature* — le journal le dit : *« allowed: client IP is in allow-axfr-ips »*. La
|
||
configuration evidente rend TSIG decoratif, sans un message. Et la valeur par defaut
|
||
autorise la boucle locale : mon premier test negatif n'en etait pas un.
|
||
- **Le primaire notifie aussi les adresses de ses NS** — donc, en production, les IP
|
||
PUBLIQUES des serveurs de noms, depuis l'interieur du locataire. `only-notify` les borne.
|
||
- **`bind-dnssec-db` est pris en charge** alors que `ldd` du module ne montrait pas SQLite.
|
||
|
||
### Trois fautes evitees en concevant
|
||
|
||
1. **Une instance a part.** Faire ecouter l'instance principale sur l'adresse de l'hote
|
||
aurait permis au serveur public du site d'INTERROGER la zone `.internal` du locataire —
|
||
de lire la carte de ses machines. C'est ce que la charte des responsabilites refuse a
|
||
l'hebergeur. `pdns@public` est la seule a ecouter l'hote.
|
||
2. **Le serial suit le contenu.** Le role portait un serial fige : sans secondaire, sans
|
||
consequence ; avec un secondaire, il garde l'ancienne zone POUR TOUJOURS.
|
||
3. **Un mot de flux, pas un nom de role.** `serveur_powerdns` existe aussi au site : une
|
||
sortie « vers serveur_powerdns » aurait vise le PowerDNS du site lui-meme.
|
||
|
||
### Ce qui est pose
|
||
|
||
- le role `serveur_dns_public` (secondaire, aucun transfert sortant, notifications des seuls
|
||
primaires, `version-string=anonymous`, aucun appel vers les serveurs de l'editeur) ;
|
||
- `serveur_powerdns` exerce `primaire-cache` dans `pdns@public` ;
|
||
- les relations se DERIVENT des plans des locataires (`site_inventaire.py`), l'adresse du
|
||
primaire de leur nomenclature ; le site transmet `dns_public_site` et `ip_publique_site`
|
||
par son contrat ;
|
||
- deux mots de flux calques sur `runner_site` ; l'alias des primaires ne vise que les
|
||
locataires qui publient (Patient0, qui n'a aucune zone publique, en a ete retire) ;
|
||
- les cles TSIG dans les trois voutes, ecrites avec les gardes connues ;
|
||
- la relation de replication avec `SITE-Technolibre`, declaree des deux cotes, inactive.
|
||
|
||
### P82
|
||
|
||
Refuse une zone `.internal` publiee, une zone declaree et non servie (ou l'inverse), une
|
||
adresse publique privee, une replication declaree d'un seul cote, une instance publique qui
|
||
autoriserait autre chose que la boucle locale au transfert — et **l'exposition du serveur
|
||
public a Internet tant que toutes ses zones ne sont pas signees DNSSEC**. La condition de la
|
||
phase 2 est ecrite comme une garde, pas comme une promesse.
|
||
|
||
## 2026-09-16 (4) — La page cesse de proposer ce que le serveur refuse
|
||
|
||
La portee etait derivee et gardee au serveur ; la page, elle, proposait encore tout. On
|
||
apprenait donc l'interdit AU CLIC — au milieu d'un geste, sur une console qu'on croyait
|
||
la bonne.
|
||
|
||
**Un seul entonnoir.** Les quatre gestes d'hote passent par `lancer(mode, index)` : une
|
||
garde la couvre les cinq endroits qui emettent un bouton « Deployer ». `POUVOIR_PAR_MODE`
|
||
y rattache chaque mode a son pouvoir, et le refus NOMME sa raison.
|
||
|
||
**Les boutons demeurent, inertes.** Les retirer ferait croire que le geste n'existe pas —
|
||
meme raisonnement que les integrations universelles, affichees en lecture seule plutot
|
||
qu'omises. Ils portent leur raison en infobulle : « exploiter n'est pas engendrer » sur la
|
||
creation de VM chez un locataire, « elle n'a la voute d'aucun locataire » sur la
|
||
configuration chez un site.
|
||
|
||
**La fabric est marquee.** Chez un locataire, les devis de commutateurs et de frontiere
|
||
sont calcules depuis une COPIE locale de la carte du cluster — et une copie avait deja
|
||
diverge, deux listes de stockages contradictoires pour le meme materiel. Le panneau reste
|
||
visible et cesse d'etre lu comme une source.
|
||
|
||
**P81 s'etend** : elle refuse desormais aussi une page qui aurait perdu `POUVOIR_PAR_MODE`
|
||
ou l'un de ses quatre modes. Le serveur et la page doivent porter la meme coupure.
|
||
|
||
### Le banc de rendu a attrape ce que `node --check` ne voit pas
|
||
|
||
Ma garde de page prenait pour un contexte TOUT ce qu'on lui donnait. Dans le banc, la
|
||
reponse d'une autre route est passee pour un contexte, puis `peut()` a lu
|
||
`contexte.peut[...]` sur `undefined` : **TypeError a l'ouverture, page morte**. La syntaxe,
|
||
elle, etait juste — `node --check` restait vert, et c'est exactement pourquoi
|
||
`test_rendu_gui.py` existe : *le rendu, et non la seule syntaxe*.
|
||
|
||
Deux corrections, et la seconde compte autant que la premiere :
|
||
|
||
- on **verifie la forme** avant d'adopter un contexte (`peut` est un objet, `portee` une
|
||
chaine) ;
|
||
- sans contexte connu, **on ne retire rien**. Une console qui griserait ses boutons parce
|
||
qu'elle n'a pas su lire sa portee serait pire que le defaut corrige : elle empecherait
|
||
un geste legitime sans rien expliquer. Le serveur, lui, refuse quand meme — c'est lui la
|
||
garde.
|
||
|
||
Et une couleur ne se relit pas, elle se **regarde** : mon badge de portee posait un texte
|
||
sombre sur un fond clair, dans une console en theme sombre. Vu sur une capture, pas dans le
|
||
code.
|
||
|
||
### Au passage, une faute de ma part, attrapee par le harnais
|
||
|
||
En eprouvant les refus, j'ai vise une machine REELLE depuis une console de locataire
|
||
simulee (`SETOPS_UNDERLAY` neutralise). `/api/instancier` n'a pas ete refuse — c'est
|
||
correct — et a donc regenere `hosts.yml` de TechnoLibre SANS la fabric : `proxmox_pont` et
|
||
`chrony_serveurs` perdus, `proxmox_etiquette_vlan` change, 13 hotes d'ecart.
|
||
|
||
**P03 l'a vu au passage suivant**, et le fichier a ete restaure par git. Une epreuve de
|
||
garde n'a pas besoin d'une cible vivante ; celle-ci en a pris une.
|
||
|
||
## 2026-09-16 (3) — La console affichait un ecosysteme vide, et c'etait le site
|
||
|
||
Servie par le runner d'un SITE, la console d'exploitation montrait **zero serveur, zero
|
||
application, zero base** — sans une erreur, sans un avertissement. Le site a sept
|
||
machines.
|
||
|
||
Trois faits corrects, mis bout a bout, produisaient un mensonge :
|
||
|
||
serveur_ops RETIRE le lien `instance` sur un runner d'hebergeur
|
||
(juste : un lien perime vers un tenant serait pire)
|
||
charger_yaml rend {"all": {"children": {}}} sur un fichier absent
|
||
(juste : une console sans plan ne doit pas tomber)
|
||
la page dessine ce vide comme un plan vide
|
||
(faux : l'inventaire d'un site est un SCRIPT, pas un hosts.yml)
|
||
|
||
C'est la forme exacte que **P79** garde partout ailleurs — *une derivation qui ne trouve
|
||
rien ne se distingue pas d'une derivation qui n'a rien a trouver* — sous son pire visage :
|
||
celui d'un ecosysteme qu'on croit sans machines.
|
||
|
||
### La portee se DERIVE, elle ne se declare pas
|
||
|
||
Un `serveur_ops_gui_mode: site|tenant` au plan aurait ete une SECONDE liste, qui prend du
|
||
retard des qu'on reconfigure un runner sans y penser. Les deux symlinks, eux, SONT le
|
||
pouvoir — et `roles/serveur_ops` exige deja de savoir lequel il est :
|
||
|
||
instance/ -> je configure CET ecosysteme (j'ai sa voute)
|
||
underlay.yml -> je materialise sur CETTE fabric (j'ai celle du site)
|
||
|
||
les deux -> poste du mainteneur instance seul -> console de locataire
|
||
underlay seul -> console de SITE aucun -> rien a piloter, et elle le dit
|
||
|
||
Meme coupure que les trois portees de `docs/responsabilites-locataire-hebergeur.md` :
|
||
calculer, configurer, materialiser.
|
||
|
||
### Ce que ca change
|
||
|
||
Une console de SITE sert desormais l'inventaire **dynamique** de ses machines
|
||
(`site_inventaire.inventaire()`, importe — pas un sous-processus par page). Ses registres
|
||
de plan partent vides **avec leur raison** : on ne fabrique pas un faux plan de tenant
|
||
pour remplir un ecran.
|
||
|
||
`POUVOIR_REQUIS` exige un pouvoir pour CHAQUE route POST, et le refus **dit pourquoi** :
|
||
« interdit » sans raison envoie chercher une panne la ou il n'y en a pas. La garde est au
|
||
serveur ; le bouton grise n'est qu'une politesse.
|
||
|
||
Et la page **nomme** la console. Trois consoles identiques a trois URL differentes etaient
|
||
un piege pour qui en ouvre deux.
|
||
|
||
### P81
|
||
|
||
Elle refuse une portee sans source d'inventaire, un site dont l'inventaire ne rend aucune
|
||
machine, et **toute route POST absente de la table** — c'est la garde anti-derive : une
|
||
route d'ecriture ajoutee demain s'executerait sinon sur une console qui n'en a pas le
|
||
pouvoir, et personne ne le verrait avant l'incident.
|
||
|
||
## 2026-09-16 (2) — Les responsabilites, deduites des pouvoirs
|
||
|
||
Un partage de responsabilites se redige d'habitude en distribuant des devoirs. Celui-ci
|
||
les DEDUIT, et la regle tient en une ligne :
|
||
|
||
> Qui peut, doit. Qui ne peut pas, ne peut pas etre tenu — et personne ne peut se
|
||
> decharger sur celui qui ne peut pas.
|
||
|
||
`docs/responsabilites-locataire-hebergeur.md` part donc des trois portees qui existent
|
||
deja dans le moteur — *calculer*, *configurer*, *materialiser* — et en tire vingt lignes,
|
||
chacune citant le mecanisme qui la rend vraie : un role, une garde, une commande.
|
||
|
||
### Les lignes qui ne se devinaient pas
|
||
|
||
**Les sauvegardes se partagent en deux, et la coupure est nette.** L'hebergeur repond de
|
||
ce que sa sonde peut constater : le depot accepte-t-il encore une ecriture ? Il ne peut
|
||
pas juger un instantane — il heberge des octets chiffres qu'il ne peut pas ouvrir. « Cet
|
||
instantane est-il recent, complet, restaurable ? » se demande a qui detient la cle. *La
|
||
verification suit la cle.*
|
||
|
||
**L'acces de secours est dit plutot que taire.** D-40 donne a l'hebergeur un `sudo` sur
|
||
les machines qu'il exploite : exploiter, c'est pouvoir tout lire. La charte l'ecrit, et
|
||
enchaine sur sa consequence — c'est la raison d'etre du second temps de la remise, et de
|
||
sa DATE.
|
||
|
||
**Une ligne sans vis-a-vis est un trou, pas une zone partagee.** C'est la garde de lecture
|
||
du tableau : le silence n'a jamais cree de responsabilite.
|
||
|
||
### Le PDF LIT la doctrine, il n'en porte pas de copie
|
||
|
||
Les chartes remises a Chezlepro et a TechnoLibre sont engendrees depuis le tableau du
|
||
depot, entre ses ancres. Deux textes qui diraient deux choses differentes seraient pires
|
||
que l'absence de charte : chacun aurait raison de croire le sien. Le generateur REFUSE de
|
||
rendre si l'ancre a disparu, plutot que de livrer une charte tronquee.
|
||
|
||
Au passage, les deux generateurs partagent desormais `commun.py` — la liste des
|
||
locataires et la feuille de style existaient en double.
|
||
|
||
### Ce qui n'est pas tranche est nomme
|
||
|
||
Le recouvrement de la cle du responsable designe, le changement de responsable, et la
|
||
duree de retention avant purge. Une ligne rassurante sans mecanisme derriere vaut moins
|
||
qu'un point ouvert ecrit.
|
||
|
||
## 2026-09-16 (1) — Remettre un ecosysteme : une procedure, un outil, une garde
|
||
|
||
Livrer se terminait par une phrase : *« tes cles te seront remises separement »*. Ce qui
|
||
se passait ensuite n'etait ecrit nulle part — ni ce qu'on remet, ni dans quel ordre, ni
|
||
**ce qu'on garde**. Le geste qui donne le controle d'une organisation etait le seul geste
|
||
lourd du depot sans procedure, sans outil et sans preuve.
|
||
|
||
Et il portait une faute qui ne se serait vue de personne : les cles vivent toutes dans le
|
||
meme dossier (`~/.config/setops-vault-*`). Remettre « les cles » d'un revers de main,
|
||
c'est remettre celles du SITE et celles des autres locataires.
|
||
|
||
### Deux temps, et ils ne se confondent pas
|
||
|
||
| | Ce qui passe | Ce que le client peut |
|
||
|---|---|---|
|
||
| **Temps 1 — l'identite** | la cle de SA voute, sa voute chiffree, la racine de SON AC | creer, retirer, habiliter ses gens — des le premier jour |
|
||
| **Temps 2 — la machine** | sa cle SSH entre, celle de l'hebergeur sort, la voute change de mot de passe, les secrets tournent | tout, y compris se passer de nous |
|
||
|
||
Le second temps applique a une LIVRAISON ce que `migration-tenant.md` applique deja a un
|
||
DEPART : **revoquer, pas transmettre**. Sans lui, l'hebergeur garde a vie l'acces aux
|
||
secrets d'un client qui se croit chez lui — et personne ne decide jamais de le garder :
|
||
on oublie de le rendre, et le silence transforme l'oubli en etat de fait.
|
||
|
||
### Ce que l'outil refuse
|
||
|
||
`scripts/remise.py` reprend la forme d'`exporter_cles.py` — ecrire puis RELIRE, ne jamais
|
||
afficher une valeur, refuser une destination dans l'infrastructure — et y ajoute deux
|
||
refus propres a la remise : partir **sans la racine de l'AC** (le client apprendrait a
|
||
cliquer sur « continuer quand meme »), et emporter autre chose que **l'ecosysteme monte**.
|
||
Cette derniere garde tient en une ligne, `_cle_de_voute()`, qui ne retient que le role
|
||
`instance` parmi les trois que `voutes.py` nomme.
|
||
|
||
`remise-recleer` **mesure avant d'estampiller** : une cle presente, une cle revoquee, une
|
||
cle de voute differente de celle remise. Un registre qui dirait « revoque » pendant que le
|
||
plan garde la cle de l'hebergeur flatterait tout le monde.
|
||
|
||
### Le registre, et une question ouverte depuis longtemps
|
||
|
||
`remise.yml` se pose chez le locataire a cote de `parente.yml` : *de qui il descend* d'un
|
||
cote, *a qui il appartient* de l'autre. Il porte des empreintes SHA256, jamais des
|
||
valeurs — il est versionne et pousse sur trois forges.
|
||
|
||
**Il declare enfin le responsable designe.** D-18 le decide depuis longtemps ;
|
||
`migration-tenant.md` §9 listait « ou est-il declare ? » parmi ses questions ouvertes. La
|
||
reponse est la seule qui ne devine rien : c'est la personne qui RECOIT, nommee au moment
|
||
ou elle recoit.
|
||
|
||
### P80
|
||
|
||
Elle refuse un registre incomplet, un second temps **echu** et non fait, un second temps
|
||
declare fait pendant que le plan ne revoque aucune cle, et un secret qui se serait glisse
|
||
dans un fichier versionne. Elle ne juge PAS un ecosysteme sans registre : le lab, patient
|
||
0 et l'ecosysteme de l'hebergeur ne seront jamais remis a personne.
|
||
|
||
## 2026-09-15 (7) — P79 : une derivation qui ne trouve rien ne passe plus pour un succes
|
||
|
||
En deux jours, pour monter l'edge du site puis les trois consoles, le meme defaut est
|
||
revenu sous sept noms. Chaque fois un repli rendait un SUCCES au lieu d'un refus :
|
||
deploiement vert, devis muet, harnais vert. *Une derivation qui ne trouve rien ne se
|
||
distinguait pas d'une derivation qui n'a rien a trouver.*
|
||
|
||
### Ce que P79 regarde
|
||
|
||
| Temoin | Le repli qu'il attrape |
|
||
|---|---|
|
||
| jumeaux | `dns_amorcage` derive sans `artefacts_amorcage` : le gabarit garde son ancien proxy apt |
|
||
| patte | une zone occupee absente de `opnsense_if_zones` : regles posees sur la patte plate |
|
||
| edge | une exposition qui retombe sur son propre groupe dans un ecosysteme qui A un edge |
|
||
| bouchon | l'edge du site sert `ssl-cert-snakeoil.pem`, faute de group_vars |
|
||
| rechargement | un edge qui renouvelle son certificat sans recharger nginx |
|
||
| internet | une sortie vers un role absent traduite en sortie vers l'Internet |
|
||
| flux | les regles d'hote rejouees pour CHAQUE locataire, pas seulement l'instance montee |
|
||
|
||
Chaque temoin lit sa source directement — inventaire, plan, table ecrite, `meta/flux.yml`
|
||
— et ne passe jamais par la derivation qu'il juge. La lecon de P43 et de P67 : une garde
|
||
qui reproduit le raisonnement qu'elle verifie ne verifie rien.
|
||
|
||
Le temoin des flux rejoue `resoudre_flux.py nftables` dans un dossier temporaire qui
|
||
reprend l'instance par liens : rien n'est ecrit dans les depots.
|
||
|
||
### Eprouvee en lui remettant chaque faute sous les yeux
|
||
|
||
Chaque temoin prend ses donnees en parametre. Les sept fautes ont ete reinjectees une a
|
||
une dans les donnees reelles du site (zone retiree de la table, `edge` retire de
|
||
`domaines.yml`, certificat, rechargement et jumeau retires de l'inventaire, regle
|
||
`!SETOPS_INTERNES` sur 636 ajoutee au devis) : sept refus, et zero sur les donnees saines.
|
||
Sans edge, le repli « le service se sert lui-meme » reste juste, et la garde se tait.
|
||
|
||
### Et elle a trouve deux ecarts reels des sa premiere execution
|
||
|
||
OPS-Chezlepro-lab 14 regles de machines retirees du plan, aucune pour ops-01
|
||
OPS-Patient0 4 regles perimees (admin en 10.17.0.0/24, sans le refus
|
||
`admin-prohibited`), aucune pour ops-01
|
||
|
||
Les deux dataient d'avant le renumerotage du site : `make flux` n'avait jamais ete relance
|
||
avec eux montes. Regeneres par `SETOPS_INSTANCE`, sans basculer l'instance. Ce sont des
|
||
apercus, rien n'est applique a une machine.
|
||
|
||
### Ce qu'elle ne couvre pas
|
||
|
||
La collision des noms publics par groupe (P67 la garde). `client_pki` qui rend
|
||
`changed=0` sans comparer ses SAN : cela se mesure sur la machine (`make
|
||
certificats-plan`). Un registre facultatif exige par `include_vars` : celui-la echouait
|
||
bruyamment.
|
||
|
||
### Au passage
|
||
|
||
Les gabarits de voute de `OPS-Patient0` et `OPS-Chezlepro-lab` portaient
|
||
`vault_setops_console_oidc` depuis la session precedente, jamais commite, et le premier
|
||
avait un commentaire coupe de sa cle. Remis en forme et commites.
|
||
|
||
## 2026-09-15 (6) — Les trois consoles repondent, chacune derriere sa propre serrure
|
||
|
||
console.genese.internal 401 vestibule HTTP Basic (pas d'annuaire)
|
||
console.chezlepro.internal 302 -> Keycloak oauth2-proxy, client `setops-console`
|
||
console.technolibre.internal 302 -> Keycloak oauth2-proxy, client `setops-console`
|
||
|
||
Chaque ecosysteme sert sa console, chez lui, sous le nom que son plan lui donne. Le site
|
||
avec un mot de passe parce qu'il n'a pas d'annuaire ; les locataires avec le leur.
|
||
|
||
### La pile, identique partout, verifiee sur la machine
|
||
|
||
127.0.0.1:8765 le GUI — sa seule serrure est de n'ecouter que la
|
||
127.0.0.1:8090 le vestibule boucle locale ; il sert sa page a qui la demande
|
||
0.0.0.0:4180 la passerelle — seule publiee, et elle renvoie vers Keycloak
|
||
|
||
### Ce que le deploiement a demande
|
||
|
||
Un secret OIDC de 48 caracteres dans chaque voute de locataire (memes gardes : copie de
|
||
surete, dechiffrement vers un fichier, relecture, en-tete verifie, empreinte comparee,
|
||
clair passe au `shred`), un client `setops-console` dans chaque Keycloak, et la regle
|
||
d'hote `edge -> ops-01:4180`.
|
||
|
||
CETTE DERNIERE A MANQUE DEUX FOIS, POUR LA MEME RAISON. `make flux` ecrit pour l'INSTANCE
|
||
MONTEE : lance avec Chezlepro monte, il n'a rien regenere pour TechnoLibre. Le devis etait
|
||
juste, le fichier de TechnoLibre n'existait simplement pas — et l'edge rendait `502`
|
||
derriere un TLS parfait.
|
||
|
||
Chez un locataire ce flux ne traverse pas la frontiere (le SDN de Proxmox tient les
|
||
passerelles de zone) : c'est le pare-feu d'HOTE qui le porte. Au site, c'etait la
|
||
frontiere. Le meme flux, deux couches differentes, selon qui route.
|
||
|
||
### Deux mesures a moi qui ont menti
|
||
|
||
**`tls=1` sur TechnoLibre.** J'ai conclu a une chaine invalide. L'AC que j'avais
|
||
recuperee faisait **0 octet** : le `ssh` qui devait la lire avait echoue sans que je
|
||
regarde son code de sortie. Le certificat servi portait le bon nom et le bon emetteur.
|
||
|
||
**Les cles d'hote de TechnoLibre ont change** a sa reconstruction, et `known_hosts` porte
|
||
encore les anciennes. Je n'y ai pas touche — c'est le fichier de l'exploitant, et une
|
||
entree qui change est exactement ce qu'un avertissement doit faire remarquer. La chaine a
|
||
donc ete lue dans ce que l'edge SERT, pas dans ce qu'une machine aurait bien voulu me
|
||
donner.
|
||
|
||
Consequence assumee : la console de TechnoLibre est verifiee par son COMPORTEMENT (302
|
||
vers son Keycloak) et par le nom de son certificat, pas par une verification complete de
|
||
chaine depuis le poste — il y faudrait sa racine, que je n'ai pas pu lire.
|
||
|
||
## 2026-09-15 (5) — La console chez les locataires, une collision de noms, et un fichier que j'ai ecrase
|
||
|
||
Les deux locataires declarent desormais leur console derriere leur SSO. Le chemin a
|
||
traverse une collision reelle et une faute de ma part.
|
||
|
||
### `console.<domaine>`, derriere oauth2-proxy
|
||
|
||
ops-01 serveur_ops_gui_actif: true, auth: oidc
|
||
le GUI sur 127.0.0.1:8765, le vestibule nginx sur 127.0.0.1:8090
|
||
la passerelle sur 4180, publiee par l'edge
|
||
|
||
`oidc` ET PAS `locale` : ces ecosystemes ont un annuaire. Cette console lance des
|
||
deploiements et peut RASER — un groupe se revoque sans deploiement (D-66), un mot de passe
|
||
partage devant ce pouvoir est un accident qui attend.
|
||
|
||
ET LE VESTIBULE N'ECOUTE PLUS QUE LA BOUCLE LOCALE EN `oidc`. Le gabarit annoncait cette
|
||
protection — *« lui seul doit pouvoir frapper ce port »* — en s'en remettant a
|
||
`meta/flux.yml`, qui ouvre le 8090 depuis `[edge, admin]`. L'edge pouvait donc joindre la
|
||
console SANS passer par la passerelle. Un port qui n'est pas ouvert ne se contourne pas ;
|
||
une regle, si.
|
||
|
||
### UNE COLLISION DE NOMS QUE LA GARDE NE VOYAIT PAS
|
||
|
||
`serveur_oauth2_proxy` est mono-instance par machine : une devant la vigie sur le noeud de
|
||
supervision, une devant la console sur le runner. La table des noms publics etait indexee
|
||
par GROUPE :
|
||
|
||
mon-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
|
||
ops-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
|
||
|
||
La passerelle de la VIGIE se croyait la console. Son URL de retour OIDC aurait vise l'autre
|
||
machine, et le SSO de la supervision serait tombe — pour un service auquel on n'avait pas
|
||
touche.
|
||
|
||
**Une application nomme un COUPLE (machine, role).** `instancier` et `site_inventaire` en
|
||
ont la clef juste desormais.
|
||
|
||
ET P67 NE LE VOYAIT PAS, parce qu'elle indexait par groupe **comme la derivation qu'elle
|
||
garde** : elle comparait la valeur a elle-meme. *Une garde qui reproduit le raisonnement
|
||
qu'elle verifie ne verifie rien.* Elle compare maintenant par machine, et elle a ete
|
||
eprouvee en lui remettant la faute sous les yeux.
|
||
|
||
### CE QUE J'AI CASSE, ET LA MESURE QUI ME L'A CACHE
|
||
|
||
J'ai ecrit `group_vars/serveur_ops.yml` avec `cat >` **sans regarder s'il existait**. Il
|
||
existait : 84 lignes chez Chezlepro, 77 chez TechnoLibre. J'en ai detruit 68 — la liste
|
||
des depots du genome, la branche `master` de TechnoLibre, l'amont de la forge, la source
|
||
de la cle de voute.
|
||
|
||
Deux preuves sont tombees (P05, P32). **J'ai d'abord accuse le retrait des forges de la
|
||
veille**, et construit une explication complete : « le site expose l'amont, le locataire
|
||
ne le porte pas ». Elle etait fausse — le fichier restaure le portait deja.
|
||
|
||
LA MESURE QUI M'A RASSURE A TORT :
|
||
|
||
(cd $e && git diff -- inventories/*/group_vars/serveur_ops.yml)
|
||
|
||
Le glob est developpe par le shell PARENT, ou `inventories/` n'existe pas. Le motif est
|
||
passe tel quel, n'a rien matche, et `git diff` n'a rien affiche. J'ai lu ce silence comme
|
||
« aucun fichier ecrase ». C'est `git status` — sans glob — qui a fini par montrer le `M`.
|
||
|
||
Une commande qui ne trouve rien et une commande qui trouve que rien n'a change rendent le
|
||
meme silence. Encore la meme forme que les six replis des deux derniers jours, cette
|
||
fois dans mes propres mains.
|
||
|
||
Restaure par `git checkout`, puis les trois lignes de la console ajoutees SANS toucher au
|
||
reste. Harnais : **77 OK**.
|
||
|
||
## 2026-09-15 (4) — La console d'exploitation est allumee, et publiee par l'edge
|
||
|
||
https://console.genese.internal/ 401 sans justificatif, la console avec
|
||
|
||
Le site est le premier ecosysteme a servir son GUI par un nom, en TLS verifie. Il a fallu
|
||
qu'il ait un edge pour que ce soit possible.
|
||
|
||
### Le vestibule reste, et c'est le contraire d'une contradiction
|
||
|
||
La veille, on RETIRAIT le vestibule de la vigie : Icinga Web 2 sait s'authentifier, en
|
||
poser un devant reinventait sa page de connexion. Ici on en GARDE un, et la difference est
|
||
tout le sujet :
|
||
|
||
Icinga Web 2 a une page de connexion, des comptes, des groupes -> pas de vestibule
|
||
GUI Set-OPS `GET /` sert la page A QUI LA DEMANDE, jeton inclus -> le vestibule EST
|
||
sa seule serrure
|
||
|
||
Le jeton du GUI garde contre le CSRF, pas contre un visiteur. Ce qui protege la fabric,
|
||
c'est que le service n'ecoute que `127.0.0.1` — verifie sur la machine :
|
||
|
||
LISTEN 127.0.0.1:8765 <- le GUI, joignable de nulle part ailleurs
|
||
LISTEN 0.0.0.0:8090 <- le vestibule, seul publie
|
||
|
||
`locale` (HTTP Basic) parce qu'un site n'a ni annuaire ni Keycloak. Chez un locataire ce
|
||
sera `oidc`, derriere oauth2-proxy — et c'est la que l'integration a sa place.
|
||
|
||
### Controle dans les deux sens
|
||
|
||
Une serrure ne se prouve qu'en la forcant ET en l'ouvrant :
|
||
|
||
sans justificatif HTTP 401
|
||
avec 152 805 octets — « Set-OPS · Votre artisan numerique »
|
||
182 vues du GUI, le jeton injecte dans la page
|
||
|
||
Et la sonde `console-ops` mesure exactement ca — elle exige un REFUS sur une requete
|
||
anonyme, parce qu'un 200 y serait la pire des reponses :
|
||
|
||
site-ops-01 | console-ops | OK | Console vivante, vestibule (locale) en place :
|
||
une requete anonyme rend HTTP 401.
|
||
|
||
### Le mot de passe le plus lourd de la voute
|
||
|
||
48 caracteres la ou les autres en ont 32 ou 40. Il n'ouvre pas une console de lecture : il
|
||
ouvre `deployer`, `creer`, `instance-utiliser` et l'edition du plan. Le cout d'un caractere
|
||
de plus est nul ; celui d'une console forcee ne l'est pas.
|
||
|
||
## 2026-09-15 (3) — Le charabia de la console : deux bases confondues, et un etat sans domicile
|
||
|
||
L'exploitant, apres s'etre connecte : *« icingaweb2 affiche un tas de charabia quand
|
||
j'ouvre une session »*. Deux causes, dont la premiere etait de mon fait.
|
||
|
||
### UN ROLE PARTAGE REND DES FAITS PARTAGES
|
||
|
||
`serveur_icingaweb2` appelle `resoudre_base` DEUX fois : une pour la base du MOTEUR
|
||
(`icingadb`, en lecture) et une pour celle des COMPTES (`icingaweb2`). Le second appel
|
||
ecrase les faits du premier — `resoudre_base_entree`, `resoudre_base_db_host`... — et les
|
||
gabarits, rendus apres les deux, lisaient la SECONDE base partout :
|
||
|
||
[icingadb] dbname = "icingaweb2" <- la base des comptes
|
||
[icingaweb_db] dbname = "icingaweb2"
|
||
|
||
La console cherchait donc `icingadb_schema` dans la base des comptes, ne l'y trouvait pas,
|
||
et rendait une trace PHP a chaque page. **Les 66 tables du moteur etaient intactes a
|
||
cote.** Un defaut qui accuse la base pendant que la base va bien.
|
||
|
||
Celui qui appelle un role partage deux fois doit NOMMER ses resultats avant de le
|
||
rappeler. C'est « une liste qui suit une autre », appliquee au temps plutot qu'a l'espace.
|
||
|
||
Introduit la veille, en remontant le bloc de la base au-dessus du rendu des `.ini` — un
|
||
correctif d'ORDRE qui a cree un defaut de PORTEE.
|
||
|
||
### LA CONSOLE N'AVAIT PAS DE DOMICILE POUR SON PROPRE ETAT
|
||
|
||
`config_backend = "ini"` laissait les preferences en fichiers. Tenable — sauf que le cadre
|
||
de MIGRATION d'Icinga Web 2 veut une instance de base quoi qu'il arrive :
|
||
|
||
Failed to load pending migrations : Please check if a db instance exists at all
|
||
Cannot load preferences for user "icinga-admin" : Cannot load resource config ""
|
||
|
||
La seconde n'apparaissait qu'A LA CONNEXION. **Un journal muet ne prouvait donc rien tant
|
||
que personne n'avait ouvert de session** — et c'est pour ca que le controle a consiste a
|
||
en ouvrir une vraie, pas a regarder le journal d'une console au repos.
|
||
|
||
Des lors que la console a une base, il n'y a aucune raison d'y ranger les comptes et pas
|
||
le reste : `config_backend = "db"`, `config_resource = "icingaweb_db"`. Les preferences
|
||
d'un utilisateur le suivent alors d'un navigateur a l'autre.
|
||
|
||
### Eprouve en ouvrant une session
|
||
|
||
session ouverte oui
|
||
tableau de bord « Current Incidents :: Dashboard »
|
||
/icingadb/services 44 459 octets, 0 trace
|
||
/icingadb/hosts 31 169 octets, 0 trace
|
||
journal 1 ligne en 100 s (aucune erreur)
|
||
|
||
### CE QUE J'AI ATTRIBUE A TORT A MON PROPRE GESTE
|
||
|
||
J'ai recharge php-fpm a la main et conclu que c'etait ca. Les horloges disent le
|
||
contraire :
|
||
|
||
derniere erreur 10:15:32
|
||
php-fpm redemarre 10:15:44 <- par le handler du role
|
||
mon rechargement ~10:22 <- sur un systeme deja sain
|
||
|
||
Le role etait deja juste. Mes mesures intermediaires portaient sur une fenetre
|
||
(`--since "-5min"`) qui enjambait le correctif : je lisais des erreurs d'AVANT en croyant
|
||
observer l'APRES. Une fenetre de mesure qui contient le moment du changement ne mesure ni
|
||
l'un ni l'autre etat.
|
||
|
||
## 2026-09-15 (2) — Un vestibule devant une application qui savait deja s'authentifier
|
||
|
||
L'exploitant, en voyant la boite du navigateur sur `vigie` : *« Dans le contexte d'un
|
||
site, cela complexifie inutilement les choses puisque Icinga Web 2 permet nativement de
|
||
gerer des comptes. Ce genre d'intervention n'est pertinente que pour integrer Icinga a
|
||
Keycloak chez les tenants. »*
|
||
|
||
Il a raison, et la correction retire du code au lieu d'en ajouter.
|
||
|
||
### Ce que le mode `locale` faisait, et pourquoi c'etait de trop
|
||
|
||
Il posait un `auth_basic` nginx devant le backend `external` : nginx demandait le mot de
|
||
passe, Icinga Web 2 croyait le `REMOTE_USER` qu'il recevait. Ca marchait. Ca reinventait
|
||
une page de connexion **devant une application qui en a une**, et ca privait l'exploitant
|
||
de ce que l'application sait faire seule.
|
||
|
||
UN VESTIBULE N'A DE SENS QUE DEVANT UNE APPLICATION QUI NE SAIT PAS S'AUTHENTIFIER. Celle
|
||
de Set-OPS — le GUI — est dans ce cas, et son vestibule reste. Icinga Web 2, non.
|
||
|
||
### Le mode `db` : le backend natif
|
||
|
||
authentication.ini backend = "db" resource = "icingaweb_db"
|
||
groups.ini backend = "db" — les groupes aussi sont natifs
|
||
nginx plus aucun auth_basic
|
||
|
||
CE QUE CA REND, ET QUI MANQUAIT. La gestion des comptes DANS l'interface : creer,
|
||
desactiver, changer un mot de passe ne demande plus un deploiement ni un passage par la
|
||
voute. Le moteur ne pose qu'UN compte — celui qui permet d'entrer la premiere fois — et
|
||
il ne l'ecrase jamais : un mot de passe change dans l'interface appartient a celui qui
|
||
l'a change.
|
||
|
||
ET D-66 REDEVIENT APPLICABLE SANS ANNUAIRE. Les groupes d'Icinga Web 2 vivent en base :
|
||
`roles.ini` habilite donc `sysadmin`, un GROUPE, et non plus une personne nommee en dur.
|
||
Revoquer quelqu'un redevient un clic.
|
||
|
||
UNE BASE A ELLE, PAS UN COIN D'`icingadb`. Les tables de comptes appartiennent a
|
||
l'application web ; celles du moteur sont reecrites par ses migrations. Les meler ferait
|
||
disparaitre les comptes le jour d'une mise a jour, sans que personne n'ait touche aux
|
||
comptes.
|
||
|
||
LE HACHAGE EST CELUI QUE L'APPLICATION VERIFIE : `password_hash()` de PHP, la fonction
|
||
exacte qu'Icinga Web 2 appelle a la connexion. Un hachage d'un autre outil donnerait une
|
||
base valide et une connexion impossible.
|
||
|
||
### Deux pieges du renommage
|
||
|
||
**`when: serveur_icingaweb2_auth != 'locale'`** gardait l'appel a `resoudre_annuaire`.
|
||
Juste tant que `locale` etait le seul mode sans annuaire ; renomme en `db`, la garde a
|
||
cesse de garder et le role a reclame un secret de liaison LDAP qui n'existe pas. Les
|
||
conditions nomment desormais les modes qui VEULENT un annuaire (`ldap`, `external`) :
|
||
une condition qui dit ce qu'elle veut survit a un renommage, une qui dit ce qu'elle
|
||
refuse, non.
|
||
|
||
**L'ordre des taches.** Le bloc de la base avait pris la place de l'ancien vestibule —
|
||
c'est-a-dire APRES le rendu des `.ini`, qui nomment cette base. Le gabarit lisait des
|
||
variables inexistantes, et l'echec etait **censure par `no_log`** : `changed: true` et
|
||
rien d'autre. Une tache qui manipule des secrets ne peut pas dire ce qui lui manque ;
|
||
c'est a l'ordre de ne pas la mettre dans cette situation.
|
||
|
||
### Eprouve
|
||
|
||
auth_basic dans nginx 0
|
||
backend declare db / icingaweb_db
|
||
compte en base icinga-admin, actif
|
||
depuis le poste 302 -> /authentication/login -> 200
|
||
la page « Icinga Web 2 Login », champs username et password
|
||
|
||
## 2026-09-15 (1) — L'edge publie : trois noms, en TLS verifie de bout en bout
|
||
|
||
observatoire.genese.internal http=302 tls=0 (Grafana, vers sa connexion)
|
||
vigie.genese.internal http=401 tls=0 (le vestibule tient)
|
||
forge.genese.internal http=200 tls=0
|
||
|
||
`tls=0` : verification reelle contre la racine du site, pas un `-k` complaisant.
|
||
|
||
### `expositions` n'etait resolu nulle part
|
||
|
||
`serveur_nginx` declare `egress port: derive, pair: expositions` — « je sors vers ce que
|
||
je publie ». Ce mot n'etait consomme par AUCUN moteur : ni le pare-feu d'hote, qui ne
|
||
filtre pas l'egress, ni le devis de frontiere, qui ne le connaissait pas.
|
||
|
||
IL N'AVAIT JAMAIS EU BESOIN DE L'ETRE. Chez un locataire, les passerelles de zone sont
|
||
tenues par le SDN de Proxmox : le trafic edge -> amont ne traverse pas le boitier. Au
|
||
SITE, elles sont tenues par la frontiere — premier endroit ou un edge doit franchir la
|
||
bordure pour atteindre ce qu'il relaie. Une declaration peut dormir des mois avant que la
|
||
premiere fabric ne la reveille.
|
||
|
||
UNE REGLE PAR AMONT, avec SON port :
|
||
|
||
opt10 tcp 443 SERVEUR_NGINX -> SERVEUR_FORGEJO
|
||
opt10 tcp 3000 SERVEUR_NGINX -> SERVEUR_GRAFANA
|
||
opt10 tcp 8080 SERVEUR_NGINX -> SERVEUR_ICINGAWEB2
|
||
|
||
Jamais une regle large. Un edge qui publie trois services ne doit joindre que ces
|
||
trois-la : l'ouvrir sur « tout le site » annulerait ce qu'une zone de publication separee
|
||
cherche a obtenir.
|
||
|
||
### LA MEME FAUTE QU'HIER, AVALEE DE LA MEME FACON
|
||
|
||
Premiere ecriture de cette resolution : `U.lire_plan_site(...)` dans un fichier ou le
|
||
module s'importe sous `underlay_mod`. Le `NameError` est tombe dans un
|
||
`except Exception` large, et le devis a rendu une note **plausible** — « le plan du site
|
||
n'en declare aucune avec un port » — alors que le plan en declarait trois.
|
||
|
||
C'est exactement le defaut du 2026-09-14 sur `site_inventaire.py`, refait 24 h plus tard
|
||
par la meme main. Le `except` est resserre a `(OSError, ValueError)` : une faute de frappe
|
||
doit faire du bruit, pas une phrase credible.
|
||
|
||
### Le schema de l'amont suivait une constante, pas le port
|
||
|
||
`proxy_pass http://…` etait ecrit en dur. La forge termine deja son TLS sur 443 : elle
|
||
recevait du clair sur un port qui attend une poignee de main, et rendait `400`. Du TLS
|
||
parfait a l'entree, une erreur de protocole a la sortie.
|
||
|
||
Le schema suit desormais le port (`serveur_nginx_ports_tls`), **et l'amont est verifie** :
|
||
`proxy_ssl_verify on` contre l'AC interne. Un edge qui relaie en TLS sans verifier ne fait
|
||
que DEPLACER la confiance — le visiteur croit l'edge, l'edge ne croit personne.
|
||
|
||
## 2026-09-14 (23) — Du TLS qui ressemblait a du TLS, et une regle qu'un tenant n'a jamais eu besoin d'ecrire
|
||
|
||
Trois defauts de plus sur le chemin de l'edge, et le premier est le plus grave de la
|
||
journee.
|
||
|
||
### L'edge servait `ssl-cert-snakeoil.pem`
|
||
|
||
Le certificat bouchon de Debian, sur les trois noms. `serveur_nginx` l'a pour DEFAUT,
|
||
avec un commentaire qui date d'avant la PKI : *« a remplacer par des certificats de l'AC
|
||
interne plus tard »*. Un locataire le remplace dans `group_vars/serveur_nginx.yml` ;
|
||
l'inventaire du site est DYNAMIQUE et n'a pas de group_vars.
|
||
|
||
LES AUTRES REPLIS RENDAIENT UN SERVICE MUET OU UNE REGLE INERTE. Celui-la rend **du TLS
|
||
qui ressemble a du TLS** : le cadenas s'affiche, la connexion est chiffree, et rien n'est
|
||
prouve — le certificat n'est signe par personne et ne porte aucun des noms servis. C'est
|
||
la seule sorte de panne qui rassure.
|
||
|
||
### Deux ecritures d'une meme regle, et seule l'une a appris
|
||
|
||
`site_inventaire.py` construisait sa PROPRE liste d'expositions, avec `edge` = le groupe
|
||
de l'application. Le commentaire disait pourquoi : *« le site n'a pas d'edge, donc chaque
|
||
service se sert lui-meme »*. C'etait vrai jusqu'a hier.
|
||
|
||
`client_pki` retient un FQDN si son `edge` est un GROUPE DE CET HOTE. L'edge appartient a
|
||
`serveur_nginx` ; cette boucle rendait `serveur_grafana`, `serveur_icingaweb2`... Aucun
|
||
nom ne correspondait. La boucle est remplacee par un appel a
|
||
`expositions_des_applications` — la regle n'existe plus qu'une fois.
|
||
|
||
### La cicatrice refaite sur la machine suivante
|
||
|
||
`site-forge-01` porte ce commentaire depuis des semaines : *« Un cert renouvele sur disque
|
||
reste servi perime tant que le consommateur n'est pas recharge. La cicatrice est deja dans
|
||
le depot ; on ne la refait pas. »* Elle a ete refaite sur `site-edge-01`, qui n'avait pas
|
||
de `client_pki_reload_services`.
|
||
|
||
disque : forge, pki, dns, sauvegarde, observatoire, vigie, site-edge-01
|
||
servi : site-edge-01
|
||
|
||
### Ou en est l'edge
|
||
|
||
certificat servi les 6 noms, signe par l'AC du site
|
||
verification TLS verif_tls=0 depuis le poste, contre la racine du site
|
||
vhosts forge, observatoire, vigie — en 443
|
||
|
||
### CE QUI RESTE, ET C'EST UNE VRAIE PIECE
|
||
|
||
Les trois noms repondent en TLS **verifie**, puis rien : `http=000`. L'edge ne joint pas
|
||
ses amonts, et le devis le dit lui-meme :
|
||
|
||
note : serveur_nginx declare un port `derive` que le plan du site ne resout pas
|
||
— aucune regle emise.
|
||
|
||
`serveur_nginx` declare bien `egress port: derive, pair: expositions`. Ce flux n'a JAMAIS
|
||
eu besoin d'etre resolu a la frontiere : chez un locataire, les passerelles de zone sont
|
||
tenues par le SDN de Proxmox, et le trafic edge -> amont ne traverse pas le boitier. **Au
|
||
site, elles sont tenues par la frontiere** — et c'est le premier endroit ou un edge doit
|
||
franchir la bordure pour atteindre ce qu'il relaie.
|
||
|
||
Resoudre `expositions` en une regle PAR AMONT (son hote, son port) est la piece qui
|
||
manque. Elle se voit maintenant parce que le site est la premiere fabric ou un edge et
|
||
ses services vivent dans des zones differentes.
|
||
|
||
Et `forge` relaie en `http://10.37.33.11:443` — du clair vers un port TLS, d'ou son `400`.
|
||
L'amont d'une exposition deja servie en TLS doit etre `https://`.
|
||
|
||
## 2026-09-14 (22) — L'edge du site est debout, et quatre replis silencieux l'ont retarde
|
||
|
||
`site-edge-01` est nee, deployee, et sert `forge`, `observatoire` et `vigie` en 443. Le
|
||
chemin jusque-la a traverse quatre defauts qui ont tous la MEME forme : un repli qui rend
|
||
un succes au lieu d'un refus.
|
||
|
||
### 1. Le gabarit dore porte une adresse d'avant le renumerotage
|
||
|
||
Failed to update apt cache after 5 retries
|
||
Acquire::http::Proxy "http://10.0.33.21:3142"
|
||
|
||
Le fichier le dit lui-meme : *« Gere par Set-OPS pendant la FABRICATION DU GABARIT »*,
|
||
date du 2026-09-01, avec l'ancienne adresse du cache. Le socle ne le remplace que
|
||
`when artefacts_amorcage is defined` — et `site_inventaire.py` derivait `dns_amorcage`
|
||
sans deriver son JUMEAU. Les machines nees AVANT le renumerotage n'ont rien vu :
|
||
l'adresse etait juste, puis `client_artefacts` a pose un fichier qui trie apres.
|
||
|
||
**La premiere machine neuve du site l'a revele.** Le site fournissait deja cette valeur a
|
||
ses locataires (`site_intrants.py`) ; il ne se la donnait pas a lui-meme.
|
||
|
||
### 2. Une table de pattes tenue a la main, et un repli qui deplace au lieu d'omettre
|
||
|
||
Le devis rendait « rien a faire », et le cache restait injoignable. `opnsense_if_zones`
|
||
associe chaque zone du site a sa patte de frontiere ; `site-publication` n'y etait pas, et
|
||
`_if_de()` retombe alors sur l'ANCIENNE PATTE PLATE. Les 20 regles de l'edge etaient
|
||
posees sur `vlan030` — syntaxiquement correctes, jamais rencontrees, puisque le trafic de
|
||
l'edge entre par `vlan037`.
|
||
|
||
Le fichier documentait deja un incident identique, un mois plus tot, pour la patte de la
|
||
fabric : *« La regle etait syntaxiquement correcte et ne correspondait jamais. »* Ajoutee,
|
||
le devis a rendu **20 a creer, 20 a retirer** — le meme jeu de regles qui change de patte.
|
||
|
||
### 3. Un registre facultatif qui faisait echouer un role
|
||
|
||
`serveur_nginx` chargeait `applications.yml` ET `domaines.yml` sans condition. Un SITE ne
|
||
publie rien a l'Internet : son plan n'a pas le second.
|
||
|
||
Could not find or access '.../SITE-Chezlepro/plan/domaines.yml'
|
||
|
||
Le role releve desormais ce qui EXISTE avant de charger. Un registre absent laisse
|
||
`domaines_publics` indefini, et le filtre le traite deja comme vide.
|
||
|
||
### 4. Le vhost genere faisait 43 octets, et le deploiement etait vert
|
||
|
||
C'est le plus retors des quatre. `expositions_des_applications` resout, pour chaque nom
|
||
expose, l'edge de son domaine PARENT — et sans registre, retombe sur **le groupe de
|
||
l'application elle-meme** (`serveur_grafana`, `serveur_icingaweb2`). Jamais
|
||
`serveur_nginx`. Aucune exposition n'etait donc retenue, le fichier ne contenait que son
|
||
en-tete, et rien n'echouait.
|
||
|
||
*Une derivation qui ne trouve rien ne se distingue pas d'une derivation qui n'a rien a
|
||
trouver.* Le site declare donc `plan/domaines.yml` : `genese.internal`, autorite
|
||
`auto-heberge`, edge `serveur_nginx`, sans DNSSEC — le TLD `internal.` est nie par la
|
||
racine signee, signer sous une chaine rompue ajoute du travail sans ajouter de preuve.
|
||
|
||
Et `grafana` n'avait pas de `port:` : le vhost le disait dans son propre en-tete — *« une
|
||
application sans hote actif, ou sans port, est listee mais NON publiee »*.
|
||
|
||
### Etat
|
||
|
||
site-edge-01 10.37.37.11 deployee (174 taches, 66 changements)
|
||
nginx ecoute 80 et 443
|
||
vhosts forge -> 10.37.33.11:443
|
||
observatoire -> 10.37.36.11:3000
|
||
vigie -> 10.37.36.11:8080
|
||
frontiere 274 regles, devis muet
|
||
|
||
### Ce qui reste, et c'est precis
|
||
|
||
**Le certificat de l'edge ne porte encore que `site-edge-01.genese.internal`.** Il a ete
|
||
emis avant que les expositions existent, et `client_pki` rend `changed=0` : il ne compare
|
||
pas son jeu de SAN a celui qu'il derive maintenant. Tant que ce n'est pas fait, les trois
|
||
noms repondent sur un certificat qui ne les couvre pas.
|
||
|
||
`forge` relaie par ailleurs en `http://10.37.33.11:443` — du clair vers un port TLS, d'ou
|
||
son `400`. L'amont d'une exposition deja servie en TLS doit etre `https://`.
|
||
|
||
Deux corrections a faire, et aucune n'est une surprise : ce sont les deux dernieres
|
||
jointures entre le plan et ce que l'edge en tire.
|
||
|
||
## 2026-09-14 (21) — Le site gagne un edge, et P23 a nomme le geste qui manque
|
||
|
||
Le site servait trois applications web sans jamais les PUBLIER. L'observatoire, la vigie
|
||
et la console d'exploitation ne se joignaient que par `adresse:port`, en clair, depuis le
|
||
plan d'administration — et `GF_SERVER_ROOT_URL` promettait un `https://` que personne ne
|
||
terminait.
|
||
|
||
### Une zone a elle seule, et pas un coin d'une autre
|
||
|
||
`site-publication`, VLAN 37, `10.37.37.0/24`. Toutes les autres zones du site separent ce
|
||
qui AGIT de ce qui est AGI : le runner seul, l'autorite seule, le temoin a part. Celle-ci
|
||
separe autre chose — **ce qui est adresse de l'exterieur de ce qui ne doit jamais
|
||
l'etre**.
|
||
|
||
Un edge est par definition la machine qu'on attaque en premier : c'est la seule dont
|
||
l'adresse est publiee. La loger dans `site-supervision` l'aurait mise dans le meme domaine
|
||
de diffusion que la base d'Icinga ; dans `site-genome`, a cote de la forge dont tout
|
||
descend. Une machine exposee ne partage pas son voisinage.
|
||
|
||
site-edge-01 10.37.37.11 2 vCPU, 2 Go, 20 Go vmid 9012
|
||
|
||
PETITE, ET SANS SAUVEGARDE, parce qu'elle ne porte aucun etat : un edge termine du TLS et
|
||
relaie. Ce qu'il perdrait en tombant se recompose en le redeployant — sauvegarder un
|
||
relais, ce serait sauvegarder une copie du plan.
|
||
|
||
ELLE NE S'EXPOSE PAS ELLE-MEME. Ce sont les `expose:` des AUTRES applications qui
|
||
deviennent ses vhosts et les SAN de son certificat. Elle n'est le nom de rien ; elle est
|
||
la porte de tous.
|
||
|
||
### P23 a nomme le geste que l'outillage ne sait pas poser
|
||
|
||
reseau 'site-publication': passerelle 10.37.37.1 n'est l'adresse d'aucun hote
|
||
declare sur ce reseau — passerelle fantome
|
||
|
||
`appliquer_opnsense` sait poser des REGLES et des ROUTES ; il ne cree pas d'INTERFACE.
|
||
Declarer la patte `10.37.37.1` dans les hotes de l'underlay n'est donc pas une formalite :
|
||
c'est ce qui rend le geste manuel **visible et verifiable**. Tant que `vlan037` n'existe
|
||
pas sur la frontiere, cette ligne est une promesse que le reel doit tenir — et le harnais
|
||
la relit a chaque passage.
|
||
|
||
C'est le meme principe que partout ici : on ne cache pas ce qu'on ne sait pas faire, on le
|
||
DECLARE, et la garde se charge de le rappeler.
|
||
|
||
### Ce qui est pret, et ce qui attend une main
|
||
|
||
Pret et derive : la zone, la machine, l'application, les 28 regles de frontiere (aucun
|
||
retrait), le trunk du commutateur — `switchport trunk allowed vlan 1,31,32,33,34,35,36,37,40`
|
||
— et le `vlan 37 / name site-publication` a declarer.
|
||
|
||
Attend une main, parce qu'aucune API du depot ne le couvre :
|
||
|
||
1. OPNsense — creer le VLAN 37 sur le parent des zones du site, l'assigner,
|
||
lui donner 10.37.37.1/24, l'activer.
|
||
2. Le commutateur — les deux lignes ci-dessus.
|
||
|
||
Ensuite seulement : `make site-creer`, `make frontiere-appliquer`, et le deploiement.
|
||
Creer la VM avant la passerelle donnerait une machine sans route — pire qu'aucune machine.
|
||
|
||
Harnais : **77 OK, 0 echec**.
|
||
|
||
## 2026-09-14 (20) — La console d'exploitation devient un service, et elle n'avait aucune serrure
|
||
|
||
Decision de l'exploitant : *« j'aimerais que chaque runner expose le GUI de Set-OPS a
|
||
travers ce edge »*. La construire a d'abord demande de mesurer ce qu'on allait publier.
|
||
|
||
### CE QUI A CHANGE LA FORME DE TOUT LE RESTE
|
||
|
||
**Le GUI n'a AUCUNE authentification.** Mesure du 2026-09-14, dans son propre code :
|
||
|
||
GET / sert la page A QUI LA DEMANDE, avec le jeton ECRIT DEDANS
|
||
POST /api/* exige ce jeton — que la page vient de donner a tout le monde
|
||
|
||
Le jeton est une garde **CSRF**, pas une serrure. La seule serrure est
|
||
`--hote 127.0.0.1` : le GUI est sur parce qu'il n'est joignable que de sa propre machine.
|
||
|
||
Ce qu'il offre a qui entre : `deployer`, `creer`, `instance-creer`, `instance-utiliser`,
|
||
`pousser`, et l'edition du plan. **La fabric entiere.** L'exposer tel quel aurait livre
|
||
un ecosysteme a quiconque trouve l'URL.
|
||
|
||
### Ce qui est construit
|
||
|
||
Le service reste sur la boucle locale, **toujours**. Ce qui est publie est un nginx local
|
||
qui authentifie D'ABORD et relaie ensuite.
|
||
|
||
| | |
|
||
|---|---|
|
||
| `setops-gui.service` | le GUI, `127.0.0.1:8765`, sous le compte `setops`, dans le venv du runner |
|
||
| nginx local | port declare au plan, `server_name` derive du plan (P67) |
|
||
| vestibule | `oidc` (oauth2-proxy -> Keycloak) **par defaut**, `locale` (HTTP Basic) en repli |
|
||
| flux | `ingress [edge, admin]` — la meme paire que Grafana et la vigie, pour la meme raison |
|
||
| sonde | `console-ops` |
|
||
|
||
`oidc` PAR DEFAUT, ET CE N'EST PAS UNE PREFERENCE. Cette console peut raser un
|
||
ecosysteme. Un mot de passe partage devant ce pouvoir est un accident qui attend ; un
|
||
groupe d'annuaire se revoque sans deploiement (D-66). `locale` existe pour un ecosysteme
|
||
qui n'a pas d'annuaire — un SITE — et c'est ecrit comme un repli, pas comme un choix.
|
||
|
||
L'AUTHENTIFICATION EST POSEE AU NIVEAU DU `server`, pas d'un `location` : placee dans le
|
||
seul bloc de relais, elle laisserait passer tout chemin qu'un `location` plus specifique
|
||
attraperait en premier. Meme geste que pour la vigie, et meme raison.
|
||
|
||
DEUX REGLAGES QUI NE SONT PAS DU CONFORT. `proxy_read_timeout` a 3600 s : un deploiement
|
||
dure, et le delai par defaut de nginx couperait la reponse au milieu — la page dirait
|
||
echec pendant que la fabric continue. `proxy_buffering off` : sans lui, la sortie d'un
|
||
deploiement arriverait d'un bloc a la fin, et la console resterait muette des minutes.
|
||
|
||
### La sonde mesure une SERRURE, ce qu'aucun greffon ne pense a faire
|
||
|
||
`console-ops` verifie deux choses, et la seconde est l'inverse d'une sonde ordinaire :
|
||
|
||
1. le GUI repond sur la boucle locale -> sinon plus personne ne pilote
|
||
2. une requete ANONYME au vestibule est REFUSEE (401/403, ou renvoi en `oidc`)
|
||
|
||
**Un 200 y est la pire des reponses.** Il veut dire que le vestibule laisse entrer. Ca ne
|
||
fait echouer personne — c'est exactement pourquoi il faut le mesurer.
|
||
|
||
### P54 a attrape une contrainte que j'avais ratee
|
||
|
||
`serveur_ops` est un role **d'insemination** : le SITE le pose sur le runner d'un
|
||
locataire qui vient de naitre, **sans detenir la voute de ce locataire**. Citer
|
||
`vault_setops_gui_admin` dans ce role rendait l'insemination impossible — le site
|
||
reclamait un secret qu'il n'a pas, et par construction ne doit pas avoir.
|
||
|
||
serveur_ops cite vault_setops_gui_admin dans roles/serveur_ops/defaults/main.yml :
|
||
le SITE ne detient pas cette voute
|
||
|
||
Le role declare donc un PARAMETRE vide, et la couche qui detient le secret est la seule a
|
||
le nommer. La garde a tenu deux fois : la seconde, le nom ne survivait plus que dans le
|
||
TEXTE d'un message d'erreur — ou il etait de toute facon faux, puisque la voute varie
|
||
selon qui publie.
|
||
|
||
### Ce qui reste
|
||
|
||
Le site n'a pas d'edge. L'exploitant vient d'en autoriser un : il publiera l'observatoire,
|
||
la vigie et la console du site sous leurs noms, en TLS. C'est la suite.
|
||
|
||
## 2026-09-14 (19) — Une console pour le site, et un repli qui ouvrait vers l'Internet
|
||
|
||
Le site calculait 88 verdicts que personne ne pouvait lire. Il a desormais sa console —
|
||
et la construire a revele un defaut du devis de frontiere qui, lui, etait deja applique.
|
||
|
||
### Un troisieme mode d'authentification : `locale`
|
||
|
||
Icinga Web 2 n'a pas d'OIDC natif. Ses deux modes existants supposent l'un un ANNUAIRE
|
||
(`ldap`), l'autre une PASSERELLE SSO (`external`). **Un SITE n'a ni l'un ni l'autre** :
|
||
ce sont des services d'ECOSYSTEME, et l'hebergeur n'en est pas un.
|
||
|
||
`locale` : nginx authentifie en HTTP Basic et pose `REMOTE_USER` ; l'application croit ce
|
||
que le serveur web lui dit — c'est exactement le contrat du backend `external`, avec un
|
||
vestibule plus simple. Ni backend d'annuaire, ni backend de groupes, ni ressource LDAP :
|
||
un backend `ldap` qui vise une ressource inexistante ne rend pas une liste vide, il fait
|
||
ECHOUER chaque ouverture de session.
|
||
|
||
C'est le meme choix que `serveur_grafana_connexion_locale` et le compte local de Forgejo
|
||
au site. **L'habilitation nomme alors une personne**, ce qui contredit D-66 (« le groupe,
|
||
jamais des personnes ») — la regle suppose un annuaire, et il n'y en a pas. C'est ecrit
|
||
dans `roles.ini.j2` plutot que contourne en silence.
|
||
|
||
`auth_basic` est pose au niveau du `server`, pas du seul bloc PHP : place la, il aurait
|
||
laisse passer tout ce que `try_files` sert directement. Une authentification qu'on
|
||
contourne par un chemin voisin n'en est pas une.
|
||
|
||
### Deux pieges rencontres en chemin
|
||
|
||
**`resoudre_annuaire` etait appele sans condition**, et tombait sur un plan sans annuaire :
|
||
|
||
object of type 'NoneType' has no len()
|
||
|
||
Un message qui ne nomme ni l'annuaire, ni le role qui le demandait, ni la raison. Deux
|
||
corrections : le consommateur ne resout plus d'annuaire quand son mode n'en a pas, et
|
||
`resoudre_annuaire` emploie `default('', true)` — le second argument dit « remplace aussi
|
||
ce qui est FAUX », c'est-a-dire `None`. Sans lui, `None` traverse et heurte `| length`.
|
||
|
||
**`serveur_icingaweb2` ne declarait son entree que pour l'`edge`.** Le site n'en a pas :
|
||
nginx ecoutait, php-fpm repondait, et le pare-feu ne laissait entrer personne.
|
||
`serveur_grafana` portait deja la reponse — *joignable depuis le plan d'administration
|
||
partout, sans quoi un deploiement sans edge n'a plus de console*. Les deux consoles de
|
||
l'observabilite avaient la meme contrainte et une seule des deux l'avait ecrite.
|
||
|
||
### LE DEFAUT DU DEVIS : un role absent traduit en « tout l'Internet »
|
||
|
||
`make frontiere-plan` proposait cinq regles. Quatre etaient attendues. La cinquieme :
|
||
|
||
+ regle opt9 tcp 636 SETOPS_SITE_SERVEUR_ICINGAWEB2 -> !SETOPS_INTERNES
|
||
|
||
`serveur_icingaweb2` declare une sortie LDAPS vers `serveur_openldap`. Le site n'en a pas.
|
||
Le devis cherchait la destination parmi les roles PRESENTS, n'en trouvait aucun, et
|
||
retombait sur `!SETOPS_INTERNES` — la forme de « vers l'Internet ». La frontiere aurait
|
||
autorise la console a parler LDAPS a **n'importe quelle machine du monde**, pour joindre
|
||
un annuaire qui n'existe pas.
|
||
|
||
C'est « une source vide ouvre le port », cote DESTINATION. Le repli est juste quand le
|
||
pair est lointain — un depot Debian, un serveur NTP. Il est faux des que le pair NOMME un
|
||
role : le flux ne parle alors pas de l'Internet, il parle d'une machine, et elle n'est pas
|
||
la. Le devis distingue desormais les deux, et le dit :
|
||
|
||
note : serveur_icingaweb2 declare une sortie vers serveur_openldap, absent de ce
|
||
site — aucune regle emise (le repli aurait ouvert le port vers l'Internet).
|
||
|
||
**ET TROIS REGLES DU MEME DEFAUT ETAIENT DEJA POSEES.** Le devis corrige les signale
|
||
perimees :
|
||
|
||
- opt9 TCP 24 SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES (vers serveur_dovecot)
|
||
- opt9 TCP 12345 SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES (vers serveur_dovecot)
|
||
- opt9 TCP 636 SETOPS_SITE_SERVEUR_POSTFIX -> !SETOPS_INTERNES (vers serveur_openldap)
|
||
|
||
Le relais de courriel du site n'a ni Dovecot ni annuaire a joindre. Ces trois regles ne
|
||
pouvaient porter aucun trafic utile — elles ne faisaient qu'elargir la frontiere.
|
||
Les retirer est un RETRECISSEMENT.
|
||
|
||
### Etat
|
||
|
||
Console deployee, verifiee sur la machine : nginx et php-fpm actifs, `401` sans
|
||
identifiants, `verify-full` vers PostgreSQL, **zero** reference LDAP dans les trois `.ini`.
|
||
`vault_icingaweb2_admin` pose dans la voute du site avec les memes gardes que la veille,
|
||
et ajoute aux quatre gabarits d'ecosysteme.
|
||
|
||
**LA FRONTIERE N'EST PAS ECRITE.** Elle porte la production, et `frontiere-appliquer`
|
||
exige `CONFIRMER=true` — ce garde-fou existe pour un humain. Le devis est lu, les quatre
|
||
ajouts et les trois retraits sont compris ; l'ecriture attend un mot.
|
||
|
||
## 2026-09-14 (18) — La supervision du SITE etait morte depuis 11:10, et rien ne le disait
|
||
|
||
L'exploitant : *« je ne vois pas de serveur icinga pour le site ? »*. Il y en a un. Il
|
||
tournait. Et il ne servait a rien.
|
||
|
||
### La panne
|
||
|
||
icingadb.service : failed depuis 11:10:27
|
||
|
||
pq: aucune entree dans pg_hba.conf pour l'hote « 10.37.36.11 »,
|
||
utilisateur « icingadb », base « icingadb », aucun chiffrement
|
||
|
||
`icinga2` tournait, `icingadb-redis` tournait, les sondes poussaient — et **rien
|
||
n'atteignait la base**. Les verdicts se calculaient dans le vide. Aucun deploiement n'a
|
||
echoue, aucun service visible n'est tombe : le moteur de supervision etait mort et
|
||
personne n'avait de quoi l'apprendre, puisque c'est precisement lui qui le dirait.
|
||
|
||
### La cause : une seconde liste, tenue a la main
|
||
|
||
`serveur_postgresql_tls_force` pose `hostssl` dans `pg_hba` — toute connexion non chiffree
|
||
refusee. Chaque consommateur avait alors SON interrupteur a allumer separement, dans les
|
||
`group_vars` de chaque ecosysteme :
|
||
|
||
| | Chezlepro | SITE |
|
||
|---|---|---|
|
||
| `serveur_postgresql_tls_force` | true | **true** |
|
||
| `serveur_icinga_db_tls` | true | *absent* |
|
||
| `serveur_keycloak_db_sslmode` | verify-full | *absent* |
|
||
|
||
Chezlepro avait les trois. Le site avait le premier. Et le commentaire de
|
||
`site_inventaire.py` cite deja cette erreur mot pour mot : le cote SERVEUR avait ete
|
||
corrige le 2026-09-12 (`reseaux_autorises`), le cote CONSOMMATEUR jamais.
|
||
|
||
`serveur_forgejo` disait pire que rien : `sslmode: "disable"` ecrit en dur. Le
|
||
consommateur affirmait le contraire de ce que le serveur imposait.
|
||
|
||
### Le remede : le consommateur suit son serveur
|
||
|
||
`resoudre_base` expose desormais `resoudre_base_db_tls_force`, lu dans les `hostvars` de
|
||
la machine qui PORTE la base. Un consommateur n'a plus a savoir qu'il doit chiffrer — il
|
||
le deduit de ce que sert son serveur. Les trois interrupteurs en derivent.
|
||
|
||
`default(false)` : un serveur qui ne declare rien ne force rien, et le consommateur reste
|
||
en clair. On ne casse pas un ecosysteme qui n'a pas bascule.
|
||
|
||
**P78** refuse qu'un consommateur porte une valeur ECRITE : elle doit deriver. Une valeur
|
||
ecrite est une seconde liste, et une liste qui suit une autre prend du retard. La preuve
|
||
nomme aussi les deux consommateurs SANS reglage TLS — `serveur_icingaweb2`,
|
||
`serveur_nextcloud` — plutot que de rendre un vert muet sur eux.
|
||
|
||
### Apres
|
||
|
||
icingadb active
|
||
connexions vues par PostgreSQL icingadb | t | TLSv1.3 | 10.37.36.11
|
||
en base 16 hotes, 88 services
|
||
|
||
Et les cinq sondes ecrites hier pour les marqueurs du site etaient **INCONNU** : Icinga
|
||
avait bien defini les services depuis leurs `meta/supervision.yml`, mais leurs roles
|
||
n'avaient pas ete redeployes, donc aucun script n'etait pose. Les cinq roles appliques,
|
||
un rapport force, et elles disent leur phrase :
|
||
|
||
site-backup-01 depot-locataires OK 2 locataire(s) etanche(s), 374 Go libres
|
||
site-cache-01 cache-site-racine OK racine sans amont, et l'index se sert
|
||
site-dns-01 resolution-locataires OK 3 supernet(s) de locataire admis
|
||
site-forge-01 genome-servi OK 6 depots servis sous « genome », aucun vide
|
||
site-ops-01 pouvoir-materialiser OK carte en place, voute chiffree, cle en 600
|
||
|
||
### Ce que la supervision, une fois vivante, a immediatement signale
|
||
|
||
site-mon-01 journaux-frontiere CRITIQUE LA FRONTIERE NE JOURNALISE PLUS
|
||
site-forge-01 / site-pki-01 / site-mon-01 sante CRITIQUE setops-verification-depot.service
|
||
|
||
Deux constats a instruire, et c'est exactement ce qu'on attend d'un moteur qu'on vient de
|
||
remettre en marche : il ne rassure pas, il rapporte.
|
||
|
||
### Ce qui manque encore, et c'est une decision
|
||
|
||
Le plan du site declare `serveur_icinga` et **pas** `serveur_icingaweb2`. Le site calcule
|
||
88 verdicts que personne ne peut LIRE. Grafana montre les series, pas les verdicts. Ajouter
|
||
la console est une machine de zero (elle se co-localise) et un nom expose de plus.
|
||
|
||
## 2026-09-14 (17) — La garde regardait un seul cote de la cloture
|
||
|
||
L'exploitant, en cherchant son tableau : *« grafana c'etait observatoire.genese.internal »*.
|
||
Il avait raison, et Grafana ne le savait pas.
|
||
|
||
### Le defaut
|
||
|
||
Le plan du site expose `observatoire.genese.internal`. `serveur_grafana` devinait
|
||
`grafana.{{ domaine_interne }}` — sa valeur par defaut. Grafana fabriquait donc son
|
||
`GF_SERVER_ROOT_URL`, ses liens d'alerte et son URL de retour OIDC avec un nom **que rien
|
||
ne sert**.
|
||
|
||
C'est MOT POUR MOT le defaut du 2026-09-10, quatre jours plus tard, de l'autre cote de la
|
||
cloture. `instancier` derive `<groupe>_hostname` de l'exposition du plan depuis ce
|
||
jour-la, et **P67** le garde. Les deux ne connaissent que l'instance MONTEE — donc un
|
||
locataire. L'inventaire du SITE, lui, n'en derivait aucun :
|
||
|
||
| groupe expose | le plan dit | le role devinait |
|
||
|---|---|---|
|
||
| `serveur_forgejo` | `forge.genese.internal` | forge… — juste par convention |
|
||
| `serveur_step_ca` | `pki.genese.internal` | pki… — juste |
|
||
| `serveur_resolveur` | `dns.genese.internal` | dns… — juste |
|
||
| `serveur_backup_site` | `sauvegarde.genese.internal` | juste |
|
||
| **`serveur_grafana`** | **`observatoire.genese.internal`** | **`grafana.…` — faux** |
|
||
|
||
Quatre devinettes sur cinq tombaient juste, et c'est precisement ce qui rend ce defaut
|
||
invisible : *tant que le plan suit la meme convention, la devinette tombe juste et
|
||
personne ne voit qu'il y a deux sources*. Le seul nom qui s'ecarte de la convention est le
|
||
seul qui ment.
|
||
|
||
### Ce qui est corrige
|
||
|
||
`site_inventaire.py` derive `<groupe>_hostname` de l'exposition unique du plan du site —
|
||
le meme geste, au meme endroit, que chez un locataire. Les cinq noms viennent maintenant
|
||
du plan.
|
||
|
||
**P67 regarde les deux inventaires.** Le site a un inventaire DYNAMIQUE : la preuve
|
||
l'EXECUTE au lieu de lire un fichier — juger sa source reviendrait a relire le
|
||
raisonnement au lieu du resultat. Eprouvee dans les deux sens : sans la derivation, elle
|
||
nomme les cinq services fautifs ; avec, elle en compte **10 sur 2 inventaires**.
|
||
|
||
Environment=GF_SERVER_DOMAIN=observatoire.genese.internal
|
||
Environment=GF_SERVER_ROOT_URL=https://observatoire.genese.internal/
|
||
|
||
### La lecon, et elle est generale
|
||
|
||
Une garde posee la ou un defaut s'est montre ne couvre pas la ou il peut se montrer aussi.
|
||
Le site et le locataire ont deux inventaires, et le depot en compte plusieurs autres qui
|
||
ne regardent qu'un des deux. C'est la meme forme que « une liste qui suit une autre prend
|
||
du retard », appliquee aux gardes elles-memes.
|
||
|
||
### Et la console etait joignable depuis le debut
|
||
|
||
Le poste porte `10.37.0.17/24`, et nftables accepte le port 3000 depuis `10.37.0.0/24`. Il
|
||
manquait une ROUTE : sans elle, le noyau envoyait les paquets a la passerelle du LAN avec
|
||
l'adresse du LAN comme source, et le pare-feu du site voyait arriver un inconnu. Forcer la
|
||
source ne sert a rien — c'est la route qui la choisit.
|
||
|
||
Les routes du poste pointaient aussi les deux locataires vers `10.17.0.1`, une passerelle
|
||
que **la frontiere ne porte pas** (elle route les tenants par `10.0.4.41` sur le vlan040).
|
||
Cinq routes vers un routeur inexistant, dont les `/16` des deux ecosystemes : c'est ce qui
|
||
donnait « No route to host » en cherchant la console de Chezlepro. Routes refaites par
|
||
l'exploitant ; les trois runners repondent.
|
||
|
||
## 2026-09-14 (16) — Le dernier maillon : l'exportateur PostgreSQL, et huit panneaux qui repondent
|
||
|
||
L'entree precedente laissait le tableau de PostgreSQL assemble, charge, et **vide** :
|
||
`serveur_postgresql` saute son exportateur tant que `vault_pg_exportateur` n'est pas dans
|
||
la voute. Il y est maintenant, et la chaine est complete de bout en bout.
|
||
|
||
### Le secret, pose avec ses gardes
|
||
|
||
`vault_pg_exportateur` etait deja au **gabarit** de voute — `scripts/voute.py lister` le
|
||
reclamait pour `serveur_postgresql`. Il manquait a la voute REELLE du site. Le gabarit
|
||
disait quoi mettre, personne ne l'avait mis : c'est exactement ce que ce gabarit existe
|
||
pour attraper, et il l'a attrape.
|
||
|
||
Quarante caracteres **alphanumeriques**, et ce n'est pas de la pruderie : ce mot de passe
|
||
entre dans une URI de connexion PostgreSQL (`DATA_SOURCE_NAME`). Un « @ », un « : » ou un
|
||
« / » y couperait l'URI en deux, et l'echec dirait « mauvais mot de passe » au lieu de
|
||
« URI mal formee ».
|
||
|
||
LES GARDES, toutes passees : copie de surete avant toute ecriture ; dechiffrement vers un
|
||
FICHIER et jamais vers un tube — `ansible-vault` ecrit sur stderr, et un tube ferme le fait
|
||
echouer en silence ; relecture du YAML avant de rechiffrer ; en-tete `$ANSIBLE_VAULT`
|
||
verifie apres ; empreinte comparee ; les quinze cles relues une a une ; copies en clair
|
||
passees au `shred`, pas au `rm`.
|
||
|
||
UN DETOUR, CORRIGE AVANT DE NUIRE. Le premier rechiffrement, fait sous
|
||
`--encrypt-vault-id`, a produit du **format 1.2 avec une etiquette** la ou la voute portait
|
||
du 1.1 sans etiquette. Le runner du site ouvre cette voute avec un unique fichier-cle : une
|
||
etiquette qu'il ne connait pas etait un risque pour rien. Rechiffree en 1.1, comme avant.
|
||
|
||
### Le compte, et ce qu'il peut
|
||
|
||
setops_metriques superuser=f createdb=f createrole=f membre de : pg_monitor
|
||
|
||
`pg_monitor` donne les vues de statistiques et **elles seules** — aucune donnee
|
||
applicative. Faire tourner un exportateur en `postgres` serait donner les cles de la base
|
||
pour lire des compteurs.
|
||
|
||
### Les huit panneaux, mesures
|
||
|
||
| tableau | panneau | ce que Prometheus rend |
|
||
|---|---|---|
|
||
| client_metrique | Memoire disponible | 10 series, 20 % a 87 % |
|
||
| | Espace libre (le plus serre) | 10 series, 28 Go a 401 Go |
|
||
| | Charge par coeur | 10 series |
|
||
| | Trafic reseau entrant | 10 series, 65 o/s a 5,4 Mo/s |
|
||
| serveur_postgresql | Connexions utilisees | 1 serie |
|
||
| | Taux de succes du cache | 1 serie |
|
||
| | Taille des bases | 4 series, 7,5 a 10,9 Mo |
|
||
| | Transactions par seconde | 1 serie |
|
||
|
||
DEUX PANNEAUX ONT MIS DEUX MINUTES A REPONDRE, et il fallait savoir pourquoi avant de
|
||
conclure. Les deux emploient `rate(...[5m])` : un taux exige au moins deux echantillons
|
||
dans la fenetre, et l'exportateur venait de naitre. La verification n'a pas attendu en
|
||
esperant — elle est allee lire les metriques BRUTES chez l'exportateur (4 series chacune)
|
||
puis dans Prometheus (4 series chacune) : les noms etaient bons, seul le temps manquait.
|
||
Un panneau vide parce qu'une metrique n'existe pas et un panneau vide parce qu'il est trop
|
||
tot se ressemblent parfaitement, et n'appellent pas le meme geste.
|
||
|
||
## 2026-09-14 (15) — Le generateur de tableaux : les panneaux declares deviennent des tableaux
|
||
|
||
Les roles declaraient leurs panneaux dans `meta/metriques.yml`, Prometheus en derivait deja
|
||
ses cibles, les expressions repondaient — et RIEN ne les assemblait. Un panneau declare que
|
||
personne ne peut regarder n'est pas une observabilite, c'est une intention.
|
||
|
||
### Ce qui a ete construit
|
||
|
||
`serveur_grafana` lit desormais les memes `meta/metriques.yml` que `serveur_prometheus`, et
|
||
en assemble **un tableau par role**. Exactement le patron du voisin : le role declare, le
|
||
moteur derive.
|
||
|
||
meta/metriques.yml --exportateur--> cible de scrutation (serveur_prometheus)
|
||
--panneaux-----> tableau Grafana (serveur_grafana)
|
||
|
||
UN TABLEAU PAR ROLE, pas un grand. La question qu'on se pose est « comment va
|
||
PostgreSQL », pas « comment va la flotte » — celle-la est deja repondue par Icinga. Le
|
||
role est l'unite qui declare, donc l'unite qui s'affiche.
|
||
|
||
LA `raison` DEVIENT LA DESCRIPTION DU PANNEAU. C'est le seul champ qui ne produit aucun
|
||
pixel, et le plus important : Grafana l'affiche au survol du titre. Sans elle, celui qui
|
||
regarde six mois plus tard voit une courbe sans savoir ce qu'elle annonce.
|
||
|
||
LA MOISSON, comme pour les sondes orphelines. Un tableau qu'aucun role ne declare plus est
|
||
retire. Un tableau orphelin est moins grave qu'une sonde orpheline — il n'echoue pas, il
|
||
MENT : il affiche « aucune donnee » pour un service disparu, et celui qui regarde croit a
|
||
une panne. Le prefixe `setops-role-` protege `journaux-flotte.json`, ecrit a la main.
|
||
|
||
LE JSON EST VALIDE AVANT D'ETRE POSE. Grafana n'echoue pas sur un tableau illisible : il
|
||
le SAUTE, en silence. Sans `validate:`, une expression mal repliee ferait perdre un tableau
|
||
sans qu'aucune tache ne devienne rouge.
|
||
|
||
### Un second rôle qui déclare, et il donne des données tout de suite
|
||
|
||
`client_metrique` declare **quatre panneaux sans exportateur** — le cas symetrique de
|
||
PostgreSQL. Le job `node` est universel et ecrit une fois dans `prometheus.yml.j2` ; le
|
||
deriver le ferait exister deux fois.
|
||
|
||
Le panneau qui compte : **memoire disponible en part du total**. Depuis que le ballon est
|
||
actif, l'hyperviseur reprend de la memoire a une VM qui n'en a pas besoin, 100 Mio par
|
||
cycle. La VM ne voit pas son plafond bouger, elle voit sa marge fondre. Aucun verdict ne
|
||
peut se poser la-dessus : il n'y a pas de seuil juste, c'est la PENTE qui dit si le
|
||
plancher a ete pris trop bas.
|
||
|
||
### Eprouve sur le site, de bout en bout
|
||
|
||
| | |
|
||
|---|---|
|
||
| fichiers assembles | `setops-role-serveur_postgresql.json`, `setops-role-client_metrique.json` |
|
||
| Grafana les a-t-il charges ? | oui — `setops-postgresql` et `setops-client-metrique` dans le stockage unifie |
|
||
| moisson | un `setops-role-serveur_fantome.json` pose a la main a ete retire, les deux autres conserves |
|
||
| les panneaux repondent-ils ? | les 4 de `client_metrique` : 10 series chacun, donnees reelles |
|
||
|
||
**Le journal ne prouvait rien.** `finished to provision dashboards` s'ecrit aussi quand
|
||
Grafana saute un fichier. La verification est allee lire ce que Grafana a VRAIMENT
|
||
enregistre — et Grafana 13 range les tableaux dans le **stockage unifie** (table
|
||
`resource`), plus dans la table `dashboard`, qui est vide et le restera. Une premiere
|
||
lecture y a vu « zero tableau » ; c'etait la mauvaise table.
|
||
|
||
### Un panneau faux, trouve en le regardant
|
||
|
||
« Espace libre — le point de montage le plus serre » rendait **0 octet** pour les trois
|
||
hyperviseurs. Le coupable : `/var/lib/lxcfs`, un systeme FUSE virtuel qui rapporte toujours
|
||
zero, et qui n'est pas en lecture seule — aucun filtre malin ne l'ecartait.
|
||
|
||
**Un `min()` est impitoyable** : un seul montage pathologique rend le panneau inutile pour
|
||
toujours, et sa courbe plate ressemble a un disque plein. La pire sorte de faux — lisible,
|
||
alarmant, et faux.
|
||
|
||
Corrige en **liste blanche** (`ext4|xfs|btrfs|zfs`) plutot qu'en liste noire. Une liste
|
||
noire suit ce qui existe, donc prend du retard. Une liste blanche ignore par defaut : un
|
||
montage inconnu MANQUE au graphe, ce qui se voit, au lieu de l'ecraser, ce qui ne se voit
|
||
pas. Apres correction : min 28 Go, max 401 Go.
|
||
|
||
### La garde
|
||
|
||
**P77** exige de chaque panneau un titre, une expression, une unite et une raison — et que
|
||
l'unite figure dans la table de traduction de `serveur_grafana`. C'est encore une liste qui
|
||
en suit une autre : une unite inventee ne casse rien, le panneau retombe sur `short`, et un
|
||
graphe d'octets gradue en unites brutes reste parfaitement lisible et parfaitement faux.
|
||
Eprouvee dans les deux sens.
|
||
|
||
`docs/supervision-conception.md` porte desormais la moitie « metriques » a cote de la
|
||
moitie « sondes ».
|
||
|
||
### Et la fiche cachait ce que le role declarait
|
||
|
||
`fiche_role.py` imbriquait le tableau des panneaux DANS la condition de l'exportateur. Le
|
||
premier role a declarer des panneaux sans exportateur affichait donc « aucun exportateur
|
||
declare » — et cachait ses quatre panneaux.
|
||
|
||
Une fiche qui tait ce qu'un role declare est pire qu'une fiche absente : elle affirme que
|
||
rien n'existe. Les deux moities sont desormais lues separement, et chacune dit ce qu'elle
|
||
trouve — y compris « series collectees, aucun panneau declare : personne ne les regarde
|
||
encore », qui est une carte de ce qui reste a faire.
|
||
|
||
### Ce qui reste
|
||
|
||
Le tableau de PostgreSQL est assemble, charge, et **vide** : l'exportateur exige
|
||
`vault_pg_exportateur` dans la voute du SITE, qui n'y est pas. Ajouter un secret a cette
|
||
voute-la n'est pas un geste a poser sans le dire.
|
||
|
||
## 2026-09-14 (14) — L'echappatoire du wiki etait documentee cinq fois et n'existait pas
|
||
|
||
En publiant les huit fiches neuves, `make wiki-publier` a refuse : l'ecosysteme monte est
|
||
`OPS-Technolibre`, qui lit son genome ailleurs et ne republie donc pas d'ici. C'est le
|
||
comportement voulu, et le message propose le remede :
|
||
|
||
Ex: make wiki-publier WIKI_REMOTE=ssh://git@forge.<domaine>/<proprio>/<depot>.wiki.git
|
||
|
||
Le remede ne marchait pas. La cible lisait
|
||
|
||
remote="$$remote"
|
||
|
||
— c'est-a-dire `$remote`, une variable de SHELL que rien ne definit — au lieu de
|
||
`$(WIKI_REMOTE)`, la variable de make. **L'option etait documentee cinq fois dans ce
|
||
Makefile**, dont dans le message d'erreur qui la proposait, et la cible ne l'a jamais lue.
|
||
|
||
CE QUI LA REND VISIBLE : `WIKI_BRANCHE`, deux cibles plus bas, est ecrit `$(WIKI_BRANCHE)`
|
||
depuis le debut. Deux options soeurs, deux ecritures. Une option qui se lit autrement que
|
||
sa voisine est l'endroit ou regarder.
|
||
|
||
Le wiki est publie sur eregion : **95 pages**, et les huit fiches neuves portent bien leur
|
||
sonde — verifie en clonant la forge, pas en lisant le temoin.
|
||
|
||
## 2026-09-14 (13) — Les huit derniers roles sans sonde, et deux defauts que l'epreuve a trouves
|
||
|
||
Les 33 roles `serveur_*` declarent desormais une supervision. Les huit qui manquaient
|
||
n'avaient pas ete oublies par hasard : ce sont ceux dont la verite ne ressemble pas a
|
||
« ce service repond-il ».
|
||
|
||
### Quatre marqueurs du site, quatre verites qu'aucun installateur ne voit
|
||
|
||
Un marqueur n'installe rien. Il dit qu'une machine rend un service AUX LOCATAIRES, et
|
||
`tasks/main.yml` le verifie — une fois, au deploiement. Ce qu'il verifie cesse d'etre vrai
|
||
sans que rien ne tombe.
|
||
|
||
| role | sonde | ce qui se degrade en silence |
|
||
|---|---|---|
|
||
| `serveur_cache_site` | `cache-site-racine` | la racine se retrouve chainee, ou ne remplit plus |
|
||
| `serveur_forge_site` | `genome-servi` | la forge repond et n'a plus rien dedans |
|
||
| `serveur_resolveur_site` | `resolution-locataires` | un locataire n'est plus admis a resoudre |
|
||
| `serveur_backup_site` | `depot-locataires` | l'isolation glisse, ou la place manque |
|
||
|
||
`genome-servi` mesure exactement la panne du 2026-09-14 : le wiki du site avait **zero
|
||
commit** apres une reconstruction, et la forge etait verte tout du long. Un depot vide ne
|
||
fait pas echouer un clone — il rend un arbre sans fichiers.
|
||
|
||
### Les quatre autres
|
||
|
||
`serveur_ops_site` ne sert rien : il DETIENT un pouvoir. `pouvoir-materialiser` verifie que
|
||
la carte de la fabric est la, que la voute du site est **chiffree** et que sa cle est en
|
||
0600 — sans jamais lire le contenu d'aucun des trois. Une voute dechiffree en transit ne
|
||
fait echouer aucun deploiement : Ansible la lit tres bien, elle expose simplement tout.
|
||
|
||
`serveur_icingaweb2` porte la sonde la plus retorse du dispositif : **la supervision
|
||
surveille sa propre vitrine**. Si la console meurt, le moteur collecte toujours, les
|
||
verdicts restent verts, et l'exploitant est aveugle.
|
||
|
||
`serveur_web_frontal` et `serveur_web_dorsal` frappent chaque site et chaque app declares.
|
||
Une webapp ne tombe presque jamais en `failed` — elle se coince : le processus vit, systemd
|
||
la dit active, et plus une requete n'aboutit. Le rapport d'unites en echec ne verra jamais
|
||
ca.
|
||
|
||
### DEFAUT 1 — quatre gabarits qu'Ansible aurait refuse de rendre
|
||
|
||
`${#tableau[@]}` est la seule facon d'obtenir la longueur d'un tableau en bash. Elle
|
||
contient `{#`, que Jinja lit comme un **debut de commentaire** :
|
||
|
||
TemplateSyntaxError: Missing end of comment tag
|
||
|
||
Rien ne le signalait : le fichier est valide en shell, valide a la lecture, et
|
||
`--syntax-check` ne rend pas les gabarits. La panne serait arrivee sur la machine, pendant
|
||
un deploiement.
|
||
|
||
Le depot connaissait deja le remede — une en-tete `#jinja2:` qui deplace le delimiteur — et
|
||
l'appliquait **trois fois**, exactement la ou quelqu'un s'etait fait prendre. Nulle part
|
||
ailleurs. Quatre sondes neuves l'ont refait d'un coup.
|
||
|
||
**P76 est la garde qui manquait** : elle REND chaque gabarit de role, avec les memes
|
||
delimiteurs qu'Ansible en tirerait. Pas une recherche de motif — un rendu, qui attrapera
|
||
aussi le prochain piege, quel qu'il soit.
|
||
|
||
### DEFAUT 2 — la doctrine promettait des greffons que la flotte n'a pas
|
||
|
||
`docs/supervision-conception.md` annoncait « le paquet en fournit **54** » et citait
|
||
`check_pgsql`. Mesure du 2026-09-14 : `client_sante` installe
|
||
`monitoring-plugins-basic` — **53** greffons, et ni `check_pgsql`, ni `check_dns`, ni
|
||
`check_ldap` n'en font partie. Ils sont dans `monitoring-plugins-standard`, qui traine
|
||
**samba, smbclient et une pile SNMP** sur chaque machine de la flotte.
|
||
|
||
La premiere ecriture de `resolution-locataires` appelait `check_dns`. Elle sortait en
|
||
**127** — qui n'est pas un code Nagios : la sonde serait devenue illisible au lieu
|
||
d'echouer proprement. Elle emploie `dig`, comme la sonde voisine le faisait deja.
|
||
|
||
Le paquet n'est pas ajoute : le durcissement dit le contraire d'installer Samba partout
|
||
pour trois greffons. C'est le document qui est corrige.
|
||
|
||
### Un defaut de plus, trouve en eprouvant
|
||
|
||
`genome-servi` interrogeait d'abord `/api/v1/repos/search?owner=<org>`. Avec
|
||
`owner=organisation-qui-nexiste-pas`, elle rendait **les six depots de la forge** et sortait
|
||
VERTE : le parametre est ignore. Elle n'aurait jamais mesure le genome, seulement « cette
|
||
forge a des depots ». La route `/api/v1/orgs/<org>/repos`, elle, rend 404.
|
||
|
||
### Controles negatifs
|
||
|
||
Chaque sonde a ete mise en defaut sur la machine reelle avant d'etre ecrite dans son role —
|
||
21 cas au total, sur `site-cache-01`, `site-forge-01`, `site-dns-01`, `site-backup-01` et
|
||
`site-ops-01`. Les huit gabarits rendent et passent `bash -n`. `ansible-lint` profil
|
||
production : 0 sur 91 fichiers. Harnais : **75 OK, 0 echec**.
|
||
|
||
## 2026-09-14 (12) — La decision appliquee a TOUTE la flotte : une machine de moins par ecosysteme
|
||
|
||
L'entree precedente prend la decision. Celle-ci la pose partout, sur ordre de
|
||
l'exploitant : *« meme principe pour tous les tenants et pour tous les modeles, sauf celui
|
||
qui doit avoir une forge »*.
|
||
|
||
### Ce qui est parti
|
||
|
||
| plan | avant | apres | ce qui est retire |
|
||
|---|---|---|---|
|
||
| `OPS-Technolibre` | 14 machines | **13** | `forge-01` |
|
||
| `OPS-Chezlepro` | 14 machines | **13** | `forge-01` |
|
||
| `OPS-Chezlepro-lab` | 15 machines | **14** | `forge-01` |
|
||
| `Modeles/origine` | 5 machines | **4** | `forge-01`, et `serveur_artefacts` avec elle |
|
||
| `Modeles/identite` | 8 | 8 | `serveur_artefacts` (colocalise, pas de VM) |
|
||
| `Modeles/observabilite` | 8 | 8 | `serveur_artefacts` |
|
||
| `Modeles/collaboration` | 9 | 9 | `serveur_artefacts` |
|
||
| `Modeles/presence-web` | 8 | 8 | `serveur_artefacts` |
|
||
|
||
Retirer un service n'est jamais une ligne : c'est **cinq points d'attache**. Le service
|
||
dans `applications.yml`, sa machine dans `serveurs.yml`, sa base dans `bases-donnees.yml`,
|
||
son client SSO dans `group_vars/serveur_keycloak.yml`, et sa configuration de role. En
|
||
oublier un laisse un inventaire qui se genere et un deploiement qui echoue plus tard, sur
|
||
une machine qui n'existe plus.
|
||
|
||
`origine` compte doublement : **tout ecosysteme neuf en descend**. Y laisser ces deux
|
||
services les ferait renaitre dans chaque enfant.
|
||
|
||
### Les deux modeles qui gardent leur forge, et pourquoi c'est ecrit
|
||
|
||
`forge` et `integral` la gardent. Ce n'est pas une exception concedee, c'est une fonction
|
||
differente : ils vendent une forge au **code des gens** — l'offre Atelier. La raison est
|
||
desormais dans leur plan, pas dans la tete de celui qui l'a decidee.
|
||
|
||
### Ce que ca vaut
|
||
|
||
Par ecosysteme : une VM de 80 Go a sauvegarder, superviser, durcir et reconstruire ; une
|
||
organisation Forgejo ; ses depots ; un mot de passe d'admin en voute ; un miroir Debian a
|
||
tenir a jour. Pour republier ce que le locataire vient tout juste de lire chez son site.
|
||
|
||
Preuve statique apres coup : **74 OK, 0 echec**.
|
||
|
||
### Reste a trancher
|
||
|
||
`OPS-Patient0` n'est pas touche. Il est enregistre comme *« l'ecosysteme d'origine,
|
||
detenteur du genome — pas un locataire ordinaire »*, et le genome de la flotte y vit.
|
||
Retirer sa forge est une decision d'un autre ordre que les autres.
|
||
|
||
Et les VM `forge-01` de Chezlepro et de TechnoLibre **tournent encore**. Le plan ne les
|
||
declare plus ; l'hyperviseur les porte toujours. Raser une machine est une action
|
||
destructive : elle attend un ordre explicite.
|
||
|
||
## 2026-09-14 (11) — Le wiki suit le genome, et un locataire n'en est pas depositaire
|
||
|
||
Decision de l'exploitant : **seuls les SITES portent la forge, le cache APT et les
|
||
artefacts.** Un locataire n'a pas besoin de sa propre forge pour le moteur — il le lit
|
||
chez son hebergeur, comme il y prend ses paquets et son gabarit.
|
||
|
||
C'est le meme geste que le retrait de `serveur_artefacts` du plan d'un locataire, un cran
|
||
plus loin, et la meme phrase le porte : *le site fournit tout ce dont un tenant a besoin
|
||
pour venir au monde*.
|
||
|
||
### Ce que la soiree avait deja chiffre
|
||
|
||
Une forge par locataire, c'est une organisation, des depots, un mot de passe d'admin en
|
||
voute, un mecanisme d'amorcage, un wiki, un miroir a tenir synchrone, et une preuve qu'il
|
||
ne derive pas. Mesure du 2026-09-14 sur TechnoLibre : forge servie en `https=200`, cle du
|
||
runner acceptee, **zero depot**. La chaine n'existait pas — pour UN locataire.
|
||
|
||
### La distinction qui reste vraie
|
||
|
||
« Un locataire n'a pas besoin d'une forge **pour le genome** » n'est pas « un locataire n'a
|
||
jamais de forge ». L'offre **Atelier** vend precisement une forge a des equipes qui
|
||
developpent : depots, tickets, revues, pour LEUR code. Ce n'est simplement pas l'endroit ou
|
||
vit le moteur.
|
||
|
||
### Ce qui a ete defait, et pourquoi
|
||
|
||
La generalisation de `forge_amorcer.py` a un locataire est **revenue en arriere** : elle
|
||
implementait le modele ecarte. La garder en ferait du code mort au mieux, un piege au pire
|
||
— quelqu'un finirait par le lancer.
|
||
|
||
`ma_forge.py` change de regle : il ne derive plus « MA forge » mais **la forge qui porte
|
||
mon genome**. Le signal est deja au plan — `serveur_ops_forge_externe: true` dit « je lis
|
||
mon genome ailleurs ». Un ecosysteme qui le declare ne publie pas : il consulte, la ou le
|
||
genome vit.
|
||
|
||
OPS-Technolibre -> refus : lit son genome sur 10.37.33.11
|
||
SITE-Chezlepro -> forge.genese.internal
|
||
|
||
## 2026-09-14 (10) — Le temoin du wiki pointait une forge VIDE
|
||
|
||
En publiant les fiches de role, `make wiki-publier` a refuse : le wiki vise par le temoin
|
||
— `forge.genese.internal`, la forge du SITE — n'a **aucun commit**.
|
||
|
||
Refus: ce wiki est VIDE (aucun commit).
|
||
|
||
Mesure des deux forges :
|
||
|
||
| forge | pages | branche |
|
||
|---|---|---|
|
||
| `eregion.chezlepro.ca` | **26** | `main` |
|
||
| `forge.genese.internal` | **0** | — |
|
||
|
||
Le wiki vivant est sur eregion. Le temoin, lui, affirmait depuis le 2026-09-12 avoir
|
||
publie sur la forge du site — dont le depot wiki n'a pas survecu a sa reconstruction.
|
||
|
||
**C'est exactement ce que P60 annonce d'elle-meme** : *« un temoin dit ce qui est PARTI,
|
||
jamais ce qui est ARRIVE »*. La preuve etait verte, la documentation n'existait nulle part
|
||
a l'adresse qu'elle nommait. Elle avait raison sur ce qu'elle mesurait, et sa limite etait
|
||
ecrite — encore fallait-il aller chercher.
|
||
|
||
**Le garde-fou a tenu.** Publier sur un wiki vide aurait invente un nom de branche —
|
||
`master` depuis ce poste, alors que le wiki en service vit sur `main`. Deux wikis, deux
|
||
branches, et une divergence silencieuse de plus.
|
||
|
||
Publie sur eregion : **95 pages**, dont les 68 fiches et leur index. Le temoin nomme
|
||
desormais la bonne forge.
|
||
|
||
## 2026-09-14 (9) — Une fiche par role, generee, et publiee au wiki
|
||
|
||
Soixante-huit roles, soixante-huit fiches : qui lui parle, ce qu'il rend a la supervision,
|
||
ce qu'il expose en series, ce qu'il coute, qui entre. Un schema **mermaid** en tete, puis
|
||
les tableaux — et chaque ligne porte la **raison** que le role a declaree.
|
||
|
||
**Generees, jamais ecrites.** Soixante-huit pages redigees a la main seraient perimees
|
||
avant la fin du mois : c'est « une liste qui suit une autre prend du retard », et une
|
||
soixante-neuvieme liste n'y echapperait pas. `make fiches` les relit depuis `meta/flux`,
|
||
`supervision`, `metriques`, `empreinte`, `authentification`.
|
||
|
||
**Une section vide est une information.** Un role sans sonde l'affiche. L'index `Rôles`
|
||
recompte a chaque generation ce que la flotte ne declare pas encore : **36** sans flux
|
||
entrant, **40** sans sonde, **67** sans metrique. La carte de ce qui reste, tenue a jour
|
||
toute seule.
|
||
|
||
### Elles vivent au wiki, et la charte a du etre amendee
|
||
|
||
`Home.md` disait : « le detail du comment vit dans le depot — ce wiki y POINTE, ne le
|
||
RECOPIE pas (pour eviter la derive) ». La regle reste juste ; son MOTIF ne s'applique pas
|
||
a une page qui relit sa source a chaque generation. L'exception est donc nommee dans la
|
||
charte plutot que prise en silence.
|
||
|
||
### Trois gardes ont mordu, et toutes avaient raison
|
||
|
||
**Un script qu'aucune cible n'appelle est du code mort.** Le premier commit est parti sans
|
||
`make fiches` ; la preuve l'a vu, pas moi.
|
||
|
||
**La navigation exigeait une citation DIRECTE.** Son propre motif dit pourtant « n'est lue
|
||
par personne » — or une page citee par une page citee EST lue. L'exigence litterale
|
||
interdisait toute page d'index : citer les soixante-huit fiches dans la navigation en
|
||
ferait un mur ou plus personne ne trouverait les vingt-sept unites redigees. **La garde
|
||
forcait a degrader ce qu'elle protegeait.** Elle suit desormais les liens de proche en
|
||
proche. Eprouvee dans les deux sens : coupe le lien depuis `Home`, elle refuse les 69.
|
||
|
||
Au passage, son motif de lien ne contenait pas `_` : un lien vers `Rôle-serveur_postgresql`
|
||
n'etait NI suivi, NI signale casse. **Un motif trop etroit ne rend pas une garde prudente,
|
||
il la rend aveugle.**
|
||
|
||
**Le compte du wiki serait passe de 27 a 96.** Une fiche generee n'est pas une unite
|
||
d'apprentissage — celles-la suivent le moule en quatre temps. Le nombre aurait ete exact
|
||
et l'affirmation fausse.
|
||
|
||
## 2026-09-14 (8) — La chaine des metriques, de la declaration au graphe
|
||
|
||
Premiere verticale complete du second versant, eprouvee sur TechnoLibre.
|
||
|
||
| etape | ce qui a ete verifie |
|
||
|---|---|
|
||
| declaration | `meta/metriques.yml` : exportateur `postgresql`, port 9187, quatre panneaux |
|
||
| flux | `meta/flux.yml` ouvre 9187 **depuis l'observatoire seul** |
|
||
| installation | `prometheus-postgres-exporter` actif, compte `pg_monitor` en lecture seule |
|
||
| exposition | **657 series** servies, valeurs reelles par base |
|
||
| derivation | Prometheus ecrit de lui-meme `job_name: postgresql` / `["10.23.18.11:9187"]` |
|
||
| scrutation | cible **up** |
|
||
| interrogation | `sum(pg_stat_activity_count)` = **11** · cache = **0,9982** · bases = **84 Mo** |
|
||
|
||
Le crochet `serveur_prometheus_cibles_supplementaires` n'est plus vide pour la premiere
|
||
fois : un role declare, le moteur derive. Les cibles ecrites au plan le completent
|
||
toujours — un equipement tiers n'a aucun role Set-OPS pour se declarer.
|
||
|
||
### Ce que la voute a rappelé au passage
|
||
|
||
Le runner a d'abord saute l'exportateur sans rien signaler : `changed=0`. Sa voute datait,
|
||
parce que `vault.yml` est **gitignore** — aucun secret ne transite par la forge. Elle
|
||
n'arrive chez un runner que par `serveur_ops_tenant`, depuis le poste qui la detient.
|
||
|
||
Ce n'etait pas un defaut : c'est la ligne de partage du pouvoir qui se rappelle a nous. Le
|
||
geste d'armement n'est pas une formalite d'installation, c'est le seul transport de secret
|
||
du systeme — et il se refait a chaque fois qu'un secret change.
|
||
|
||
### Et une garde qui a servi
|
||
|
||
L'ecriture dans la voute a d'abord **pendu** : `ANSIBLE_VAULT_IDENTITY_LIST` n'etait pas
|
||
dans le shell (le Makefile l'exporte, pas un appel direct), donc `ansible-vault` attendait
|
||
une saisie qui ne venait jamais. Le filet a tenu : le clair faisait **0 octet**, la voute
|
||
etait inchangee, et la comparaison d'empreinte l'a dit avant qu'on ne croie au succes.
|
||
|
||
empreinte CHANGEE a403fd82165b0e0c -> 1577e3c1439a5aa9
|
||
33 cles -> 34
|
||
|
||
## 2026-09-14 (7) — `meta/metriques.yml` : le second versant de la supervision
|
||
|
||
Aucune metrique de SERVICE n'etait collectee. Prometheus ne scrutait que les
|
||
`node_exporter` : du systeme, et rien de PostgreSQL, de l'annuaire, des boites ou du
|
||
cache. Le crochet existait pourtant —
|
||
`serveur_prometheus_cibles_supplementaires`, documente, et que **personne ne remplissait**.
|
||
|
||
### Le pendant de `meta/supervision.yml`, et son contraire
|
||
|
||
| fichier | ce qu'il declare | la question |
|
||
|---|---|---|
|
||
| `supervision.yml` | une **sonde** rend un verdict avec un TTL | *est-ce casse ?* |
|
||
| `metriques.yml` | un **exportateur** expose une serie | *depuis quand, et vers ou ?* |
|
||
|
||
Ce n'est pas une frontiere inventee : `docs/supervision-conception.md` la pose deja dans
|
||
l'autre sens — « une metrique a seuil appartient a Prometheus et Grafana ». Ce fichier est
|
||
l'autre moitie de cette phrase.
|
||
|
||
### Le critere qui choisit les panneaux
|
||
|
||
**Une serie a sa place ici si elle PRECEDE un verdict, ou si elle n'en aura JAMAIS.**
|
||
|
||
Le taux de succes du cache n'aura jamais de verdict, et c'est pourquoi il compte : quand
|
||
les donnees depassent `shared_buffers`, la base va chercher sur disque de plus en plus
|
||
souvent. Rien ne casse, rien n'alerte, tout devient lent. C'est la panne qu'un graphe voit
|
||
et qu'une sonde ne verra jamais.
|
||
|
||
### Ce que le moteur derive, et ce qu'il ne derive pas
|
||
|
||
Il derive la **cible de scrutation** — le nom du dossier du role est le nom du GROUPE,
|
||
donc les cibles sont ses hotes actifs. Un role declare sans hote ne produit aucun job.
|
||
|
||
Il ne derive **pas le flux** : le port 9187 s'ouvre par `meta/flux.yml`, la ou vivent deja
|
||
tous les flux de ce role. Deux fichiers pour un meme fait finissent par diverger.
|
||
|
||
### Deux choses dites plutot que tues
|
||
|
||
**Le compte de metriques est en lecture seule** (`pg_monitor`), et ne lit que les vues de
|
||
statistiques — pas une ligne de donnee applicative. Faire tourner l'exportateur en
|
||
`postgres` serait donner les cles de la base pour lire des compteurs.
|
||
|
||
**Le flux est en CLAIR, et c'est une dette inscrite au fichier.** `client_metrique` sert
|
||
deja ses metriques en TLS ; celui-ci pas encore. La dette est ecrite dans la `raison` du
|
||
flux, avec son remede — `--web.config.file` + `client_pki`.
|
||
|
||
## 2026-09-14 (6) — Le site avait l'orchestration, pas le moyen de la lancer
|
||
|
||
`playbooks/site.yml` est genere par `orchestrer.py` et ordonne les couches pour
|
||
**n'importe quelle** instance — son en-tete le dit. Mais `deployer-tout` ne vise que
|
||
l'inventaire d'un LOCATAIRE, et le site n'avait que `site-appliquer GROUPE=<un seul>`.
|
||
|
||
Le site ne se deployait donc que **groupe par groupe, a la main**, dans un ordre qu'il
|
||
fallait se rappeler. Un hebergeur qu'on ne peut remonter qu'en enchainant onze groupes de
|
||
memoire n'est pas reconstructible : il est reparable par quelqu'un qui se souvient. C'est
|
||
nommement l'une des trois limites du jalon de reconstruction autonome — *« le SITE jamais
|
||
reconstruit »*.
|
||
|
||
`site-deployer-tout` comble le manque. Trois differences avec son equivalent locataire,
|
||
aucune cosmetique : l'inventaire est un **script** (un site se derive de son underlay, il
|
||
ne se fige pas dans un `hosts.yml`), la voute vit **a cote de la carte**
|
||
(`underlay.vault.yml`, hors depot), et le perimetre reste `hotes_actifs` — les
|
||
hyperviseurs et la frontiere sont dans l'inventaire mais ne se deploient pas.
|
||
|
||
### Quatre gardes que `--check` rendait folles
|
||
|
||
Le Makefile CONSEILLE l'essai a blanc — *« tester d'abord en idempotent »*. Suivre ce
|
||
conseil rendait **sept machines en echec** sur un site parfaitement sain. Quatre causes,
|
||
une seule famille : **une garde qui compare contre ce qu'une tache du meme play vient de
|
||
produire n'a rien a dire tant que rien n'a ete ecrit.**
|
||
|
||
| role | ce que `--check` cassait | remede |
|
||
|---|---|---|
|
||
| `hosts_statiques` | la lecture des deux fichiers etait sautee : `ne portent pas le meme nombre d'entrees ()` | `check_mode: false` sur la lecture |
|
||
| `hosts_statiques` | l'assertion comparait l'AVANT a l'APRES : `/etc/hosts=13 \| tmpl=2` | `not ansible_check_mode` sur l'assertion |
|
||
| `serveur_icinga` | le telechargement simule, l'installation cherchait un fichier absent | `check_mode: false` sur `get_url` |
|
||
| `serveur_ops` | la version du controleur non lue : `Le controleur tourne Python ` | `check_mode: false` sur la lecture |
|
||
|
||
**Les parentheses vides et l'espace apres « Python » sont tout le diagnostic** : il n'y
|
||
avait rien a comparer. Un essai a blanc qui ment est pire qu'aucun — il apprend a ne plus
|
||
le lancer.
|
||
|
||
Apres correction : **7/7, 0 echec**, de 174 a 309 taches par machine.
|
||
|
||
## 2026-09-14 (5) — Le site ne savait pas se configurer lui-meme
|
||
|
||
Parti pour eprouver une sonde, arrive sur un defaut structurel : **le runner du SITE ne
|
||
pouvait entrer sur aucune machine du site**. Il sait inseminer un locataire — c'est prouve
|
||
— et il ne savait pas configurer l'hebergeur qui le porte. Tout le site avait donc ete
|
||
deploye depuis le poste de l'exploitant, et rien ne disait que c'etait la seule voie.
|
||
|
||
### Trois causes empilees, et un message qui accusait la mauvaise
|
||
|
||
1. **Le site n'avait aucun moyen d'autoriser une cle d'administration.** Un locataire
|
||
declare `ssh_baseline_cles_admin` ; cote site, rien ne transmettait cette liste. Ses
|
||
machines n'autorisaient que la cle posee par cloud-init.
|
||
|
||
2. **Le rebond etait pose sans condition**, y compris pour un controleur vivant DANS le
|
||
site. Il devait alors s'authentifier aupres de la frontiere, ou sa cle n'est pas
|
||
autorisee. OpenSSH rend alors :
|
||
|
||
Host key verification failed.
|
||
Connection closed by UNKNOWN port 65535
|
||
|
||
— un message qui accuse les cles d'HOTE alors que l'echec est une AUTHENTIFICATION, et
|
||
sur le SAUTEUR, pas sur la cible.
|
||
|
||
3. **`cle_ssh` designe le chemin de la cle SUR LE POSTE.** Impose au runner, il nomme un
|
||
fichier qui n'existe pas chez lui.
|
||
|
||
Le rebond et la cle decrivent tous deux **comment on arrive**, pas ce vers quoi on va. Ce
|
||
qui depend de l'endroit d'ou l'on part n'a pas sa place dans la description d'un site.
|
||
|
||
### La faute que j'ai ecrite en corrigeant, et qui merite d'etre gardee
|
||
|
||
La fonction qui repond « suis-je une machine du site » appelait `underlay_mod` — un nom
|
||
qui n'existe pas dans ce fichier, le module y etant importe `as U`. Python levait un
|
||
`AttributeError` a chaque appel, et le `except Exception: return False` le rendait **muet**.
|
||
|
||
La fonction repondait donc toujours *non*, le rebond etait toujours pose, et rien ne le
|
||
disait. **Une garde qui se tait ne garde rien** — le defaut exact que ce depot traque
|
||
ailleurs, ecrit ici. Le `except` ne couvre plus qu'un plan illisible ; une faute de
|
||
programmation remonte.
|
||
|
||
Le critere lui-meme a demande trois formulations : « dans un RESEAU du site » (faux, le
|
||
poste porte `10.37.0.17`), « dans `underlay.hotes` » (faux, `hotes` ne contient que les
|
||
equipements), puis **par le NOM** — le runner s'appelle `site-ops-01`, une cle du plan.
|
||
|
||
### L'amorcage est circulaire, et il se rompt par le poste
|
||
|
||
Lui seul entrait : il pose la cle, le runner devient autonome. Verifie apres
|
||
`make site-appliquer GROUPE=serveur_debian` — **7/7, 0 echec** — puis **5/5** machines
|
||
atteintes par le runner, qui a ensuite configure `site-backup-01` lui-meme.
|
||
|
||
### Et la sonde, enfin prouvee
|
||
|
||
| | verdict | code |
|
||
|---|---|---|
|
||
| depot sain | `accepte l'ecriture — 4 depot(s), 374G libres` | **0** |
|
||
| chemin non inscriptible | `refuse l'ecriture pour restic` | **2** |
|
||
| chemin inexistant | `n'existe pas` | **2** |
|
||
|
||
Le vrai depot n'a pas bouge : zero fichier temoin residuel.
|
||
|
||
## 2026-09-14 (4) — Les greffons standard manquaient a la flotte
|
||
|
||
`docs/supervision-conception.md` dit qu'un greffon Nagios **est** une sonde valide, « sans
|
||
la moindre colle », et compte sur les **54** que fournit `monitoring-plugins`. Mesure du
|
||
2026-09-14 sur une machine de la flotte :
|
||
|
||
check_http : ABSENT
|
||
|
||
Aucun des 54 n'etait installe. **Le document decrivait une possibilite qui n'existait
|
||
pas** — et chaque role ayant besoin d'un controle HTTP n'avait d'autre choix que d'ecrire
|
||
du shell, precisement ce que la doctrine refuse.
|
||
|
||
### Pose par le PORTEUR, pas par chaque role
|
||
|
||
C'est l'affaire de `client_sante` de pouvoir executer des sondes. Les poser role par role
|
||
en ferait autant de copies de la meme decision, et laisserait sans greffon les machines
|
||
dont aucun role n'en reclame — alors qu'elles en auront besoin le jour ou on leur en
|
||
ajoute un. 1,2 Mo par machine.
|
||
|
||
### Trois sondes, trois enveloppes
|
||
|
||
| role | sonde | ce qu'elle voit |
|
||
|---|---|---|
|
||
| `serveur_collabora` | `edition` | `/hosting/discovery` est l'adresse que **Nextcloud interroge lui-meme** pour savoir quels documents Collabora sait ouvrir. Sans elle, le bouton « ouvrir » meurt pour tout le monde — et Nextcloud reste vert. |
|
||
| `serveur_rspamd` | `filtrage` | Un filtre muet ne bloque pas le courrier : **il le laisse passer**. Postfix sans verdict delivre sans filtrer ou differe, et le service a l'air sain pendant que le pourriel entre. |
|
||
| `serveur_oauth2_proxy` | `passerelle` | Ce qui tombe avec elle n'est pas elle. Les services derriere **restent debout et deviennent injoignables** : on cherche la panne du mauvais cote. |
|
||
|
||
Chacune delegue a `check_http` et rend son code tel quel. Chacune exige une **chaine** que
|
||
seul le bon service produit — un `200` peut venir d'une page d'erreur ou d'un cache perime.
|
||
|
||
### Le controle negatif, rejoue sur TechnoLibre
|
||
|
||
| | `edition` | `filtrage` | `passerelle` |
|
||
|---|---|---|---|
|
||
| sain | **0** | **0** | **0** |
|
||
| chaine introuvable | **2** | **2** | **2** |
|
||
|
||
La chaine attendue est la mise en defaut : la remplacer rend CRITIQUE sans rien casser.
|
||
|
||
Couverture : **24 roles `serveur_*` sur 33** declarent leur supervision (21 avant ce lot).
|
||
|
||
## 2026-09-14 (3) — La sonde de Redis, et son controle negatif rejoue
|
||
|
||
Premier des **treize** roles `serveur_*` qui ne declaraient aucune supervision. Vingt sur
|
||
trente-trois en avaient une ; celui-ci n'en avait pas, alors qu'il porte les sessions.
|
||
|
||
### La panne qu'elle voit, et que rien ne voyait
|
||
|
||
Borne a `maxmemory` avec `allkeys-lru`, un Redis plein **n'echoue jamais** : il EVICTE.
|
||
Les sessions disparaissent une a une, les gens sont deconnectes au hasard, et le service
|
||
reste vert. La panne se presente comme un defaut d'application — personne ne regarde le
|
||
cache, puisqu'il va bien.
|
||
|
||
**Une seule sonde**, parce qu'il n'y a qu'une cause d'action : *le cache ne sert plus*.
|
||
Elle s'atteint par deux chemins, et le verdict appelle le meme geste.
|
||
|
||
Le compteur d'evictions de Redis est CUMULATIF depuis le demarrage ; la sonde en fait un
|
||
**debit** entre deux passages, sinon une seule mauvaise journee laisserait le voyant rouge
|
||
pour toujours.
|
||
|
||
### Le controle negatif, rejoue sur `data-sql-01` de TechnoLibre
|
||
|
||
| | verdict | code |
|
||
|---|---|---|
|
||
| etat sain, premier passage | `Cache sain — premier releve` | **0** |
|
||
| etat sain, second passage | `Cache sain — 0 eviction(s)` | **0** |
|
||
| Redis arrete | `Redis n'est pas actif.` | **2** |
|
||
| seuil impossible (`CRIT=0`) | `Redis evicte : … des sessions se perdent en silence.` | **2** |
|
||
|
||
Les donnees de performance sortent au format Nagios et portent le vrai plafond :
|
||
`evictions=0c memoire=808232B;;;0;268435456`.
|
||
|
||
`serveur_redis_sonde_evictions_crit` est la mise en defaut **par parametre** — le poser a
|
||
`0` rend CRITIQUE sans rien casser, ce qui rend la seconde preuve REJOUABLE. C'est le meme
|
||
patron que les seuils de PostgreSQL.
|
||
|
||
### Ce qui n'est PAS dans cette sonde, et c'est la doctrine
|
||
|
||
Occupation memoire, taux de succes du cache, latence : ce sont des **series**, elles
|
||
appartiennent a Prometheus. Icinga repond a une seule question — est-ce casse ?
|
||
|
||
## 2026-09-14 (2) — Redis EST borne : le releve d'hier lisait le mauvais fichier
|
||
|
||
L'entree du 2026-09-13 (6) affirmait « aucun `maxmemory` configure ». C'etait faux, et la
|
||
faute est de METHODE : le `grep` visait `/etc/redis/redis.conf`, alors que le role ecrit
|
||
dans `/etc/redis/setops.conf`. Interroge la ou la verite vit — `redis-cli config get` —
|
||
Redis repond :
|
||
|
||
maxmemory 268435456 (256 Mo)
|
||
maxmemory-policy allkeys-lru
|
||
|
||
**La conclusion ne change pas, sa raison si.** Redis reste hors de la table des planchers,
|
||
non plus parce qu'il serait sans limite, mais parce qu'il est BORNE a 256 Mo : il ne peut
|
||
pas prendre la machine, et lui epingler plusieurs gigaoctets serait absurde.
|
||
|
||
Le commentaire porte desormais le bon seuil de vigilance : le jour ou ce plafond montera,
|
||
le plancher devra le suivre — et c'est le `maxmemory` EFFECTIF qu'il faudra lire.
|
||
|
||
**La lecon est celle de la veille, appliquee a moi-meme :** verifier d'ou l'instrument
|
||
mesure. Un fichier de configuration n'est pas la configuration ; c'est une de ses sources.
|
||
|
||
## 2026-09-13 (10) — Un runner TIRE ce qu'on pousse ; il ne le recoit pas
|
||
|
||
Trois fois dans une meme soiree, un genome perime a menace de rebatir un etat depasse :
|
||
|
||
- **le runner du SITE**, treize commits en retard, s'appretait a materialiser un locataire
|
||
avec son ancien plan — serveur de sauvegarde inutile, cache d'artefacts en trop, runner
|
||
sans pouvoir de configurer, et aucune machine avec son plancher de memoire ;
|
||
- **le runner du LOCATAIRE**, clone pendant l'insemination donc avant trois correctifs,
|
||
s'appretait a reposer un plan d'administration pointant le WAN d'un AUTRE site — et a
|
||
refermer derriere lui la porte que l'exploitant venait tout juste de rouvrir.
|
||
|
||
### Pourquoi c'est le pire mode de defaillance de cette famille
|
||
|
||
**Aucun des deux n'aurait echoue.** Un deploiement depuis un genome perime REUSSIT : il
|
||
applique fidelement un etat qui n'a plus cours. Il se presente en vert. Rien, dans la
|
||
sortie, ne distingue « la flotte converge vers ce que tu veux » de « la flotte converge
|
||
vers ce que tu voulais il y a trois heures ».
|
||
|
||
### La garde
|
||
|
||
`scripts/verifier_genome_a_jour.py`, en tete de `deployer-tout` :
|
||
|
||
| etat du depot | verdict |
|
||
|---|---|
|
||
| en RETARD | **refus** — deployer poserait un etat qu'on sait depasse |
|
||
| en AVANCE | note, on continue — un mainteneur qui travaille localement est normal |
|
||
| DIVERGE | note, on continue — c'est a l'humain de trancher, pas a une garde |
|
||
| forge injoignable | note, on continue — une forge en panne ne doit pas bloquer une exploitation |
|
||
|
||
Ce dernier point est delibere : **le silence serait la faute**, pas le passage. Une garde
|
||
qui immobilise la flotte quand la forge tousse serait pire que le defaut qu'elle surveille.
|
||
|
||
`FORCE=1` passe outre, pour le cas legitime ou l'on deploie sciemment un etat local.
|
||
|
||
Eprouvee dans les trois sens sur un clone recule de deux commits : elle mord, elle se
|
||
tait, et `FORCE=1` la leve.
|
||
|
||
## 2026-09-13 (9) — Le site expose sept intrants ; le controle n'en comparait que cinq
|
||
|
||
`site_intrants --verifier` rapportait **CONFORME** avec un aplomb complet sur un locataire
|
||
dont le plan d'administration pointait encore les bouts de WAN d'un AUTRE site.
|
||
|
||
`OU_LE_LOCATAIRE_LE_DIT` couvrait `dns_amorcage`, `artefacts_amorcage`,
|
||
`setops_depot_binaires`, `serveur_ops_forge_amont` et `client_backup_cible`. Pas
|
||
`nftables_admin_ssh`. Pas `passerelle_sortie`.
|
||
|
||
### Ce que le silence a coute
|
||
|
||
Quatorze machines materialisees, le runner insemine — et l'exploitant incapable d'entrer
|
||
sur son propre runner pour l'armer. `Connection timed out`, sans qu'aucune garde n'ait rien
|
||
eu a dire. La frontiere avait bien pose une regle d'administration, mais sur son interface
|
||
**WAN**, puisque c'est ce que le plan declarait. Bonne source, mauvaise porte.
|
||
|
||
### Ce qui change
|
||
|
||
Les deux entrees manquantes sont ajoutees. `nftables_admin_ssh` etant une **liste**, la
|
||
comparaison passe par une normalisation : l'ordre d'une liste de sources ne porte aucun
|
||
sens, et comparer `str(['a','b'])` a `str(['b','a'])` ferait crier une difference qui n'en
|
||
est pas une.
|
||
|
||
Les deux ecosystemes passent de cinq a six intrants compares — le septieme,
|
||
`passerelle_sortie`, n'est declare par aucun des deux : il se derive, et la garde le dit
|
||
plutot que de l'inventer.
|
||
|
||
### La regle, cinquieme occurrence
|
||
|
||
**Une liste qui en suit une autre prend du retard.** Le site expose ; la table compare. Deux
|
||
listes, et la seconde ne suivait pas. La garde s'ecrit EN MEME TEMPS que la seconde liste,
|
||
jamais quand on s'en sert.
|
||
|
||
## 2026-09-13 (8) — Le plancher traversait trois modules et se perdait au troisieme
|
||
|
||
Trouve en preparant la creation des quatorze machines de TechnoLibre, une commande avant
|
||
de les creer. `instancier` derivait bien `proxmox_memoire_min` dans l'inventaire ; le
|
||
playbook de clonage savait bien poser `balloon:` ; entre les deux, la table
|
||
`CHAMPS_PROXMOX` de `inventory_host.py` ne transmettait **rien**.
|
||
|
||
### Pourquoi rien n'aurait echoue
|
||
|
||
Trois silences qui s'enchainent :
|
||
|
||
1. `$SETOPS_MEMOIRE_MIN` non defini vaut la chaine vide
|
||
2. le Makefile ne passe alors pas `-e proxmox_clone_memoire_min`
|
||
3. la garde `when:` de la tache saute proprement
|
||
|
||
Quatorze machines seraient nees avec le ballooning **desactive**, sans une seule erreur.
|
||
C'est le patron d'une liste qui en suit une autre et prend du retard — deja rencontre
|
||
quatre fois.
|
||
|
||
### La garde, ecrite en meme temps que le remede
|
||
|
||
**P75** lit la cible `creer-vm` du Makefile, releve chaque `$SETOPS_X` qu'elle consomme, et
|
||
exige que la table qui les EMET le declare. Elle ne juge aucune valeur : elle refuse qu'un
|
||
maillon manque. Eprouvee dans les deux sens — elle mord quand on retire le maillon, elle
|
||
se tait quand il est la.
|
||
|
||
La regle qui en sort : **quand une variable traverse trois modules, le troisieme s'ecrit en
|
||
meme temps que le premier, pas quand on s'en sert.**
|
||
|
||
`test_inventory_host.py` a signale le changement de contrat de son cote, comme il l'avait
|
||
fait pour `SETOPS_DOMAINE` et `SETOPS_CLES_AMORCAGE`.
|
||
|
||
## 2026-09-13 (7) — Un hote retire du plan laissait son pare-feu derriere lui
|
||
|
||
En retirant `backup-01` du plan de TechnoLibre, `flux-genere/backup-01.nft` est reste sur
|
||
le disque : un ruleset complet, pour une machine qui n'existe plus, indiscernable des
|
||
autres au premier coup d'oeil. La generation ECRIVAIT sans jamais RETIRER.
|
||
|
||
Ce que ca coute : `flux-genere/` cesse d'etre une IMAGE du plan pour devenir le cumul de
|
||
tous les plans successifs. Qui lit le dossier pour savoir ce qu'un ecosysteme expose lit
|
||
alors un etat qui n'a jamais existe.
|
||
|
||
**P53 le voyait deja** — un ruleset perime n'a pas de refus audible — mais il designait le
|
||
fichier, pas la cause. La generation supprime desormais les orphelins **et le dit** :
|
||
|
||
note : backup-01.nft retire — cet hote n'est plus au plan.
|
||
|
||
C'est le meme patron que les neuf resolutions d'instance : un dossier GENERE doit etre le
|
||
reflet exact de sa source, donc la generation doit aussi supprimer.
|
||
|
||
## 2026-09-13 (6) — `balloon: 0` ne desactive pas le ballooning : il retire le peripherique
|
||
|
||
Soixante-et-un gigaoctets de RAM etaient **declares** a vingt-et-une machines. Mesure a
|
||
l'interieur des invites : **douze** reellement utilises. Les quarante-neuf autres etaient
|
||
tenus sans etre lus, et l'hote ne pouvait pas en reprendre un seul octet.
|
||
|
||
### Ce que la mesure a corrige, deux fois
|
||
|
||
La premiere table de planchers etait ecrite sur une crainte : `0,75` a PostgreSQL et a
|
||
Keycloak « parce qu'ils se dimensionnent au demarrage ». Releve sur les machines reelles :
|
||
|
||
| ce qu'on croyait | ce qui est |
|
||
|--------------------------------------|-------------------------------|
|
||
| `shared_buffers` derive de la RAM | **128 Mo**, le defaut Debian |
|
||
| une JVM qui reserve son tas | **605 Mo** sur 3 Go |
|
||
| Redis « tout en memoire » | **16 Mo** residents, borne a 256 Mo |
|
||
|
||
Ces machines tiennent du **cache de pages**, pas un jeu de donnees. Le reprendre ralentit
|
||
des lectures — sur du NVMe — ; il ne fait pas swapper. Leurs planchers sont redescendus au
|
||
cas general. Ce qui reste haut le reste pour une raison mesurable : un collecteur garde ses
|
||
series EN MEMOIRE et grossit avec le temps.
|
||
|
||
### Le piege, et il n'etait pas dans la doctrine
|
||
|
||
`balloon: 0` se lit « ballooning desactive ». C'est plus fort que ca : **le peripherique
|
||
n'est pas sur la ligne de commande QEMU**. Le moniteur repond « No balloon device has been
|
||
activated ». Donc `qm set --balloon N` ecrit la config et ne change rien tant que la VM n'a
|
||
pas ete **redemarree depuis sa config** — un `reboot` a l'interieur de l'invite ne suffit
|
||
pas, et le branchement a chaud est refuse : sur q35, `pcie.0` n'est pas enfichable.
|
||
|
||
`qm reboot` fait l'arret propre puis le demarrage depuis la config. Les vingt-et-une
|
||
machines y sont passees une par une, feuilles d'abord, DNS et PKI en dernier, chacune
|
||
verifiee sur son `balloon_min` avant la suivante.
|
||
|
||
### Comment l'hote decide, lu dans la source et non dans une doc
|
||
|
||
`pvestatd` vise **80 %** de la RAM de l'hote (`goal = memtotal × 0,80 − memused`) et ne
|
||
deplace au plus que **100 Mio par VM par cycle**. Le filet ne se tend donc que quand la
|
||
charge arrive — c'est le comportement voulu, et c'est pourquoi il ne s'est rien passe de
|
||
visible au moment de poser les planchers.
|
||
|
||
### Le resultat
|
||
|
||
| | declare | plancher | reprenable |
|
||
|---|---:|---:|---:|
|
||
| locataire Chezlepro (14) | 40 960 Mo | 22 912 Mo | **17,6 Go** |
|
||
| site (7) | 20 480 Mo | 11 264 Mo | **9,0 Go** |
|
||
|
||
`asgard` est passe de 46,1 a 36,0 Go utilises. Le moteur derive desormais le plancher sur
|
||
les **deux** chemins de creation — `instancier` pour un locataire, `site_machines` pour le
|
||
site — et le playbook de clonage le pose a cote de `memory`, de sorte qu'une machine neuve
|
||
nait avec son plancher.
|
||
|
||
Un plancher ecrit au plan (`memoire_min`) prime toujours : la table est un defaut raisonne,
|
||
pas une contrainte.
|
||
|
||
### Ce qui reste ouvert, dit franchement
|
||
|
||
Les grosses VM hors Set-OPS — ERPLibre, ZIMBRA, Nextcloud, eregion — portent **92 Go
|
||
declares avec `balloon: 0`** sur `gandalf` et `vishnu`, pour environ 48 Go reellement lus.
|
||
C'est la que dort le reste de la marge. Elles n'ont pas ete touchees : elles ne relevent
|
||
pas de ce depot.
|
||
|
||
## 2026-09-13 (5) — Le gabarit dore etait declare deux fois, et les deux clonaient
|
||
|
||
`plan/10-intrants.yml` disait **9006** (`modeleSetOPS-minimal`). `underlay.yml` disait
|
||
**99998** — que le plan nomme lui-meme `precedent`.
|
||
|
||
Le Makefile derive `VMID_MODELE` de `underlay.gabarit()`, donc du PLAN : **les locataires
|
||
clonaient 9006**. `site_machines` lisait l'autre : **les machines du site clonaient 99998**.
|
||
|
||
### Pourquoi ca ne s'est jamais vu
|
||
|
||
Les deux VMID designent un gabarit valide. Les deux clonent, les deux demarrent, rien
|
||
n'echoue. La flotte etait simplement issue de **deux souches** — et aucun message ne
|
||
pouvait le dire, puisqu'aucune operation n'avait echoue.
|
||
|
||
### Ce qui a ete fait
|
||
|
||
`site_machines` lit desormais `underlay.gabarit()`, la meme source que tout le monde. Le
|
||
motif d'origine de la seconde declaration est preserve : cette source lit le PLAN DU SITE,
|
||
jamais celui du tenant actif — elle ne depend d'aucun symlink `instance`.
|
||
|
||
`materialisation.vmid_modele` est retire des deux cartes. `precedent:` reste au plan : il
|
||
dit d'ou l'on vient, et il n'est jamais clone.
|
||
|
||
**`P74`** refuse toute redeclaration. Eprouvee dans les deux sens.
|
||
|
||
### Au passage
|
||
|
||
`SITE-Technolibre` visait 99998 — l'ancien — parce que sa valeur avait ete reprise de la
|
||
carte de Chezlepro plutot que de son plan. Corrige avant son premier clonage.
|
||
|
||
|
||
## 2026-09-13 (4) — La cle USB ne savait pas relire ce qu'on venait d'y ecrire
|
||
|
||
`exporter_cles --support-chiffre` ecrit un TAR CLAIR quand le support est deja chiffre au
|
||
repos : empiler GPG par-dessus ajouterait une phrase de passe a perdre sans rien proteger
|
||
de plus. Cette capacite a ete ajoutee cote EXPORT le 2026-09-12, et **pas cote
|
||
RESTAURATION**.
|
||
|
||
`restaurer_cles.py` — le script qui rend la cle autonome — ne savait lire que du GPG.
|
||
Constate en eprouvant l'export, pas en le supposant reussi :
|
||
|
||
```
|
||
gpg: aucune donnée OpenPGP valable n'a été trouvée.
|
||
ECHEC du dechiffrement.
|
||
Phrase de passe erronee, ou archive abimee.
|
||
```
|
||
|
||
**Le message accusait une phrase de passe qu'on n'avait jamais posee**, sur une archive
|
||
parfaitement saine. C'est le pire genre de diagnostic : il envoie chercher la faute la ou
|
||
elle n'est pas, au moment ou l'on restaure — c'est-a-dire au moment ou l'on sait le moins.
|
||
|
||
`tarfile.is_tarfile` reconnait la forme ; les deux sont desormais lues. On ne demande pas
|
||
un drapeau : ce serait une chose de plus a savoir le jour ou tout a brule.
|
||
|
||
### Ce que l'export a aussi montre de bon
|
||
|
||
Le garde-fou a REFUSE d'ecraser l'archive du 12 — « on n'ecrase pas une sauvegarde de
|
||
cles : elle est peut-etre la seule ». La nouvelle est datee, l'ancienne reste.
|
||
|
||
Dix cles sortent desormais, dont `setops-vault-site-technolibre`, creee le jour meme.
|
||
|
||
|
||
## 2026-09-13 (3) — Un site expose ce dont ses locataires ont besoin pour l'habiter
|
||
|
||
### Le constat
|
||
|
||
Un locataire ECRIT dans ses intrants les adresses des services de son site : ou resoudre,
|
||
ou prendre ses paquets, ou cloner le genome, ou deposer son etat. Une copie se perime, et
|
||
deux l'avaient fait en deux jours, avec exactement la meme forme :
|
||
|
||
- `serveur_ops_forge_amont: https://10.0.33.11` alors que la forge sert en `10.37.33.11`
|
||
depuis que le site a pris son propre index. Le commentaire juste au-dessus expliquait
|
||
encore pourquoi l'ancienne adresse figurait dans les SAN du certificat : le raisonnement
|
||
etait intact, la valeur non.
|
||
- `ac-racine-site.crt` versionne portait la racine du site d'AVANT sa reconstruction.
|
||
|
||
Aucune des deux n'etait relue par quoi que ce soit.
|
||
|
||
### `make site-intrants`
|
||
|
||
Le site lit son propre plan et rend le contrat — sept valeurs, toutes DERIVEES :
|
||
|
||
```
|
||
dns_amorcage · artefacts_amorcage · setops_depot_binaires · serveur_ops_forge_amont
|
||
client_backup_cible · nftables_admin_ssh · passerelle_sortie
|
||
```
|
||
|
||
`make site-intrants-verifier` les confronte a ce que le locataire monte declare, et **P73**
|
||
en fait une preuve. Elle a trouve le defaut de la forge des sa premiere execution.
|
||
|
||
Le contrat se DERIVE du plan du site, pas d'une liste tenue a part : ajouter un service
|
||
prete au site l'ajoute au contrat, sans qu'on ait a y penser.
|
||
|
||
### P69 restreinte au couple monte
|
||
|
||
Elle balayait TOUS les depots `OPS-*` et les comparait au site MONTE. Elle avait raison
|
||
tant qu'un seul site existait : une adresse « en 10.x.3z » ne pouvait designer que lui.
|
||
|
||
Deux sites decoupent leurs zones de la meme facon — c'est le but, un locataire doit pouvoir
|
||
habiter l'un ou l'autre sans se renumeroter. Le troisieme octet a donc cesse de distinguer
|
||
« mon site » d'« un autre site » : `10.31.34.11`, parfaitement juste pour un locataire de
|
||
TechnoLibre, etait declare faux parce que Chezlepro etait monte.
|
||
|
||
**Un locataire n'appartient a aucun site — il en habite un**, choisi par le symlink au
|
||
moment du deploiement. La seule paire qu'on puisse juger est donc celle qui est montee.
|
||
|
||
### Une marche payee en chemin
|
||
|
||
La premiere version de `site_intrants` ecrivait `RACINE / "instance" / "inventories" /
|
||
"principal"`. **P41 a mordu** : neuf modules avaient deja porte chacun leur copie de cette
|
||
resolution, et cinq defauts en etaient sortis en cinq jours. La preuve a attrape la
|
||
dixieme avant qu'elle serve.
|
||
|
||
|
||
## 2026-09-13 (2) — L'annuaire cesse de tout ouvrir avec la meme cle
|
||
|
||
### Ce que la mesure a montre
|
||
|
||
Les quatre services qui interrogent l'annuaire — Keycloak, Dovecot, Postfix, Icinga Web 2
|
||
— s'y liaient **tous avec `cn=admin`**, le compte d'administration de la base. C'est le
|
||
rootDN : slapd lui fait CONTOURNER TOUTES LES ACL. Un seul secret, quatre services, tous
|
||
les droits sur l'arbre — pour ce qui est, trois fois sur quatre, une simple lecture.
|
||
|
||
Et les droits livres par Debian etaient intacts : `to * by * read`. Sur `ldap://`, **sans
|
||
s'authentifier**, une machine du reseau enumerait tous les comptes et toutes les adresses.
|
||
|
||
**L'indice etait deja dans le depot.** `validatePasswordPolicy` existe parce que « slapd
|
||
n'applique pas ses controles de qualite au rootDN ». La consequence etait compensee, la
|
||
cause intacte.
|
||
|
||
### Ce qui a ete fait
|
||
|
||
- `ou=services` et **un compte par consommateur**, secret propre en voute.
|
||
- **Sept regles d'acces**, posees en entier (`state: exact`) plutot qu'inserees : l'ordre
|
||
est la regle, et inserer c'est parier sur ce que le paquet aura mis avant nous. Keycloak
|
||
ecrit (mots de passe, creations), les trois autres lisent, les comptes de service ne se
|
||
voient pas entre eux, et **la lecture anonyme est fermee**.
|
||
- `amorcage_acces` garde le compte d'administration, et c'est **nomme comme l'exception** :
|
||
il ne consomme pas l'annuaire, il le PROVISIONNE, depuis la socket locale.
|
||
- La sonde passe de `-x` a `-Y EXTERNAL`. Elle lisait en anonyme : elle aurait annonce un
|
||
annuaire VIDE — la panne la plus grave — sur un annuaire parfaitement sain.
|
||
- **La rotation du compte d'administration est enfin possible.** Son mot de passe n'etait
|
||
pose qu'a l'installation, par debconf : le faire tourner en voute ne descendait nulle
|
||
part, et les deux divergeaient en silence. Un secret qu'on ne peut pas faire tourner est
|
||
un secret qu'on ne fera pas tourner.
|
||
|
||
### Quatre marches payees en chemin
|
||
|
||
1. **Un cinquieme appelant.** `amorcage_acces` inclut aussi le role partage. Oublie, il
|
||
echouait — et `no_log`, qui protege la valeur, masquait aussi la RAISON. La garde
|
||
refuse desormais dans une tache SANS `no_log` : elle nomme la cle absente, jamais son
|
||
contenu.
|
||
2. **Une garde qui surveillait deux champs sur trois.** La federation Keycloak ne
|
||
reecrivait son `bindDn` que si l'URL ou le mode changeaient. Nouveau secret, ancien
|
||
nom : `error code 49 - Invalid Credentials`, qui accuse les identifiants sans dire
|
||
lequel des deux a bouge.
|
||
3. **Une rotation placee trop tard.** Les taches qui se lient en administrateur passaient
|
||
AVANT la mise a jour du mot de passe. Un secret qui tourne doit descendre avant que
|
||
quoi que ce soit s'en serve.
|
||
4. **`ansible-vault` et son tube.** Sortie non bloquante = echec silencieux ; la voute
|
||
paraissait tournee et etait identique a l'octet. La garde qui compare l'empreinte du
|
||
FICHIER avant et apres l'a rattrape — c'est la lecon deja ecrite dans ce depot, et elle
|
||
vient de se repayer.
|
||
|
||
### La garde
|
||
|
||
`P72` exige que tout role incluant `resoudre_annuaire` NOMME son compte de service, et
|
||
qu'aucun sauf `amorcage_acces` ne nomme `admin`. Eprouvee dans les deux sens.
|
||
|
||
### Rotation
|
||
|
||
`vault_openldap_admin` et `vault_ldap_bind_postfix` ont ete renouveles : les deux avaient
|
||
transite en clair par une session d'exploitation. Verifie sur l'infrastructure — les
|
||
anciennes valeurs rendent `Invalid credentials (49)`, les nouvelles ouvrent, et les quatre
|
||
services repondent.
|
||
|
||
|
||
## 2026-09-13 — Le genome ne nait plus chez un locataire
|
||
|
||
### Ce que la console montrait
|
||
|
||
`Chezlepro-17` contenait **vingt et une VM** : les quatorze du locataire ET les sept du
|
||
genome, melangees. `OPS-Chezlepro` et `OPS-Technolibre`, crees a la main, etaient vides.
|
||
Le gabarit vivait seul dans un pool `Set-OPS`.
|
||
|
||
### La cause, et pourquoi le rangement n'aurait pas tenu
|
||
|
||
`site-creer` appelle `cloner-vm`, qui derive son pool par `--pool-actif` — c'est-a-dire
|
||
le pool du **tenant lie**. Les machines du site heritaient donc du locataire courant.
|
||
|
||
Range a la main, ca se serait defait au prochain `site-creer`, **sans un mot** : la VM est
|
||
bien creee, bien nommee, bien adressee. Seule son appartenance est fausse, et rien ne la
|
||
regarde.
|
||
|
||
### Ce qui a ete fait
|
||
|
||
- `devis_proxmox_pools.py --pool-site` rend le nom invariable du pool du genome. Une
|
||
option DISTINCTE, pas un drapeau sur la premiere : le site et un tenant ne repondent pas
|
||
a la meme question, et une fonction qui repond aux deux finit par se tromper d'appelant.
|
||
- `cloner-vm` accepte une surcharge `POOL=` ; `site-creer` la nomme. La substitution se
|
||
fait au niveau **make**, pas shell — `POOL` arrive du sur-make comme variable make, et
|
||
`$${POOL}` ne l'aurait jamais vue.
|
||
- **`P71`** exige que `site-creer` nomme son pool. Eprouvee dans les deux sens : elle passe
|
||
sur le Makefile sain, elle tire des qu'on retire l'argument.
|
||
|
||
### Applique au cluster
|
||
|
||
| pool | avant | apres |
|
||
|---|---|---|
|
||
| `Site-OPS` | — | **9** (7 du site + 2 gabarits) |
|
||
| `OPS-Chezlepro` | 0 | **14** |
|
||
| `OPS-Patient0` | — | **5** |
|
||
| `Chezlepro-17`, `Patient0-29`, `Set-OPS` | 21 / 5 / 1 | supprimes, vides |
|
||
|
||
Les pools anterieurs a Set-OPS (`Prod.Chezlepro`, `Prod.TechnoLibre`, `Dev.TechnoLibre`,
|
||
`Lab.TechnoLibre`) et les quinze VM hors pool n'ont pas ete touches.
|
||
|
||
|
||
## 2026-09-12 (6) — Le site tient aussi les binaires, pas seulement les paquets
|
||
|
||
### Ce qui manquait
|
||
|
||
Le cache du site couvre `apt` depuis longtemps : 1,34 Go tires de l'amont, 6,13 Go servis
|
||
a la flotte pendant la reconstruction d'un locataire — un rapport de 4,58. Mais quatre
|
||
artefacts arrivent autrement, parce qu'ils ne vivent dans aucun depot apt : Forgejo,
|
||
Keycloak, Nextcloud, oauth2-proxy. Le CONTROLEUR les tire (`delegate_to: localhost`) puis
|
||
les pousse par SSH.
|
||
|
||
**Mesure du 2026-09-12** : `/opt/setops/.cache/setops` est ABSENT sur le runner du site.
|
||
Un second locataire monte depuis ce runner sortait donc chercher **570 Mo** sur
|
||
codeberg.org, github.com (deux fois) et download.nextcloud.com — alors que le meme
|
||
ecosysteme ne demandait plus un seul paquet a Debian. Le poste du mainteneur, lui, a ces
|
||
1,6 Go depuis toujours : c'est pourquoi personne ne l'avait vu.
|
||
|
||
### Pourquoi pas un relais transparent
|
||
|
||
Les `Remap-*` du cache font deja passer quatre fournisseurs HTTPS. Mesure des quatre
|
||
amonts avant de decider :
|
||
|
||
| amont | reponse |
|
||
|---|---|
|
||
| `codeberg.org` | 200, aucune redirection |
|
||
| `download.nextcloud.com` | 200, aucune redirection |
|
||
| `github.com` (x2) | 302 vers `release-assets.githubusercontent.com`, **URL signee valable une heure** |
|
||
|
||
L'URL finale de GitHub change a chaque requete. Un cache qui la prend pour cle ne fait
|
||
jamais mouche, et ce qu'il garderait serait perime avant d'etre relu. Le relais marche
|
||
pour deux amonts sur quatre : ce n'est pas un mecanisme, c'est une coincidence.
|
||
|
||
### Ce qui a ete fait
|
||
|
||
**Un vrai depot de fichiers, dans le service qui existe deja.** `LocalDirs` d'apt-cacher-ng
|
||
publie un repertoire du disque sous un prefixe — eprouve sur `site-cache-01` AVANT d'ecrire
|
||
le role : 200, 90 158 octets, identiques a l'octet. Donc **aucun service, aucun port,
|
||
aucun certificat, aucun flux nouveaux** : l'`ingress 3142 pair: flotte` deja declare couvre
|
||
exactement ce chemin.
|
||
|
||
- `serveur_artefacts` publie `/var/lib/setops/artefacts-directs` sous `setops-binaires`,
|
||
et le remplit depuis les amonts — une seule machine sort, pour tout le site et pour les
|
||
ecosystemes qui naitront demain.
|
||
- **Les versions ne sont pas recopiees.** Le role lit les defauts des quatre roles
|
||
consommateurs (`include_vars` SANS `name:` — enfermees dans un dictionnaire, les valeurs
|
||
qui se citent entre elles ne se resolvent plus ; le devis des certificats avait deja paye
|
||
cette marche).
|
||
- Les quatre roles recoivent **une tache ajoutee, placee avant leur `stat` de cache**. Si
|
||
le depot sert le fichier, le `stat` le voit et la tache de telechargement amont se saute
|
||
d'elle-meme : aucune tache existante n'a change.
|
||
- Les deux signatures detachees passent aussi par le depot. Elles pesent 228 octets, mais
|
||
elles sont demandees a chaque passage : une sortie reste une sortie.
|
||
- `setops_depot_binaires` est **derive**, jamais ecrit deux fois — de `artefacts_amorcage`
|
||
chez le locataire, du plan du site dans `site_inventaire.py`.
|
||
|
||
### La garde, ecrite le meme jour que la liste
|
||
|
||
`P70` exige que tout `dest:` ecrit sous un `<role>_cache_local` figure au depot. Sans elle,
|
||
une version montee dans un role sans l'etre dans le depot produirait exactement le defaut
|
||
que ce depot traque : le runner ne trouve pas le fichier, retombe sur Internet, **et tout
|
||
fonctionne** — sans que rien ne le dise.
|
||
|
||
Une liste qui suit une autre prend du retard. Celle-ci est nee avec son garde-fou.
|
||
|
||
### Une erreur corrigee en cours de route
|
||
|
||
La premiere version des gardes de signature s'appuyait sur `failed_when: false` puis testait
|
||
`is not succeeded`. `failed_when: false` REECRIT le verdict : la tache n'est plus jamais
|
||
`failed`, donc la garde etait toujours vraie et ne gardait rien — la faute exacte que
|
||
`client_artefacts` documente depuis le 2026-08-31. Les gardes **mesurent le fichier**
|
||
desormais, pas le verdict de la tache.
|
||
|
||
### Ce que ca ne couvre pas encore
|
||
|
||
Le cache du contrôleur reste per-runner : un runner tout neuf demande le depot, et si le
|
||
depot ne l'a pas encore, il sort. Le depot se remplit au deploiement du site — donc un
|
||
locataire monte avant que le site n'ait ete redeploye sortira une derniere fois.
|
||
|
||
|
||
## 2026-09-12 (5) — Une reprise conditionnelle ne reprend rien
|
||
|
||
Cinq roles telechargeaient une cle de signature avec une boucle `until` exigeant une taille
|
||
non nulle. Des que le fichier existe — meme a zero octet — `get_url` emet une requete
|
||
CONDITIONNELLE ; l'amont repond `304 Not Modified` ; la tache reussit sans rien ecrire ; et
|
||
la boucle retente cinq fois contre un serveur qui repondra toujours 304 :
|
||
|
||
```
|
||
HTTP Error 304: Not Modified size: 0 attempts: 5
|
||
```
|
||
|
||
La boucle de reprise se battait contre elle-meme. `force: true` sur les cinq
|
||
(`client_pki`, `client_journal`, `serveur_collabora`, `serveur_grafana`, `serveur_loki`) —
|
||
le `when:` qui precede garantit deja qu'on ne retelecharge pas une cle valide, donc `force`
|
||
ne concerne que le cas ou l'on a DECIDE d'aller chercher.
|
||
|
||
|
||
## 2026-09-12 (4) — Pools nommes comme les depots, et les cles sortent du poste
|
||
|
||
### Le nom d'un pool est celui de son depot
|
||
|
||
`Chezlepro-17` devient `OPS-Chezlepro`. Le seed se lisait dans le nom, ce qui etait juste
|
||
mais obligeait a connaitre le codage — et surtout **le nom CHANGEAIT si l'index changeait** :
|
||
la renumerotation du site l'a montre le jour meme. Ce qu'on lit dans la console est
|
||
desormais ce qu'on tape dans un terminal.
|
||
|
||
L'unicite ne vient plus de l'index mais du nom de dossier, deja unique par construction.
|
||
|
||
**`Site-OPS` ne derive de rien, et c'est le point.** Les machines du genome — cache, forge,
|
||
AC, noms, depot, supervision — ne dependent d'aucun index : elles sont l'infrastructure SUR
|
||
laquelle les index vivent. Un nom invariable dit cela, et il reste le meme d'un hebergeur a
|
||
l'autre. Sans ce bloc, les sept restaient hors de tout pool : sept orphelines a cote de
|
||
trois flottes rangees.
|
||
|
||
CONFORME : 4 pool(s) Proxmox, 41 VM placee(s), aucun nom ni VMID en collision.
|
||
|
||
### P69 — l'amorcage d'un tenant designe-t-il le site REEL ?
|
||
|
||
`dns_amorcage` et `artefacts_amorcage` sont ecrits A LA MAIN dans les intrants d'un tenant,
|
||
et c'est voulu : au moment ou ils servent, la premiere machine ne resout aucun nom. Une
|
||
adresse, pas un nom.
|
||
|
||
Mais ces adresses designent des machines DU SITE. Le site a change d'index ; elles n'ont
|
||
pas suivi. La reconstruction du locataire s'est arretee sur `infra-pki-01` avec
|
||
« Failed to update apt cache » — a quinze couches de sa cause.
|
||
|
||
La preuve ne juge que les valeurs qui PRETENDENT designer le site : un tenant peut
|
||
legitimement s'amorcer sur un resolveur public, et `OPS-Technolibre` comme `OPS-Patient0`
|
||
visent `9.9.9.9`. C'est un choix, pas un oubli.
|
||
|
||
### Les cles sortent du poste, en clair, et c'est raisonne
|
||
|
||
Les neuf fichiers (1 644 o) sont sur la cle USB LUKS. **En tar clair**, par
|
||
`--support-chiffre`, option ajoutee ce jour et explicite — le defaut reste GPG.
|
||
|
||
Deux menaces, deux reponses :
|
||
|
||
le support est perdu ou vole -> LUKS s'en charge
|
||
le poste est compromis -> la 2e couche n'aide pas : les originaux sont
|
||
dans ~/.config sur ce meme poste
|
||
|
||
La couche GPG n'ajoutait donc presque rien, et coutait une phrase de passe STOCKEE NULLE
|
||
PART — le seul point que la procedure elle-meme ne couvre pas. On echangeait un risque de
|
||
divulgation deja couvert contre un risque de PERTE qui ne l'etait pas.
|
||
|
||
Pourquoi ce n'est pas le defaut : un support non chiffre est le cas le plus frequent, et
|
||
le silence ne doit jamais pencher du cote de la divulgation.
|
||
|
||
**Et la note qui disait les cles sorties depuis le 2026-09-05 etait fausse** : le support
|
||
ne portait que le depot hors site du 1er. Verifie avant d'ecrire, pas apres.
|
||
|
||
## 2026-09-12 (3) — Trois reconstructions : trouver, verifier, prouver
|
||
|
||
La premiere reconstruction avait trouve seize defauts. Deux tours de plus ont ete faits, et
|
||
c'est le second qui compte le plus : **un runbook ecrit d'apres un recit de depannage
|
||
contenait une erreur qu'aucune relecture n'aurait montree.**
|
||
|
||
Tour 1 8 passages ~2 h 30 16 defauts trouves
|
||
Tour 2 3 passages 53 min 2 defauts trouves
|
||
Tour 3 2 passages 37 min 24 0
|
||
|
||
### Ce que le tour 2 a corrige dans le runbook
|
||
|
||
J'avais annonce DEUX passages. Il en fallait TROIS, et pour une raison instructive : les
|
||
deux blocages connus — la cle de Forgejo et la forge vide — sont **en serie**. On ne peut
|
||
pas amorcer une forge qui ne tourne pas, et elle ne tourne qu'apres la convergence de la
|
||
cle. Je les croyais franchissables dans le meme passage.
|
||
|
||
Il fallait SUIVRE le runbook pour le voir.
|
||
|
||
### Deux murs de plus, invisibles au tour 1
|
||
|
||
**#17 — `site-creer` rend la main avant que les machines repondent.** Mesure : 3 min 20 au
|
||
tour 3. Le chemin locataire a `_attendre-flotte` ; le site n'a pas d'equivalent. Un
|
||
enchainement automatique echouerait sur `UNREACHABLE` qui n'est qu'une impatience.
|
||
|
||
**#18 — les cles d'hote changent a chaque reconstruction.** `known_hosts` porte l'ancienne,
|
||
`git` refuse — a juste titre — et sans terminal il ne peut rien demander. Le message qu'il
|
||
rend parle de droits d'acces et de depot inexistant.
|
||
|
||
`accept-new` accepte une PREMIERE rencontre, jamais un CHANGEMENT : il ne suffit donc pas
|
||
pour une machine reconstruite sur une adresse deja connue. `forge-amorcer` purge desormais
|
||
l'entree perimee — celle de l'hote que **git** va contacter, lu dans `git remote get-url`,
|
||
et non celle de l'API : quand l'amorcage passe par un tunnel, les deux different et purger
|
||
la mauvaise ne se verrait qu'au push.
|
||
|
||
### Le cycle de la cle, denoue
|
||
|
||
`client_pki` posait les droits de la cle d'hote pour le groupe `git`, que le paquet de
|
||
Forgejo cree dans une couche POSTERIEURE. Aucun ordre de couches ne denoue ce cycle : les
|
||
certificats doivent venir tot, tout le monde en depend.
|
||
|
||
C'est la PROPRIETE DU GESTE qui a change de main. `serveur_forgejo` revendique la cle au
|
||
moment ou il cree le groupe ; `client_pki` garde la sienne et la repose a chaque passage,
|
||
parce que `step` reecrit la cle a chaque renouvellement. Les deux se recouvrent, et c'est
|
||
voulu : l'une amorce, l'autre entretient.
|
||
|
||
Gain mesure : un passage entier, et 300 secondes d'attente perdue.
|
||
|
||
### Ce que mon propre outil masquait
|
||
|
||
`forge-amorcer` n'affichait que la DERNIERE ligne de `stderr` — or `git` termine toujours
|
||
par « assurez-vous que le depot existe », quelle que soit la cause. Un aller-retour de
|
||
diagnostic pour une cle d'hote. Il rend maintenant les quatre premieres lignes.
|
||
|
||
C'est le meme defaut que ceux trouves dans le moteur, commis par moi : **un message qui
|
||
pointe ailleurs que sa cause**.
|
||
|
||
### La sequence est desormais mesuree, pas reconstituee
|
||
|
||
`docs/runbooks-exploitation.md` §9 porte les six etapes du tour 3, avec leurs durees
|
||
reelles et les dix-huit murs. Le troisieme tour n'a rien trouve de nouveau : c'est le
|
||
premier ou reconstruire le site est une PROCEDURE et non une enquete.
|
||
|
||
## 2026-09-12 (2) — Le site est reconstruit depuis zero, et il a fallu seize corrections
|
||
|
||
`SITE-Chezlepro` n'avait jamais ete rase. Il avait ete monte par ajouts successifs, sur des
|
||
semaines, avec un service deja debout a chaque etape. La limite qu'on repetait partout —
|
||
« l'infrastructure d'accueil n'a jamais ete reconstruite depuis zero » — se lisait comme de
|
||
la prudence.
|
||
|
||
7 machines 0 echec 0 injoignable 46 couches
|
||
16:31 -> 19:00 creation 12 min 08 s, puis HUIT passages de deploiement
|
||
|
||
### Ce qui rend un site different d'un locataire
|
||
|
||
Un locataire naît dans un monde deja peuple : le site lui fournit les paquets, les noms, le
|
||
genome, l'heure et le depot de sauvegarde. **Un site n'a personne au-dessus de lui**, sauf
|
||
sa frontiere. Tout ce qu'un locataire recoit, un site doit se le donner — et pendant qu'il
|
||
se le donne, il ne l'a pas.
|
||
|
||
Onze des quinze murs viennent de la.
|
||
|
||
### Deux capacites qui n'existaient pas
|
||
|
||
**`make site-raser`.** `raser.py` ne visait que l'instance active — un locataire. Rien ne
|
||
detruisait les machines du site. Ce n'est donc pas que personne n'avait essaye de le
|
||
reconstruire : **l'outil n'en offrait pas le moyen**. La limite etait un trou d'outillage,
|
||
pas une fatalite. Les quatre verrous de `raser.py` sont repris tels quels.
|
||
|
||
**`make forge-amorcer`.** Le runner clone le genome depuis SA PROPRE forge, et
|
||
`serveur_forgejo` ne cree ni organisation ni depot. A froid la forge naît vide. Le geste
|
||
part du POSTE, et c'est structurel : a cet instant il est le seul endroit qui detienne le
|
||
genome. Un amorcage vient toujours de l'exterieur de ce qu'il amorce.
|
||
|
||
### Trois gardes qui verifiaient la forme au lieu du resultat
|
||
|
||
C'est la famille la plus couteuse : elles donnent l'apparence d'une verification.
|
||
|
||
**Le resolveur.** « Le `resolv.conf` nomme-t-il deja le resolveur declare ? » — juste en
|
||
regime etabli. A froid, les sept machines naissent avec `nameserver <site-dns-01>`, qui est
|
||
l'une des sept et n'a aucun resolveur. La garde concluait « elle pointe deja au bon
|
||
endroit » et **protegeait l'etat casse**. Elle mesure desormais si la resolution ABOUTIT.
|
||
|
||
**L'administration reconnue a son port.** `elif "22" in _ports(fl)` — juste tant que le seul
|
||
flux administratif etait SSH. L'exploitant declare `admin` sur le 443 de sa forge, la machine
|
||
accepte, la frontiere refuse, et les deux couches se croient d'accord.
|
||
|
||
**Un flux a deux paires n'obtient qu'une branche.** La forge declare `pair: [flotte, admin]`;
|
||
la chaine de `elif` rangeait le flux dans le premier cas et la part administrative
|
||
disparaissait en silence. La portee administrative s'AJOUTE desormais.
|
||
|
||
### Un ecart de securite, que seule la construction pouvait montrer
|
||
|
||
Le PostgreSQL du SITE servait le certificat AUTO-SIGNE du paquet Debian, pas celui de son
|
||
AC — `serveur_postgresql_tls_actif` vaut `false` par defaut et le site ne le declarait
|
||
nulle part, quand le locataire le met a `true`. Ni `reseaux_autorises`, ni `tls_force`.
|
||
|
||
Personne ne l'avait vu parce qu'aucun client n'exigeait la verification. Le premier a
|
||
l'exiger — l'import du schema Icinga DB — a echoue sur `certificate verify failed`.
|
||
**Le defaut n'a pas casse la construction : la construction a revele le defaut.**
|
||
|
||
### L'acces de l'exploitant etait accidentel
|
||
|
||
L'ancien plan d'administration `10.17.0.0/24` vivait DANS `10.17.0.0/16`, que les
|
||
expositions du site acceptent deja pour les locataires. Sortir le site de ce supernet a
|
||
revele qu'**aucune declaration n'avait jamais accorde cet acces**.
|
||
|
||
C'est l'explication complete d'une mesure du matin meme — « le site a une console deployee
|
||
et sans chemin d'acces ». Ce n'etait pas Grafana : c'etait tout le site. La decision de
|
||
separer les index n'a pas cause le probleme, elle a **retire le hasard qui le masquait**.
|
||
|
||
### Deux passages, au minimum
|
||
|
||
`client_pki` pose les droits d'une cle pour un consommateur qu'une couche ULTERIEURE cree.
|
||
Au premier passage le groupe `git` n'existe pas, la cle reste `root:root` — donc FERMEE,
|
||
jamais plus ouverte — et Forgejo ne demarre qu'au second.
|
||
|
||
C'etait d'abord un blocage CIRCULAIRE : l'echec arretait le play avant la couche qui aurait
|
||
cree le groupe. Rejouer n'y changeait rien. La tache constate desormais l'absence et
|
||
reporte, au lieu d'echouer.
|
||
|
||
### La sequence est ecrite
|
||
|
||
`docs/runbooks-exploitation.md` §9 — « Le premier jour d'un site », avec les seize murs et
|
||
ce que chacun enseigne. Le seizieme s'est montre en ECRIVANT cette entree : `make
|
||
wiki-publier` refuse, parce que Forgejo ne cree `<depot>.wiki.git` qu'a la premiere page.
|
||
Sans cette section, le prochain site les rencontrerait tous.
|
||
|
||
## 2026-09-12 (1) — Un site prend son propre index, et la garde les compte enfin
|
||
|
||
Decision de l'exploitant : **sites et locataires se partagent la classe A**, chacun avec
|
||
son propre index. L'exception du guide — « un site derive du meme index que son tenant » —
|
||
disparait. Il reste une seule regle : tout prend un index.
|
||
|
||
### Ce que l'exception cachait
|
||
|
||
Chez l'hebergeur de reference, le plan d'administration du site vit dans `10.17.0.0/24`,
|
||
**a l'interieur du supernet du locataire `OPS-Chezlepro`**. Ce n'est pas dangereux — les
|
||
zones d'un tenant commencent a l'octet 16 par construction, mesure faite — mais
|
||
`10.17.0.0/16` designait alors deux choses : un locataire, et le plan d'administration de
|
||
celui qui l'heberge. Deux sens pour une adresse.
|
||
|
||
Les zones du site, elles, ne derivaient de rien : `10.0.31` a `10.0.36`, tapees a la main
|
||
dans `underlay.yml`. Le site etait la seule partie du systeme qui n'avait pas de seed.
|
||
|
||
### La seconde liste, et sa garde ecrite le meme jour
|
||
|
||
Tant que les sites n'avaient pas d'index, ils ne pouvaient heurter personne. Depuis cette
|
||
decision, un site et un locataire peuvent reclamer le meme nombre — et
|
||
`make instances` ne voyait que les depots `OPS-*` :
|
||
|
||
for chemin in sorted(FRERES.glob("*/plan/nomenclature.yml")):
|
||
|
||
Un site n'a pas de `nomenclature.yml` : il etait invisible a la garde des collisions, celle
|
||
qui refuse deux instances aux memes VLAN et VMID. La decouverte lit desormais aussi
|
||
`SITE-*/underlay.yml` et son champ `index`. Un site qui n'en declare pas reste hors du
|
||
compte — il y entre le jour ou il en prend un.
|
||
|
||
OPS-Chezlepro 17 1171-1176
|
||
OPS-Technolibre 23 1231-1236
|
||
OPS-Patient0 29 1291-1296
|
||
SITE-Technolibre 31 —
|
||
|
||
### SITE-Technolibre
|
||
|
||
Squelette du deuxieme site, pour la visite du 14 septembre. Un seul noeud Proxmox, en
|
||
version 9, derriere un OPNsense. Meme forme que le site de Chezlepro — sept machines, six
|
||
zones, memes services — adresse depuis l'index **31** : gestion en `10.31.0.0/24`, zones en
|
||
`10.31.31` a `10.31.36` (le 3e octet reste le numero de VLAN).
|
||
|
||
Trois differences ecrites dans son README plutot que decouvertes sur place : le SPOF est
|
||
total (gabarit et clones sur la meme machine), les clones sont COMPLETS (un clone lie
|
||
n'achete rien sans second noeud), et **rien n'a jamais tourne contre un PVE 9**.
|
||
|
||
`COLLECTE.md` liste les 35 valeurs a relever, dont la question de forme : ce qu'il y a
|
||
entre le noeud et l'OPNsense decide du mode de routage.
|
||
|
||
### Ce qui reste a faire, et qui appartient a l'exploitant
|
||
|
||
`OPS-Chezlepro` passe a l'index **37**, ce qui libere 17 pour le site qui l'utilise deja.
|
||
C'est un renumerotage de quatorze machines : il se fera quand l'exploitant le decidera.
|
||
`SITE-Chezlepro` ne declare donc pas encore son index — il entrera dans le compte ce
|
||
jour-la.
|
||
|
||
## 2026-09-11 (5) — Les correctifs de securite reviennent au quotidien, sans personne
|
||
|
||
Decision de l'exploitant, et elle corrige un mauvais jugement de ma part. J'avais decrit
|
||
la situation correctement — « la seule operation de la flotte qui exige un humain » — puis
|
||
propose un minuteur MAISON pour appliquer le socle, plutot que le mecanisme que Debian
|
||
fournit exactement pour ca.
|
||
|
||
### Ce qui etait desarme, et pourquoi ca ne tenait plus
|
||
|
||
Depuis le 2026-08-25, `common_packages` masquait `apt-daily.timer`,
|
||
`apt-daily-upgrade.timer` et `unattended-upgrades.service` sur toute la flotte.
|
||
|
||
LE MOTIF ETAIT REEL : apres six redemarrages et des heures sans reseau, le rattrapage
|
||
d'`unattended-upgrades` a tenu le verrou dpkg 48 minutes et fait echouer trois
|
||
deploiements de suite.
|
||
|
||
MAIS LA CONTREPARTIE ETAIT ECRITE ICI SANS ETRE MESUREE — « les correctifs ne s'appliquent
|
||
plus qu'au passage de Set-OPS ». Mesure le 2026-09-11 : les sauvegardes tournent seules,
|
||
les certificats se renouvellent seuls, la sante se rapporte seule. Les correctifs, non.
|
||
Une absence de trois jours devenait une exposition sur vingt et une machines.
|
||
|
||
### Ce qui rend la coexistence tenable, et qui n'existait pas en aout
|
||
|
||
_attendre-hote attend apt-daily* ET le verrou avant tout deploiement
|
||
lock_timeout: 300 pose en module_defaults sur chaque playbook de groupe
|
||
Origins-Pattern `-security` SEULEMENT — pas un `upgrade: full`
|
||
Automatic-Reboot false : aucun demon ne redemarre un service seul
|
||
|
||
La course de premier demarrage reste. Elle est desormais ATTENDUE plutot que supprimee —
|
||
c'est la difference entre subir un concurrent et le connaitre.
|
||
|
||
### Rearmer ne se deduit pas de ne plus desarmer
|
||
|
||
Passer le drapeau a `false` SAUTE la tache de desarmement ; il ne defait rien. Une machine
|
||
deja masquee le serait restee pour toujours, et le drapeau aurait dit le contraire de
|
||
l'etat reel. Une tache de rearmement explicite est donc posee le jour meme ou la decision
|
||
s'inverse — masque retire, unite activee, unite demarree.
|
||
|
||
### On arme, on ne declenche pas — et la nuance a coute dix machines
|
||
|
||
La premiere version du rearmement faisait `state: started` sur les trois unites. Demarrer
|
||
`unattended-upgrades.service` apres des semaines de masquage lance son RATTRAPAGE
|
||
immediatement — en plein deploiement :
|
||
|
||
'apt-get autoremove' failed: E: Could not get lock /var/lib/dpkg/lock-frontend.
|
||
It is held by process 185135 (unattended-upgr)
|
||
|
||
Exactement la course qui avait motive le desarmement en aout, rejouee par la tache censee
|
||
le defaire, et malgre `lock_timeout: 300`. Dix machines sur vingt et une.
|
||
|
||
LE TRAVAIL QUOTIDIEN NE PASSE PAS PAR CE SERVICE : `apt-daily-upgrade.timer` appelle
|
||
`apt.systemd.daily`, qui invoque `unattended-upgrade` lui-meme. Le `.service` ne sert
|
||
qu'aux passages de demarrage et d'extinction. L'ACTIVER suffit — il partira au prochain
|
||
redemarrage, sur une machine au repos. Armer un minuteur, en revanche, ne declenche pas
|
||
sa cible : il la programme.
|
||
|
||
### Ce que la sonde `correctifs` devient
|
||
|
||
Elle ne mesure plus un geste manquant mais un mecanisme en panne. Son seuil de 72 h
|
||
cesse d'etre une tolerance pour devenir une alarme : avec un passage quotidien, trois
|
||
jours de retard veulent dire que quelque chose ne tourne plus.
|
||
|
||
## 2026-09-11 (4) — L'heure vient de la frontiere : la derniere dependance vivante tombe
|
||
|
||
L'exploitant a presume qu'un ecosysteme fonctionnel n'avait plus besoin d'Internet pour ses
|
||
operations internes. Mesure sur les quatorze machines : presque.
|
||
|
||
sorties TCP etablies vers une adresse publique aucune, sur 14/14
|
||
apt par le cache du site
|
||
noms internes autoritaires, en local
|
||
unattended-upgrades / apt-daily masked, masked, masked
|
||
|
||
pool 2.debian.pool.ntp.org iburst QUATRE pairs publics, sur les 14
|
||
|
||
Le role `chrony` posait le fuseau et INSTALLAIT le demon sans jamais toucher a ses sources.
|
||
Le defaut de Debian tenait depuis le premier jour : herite, jamais choisi.
|
||
|
||
### Pourquoi c'etait la mauvaise a laisser trainer
|
||
|
||
step-ca emet des certificats a duree courte ; TLS refuse un certificat pas encore valide ;
|
||
Loki rejette une ligne trop loin dans le futur ; les silences d'Icinga reposent sur des
|
||
`ttl`. Une flotte dont les horloges divergent tombe en panne DE L'INTERIEUR, avec des
|
||
symptomes qui ressemblent a tout sauf a une horloge.
|
||
|
||
Et la derive est lente : couper Internet ne casse rien le premier jour. Le systeme parait
|
||
souverain jusqu'a ce qu'il ne le soit plus, sans signal entre les deux.
|
||
|
||
### L'autorite est la frontiere — decision de l'exploitant
|
||
|
||
Elle etait deja `stratum 2`, synchronisee, et ecoutait en 123 sur chaque patte. Il ne
|
||
manquait que le passage : son `pf` est en refus par defaut et aucun flux NTP n'etait
|
||
declare vers elle.
|
||
|
||
a creer : 14 | a retirer : 9
|
||
|
||
Les NEUF retires sont les anciennes regles `port 123 -> !SETOPS_INTERNES` : le 123 etait
|
||
autorise vers N'IMPORTE QUELLE adresse d'Internet. **Le changement resserre autant qu'il
|
||
centralise.** La destination est desormais nommee, alias `SETOPS_FRONTIERE`.
|
||
|
||
### Deux chemins, parce que la topologie en a deux
|
||
|
||
Un tenant n'atteint PAS la frontiere par sa passerelle de zone : `10.17.x.1` est tenue par
|
||
le SDN de Proxmox. Il sort par le lien de transit — `10.0.4.1`, patte libellee TENANTS,
|
||
qui manquait a `opnsense_if_zones` pour la meme raison que `grappe-controle` la veille.
|
||
Une machine du site, elle, a la frontiere pour passerelle directe.
|
||
|
||
La valeur est DERIVEE des deux cotes, jamais ecrite : `instancier` prend la patte sur le
|
||
reseau de transit (via `reseau_transit()`, qui le derive de `passerelle_sortie` — le
|
||
reconnaitre a son prefixe d'adresse aurait marche ici et menti chez le prochain
|
||
hebergeur) ; `site_inventaire` prend la patte de la zone de la machine.
|
||
|
||
14 machines du tenant ^* 10.0.4.1 publiques=0
|
||
7 machines du site ^* 10.0.3x.1 ecarts en nanosecondes
|
||
|
||
### Le drop-in ajoute, il ne retire pas
|
||
|
||
`/etc/chrony/conf.d/` est lu EN PLUS du fichier principal. Y declarer notre serveur sans
|
||
neutraliser le `pool` de Debian aurait laisse cinq sources, dont quatre sur Internet —
|
||
l'horloge centralisee en apparence, la dependance intacte.
|
||
|
||
### La sonde `horloge`, ecrite en meme temps
|
||
|
||
Elle mesure DEUX choses, et la seconde est celle qui manquait : la machine est-elle
|
||
synchronisee, ET contre la source DECLAREE ? Une machine peut etre parfaitement a l'heure
|
||
en interrogeant quatre serveurs publics — c'est exactement l'etat d'avant, et mesurer la
|
||
seule derive l'aurait laisse invisible.
|
||
|
||
Le seuil d'ecart (500 ms) est tres large, et c'est voulu : il doit crier sur une horloge qui
|
||
DECROCHE, pas sur la respiration d'un `chronyd`. L'autre moitie, elle, ne tolere rien.
|
||
|
||
### Ce qui aura toujours besoin d'Internet, et c'est normal
|
||
|
||
Les correctifs de securite, la construction d'un ecosysteme neuf au-dela de ce que le cache
|
||
detient, la resolution des noms externes, le courriel sortant. Aucun n'est une operation
|
||
interne.
|
||
|
||
## 2026-09-11 (3) — `make expositions-etat` : le certificat SERVI, pas celui du disque
|
||
|
||
Renommer une exposition touche cinq choses. Quatre suivent au deploiement ; la cinquieme,
|
||
non — et c'est celle que le navigateur regarde.
|
||
|
||
serveur_powerdns la zone publie le nouveau nom OK
|
||
serveur_keycloak le client OIDC accepte le retour OK
|
||
le service il fabrique ses URL avec le bon nom OK (P67)
|
||
serveur_nginx le vhost repond sur le nouveau nom OK
|
||
client_pki le SAN du certificat porte le nom NON il faut le rejouer
|
||
|
||
Mesure DEUX FOIS le 2026-09-10, sur `grafana -> observatoire` puis `icinga -> vigie`. Le
|
||
symptome est trompeur : le site repond, la page s'affiche, et c'est le NAVIGATEUR qui
|
||
refuse — avec une erreur de certificat que personne ne relie a un renommage fait la veille.
|
||
|
||
### Il regarde dans les deux sens
|
||
|
||
Un nom RESTE dans le SAN apres avoir quitte le plan est un nom que le certificat continue
|
||
d'authentifier. C'est exactement ce qu'avait laisse le premier renommage.
|
||
|
||
Le piege de ce second sens, trouve en l'ecrivant : `client_pki` met TOUJOURS le FQDN et le
|
||
nom court de la machine dans le SAN. Les compter comme vestiges faisait crier le controle a
|
||
chaque execution — et un controle qui crie toujours ne se lit plus.
|
||
|
||
### Deux formes de terminaison, une seule question
|
||
|
||
Le locataire termine son TLS sur un edge ; `sans_exposition` y designe le porteur. Le SITE
|
||
n'a pas d'edge — `site-forge-01` ecoute lui-meme sur 443 — et la variable n'y existe donc
|
||
pas. Le porteur se derive alors de l'application : son `hote` dit quelle machine la sert.
|
||
|
||
OPS-Chezlepro 6 expositions toutes servies, certificat conforme, rien en trop
|
||
SITE 5 expositions INJOIGNABLE depuis ce poste
|
||
|
||
Le second resultat n'accuse pas le service, et le controle le dit : les zones du SITE ne
|
||
sont pas routees depuis le plan d'administration du locataire. Le mur est la frontiere.
|
||
|
||
### Pourquoi pas une preuve
|
||
|
||
`make prouver` est STATIQUE — il lit le depot, zero appel reseau, et c'est ce qui le rend
|
||
rejouable partout. Rien de statique ne peut lire un certificat SERVI : il faut ouvrir la
|
||
connexion.
|
||
|
||
Les quatre controles a la demande n'etaient nommes nulle part comme famille —
|
||
`routes-fabric-etat` n'apparaissait dans aucune documentation. Ils ont maintenant leur
|
||
section (§8 des runbooks). Une garde que personne ne sait lancer ne sert a rien.
|
||
|
||
## 2026-09-11 (2) — La piste d'audit sort de la machine auditee
|
||
|
||
`auditd` tournait, onze regles etaient armees, et rien de tout cela ne servait a grand-chose.
|
||
Quatre manques, mesures avant d'etre combles.
|
||
|
||
### 1. Rien ne sortait de la machine
|
||
|
||
C'etait le trou decisif. La configuration d'Alloy contenait **zero** reference a l'audit, et
|
||
les cinq greffons d'`auditd` etaient tous `active = no`. La piste vivait uniquement sur la
|
||
machine auditee : qui la compromet possede la preuve de l'avoir fait, et le fichier est
|
||
precisement ce qu'on efface en premier.
|
||
|
||
Alloy lit maintenant `/var/log/audit/audit.log` et le pousse vers Loki. **Aucune permission
|
||
a poser** — mesure, pas suppose :
|
||
|
||
uid=999(alloy) groupes=989(alloy),4(adm),999(systemd-journal)
|
||
drwxr-x--- 2 root adm /var/log/audit
|
||
|
||
ON A ECARTE L'AUTRE VOIE, et pour une raison precise : activer le greffon `syslog.conf`
|
||
ferait passer les evenements par journald, deja collecte — mais journald applique une LIMITE
|
||
DE DEBIT. Une rafale d'evenements d'audit, c'est-a-dire exactement le moment qui compte,
|
||
serait tronquee en silence.
|
||
|
||
L'activation DERIVE de l'appartenance au groupe (`serveur_durci` dans `group_names`), pas
|
||
d'une liste par instance : une liste de plus qui suit une autre prendrait du retard.
|
||
|
||
### 2. Aucune sonde, alors que le depot racontait lui-meme la panne
|
||
|
||
`roles/auditd/` n'avait pas de `meta/` du tout. Et son propre `defaults/main.yml` documentait
|
||
l'incident du 2026-08-30 : onze regles armees dans le noyau sur quinze machines ou `auditd`
|
||
etait mort, `auditctl -l` les affichant toutes.
|
||
|
||
La sonde `audit` mesure donc la COLLECTE — service actif **et** journal qui a bouge. Le
|
||
nombre de regles part en **perfdata, jamais en verdict** : c'est exactement le chiffre qui
|
||
restait bon pendant la panne.
|
||
|
||
Declaree dans `roles/serveur_durci/meta/` (le GROUPE) et deposee par `roles/auditd/` : la
|
||
derivation de supervision ne traverse pas les roles-taches. Meme lecon que la sonde
|
||
`correctifs`, deplacee la veille.
|
||
|
||
### 3. La retention n'etait declaree nulle part
|
||
|
||
Mesure sur `infra-edge-01` au repos, deploiement termine :
|
||
|
||
3 380 octets / 60 s -> ~4,6 Mo/jour
|
||
Debian : 8 Mo x 5 = 40 Mo -> ~9 jours
|
||
|
||
Sauf qu'une reconstruction a elle seule en brule 4 Mo. Portee a 16 Mo x 8 = 128 Mo (~28
|
||
jours), et le raisonnement a change en route : depuis (1), l'archive n'est plus ici, elle est
|
||
chez le collecteur. Le local n'est plus qu'un TAMPON pour traverser une panne de Loki.
|
||
|
||
### 4. Les regles ignoraient le materiel qui signe tout le reste
|
||
|
||
Elles surveillaient `passwd`, `sudoers`, `sshd_config`, `/etc/apt/` — les fichiers par
|
||
lesquels on prend un COMPTE. Aucune ne regardait `/etc/step/certs/`, la cle privee de
|
||
l'hote : qui la copie parle ensuite au nom de la machine devant toute la flotte, sans
|
||
toucher a un mot de passe ni declencher une seule des regles precedentes.
|
||
|
||
-w /etc/step/ -p wa -k pki
|
||
-w /etc/step-ca/ -p wa -k pki-autorite (autorite seulement)
|
||
|
||
**LA SECONDE EST CONDITIONNELLE, ET CE N'EST PAS UN DETAIL.** `auditctl` REFUSE un `-w` vers
|
||
un chemin absent, et `augenrules` fait alors echouer le chargement ENTIER — `auditd` ne
|
||
demarre plus, faute de sa dependance. Poser cette ligne sur les treize machines qui ne sont
|
||
pas l'autorite rejouerait exactement la panne du 2026-08-30. Verifie sur l'autorite d'abord,
|
||
seule : 13 regles armees, `audit-rules` non en echec, sonde a 0.
|
||
|
||
### Un effet de bord assume
|
||
|
||
Creer `roles/serveur_durci/` pour porter la declaration en fait un role a part entiere : il
|
||
lui faut son README et sa `meta/authentification.yml`, comme `serveur_debian`. Trois preuves
|
||
l'ont exige (P29, P31, P48) — elles ont fait exactement leur travail.
|
||
|
||
## 2026-09-11 (1) — Reconstruction a froid : les noms derives naissent justes
|
||
|
||
Quatrieme reconstruction depuis zero de Chezlepro, demandee pour une raison precise : trois
|
||
roles dependent desormais d'une variable que le GENERATEUR pose. En incrementiel ils avaient
|
||
marche parce que la configuration existait deja. A froid, si `instancier` ne posait pas
|
||
`<groupe>_hostname` sur un chemin quelconque, le defaut du role reprendrait la main **en
|
||
silence** — et il tomberait juste pour `forge` et `cloud` (la convention coincide), faux pour
|
||
`observatoire`.
|
||
|
||
14/14 VM rasees puis recreees 32 min 14 s
|
||
un seul echec, et il n'a rien a voir avec les noms
|
||
|
||
Le certificat ne A FROID porte `observatoire` et `vigie`, et aucun ancien nom. Les deux
|
||
retours SSO derivent juste, sans reprise :
|
||
|
||
observatoire redirect_uri=https%3A%2F%2Fobservatoire.chezlepro.internal%2Flogin%2Fgeneric_oauth
|
||
vigie redirect_uri=https%3A%2F%2Fvigie.chezlepro.internal%2Foauth2%2Fcallback
|
||
|
||
En incrementiel, le SAN avait demande un second passage de `client_pki`. A froid, il naît
|
||
juste du premier coup — la sequence penible n'existe qu'en incrementiel.
|
||
|
||
### Une reussite n'est pas un contenu
|
||
|
||
L'echec unique, sur `infra-mail-01` et sur elle seule : `get_url` a rendu **0 octet sans
|
||
erreur**. Le cache a servi un 200 au corps vide. Le role a continue, satisfait.
|
||
|
||
Le defaut ne s'est pas lu la. Il s'est lu deux cents lignes plus loin, dans un `apt` qui
|
||
accusait la SIGNATURE :
|
||
|
||
Missing key 35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3, which is needed to verify signature
|
||
|
||
Un message qui envoie chercher une cle revoquee chez le fournisseur, alors que le fichier
|
||
local faisait zero octet. Meme famille que l'index tronque du cache la veille : l'octet
|
||
manquant se denonce toujours ailleurs qu'ou il manque.
|
||
|
||
**Et la garde posee hier ne mordait pas.** « Retirer une ressource VIDE avant de la
|
||
redemander » ne vaut qu'au passage SUIVANT : au premier, le fichier n'existe pas encore, il
|
||
n'y a rien a retirer. Elle repare le second essai, elle ne protege pas le premier.
|
||
|
||
Les cinq roles qui telechargent une cle la MESURENT maintenant dans la meme execution — le
|
||
`until` exige une taille non nulle (cinq tentatives), puis une assertion nomme la vraie
|
||
cause. **P68** garde le motif. Les roles qui deposent une cle embarquee (`copy` depuis
|
||
`files/`) n'entrent pas dans le compte : rien de reseau ne s'interpose.
|
||
|
||
`infra-mail-01` redeployee : la cle fait 1022 octets, comme les treize autres.
|
||
|
||
### Les trois bruits connus
|
||
|
||
Revenus comme prevu, et ce ne sont pas des regressions : `auditd` livre sans regles au
|
||
gabarit, la course sur le verrou `dpkg`, l'index d'`apt.grafana.com` absent du cache
|
||
hors-ligne.
|
||
|
||
## 2026-09-10 (18) — `vigie` pour Icinga, et la quatrieme liste tombe
|
||
|
||
Suite du renommage precedent, decide par l'exploitant : `vigie` est le nom de la console de
|
||
supervision. Le mot avait ete ecarte pour Grafana precisement parce qu'il connote la guette
|
||
du danger — c'est le metier d'Icinga, pas celui d'un tableau de bord.
|
||
|
||
icinga.chezlepro.internal -> vigie.chezlepro.internal
|
||
icinga.technolibre.internal -> vigie.technolibre.internal
|
||
icinga.lab.chezlepro.internal -> vigie.lab.chezlepro.internal
|
||
|
||
Les trois locataires en meme temps : deux vocabulaires entre ecosystemes, c'est le defaut
|
||
qu'on vient de corriger.
|
||
|
||
### La quatrieme liste
|
||
|
||
`serveur_oauth2_proxy_redirect_url` etait posee A LA MAIN dans les group_vars de chaque
|
||
instance, avec le FQDN recopie du plan. Quatrieme recopie du meme nom, apres les SAN (deja
|
||
derives), les clients Keycloak (P66) et les noms publics des roles (P67).
|
||
|
||
Elle derive maintenant de `serveur_oauth2_proxy_hostname`, que `instancier` pose depuis
|
||
`expose:`. Le repli reste VIDE et non fabrique : l'assertion du role doit refuser un
|
||
deploiement sans exposition, pas inventer un nom que personne ne resout. La ligne a ete
|
||
retiree des trois instances.
|
||
|
||
### Ce qu'il faut savoir pour le prochain renommage
|
||
|
||
Changer une exposition ne suffit pas a refaire le certificat de l'edge. Deployer
|
||
`serveur_nginx` pose le vhost mais laisse le SAN en arriere ; c'est `client_pki` qui
|
||
reemet. L'ordre eprouve deux fois aujourd'hui :
|
||
|
||
serveur_powerdns -> serveur_keycloak -> le service -> serveur_nginx -> client_pki
|
||
|
||
Le certificat servi a ete VERIFIE apres coup, pas suppose : il porte `observatoire` et
|
||
`vigie`, et plus aucun des deux anciens noms. Aucune garde ne compare encore le SAN SERVI aux
|
||
expositions du plan — ce serait un controle a la demande, pas une preuve statique.
|
||
|
||
## 2026-09-10 (17) — La console d'observabilite s'appelle pareil des deux cotes
|
||
|
||
Deux ecosystemes, deux noms pour le meme service : `grafana.chezlepro.internal` chez le
|
||
locataire, `tableaux.genese.internal` au site. Le second etait de notre fait, pose le jour
|
||
meme en montant la pile d'observabilite du site.
|
||
|
||
grafana.chezlepro.internal -> observatoire.chezlepro.internal
|
||
tableaux.genese.internal -> observatoire.genese.internal
|
||
|
||
### Pourquoi pas `grafana`
|
||
|
||
Un nom de produit dans une URL se grave ailleurs qu'a l'ecran : dans les SAN du certificat,
|
||
dans les URI de redirection du SSO, dans les signets de l'exploitant. Remplacer Grafana
|
||
obligerait alors a renommer le service — donc a refaire le certificat et le client Keycloak
|
||
pour une raison qui n'a rien a voir avec eux.
|
||
|
||
Le plan du locataire nommait deja quatre services par leur FONCTION (`auth`, `cloud`,
|
||
`bureau`, `forge`) et deux par leur PRODUIT (`grafana`, `icinga`). Celui-ci rejoint le
|
||
registre majoritaire. `icinga` reste, pour l'instant : c'est un autre geste.
|
||
|
||
### Ce que le renommage a revele
|
||
|
||
Les URI de redirection des clients Keycloak **repetent a la main** les FQDN que
|
||
`plan/applications.yml` declare dans `expose:`. Rien ne les relie. Renommer l'exposition
|
||
regenere `hosts.yml`, le certificat et la zone DNS — et laisse le client OIDC viser l'ancien
|
||
nom. Keycloak ne proteste pas : c'est l'utilisateur qui le decouvre au retour du SSO.
|
||
|
||
**P66** (`preuve_clients_oidc_vises_sur_une_exposition`) : chaque `redirect_uris` et chaque
|
||
`web_origins` doit viser un FQDN qu'une application expose. Ecrite EN MEME TEMPS que le
|
||
renommage, parce que c'est le seul moment ou l'on sait encore que les deux listes existent.
|
||
|
||
P66 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose
|
||
|
||
### Et une TROISIEME liste, que le deploiement a revelee
|
||
|
||
Le renommage deploye, nginx servait le nouveau nom, le certificat le portait, les deux zones
|
||
le publiaient — et Grafana fabriquait toujours son URL de retour OIDC avec l'ancien :
|
||
|
||
redirect_uri=https%3A%2F%2Fgrafana.chezlepro.internal%2Flogin%2Fgeneric_oauth
|
||
|
||
Quatre roles — grafana, forgejo, nextcloud, keycloak — portaient en defaut une DEVINETTE du
|
||
nom sous lequel ils sont servis (`grafana.{{ domaine_interne }}`). Tant que le plan suit la
|
||
meme convention, la devinette tombe juste et rien ne revele qu'il y a deux sources. Keycloak
|
||
avait deja son remede, dans le role, en relisant le plan a l'execution — un remede par role,
|
||
donc trois roles sans remede.
|
||
|
||
`instancier` derive desormais `<groupe>_hostname` de l'exposition declaree, quand elle est
|
||
UNIQUE (deux expositions ne designent aucun nom canonique : le role garde alors la main).
|
||
Six services en heritent : collabora, forgejo, grafana, keycloak, nextcloud, oauth2_proxy.
|
||
|
||
**P67** garde la derivation. Les quatre instances ont ete regenerees.
|
||
|
||
### Une erreur de lecture, au passage
|
||
|
||
En cherchant pourquoi `tableaux.genese.internal` ne resolvait pas depuis le poste, on a
|
||
conclu que le nom n'etait pas dans la zone. Il y etait — PowerDNS le publiait correctement,
|
||
comme `sauvegarde.genese.internal`. Ce qui ne les connaissait pas, c'est le RESOLVEUR DU
|
||
POSTE (`192.168.10.10`), qui porte trois entrees faites a la main et aucune delegation de
|
||
`genese.internal`. L'index consulte n'etait pas la source.
|
||
|
||
## 2026-09-10 (16) — La frontiere journalise a nouveau, et une garde veille cette fois
|
||
|
||
Doctrine posee par l'exploitant : *des lors que le site a son Loki, la journalisation de la
|
||
frontiere et des hyperviseurs doit y etre dirigee.*
|
||
|
||
46 681 lignes recues de la frontiere en 30 min
|
||
site : 68 services, tous OK
|
||
|
||
### Ce qui etait casse, et depuis quand
|
||
|
||
La destination syslog d'OPNsense pointait `10.17.20.11:3100` — une adresse de **TENANT**,
|
||
sur le port de Loki, en **UDP**, ce que Loki ne sait pas lire. La VM a ete rasee ; une cible
|
||
morte a arrete TOUTE la journalisation d'OPNsense pendant dix jours. La destination a ete
|
||
ETEINTE pour reparer, et jamais remplacee.
|
||
|
||
Deux fautes en une : la frontiere ne journalisait plus nulle part, **et** sa cible etait chez
|
||
un locataire — ce que D-87 refuse dans l'autre sens.
|
||
|
||
L'intention, elle, etait juste : les bonnes facilites, `info` et au-dessus. On l'a REPRISE
|
||
telle quelle et change uniquement la destination — ces choix avaient ete faits, ce n'etait
|
||
pas a nous de les redecider.
|
||
|
||
### Un traducteur, parce que Loki ne parle pas syslog
|
||
|
||
`loki.source.syslog` dans l'Alloy qui tourne DEJA sur le collecteur — un second agent
|
||
n'aurait servi qu'a tenir deux configurations en phase. Port 1514 et non 514 : au-dessus de
|
||
1024, donc ecoute sans privilege. *Un collecteur qui aurait besoin des droits du systeme
|
||
pour entendre un equipement serait un mauvais echange.*
|
||
|
||
### Une regle de relabel sans garde n'ignore pas : elle EFFACE
|
||
|
||
Trois quarts d'heure sur un symptome absurde — la configuration deposee portait
|
||
`host = "bifrost-1"`, visible a l'oeil dans le fichier, et le flux arrivait sans etiquette.
|
||
|
||
rule { source_labels = ["__syslog_connection_ip_address"]
|
||
target_label = "host" } # sans regex
|
||
|
||
Sans `regex`, la regle correspond **toujours** — meme quand sa source n'existe pas — et pose
|
||
`host = ""`. Loki jette une etiquette vide, et celle de l'ecouteur disparaissait avec elle.
|
||
Les deux autres regles etaient gardees ; celle-la ne l'etait pas.
|
||
|
||
Et la verification finale a corrige une seconde erreur, la mienne : `label/host/values`
|
||
rendait une liste en CACHE. La requete directe `{host="bifrost-1"}` rendait bien un flux.
|
||
*Un index qui ne montre pas une chose ne prouve pas qu'elle n'existe pas.*
|
||
|
||
### La garde, qui manquait depuis l'incident
|
||
|
||
`journaux-frontiere` demande a **Loki ce qu'il a RECU**, pas a la frontiere ce qu'elle croit
|
||
avoir envoye — la destination est le seul juge, et un emetteur qui parle a un trou noir se
|
||
porte tres bien.
|
||
|
||
**La fenetre est DERIVEE du debit observe**, pas choisie : ~1 500 lignes/minute mesurees.
|
||
Une frontiere qui filtre ne se tait jamais dix minutes.
|
||
|
||
Trois etats, tous eprouves sur une copie :
|
||
|
||
frontiere muette rc=2 « LA FRONTIERE NE JOURNALISE PLUS »
|
||
Loki injoignable rc=3 « la sonde ne peut rien affirmer »
|
||
debit normal rc=0 « 973 ligne(s) recue(s) en 10m »
|
||
|
||
Le `3` compte autant que le `2` : *« je ne peux rien affirmer » n'est pas « c'est casse »*.
|
||
Une sonde qui confondrait les deux accuserait la frontiere d'un silence qui serait le sien.
|
||
|
||
## 2026-09-10 (15) — Une source vide n'est pas « tout le monde »
|
||
|
||
Les journaux de la fabric, et le defaut de classe que leur mise en place a revele.
|
||
|
||
journaux 10 hotes dans Loki (7 VM du site + 3 hyperviseurs)
|
||
site 67 services, tous OK (51 avant)
|
||
|
||
### Metriques TIREES, journaux POUSSES — et ce n'est pas un caprice
|
||
|
||
Un scrape part du collecteur et doit REVENIR : il exige un chemin symetrique, que la route
|
||
par defaut gelee (D-57) interdit. Un push part de la source et n'attend qu'un accuse : la
|
||
route SPECIFIQUE vers les zones du site suffit — celle qu'on venait justement de completer.
|
||
|
||
### Trois trous dans les generateurs, du meme jour
|
||
|
||
**Le devis de la frontiere ne connaissait `fabric` qu'en DESTINATION.** Un flux entrant
|
||
depuis la fabric tombait dans « rien d'autre n'entre » et n'emettait AUCUNE regle, sans
|
||
rien dire. Meme forme que le trou des integrations universelles, trouve le matin meme.
|
||
|
||
**La regle etait sur la mauvaise patte.** D-61 fait raisonner le devis en ARRIVEE ; la
|
||
regle etait posee sur l'interface de la DESTINATION. `opt7` — la patte face a la fabric,
|
||
libellee PROXMOX — manquait a la table des zones, qui ne recensait que le site. Elle y est,
|
||
et l'interface se derive desormais du reseau ou vivent les hyperviseurs.
|
||
|
||
**Et le plus grave : `fabric` etait un mot RECONNU mais NON RESOLU.** Le generateur de
|
||
pare-feu d'hote l'acceptait comme valide, ne trouvait aucune adresse, et une source vide
|
||
produit une regle sans `saddr` :
|
||
|
||
tcp dport 3100 accept # ouvert a tout le monde
|
||
|
||
*Un mot reconnu mais non resolu est pire qu'un mot inconnu* : celui-ci serait refuse a la
|
||
validation, celui-la produit une porte grande ouverte qui a l'air d'un flux precis.
|
||
|
||
### La classe entiere, refermee
|
||
|
||
Le defaut n'etait pas propre a `fabric`. **Toute** paire nommant un ensemble et ne
|
||
resolvant rien ouvrait le port. Trouve en lisant les fichiers generes :
|
||
|
||
tcp dport 5665 accept # serveur_icinga, sur le mon-01 d'un TENANT
|
||
|
||
L'API de supervision ouverte a tous, parce que `pair: serveur_backup` ne resout rien — cet
|
||
ecosysteme depose son etat chez le site et n'a pas de depot a lui.
|
||
|
||
`expositions` et `externe`, EUX, veulent bien dire « tout le monde » : ce sont des services
|
||
publies, et leur ouverture est une INTENTION. Ailleurs : **pas de source, pas de regle** —
|
||
et le generateur le DIT, parce qu'un flux tu en silence est une porte qu'on croit fermee.
|
||
|
||
Trois regles se sont refermees. Chacune verifiee AVANT d'appliquer :
|
||
|
||
| regle | verdict |
|
||
|---|---|
|
||
| `site-forge-01:3000` | rien n'ecoute — la forge sert 443 |
|
||
| `mon-01:5665` (tenant) | deux autres regles couvrent les pousseurs |
|
||
| `site-mon-01:3000` | **Grafana ecoute** — a corrige avant de fermer |
|
||
|
||
### Grafana : `edge` ET `admin`
|
||
|
||
Chez un tenant, le nginx d'edge termine le TLS : `edge` resout, c'est le seul chemin. Le
|
||
SITE n'a PAS d'edge — chaque service s'y sert lui-meme. `edge` n'y resolvait donc rien, et
|
||
la console etait ouverte a l'Internet PAR ACCIDENT, sous un commentaire qui parlait d'un
|
||
edge inexistant.
|
||
|
||
Ajouter `admin` rend explicite ce qui etait accidentel. Et la mesure a montre autre chose :
|
||
**Grafana etait deja injoignable depuis le poste** — la frontiere ne laisse pas passer le
|
||
3000. La regle ouverte ne servait a rien, sauf a rester ouverte si la frontiere s'ouvrait
|
||
un jour.
|
||
|
||
*Le site a donc une console deployee et sans chemin d'acces.* Ce n'est pas corrige ici,
|
||
mais c'est dit.
|
||
|
||
## 2026-09-10 (14) — Le site surveille enfin sa fabric
|
||
|
||
La supervision du site voyait ses sept VM et rien d'autre. Mesure de depart, depuis
|
||
`site-mon-01` : les trois hyperviseurs, les neuf pattes de la frontiere et **sa propre
|
||
passerelle par defaut** rendaient tous 100 % de perte au ping.
|
||
|
||
16 hotes UP / 16 dont 9 pattes de frontiere en controle ACTIF
|
||
11 cibles Prometheus dont 3 hyperviseurs, job `fabric` separe
|
||
|
||
### Le partage : ce qui peut porter un agent, et ce qui ne le peut pas
|
||
|
||
**Les hyperviseurs** portent `node_exporter` — D-48 l'autorise. Chacun expose 6 600 a
|
||
7 900 lignes de metriques, dont **1 005 unites systemd avec leur etat** : soit exactement
|
||
ce que la sonde `sante` mesurait, plus la charge, le disque et l'horloge.
|
||
|
||
**Ils n'entrent PAS dans le socle**, et c'est le point delicat. Un hyperviseur Proxmox
|
||
n'est pas une VM de la flotte : lui appliquer `serveur_durci` reecrirait son pare-feu, son
|
||
SSH et ses sysctl — sur la machine qui tient tout le reste. Ils vivent dans leur propre
|
||
groupe, hors de `GROUPE_SOCLE` et de `hotes_actifs`.
|
||
|
||
**La frontiere** n'accueille aucun agent : controle ACTIF, une entree par PATTE. La
|
||
frontiere est un seul boitier, mais chaque zone depend de SON interface — une interface
|
||
eteinte coupe une zone pendant que les autres vont bien, et on en a deja vu (les routes
|
||
creees `disabled` le 2026-09-02). Un ping vers une seule adresse dirait « la frontiere est
|
||
debout » et manquerait ce cas.
|
||
|
||
### On TIRE, on ne pousse pas — et c'est la route gelee qui le decide
|
||
|
||
J'avais propose du passif, et il avait ete valide. **La mesure a dit non** :
|
||
|
||
asgard -> 10.0.36.11 via 192.168.11.254 (le routeur du site)
|
||
route par defaut via 192.168.11.254 GELEE (D-57)
|
||
|
||
Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site : le porteur
|
||
de sante y expirait en 20 s. Le remede evident — router 10.0.0.0/8 par la frontiere —
|
||
touche la route par defaut d'une machine EN SERVICE, ce que D-57 interdit. On tire donc,
|
||
dans le sens que la frontiere route deja.
|
||
|
||
### Le vrai defaut : une liste qui n'a pas suivi
|
||
|
||
Le mecanisme de routage EXISTAIT sur `vmbr0`, avec un commentaire du 2026-08-26 tenant
|
||
exactement le raisonnement qu'on venait de refaire. Sa liste s'arretait a `10.0.34.0/24` :
|
||
|
||
le site declare 6 zones (31 -> 36)
|
||
les hyperviseurs 4 routes (31 -> 34)
|
||
|
||
Les zones `sauvegarde` (35) et `supervision` (36) sont nees, **les routes n'ont pas suivi**.
|
||
Le symptome ne ressemblait pas a une route manquante : il ressemblait a un pare-feu, puis a
|
||
un probleme de reseau chez l'exploitant.
|
||
|
||
**`make routes-fabric-etat`** compare desormais TROIS choses : les zones declarees, les
|
||
routes declarees dans `/etc/network/interfaces`, et les routes vivantes dans le noyau. Le
|
||
cas le plus traitre est le troisieme — *vivante mais non declaree* : tout fonctionne, la
|
||
supervision est verte, et la panne attend la prochaine maintenance. Une garde qui ne
|
||
comparerait que le vivant ne le verrait jamais. Eprouvee dans les deux sens.
|
||
|
||
### Deux defauts trouves en construisant
|
||
|
||
**`bifrost-2` est un nom reserve, pas un boitier.** L'underlay le disait — « pour que les
|
||
noms soient reserves » — mais en PROSE, illisible par le moteur. Le surveiller aurait donne
|
||
deux CRITICAL permanents pour un equipement absent. Il porte desormais `etat: reserve` ; le
|
||
jour ou le second boitier arrive, retirer la cle le fait entrer dans la supervision.
|
||
|
||
**Un service passif n'existe que pour un hote qui peut POUSSER.** Les hyperviseurs sont
|
||
dans `client_metrique` — c'est par ce groupe qu'on leur deploie node_exporter — et la
|
||
derivation en tirait `asgard!metriques`. Icinga refusait la configuration ENTIERE. Meme en
|
||
definissant l'hote, le service serait reste UNKNOWN pour toujours. *L'appartenance a un
|
||
groupe sert deux choses qui ne coincident pas toujours : a qui l'on deploie, et de qui l'on
|
||
attend un rapport.* La seconde se lit sur `client_sante`, et nulle part ailleurs.
|
||
|
||
### Les commutateurs restent dehors
|
||
|
||
Choix de l'exploitant, coherent avec D-48 : ils sont hors flotte, et rien ne les rend
|
||
interrogeables sans leur ouvrir un acces qu'on ne veut pas leur ouvrir.
|
||
|
||
## 2026-09-10 (13) — D-88 : le noeud du gabarit, point unique de la REPRODUCTION
|
||
|
||
Question de l'exploitant : *« le modele vit sur vishnu, les clones sont sur asgard — qu'
|
||
arriverait-il si vishnu tombait ? »*. Mesuree, la reponse se coupe en deux.
|
||
|
||
**Les donnees survivent.**
|
||
|
||
pool CephNVMe size=3 min_size=2
|
||
OSD sur asgard, gandalf, vishnu
|
||
base-9006-disk-0, base-9006-disk-1 repliquees
|
||
|
||
L'image reste lisible avec un noeud en moins, et les quatorze VM d'un ecosysteme tournent
|
||
ailleurs sur leurs propres disques Ceph : elles ne s'apercoivent de rien.
|
||
|
||
**La reproduction, non.** La configuration du gabarit porte le nom du noeud dans son chemin
|
||
meme — `/etc/pve/nodes/vishnu/qemu-server/9006.conf` — et le clonage appelle
|
||
`nodes/vishnu/qemu/9006/clone`. Noeud eteint, API muette, **aucune VM nouvelle ne peut
|
||
naitre**. Or la reproduction est ce que ce depot existe pour garantir.
|
||
|
||
### La decision est d'ASSUMER la dependance et de la rendre COURTE
|
||
|
||
Pas de la supprimer. Depuis que le disque du gabarit vit sur un stockage partage (migration
|
||
du matin), la remise en route est un **deplacement de fichier de configuration** — quelques
|
||
minutes, aucun mouvement de donnees — suivi de la declaration `gabarit.noeud`. Sur un
|
||
stockage local, il aurait fallu recopier 16 Go ou refabriquer le gabarit.
|
||
|
||
*Un benefice de la migration sur Ceph qu'on n'avait pas cherche : elle a raccourci une panne
|
||
qu'on n'avait pas encore nommee.*
|
||
|
||
### Trois endroits, parce qu'un seul ne suffit pas
|
||
|
||
- **D-88** dans les decisions : la dependance est nommee et son perimetre borne ;
|
||
- **`runbooks-exploitation.md` §7** : la manoeuvre, dans l'ordre, avec ce qui casse si on
|
||
l'inverse — deplacer sans declarer laisse `gabarit_etat` en ecart, declarer sans deplacer
|
||
fait echouer le clonage ;
|
||
- **le plan du site**, dans le bloc `gabarit` lui-meme : c'est la que l'exploitant lit
|
||
`noeud: vishnu`, et c'est donc la que l'avertissement doit vivre.
|
||
|
||
### Ce qui n'est PAS fait, et qui est dit
|
||
|
||
**Rien ne MESURE cette dependance.** `gabarit_etat` compare le declare au reel ; il ne
|
||
demande pas si le noeud du gabarit heberge autre chose que le gabarit. Deux remedes de fond
|
||
restent ouverts : deplacer le gabarit la ou vivent deja les VM (ce qui ne supprime pas le
|
||
point unique mais cesse d'en avoir DEUX), ou une garde qui refuse quand la reproduction
|
||
depend d'un noeud qui ne porte rien d'autre.
|
||
|
||
*Une dependance qu'on documente sans la mesurer reste une dependance qu'on decouvrira au
|
||
mauvais moment.*
|
||
|
||
## 2026-09-10 (12) — Un fichier vide existe, et une sonde pour les correctifs
|
||
|
||
### La garde « fichier entier » — et pourquoi il a fallu DEUX corrections
|
||
|
||
Un telechargement interrompu laisse un fichier de zero octet, **qui existe**. Toutes les
|
||
gardes de ce depot demandaient *« ce fichier est-il la ? »* :
|
||
|
||
- name: Cette ressource est-elle deja recuperee ?
|
||
ansible.builtin.stat: ...
|
||
- name: Telecharger la cle
|
||
when: not ..._present.stat.exists
|
||
|
||
`infra-mail-01` a garde une cle smallstep de **0 octet** apres l'epreuve hors ligne. Treize
|
||
machines portaient 1022 octets, elle portait le vide — et `apt` refusait le depot.
|
||
|
||
**La premiere correction n'a pas suffi.** Ajouter le controle de taille a bien fait
|
||
s'executer la tache (`ok: [infra-mail-01]`) — et le fichier faisait **toujours 0 octet au
|
||
passage suivant**. `get_url` sur une destination existante emet une requete CONDITIONNELLE :
|
||
l'amont repond « non modifie », le module rend `ok`, la ruine reste. *Le play etait vert et
|
||
ne reparait rien.*
|
||
|
||
Il faut donc **effacer avant de redemander**. `state: absent` ne mord que sur un fichier
|
||
vide : une cle valide n'est jamais retiree.
|
||
|
||
Controle negatif : fichier vide volontairement, role rejoue → **0 → 1022 octets, 0 erreur
|
||
apt**. Cinq roles portent le patron complet.
|
||
|
||
*Cette etape intermediaire est la lecon de la journee en miniature : un `failed=0` ne dit
|
||
pas que quelque chose a ete fait.*
|
||
|
||
### La sonde `correctifs` — 23e sonde
|
||
|
||
Set-OPS **desarme** `unattended-upgrades` (masque sur les vingt et une machines) et applique
|
||
les correctifs par `upgrade: full` au passage du socle. Choix defendable — un minuteur de
|
||
fond qui se dispute le verrou `dpkg` avec un deploiement est un tirage au sort, et il a fait
|
||
decrocher `infra-mail-01` d'une reconstruction entiere le matin meme.
|
||
|
||
Mais rien ne disait **quand le geste etait du**. Vingt-deux sondes, aucune sur le retard de
|
||
securite : une flotte pouvait deriver des mois en restant verte.
|
||
|
||
Elle mesure les paquets en attente venant d'un depot de SECURITE, **et depuis quand** — le
|
||
retard seul ne dit rien, c'est sa duree qui transforme un correctif publie en exposition
|
||
acceptee. Trois etats eprouves sur une copie : `rc=0` a jour, `rc=1` des le premier
|
||
correctif, `rc=2` au-dela du seuil.
|
||
|
||
**Deux choix dits franchement.** Elle ne lance PAS `apt-get update` : une sonde qui
|
||
rafraichit l'index toutes les quinze minutes deviendrait la cause de la panne qu'elle
|
||
surveille. Et le seuil de 72 h est un CHOIX D'EXPLOITATION, pas une derivation — les autres
|
||
seuils se deduisent d'un mecanisme (le certificat vit 24 h, donc on alerte a 6 h) ; ici le
|
||
mecanisme est un geste humain, il n'y a rien a en deduire.
|
||
|
||
### P64 refusait une declaration correcte
|
||
|
||
Declaree dans `common_packages`, la sonde etait **invisible d'Icinga** : la derivation croise
|
||
les GROUPES, et ce role n'en est pas un. Pire, `client_sante` l'aurait retiree comme
|
||
orpheline au passage suivant — la garde ecrite le matin meme.
|
||
|
||
Mais la declarer dans `serveur_debian` faisait echouer P64, qui exigeait declaration et depot
|
||
dans le MEME role. Or `serveur_debian` et `serveur_durci` sont des roles de **declaration
|
||
pure** : ils portent `flux.yml`, `authentification.yml`, `supervision.yml`, et pas une tache.
|
||
Le travail est fait par les roles que leur playbook applique.
|
||
|
||
**Une garde qui force a contourner ce qu'elle protege est un defaut.** P64 suit desormais le
|
||
playbook du groupe : ce qu'il applique compte comme depose. Elle ne s'affaiblit pas — elle
|
||
apprend ou le depot a le droit de vivre. Controle negatif refait : depot desactive → refus,
|
||
restaure → 65 OK.
|
||
|
||
### Etat mesure
|
||
|
||
sonde `correctifs` 21 / 21 machines vertes
|
||
prouver 65 preuves, 23 sondes, 0 echec
|
||
ansible-lint 0 defaut
|
||
|
||
## 2026-09-10 (11) — Le trou reste ouvert derriere la porte qu'on croyait fermee
|
||
|
||
Troisieme reconstruction complete de Chezlepro : **32 minutes, un seul echec — le mien**,
|
||
introduit par le passage au cache et revele par la naissance.
|
||
|
||
clonage 3 min 52
|
||
placement {'asgard': 14}
|
||
`fatal:` dans le journal 0, pas meme un ignore
|
||
servi par le cache +963 Mo
|
||
tire de l'Internet +183 Mo -> 81 % servis localement
|
||
|
||
### `get_url` IGNORE la configuration d'apt
|
||
|
||
Le mandataire pose dans `/etc/apt/apt.conf.d/` ne vaut **que pour apt**. Les six cles de
|
||
signature sortaient donc TOUJOURS en direct, malgre tout le travail sur le remap.
|
||
|
||
Et ce n'etait pas theorique. Depuis `collab-01` :
|
||
|
||
en direct grafana 200 collabora TIMEOUT
|
||
via le cache collabora 200
|
||
|
||
La route directe vers Collabora ne passe pas depuis cette zone. Trois cles sur quatre
|
||
avaient reussi PAR CHANCE — parce que leurs fournisseurs, eux, etaient joignables. Le meme
|
||
geste corrige les deux : plus rien ne sort, et la machine qui n'avait pas de route en
|
||
trouve une.
|
||
|
||
### Pourquoi seule une NAISSANCE pouvait le montrer
|
||
|
||
Mes deploiements de convergence rendaient `failed=0` — parce que les cles etaient **deja
|
||
sur disque** et que la tache etait sautee. Le defaut existait depuis le premier commit du
|
||
remap, invisible a tout deploiement sur une flotte existante.
|
||
|
||
C'est l'argument de la reconstruction depuis zero, applique a moi-meme : *un correctif
|
||
qu'on ne verifie que sur une machine deja construite n'est pas verifie.*
|
||
|
||
### La derive s'est effacee toute seule
|
||
|
||
`apt-cacher-ng` tournait encore sur `forge-01`, que plus aucun plan ne declarait. Je
|
||
proposais de l'arreter a la main. La reconstruction l'a fait :
|
||
|
||
apt-cacher-ng : absent paquet : 0 sondes : les 4 legitimes
|
||
|
||
**Ce qui n'est pas au plan n'existe pas apres une naissance.** La propriete centrale du
|
||
depot, verifiee sur un cas qu'on n'avait pas provoque pour elle.
|
||
|
||
### Etat mesure
|
||
|
||
14 / 14 machines 0 source en HTTPS direct
|
||
14 / 14 machines 0 erreur `apt-get update`
|
||
prouver 65 OK, 0 echec
|
||
ansible-lint 0 defaut sur 79 fichiers
|
||
|
||
### Les trois reconstructions de la journee
|
||
|
||
1re (matin) 3 echecs 1 h 08
|
||
2e (confirmation) 0 echec 34 min
|
||
3e (avec le cache) 1 echec 32 min — le mien, corrige
|
||
|
||
## 2026-09-10 (10) — Plus rien ne sort chercher ses paquets, et un tenant de moins a nourrir
|
||
|
||
Deux mouvements d'une seule doctrine : **le site fournit tout ce dont un tenant a besoin
|
||
pour venir au monde.**
|
||
|
||
### 1. Les depots tiers passent enfin par le cache
|
||
|
||
Set-OPS tire ses paquets applicatifs de fournisseurs qui ne publient **qu'en HTTPS** —
|
||
Grafana, Icinga, Smallstep, Collabora. `apt-cacher-ng` ne relaie pas un tunnel, et le socle
|
||
pose donc deliberement `Acquire::https::Proxy "DIRECT"` : chaque machine sortait
|
||
elle-meme sur Internet. Mesure du matin : 58 references de depot, quatorze machines sortant
|
||
chacune de son cote.
|
||
|
||
Le remede tient en deux moities, et une seule ne sert a rien :
|
||
|
||
- le cache **DECLARE** un `Remap-*` par fournisseur — le client demande en `http://`, le
|
||
cache va chercher en `https://` ;
|
||
- chaque role **DEMANDE** en `{{ ..._depot_schema }}://`, qui vaut `http` des qu'un cache
|
||
d'amorcage est declare, `https` sinon. *Degrader, jamais deviner.*
|
||
|
||
Le TLS n'est rompu nulle part : il est **termine au cache**, qui est notre machine. Et
|
||
l'integrite ne vient pas du transport mais des signatures du depot — le raisonnement deja
|
||
tenu pour `deb.debian.org` depuis toujours.
|
||
|
||
**Trois choses que la mesure a apprises :**
|
||
|
||
**Le remap appartient au cache qui SORT.** Pose aussi sur le cache du tenant — chaine vers
|
||
celui du site — il tentait le HTTPS *a travers* son amont, ce qui exige un `CONNECT` que
|
||
l'amont ne fait pas :
|
||
|
||
remap sur le cache du tenant (chaine) -> 503
|
||
remap sur le seul cache du site -> 200
|
||
|
||
Un cache qui relaie n'a rien a remapper : il passe la demande a qui sort. Meme forme que la
|
||
filiation elle-meme.
|
||
|
||
**`apt_repository` AJOUTE, il ne remplace pas.** Passer une source de `https` a `http` y
|
||
ecrivait une SECONDE ligne ; apt interrogeait les deux, et l'ancienne sortait toujours.
|
||
Le symptome le disait sur les quatorze machines : *« La cible Packages est specifiee
|
||
plusieurs fois dans grafana.list:1 et :2 »*.
|
||
|
||
**La liste des fournisseurs ne se devine pas.** J'en avais recense trois ; l'audit des
|
||
sources en a revele un quatrieme — Collabora, sur une seule machine. **P65** refuse
|
||
desormais tout role visant un depot RELAYE en `https://` ecrit en dur. Sa limite est dite :
|
||
elle empeche une regression sur ce qui est connu, elle ne decouvre pas l'inconnu.
|
||
|
||
avant : 4 fournisseurs en HTTPS direct, 14 machines sortant seules
|
||
apres : 0 source en HTTPS direct, 0 erreur apt sur 14 machines
|
||
|
||
### 2. Le cache d'artefacts quitte le plan du tenant
|
||
|
||
La ligne portait son propre retrait depuis toujours :
|
||
|
||
> *« c'est un service MUTUALISABLE — un ecosysteme au premier age peut aussi bien pointer
|
||
> sur celui de son hote »*
|
||
|
||
Retiree. `client_artefacts` le voit tout seul : sans hote portant `serveur_artefacts`, il
|
||
n'ecrit rien et laisse en place le plancher d'amorcage qui vise `site-cache-01`.
|
||
|
||
**Ce qu'on perd, dit franchement** : les quatorze machines interrogent desormais le cache du
|
||
SITE a travers la frontiere, au lieu d'un cache local a la zone. Plus de trafic inter-zone,
|
||
plus de charge sur `site-cache-01` — contre un service de moins a poser, superviser et
|
||
reproduire dans chaque ecosysteme.
|
||
|
||
### 3. Un role qu'on retire doit pouvoir DEFAIRE ce qu'il a fait
|
||
|
||
Retirer le role a revele que rien ne nettoie derriere lui. Deux fois :
|
||
|
||
- **le fichier apt** `00-setops-artefacts` continuait de viser un cache eteint, en ecrasant
|
||
le plancher qui, lui, fonctionnait — exactement le defaut deja paye (« quinze machines ont
|
||
perdu apt d'un coup »). Le SOCLE le retire desormais, parce qu'il est le seul a tourner
|
||
dans les deux cas ;
|
||
- **les sondes** `cache-apt` et `cache-apt-volume` restaient sur la forge, et le porteur
|
||
poussait pour des services qu'Icinga ne definit plus :
|
||
|
||
ECHEC du rapport Icinga pour « cache-apt » : {"error":404,"status":"No objects found."}
|
||
|
||
`client_sante` derive maintenant les sondes ATTENDUES sur chaque hote — ses groupes
|
||
croises avec les `meta/supervision.yml` — et retire celles dont plus aucun groupe ne
|
||
repond. Meme derivation que celle de `serveur_icinga`, du cote du porteur.
|
||
|
||
### Etat mesure
|
||
|
||
sources apt en HTTPS direct 0 / 14 machines
|
||
erreurs `apt-get update` 0 / 14 machines
|
||
mandataire site-cache-01 seul, partout
|
||
Icinga 87 OK | 9 UNKNOWN sur 96 services
|
||
(les 9 = `sauvegarde`, minuteur nocturne)
|
||
prouver 65 OK, 0 echec
|
||
ansible-lint 0 defaut
|
||
|
||
**Reste, et c'est dit** : `apt-cacher-ng` tourne toujours sur `forge-01`, que plus aucun
|
||
plan ne declare. La prochaine reconstruction ne l'installera pas ; sur la machine actuelle,
|
||
il subsiste. Un service qu'aucun plan ne reclame est une derive, meme benigne.
|
||
|
||
## 2026-09-10 (9) — La reconstruction de confirmation : 0 echec, 34 minutes
|
||
|
||
Seconde reconstruction complete de Chezlepro dans la journee, cette fois pour EPROUVER les
|
||
quatre correctifs livres entre les deux. Rien de nouveau n'a ete construit : c'est une
|
||
mesure.
|
||
|
||
rc=0 0 echec 0 injoignable 14 / 14 machines
|
||
34 min contre 68 le matin meme
|
||
|
||
### Les quatre points, et leur verdict
|
||
|
||
| ce qui etait a eprouver | verdict |
|
||
|---|---|
|
||
| gabarit sur `CephNVMe` | phase de clonage **4 min 30** contre ~30 min |
|
||
| placement declare (`noeud: asgard`) | `{'asgard': 14}` — les quatorze au bon endroit |
|
||
| `hosts_statiques` conditionne a cloud-init | `changed` au 1er passage, `skipping` au 2e |
|
||
| `monitoring-plugins-basic` au role | `ping4` **14/14 OK** sur une `mon-01` nee neuve |
|
||
|
||
Le troisieme est le plus instructif : **les deux branches sont exercees dans la meme
|
||
execution.** `infra-pki-01` recoit le gabarit maitre de cloud-init au premier passage
|
||
(cloud-init est encore la), puis la tache est SAUTEE au second (le durcissement l'a
|
||
retire). Aucune preuve statique ne pouvait montrer cela — il fallait une naissance.
|
||
|
||
### Le facteur 6,6, et pourquoi ce n'est pas 45
|
||
|
||
Un clone isole sur Ceph prend 8 s contre 352 a 480 s sur TrueNAS : facteur 45. En
|
||
conditions reelles il tombe a **6,6** — quatre clones se disputent le meme pool, et le
|
||
redimensionnement du disque s'ajoute a la copie. On retient la mesure en conditions
|
||
reelles : celle qui compte est celle qu'on paie.
|
||
|
||
### Ce que les echecs du matin sont devenus
|
||
|
||
- **`/etc/cloud`** — corrige, prouve dans ses deux branches.
|
||
- **Verrou `dpkg`** — ne s'est pas reproduit. C'etait une course, pas un defaut de
|
||
structure ; rien ne dit qu'elle ne reviendra pas, et le dire vaut mieux que la declarer
|
||
reglee.
|
||
- **Cache apt** — **n'etait pas un defaut**. Les deux seuls `fatal:` de cette execution
|
||
sont suivis de `...ignoring` : ce sont les sondes de `client_artefacts`, qui cherchent le
|
||
cache du tenant, ne le trouvent pas, et laissent la machine servie par le plancher du
|
||
SITE. C'est la filiation en train de fonctionner. Le matin, j'avais compte ces deux
|
||
lignes comme des echecs sans lire la suivante.
|
||
- **`auditd` sans regles dans le gabarit** — toujours la, mais invisible : aucune machine
|
||
n'ayant decroche avant le socle, le CRITICAL transitoire a ete repare partout avant
|
||
d'etre mesure. *Un defaut qui ne se voit que lorsqu'autre chose casse reste un defaut.*
|
||
|
||
### Etat mesure
|
||
|
||
Icinga 89 OK | 9 UNKNOWN sur 98 — aucun WARNING, aucun CRITICAL
|
||
les 9 tous des `sauvegarde` : le minuteur nocturne n'a pas encore visite
|
||
une flotte nee il y a vingt minutes
|
||
|
||
La condition posee avant de retirer les disques `unused0`/`unused1` du gabarit sur TrueNAS
|
||
est **levee** : une flotte complete est nee du nouveau gabarit, sans un echec.
|
||
|
||
## 2026-09-10 (8) — Une garde qui survit a ce qu'elle gardait
|
||
|
||
Le defaut le plus couteux de la reconstruction, corrige. Il n'arretait rien de visible :
|
||
il faisait **taire** la supervision de deux machines.
|
||
|
||
`hosts_statiques` pose `/etc/cloud/templates/hosts.debian.tmpl` — le gabarit maitre dont
|
||
cloud-init regenere `/etc/hosts` a chaque demarrage. Or **D-85 fait retirer cloud-init**
|
||
par `serveur_durci`. Le repertoire part avec lui :
|
||
|
||
Destination directory /etc/cloud/templates does not exist
|
||
|
||
L'echec n'a aucun sens : ce gabarit ne sert qu'a survivre a une reecriture qui n'a plus
|
||
lieu. Plus de cloud-init, plus de reecriture — le plancher tient tout seul, **ce qui est
|
||
le resultat recherche par D-85**.
|
||
|
||
### Ce que ca a coute, et pourquoi c'etait invisible
|
||
|
||
`_amorcer-socle` monte `infra-pki-01` et `infra-dns-01` EN ENTIER d'abord, durcissement
|
||
compris. Cloud-init y est donc deja parti quand la flotte rejoue `hosts_statiques`. La
|
||
tache echouait, **l'hote sortait du play — et tout ce qui suivait n'etait jamais pose**,
|
||
dont `icinga-ca.crt`. Leur porteur de sante s'installait ensuite normalement, tournait, et
|
||
chaque rapport echouait sur `curl: (77) error setting certificate file`.
|
||
|
||
Deux machines ont supervise dans le vide sans que rien ne le dise. *Un echec bruyant au
|
||
bon endroit avait produit une panne muette ailleurs.*
|
||
|
||
### Et le correctif evident aurait deplace l'echec d'un cran
|
||
|
||
Sauter la tache ne suffisait pas : la garde qui SUIT compare le nombre d'entrees du
|
||
plancher a celui du gabarit maitre. Sans gabarit, elle compare 21 a 0 et echoue — elle
|
||
accuserait une divergence la ou il ne reste qu'un seul fichier.
|
||
|
||
**Une garde qui survit a ce qu'elle gardait ne mesure plus rien : elle invente.** Les trois
|
||
taches — pose, releve, assertion — suivent desormais la meme condition : le repertoire des
|
||
gabarits maitres existe-t-il encore ?
|
||
|
||
Verifie sur les deux machines memes qui echouaient : `failed=0`, cloud-init absent,
|
||
plancher a 21 entrees.
|
||
|
||
## 2026-09-10 (7) — D-86 mis a l'epreuve : Chezlepro rasee et refaite depuis zero
|
||
|
||
Quatorze machines detruites, quatorze refaites. **1 h 08**, 0 injoignable. La reconstruction
|
||
a confirme D-86 — et revele quatre defauts qu'aucune preuve statique n'aurait pu voir,
|
||
parce qu'ils n'existent QUE pendant une naissance.
|
||
|
||
### D-86 tient, et voici la mesure
|
||
|
||
L'ordre declare est l'ordre reel : socle → `step_ca` → `client_pki` → **observabilite**
|
||
(postgresql, prometheus, loki, grafana, icinga) → **agents** (metrique, journal, sante) →
|
||
services → apps.
|
||
|
||
Mais l'ordre ne prouve pas que quelqu'un REGARDAIT. Ce qui le prouve :
|
||
|
||
30 resultats de controle recus a 12:20:41
|
||
fin de la reconstruction 12:35:29
|
||
|
||
Trente services rapportaient leur etat pendant que Keycloak, Forgejo et Nextcloud montaient
|
||
encore. Prometheus scrutait 14 cibles sur 15. **Ce qui se deploie ensuite l'est bien sous
|
||
l'oeil de la supervision.**
|
||
|
||
### La limite, dite franchement : le plancher n'a pas ete eprouve
|
||
|
||
D-86 affirme que le DNS peut rester en couche 6 *grace au plancher `/etc/hosts`*. Ce n'est
|
||
pas ce qui s'est passe : `_amorcer-socle` monte `infra-dns-01` EN ENTIER d'abord —
|
||
`serveur_powerdns` puis `serveur_resolveur`, bien avant l'observabilite. Le DNS etait
|
||
debout ; le plancher n'a rien eu a porter. **La partie la plus audacieuse de la decision
|
||
reste non testee**, et l'eprouver demanderait de retirer l'amorcage DNS — une autre
|
||
decision.
|
||
|
||
### Le placement des VM n'etait declare NULLE PART
|
||
|
||
Les quatorze machines vivaient sur `asgard` depuis toujours. `SETOPS_NOEUD` valait le
|
||
VIDE : ce placement n'existait que dans l'etat d'execution de Proxmox. Sans cible, le clone
|
||
reste sur le noeud du gabarit — `vishnu`, qui a **14 Go libres** et heberge `eregion` et
|
||
`site-forge-01`. Quarante Go de VM neuves y auraient atterri.
|
||
|
||
Le defaut avait survecu a la reconstruction du 2026-09-02 : les VM existaient deja, et le
|
||
clone les sautait. **Un etat qui n'est declare nulle part ne se reproduit pas** — il faut
|
||
un `from-zero` VRAI pour le voir. `noeud: asgard` est desormais au plan.
|
||
|
||
### Le clonage etait 45 fois trop lent, et la cause n'etait pas le reseau
|
||
|
||
Mesure sur le vrai gabarit :
|
||
|
||
disque sur `TrueNAS` (lvm sur iSCSI) 352 a 480 s par clone
|
||
disque sur `CephNVMe` (rbd) 8 a 9 s par clone
|
||
|
||
Source ET destination etaient sur le meme stockage : rien ne traversait d'hyperviseur a
|
||
hyperviseur. La cause est que **le LVM epais interdit a Proxmox tout clone autre que
|
||
complet** — 16 Go copies par machine, a travers iSCSI.
|
||
|
||
Le clone LIE descend a 1 s, mais enchaine chaque VM a l'image de base pour toujours. Pour
|
||
7 s gagnees sur 480, l'echange ne vaut pas la dependance : **on garde les clones complets.**
|
||
|
||
Gabarit deplace par `qm move_disk` sans `--delete` — les anciens disques restent attaches
|
||
en `unused`, le retour arriere tient en une commande.
|
||
|
||
### Un champ `stockage:` au gabarit, branche en quatre points
|
||
|
||
Declarer sans consommer, c'est decorer. Le champ vit donc partout ou il compte :
|
||
|
||
| ou | quoi |
|
||
|---|---|
|
||
| `plan/10-intrants.yml` | la declaration, avec la mesure qui la justifie |
|
||
| `underlay.py --gabarit` | l'expose |
|
||
| `Makefile` | `STOCKAGE_PROXMOX` en derive — le clone ne l'HERITE plus en silence |
|
||
| `gabarit_etat.py` | compare le declare au reel, eprouve dans les deux sens |
|
||
|
||
Et le remede devient contextuel : le conseil *« NE PAS CONVERTIR… REFABRIQUER le gabarit »*
|
||
s'imprimait pour TOUT ecart, y compris un deplacement de disque qui se repare en une
|
||
commande. *Un remede plus lourd que le mal se fait ignorer, puis le vrai avec lui.*
|
||
|
||
### Icinga ne pouvait pas faire ses propres controles
|
||
|
||
ping4 UNKNOWN execvpe(/usr/lib/nagios/plugins/check_ping) failed: No such file
|
||
|
||
`icinga2` n'apporte AUCUN greffon. Toute la supervision de Set-OPS etant PASSIVE, le manque
|
||
etait masque : les services qui rapportent d'eux-memes etaient verts, et personne ne
|
||
regardait les autres. Quatorze `ping4` muets d'une seule cause.
|
||
|
||
`monitoring-plugins-basic` entre au role — `-basic` et non le metapaquet : `-standard`
|
||
ajoute des greffons LDAP/SMTP/PostgreSQL que Set-OPS n'appelle jamais.
|
||
|
||
**Le site l'avait deja — pose A LA MAIN plus tot dans la journee, jamais declare.** Meme
|
||
defaut que le placement, troisieme occurrence du jour : un etat qui ne vit que sur la
|
||
machine ne survit pas a une reconstruction.
|
||
|
||
### Trois defauts trouves, laisses ouverts
|
||
|
||
1. **`infra-pki-01` et `infra-dns-01` attendent `forge-01:3142`** pendant l'amorçage — le
|
||
cache apt naitra bien plus tard. Un ordre qui se mord la queue.
|
||
2. **Regression D-85** : `Destination directory /etc/cloud/templates does not exist`. Un
|
||
role ecrit encore la ou cloud-init a ete supprime. **C'est ce defaut qui a fait
|
||
decrocher deux hotes de la tache deposant `icinga-ca.crt`** — leur porteur de sante
|
||
tournait, et chaque rapport echouait sur `curl: (77)`, en silence.
|
||
3. **`auditd` dans le gabarit, sans regles** : `audit-rules.service` echoue au premier
|
||
demarrage de CHAQUE VM neuve, jusqu'au passage du socle. Le CRITICAL ressemble alors a
|
||
un probleme de la machine alors qu'il vient du modele.
|
||
|
||
### Etat mesure, apres correction
|
||
|
||
Icinga Chezlepro 93 OK | 1 WARNING | 1 CRITICAL | 3 UNKNOWN sur 98
|
||
Icinga du site 51 / 51 OK
|
||
Prometheus 15 / 15 cibles
|
||
prouver 64 OK, 0 echec
|
||
ansible-lint 0 defaut, profil production
|
||
|
||
Les quatre non-OK restants sont tous des `sauvegarde` : le minuteur nocturne n'a pas encore
|
||
visite une flotte nee il y a deux heures.
|
||
|
||
## 2026-09-10 (6) — La frontiere passe, et trois sondes disaient faux
|
||
|
||
Application de ce que l'entree precedente avait prepare, puis correction de ce que
|
||
l'application a revele.
|
||
|
||
### La frontiere
|
||
|
||
`make frontiere-appliquer` : **49 regles creees, 5 retirees.** Effet mesure immediatement :
|
||
|
||
cibles Prometheus 2/8 -> 8/8
|
||
hotes dans Loki 1/7 -> 7/7
|
||
|
||
### Trois defauts, tous de la meme famille : un reglage qui ne suit pas son interrupteur
|
||
|
||
**1. La sonde de Loki interrogeait en HTTPS un Loki servant en clair.**
|
||
`serveur_loki_sonde_url` disait `https` en dur alors que `serveur_loki_tls_actif` vaut
|
||
`false` par defaut. Chez le tenant, qui l'active dans ses group_vars, l'accord etait
|
||
fortuit ; au site, qui ne l'active pas, la sonde rapportait *« Loki ne repond pas »* sur un
|
||
service en parfaite sante. **Une fausse alarme est pire qu'une sonde absente : elle apprend
|
||
a ne plus lire la sonde.** Le schema se derive desormais du commutateur.
|
||
|
||
C'est exactement le meme defaut que `GF_AUTH_DISABLE_LOGIN_FORM` chez Grafana, corrige la
|
||
veille au soir. Deux occurrences en douze heures.
|
||
|
||
**2. La sonde des journaux criait une perte deja reparee.**
|
||
Elle comparait au passage precedent sans dire QUAND ce passage avait eu lieu. Elle a
|
||
annonce *PERTE EN COURS* sur deux machines alors que le delta reel etait nul — les lignes
|
||
avaient ete perdues avant la reparation. Mesure de controle, deux lectures a 90 s :
|
||
|
||
jetees 11340 -> 11340 (delta 0)
|
||
envoyees 68 -> 86 (delta +18)
|
||
|
||
*Un ecart sans sa fenetre n'est pas une mesure, c'est un nombre.* L'etat retient
|
||
maintenant l'horodatage, et le message porte la duree.
|
||
|
||
**3. Et surtout : elle alertait sur la cicatrice, pas sur la plaie.**
|
||
Le compteur d'Alloy est cumulatif — il ne redescend qu'au redemarrage. Les six machines
|
||
qui avaient perdu des lignes pendant que la frontiere etait fermee les portaient donc pour
|
||
toujours, condamnees a l'orange permanent. **Ce qui alerte est la CROISSANCE.** Le total
|
||
reste dans le texte et dans les metriques, la ou il sert au diagnostic sans crier.
|
||
|
||
En echange, un cas qui ne levait rien le fait desormais : `envoyees == 0` et `jetees == 0`
|
||
n'est pas « tout va bien », c'est « Alloy ne fait rien du tout ».
|
||
|
||
Controles negatifs, sur des COPIES des sondes :
|
||
|
||
port d'Alloy inexistant -> CRITICAL rc=2
|
||
compteur de pertes en hausse -> CRITICAL rc=2
|
||
port de node_exporter ferme -> CRITICAL rc=2
|
||
|
||
### Neuf services qu'Icinga attendait et que personne ne lui envoyait
|
||
|
||
Le temoin declarait 51 services et n'en recevait que 42. Les neuf muets avaient une sortie
|
||
**vide** : jamais un seul resultat. `setops-sondes.conf` derive les services de tous les
|
||
`meta/supervision.yml` — Icinga savait donc les attendre bien avant que les roles n'aient
|
||
ete rejoues pour deposer les scripts.
|
||
|
||
*Une declaration suffit a creer l'attente ; il faut un deploiement pour creer la reponse.*
|
||
Huit roles rejoues au site : `serveur_artefacts`, `serveur_resolveur`, `serveur_powerdns`,
|
||
`serveur_forgejo`, `serveur_postgresql`, `serveur_postfix`, `serveur_step_ca`, `serveur_ops`.
|
||
|
||
### Grafana
|
||
|
||
`vault_grafana_admin` depose dans la voute du site. Verifie sur la machine : formulaire
|
||
local **offert**, zero ligne `GENERIC_OAUTH`. Sonde `tableaux` verte.
|
||
|
||
*Note d'exploitation :* `ansible-vault` refuse de tourner quand stdout n'est pas bloquant —
|
||
il faut le faire passer par un tube. Un premier essai a tronque la voute de 11,8 K a 873 o
|
||
parce que le chiffrement s'est execute apres une lecture qui avait echoue. Restauree depuis
|
||
la copie prise avant, identique a l'octet pres. **Le remede est dans l'ordre des gardes :
|
||
lire, VERIFIER le nombre de cles, ecrire vers un fichier neuf, verifier ce fichier, et
|
||
seulement alors remplacer.**
|
||
|
||
### Etat mesure
|
||
|
||
| | |
|
||
|---|---|
|
||
| cibles Prometheus | **8/8** |
|
||
| hotes dans Loki | **7/7** |
|
||
| sonde `journaux` | **7/7 vert** |
|
||
| sonde `metriques` | **7/7 vert** |
|
||
| services Icinga | 51, dont 42 OK avant redeploiement des huit roles |
|
||
| `make prouver` | 64 OK, 0 echec |
|
||
| `ansible-lint` | 0 defaut, profil `production` |
|
||
|
||
### Suite : le runner ne pouvait plus cloner, et trois sondes de plus disaient faux
|
||
|
||
Redeployer `serveur_ops` au site a echoue sur le clonage du genome. Mesure depuis les sept
|
||
machines :
|
||
|
||
10.0.33.11:443 injoignable de PARTOUT sauf depuis la forge elle-meme
|
||
|
||
Y compris depuis `site-cache-01`, qui est dans le MEME sous-reseau — ce qui excluait la
|
||
frontiere et designait le pare-feu de l'hote. Le diff de `flux-genere/` l'a confirme : la
|
||
ligne fautive etait la AVANT nos changements, qui n'ajoutaient que les regles des sondes.
|
||
**Defaut preexistant, revele par le redeploiement, pas cause par lui.**
|
||
|
||
**La cause : `generer_nftables(site=True)` lisait le plan du TENANT.** `ports_plan =
|
||
_ports_du_plan()`, sans condition. `derive` se resolvait donc contre
|
||
`OPS-Chezlepro/plan/applications.yml`, ou Forgejo vaut 3000 — le port qu'il ecoute DERRIERE
|
||
un edge. Le plan du site dit 443, et le disait explicitement :
|
||
|
||
# 443, ET NON 3000. [...] garder 3000 aurait grave `:3000` dans ROOT_URL
|
||
|
||
La regle d'hote de la forge ouvrait donc un port que personne n'ecoute et laissait 443
|
||
ferme a toute la flotte. Un seul registre interroge pour deux verites opposees.
|
||
|
||
**Trois sondes de plus corrigees, toutes de la meme famille** — un parametre qui ne suit
|
||
pas l'interrupteur dont il depend :
|
||
|
||
| sonde | ce qu'elle disait | la cause |
|
||
|---|---|---|
|
||
| `runner` | 2 depots divergent | comparait les 6 depots a UNE branche, quand le plan en declare une PAR depot (`master` pour deux d'entre eux) |
|
||
| `forge` | reponse illisible | `http://` en dur sur un port 443 servant du TLS |
|
||
| `forge` | ne repond pas | `curl -s` sans `-k` : cert step-ca emis pour le FQDN, interroge sur `127.0.0.1` |
|
||
|
||
La sonde `runner` est l'exemple le plus net : **elle contredisait la declaration qu'elle
|
||
etait censee verifier.** Elle n'accusait pas la machine, elle s'accusait elle-meme.
|
||
|
||
Avec Grafana, Loki et Forgejo, cela fait **cinq occurrences du meme defaut en une journee**.
|
||
Le motif merite d'etre nomme : *un reglage corrige a moitie ne dit rien tant que le
|
||
deploiement ne change pas de camp.* Le tenant restait juste dans les cinq cas — c'est le
|
||
site, qui deploie ces roles autrement, qui les a tous reveles d'un coup.
|
||
|
||
### Etat final mesure
|
||
|
||
Icinga 51 / 51 services OK
|
||
Prometheus 8 / 8 cibles up
|
||
Loki 7 / 7 hotes presents
|
||
prouver 64 OK, 0 echec
|
||
lint 0 defaut sur 82 fichiers, profil production
|
||
|
||
Controles negatifs, sur des COPIES : port d'Alloy, compteur de pertes, node_exporter,
|
||
forge — les quatre rendent CRITICAL.
|
||
|
||
## 2026-09-10 (5) — Le site prend sa propre pile d'observabilite
|
||
|
||
Suite directe de D-87. La decision disait : *l'hebergeur n'a pas le droit de voir les
|
||
journaux de ses locataires.* Elle avait une face cachee — **a force de refuser de voir
|
||
ceux des autres, le site s'etait prive des siens.** Ses sept machines n'expediaient nulle
|
||
part.
|
||
|
||
Le remede n'est pas d'assouplir la frontiere, c'est de donner au site **sa** pile :
|
||
`prometheus`, `loki` et `grafana` sur `site-mon-01`, pour les machines du site, sans
|
||
aucun lien avec ceux d'un tenant.
|
||
|
||
### Ce qui bloquait etait deja documente, dans le plan lui-meme
|
||
|
||
`10-intrants.yml` exemptait toutes les machines du site de `client_metrique` et
|
||
`client_journal`. La prose de l'exemption portait sa propre condition de levee :
|
||
|
||
> *« Le jour ou le site prend un Loki et un Prometheus, on retire ces deux lignes. »*
|
||
|
||
Ce jour-la etant venu, l'exemption s'est refermee toute seule. Elle aura tenu cinq jours.
|
||
|
||
### Grafana au site n'a pas de SSO, et le role l'ignorait
|
||
|
||
Le site n'a ni Keycloak ni `domaines.yml` — l'identite ne monte pas dans le site, c'est
|
||
D-87. `serveur_grafana` reclamait pourtant l'IdP **avant** de regarder s'il en voulait un :
|
||
le deploiement echouait sur un registre absent, pour deriver une URL qu'aucun gabarit
|
||
n'allait ecrire. L'interrupteur `serveur_grafana_oidc_actif` existait, il n'etait pas honore.
|
||
|
||
Trois corrections, dont deux depassent le site :
|
||
|
||
- `resoudre_idp` n'est appele que si le SSO est actif ;
|
||
- le role **refuse** SSO eteint *et* formulaire local eteint — la combinaison deploie un
|
||
Grafana en parfaite sante ou personne ne peut entrer ;
|
||
- `GF_AUTH_DISABLE_LOGIN_FORM` sort du `{% if %}` du SSO. Il y disparaissait quand le SSO
|
||
etait eteint, et c'est le defaut amont qui decidait en silence. *Un reglage
|
||
d'authentification qu'aucun fichier n'ecrit est un reglage que personne ne peut relire.*
|
||
|
||
### Deux manques se cachaient l'un l'autre dans le devis de la frontiere
|
||
|
||
Prometheus voyait **1 cible sur 7**. Les regles d'hote etaient justes ; c'est le devis
|
||
OPNsense qui avait deux trous, et le second masquait le premier.
|
||
|
||
**1. Le devis ne connaissait pas les integrations universelles.** Il derivait les groupes
|
||
d'une machine du site de `applications.yml` seul — qui declare les *services*. Il ne voyait
|
||
donc ni `client_metrique`, ni `client_journal`, ni `client_pki`, ni `client_sante`, alors
|
||
que ces groupes portent des flux et **ouvrent des ports d'ecoute**. La source est desormais
|
||
l'inventaire du site, seule autorite sur ce qu'une machine porte vraiment.
|
||
|
||
**2. Une sortie vers un role du site visait « l'exterieur ».** Symetrique du correctif du
|
||
2026-09-02, qui n'avait traite que l'entree. `!SETOPS_INTERNES` est la bonne destination
|
||
quand le pair est lointain ; quand il **nomme un role du site**, elle dit exactement
|
||
l'inverse du flux declare — elle exclut la seule machine visee :
|
||
|
||
client_journal -> !SETOPS_INTERNES port 3100
|
||
|
||
Le devis autorisait a expedier les journaux du site a n'importe quel Loki du monde, et a
|
||
nul autre endroit qu'a celui-la. Neuf flux etaient dans ce cas : PKI, sante, resolveur,
|
||
sauvegarde, courriel de la forge, base d'Icinga.
|
||
|
||
**Et les deux declarations ne font qu'une regle.** `appliquer_opnsense` pose tout en
|
||
`direction: in` (D-61) : deux regles qui ne different que par leur `sens` sont le meme
|
||
filtre pose deux fois. Dedoublonnage sur ce que la frontiere applique vraiment — la forme
|
||
`ingress` gagne, pour ne pas retirer-puis-recreer des regles deja justes.
|
||
|
||
Devis : **49 regles a creer, 5 a retirer** — les cinq etant exactement les sorties trop
|
||
larges dont la jumelle etroite existe deja.
|
||
|
||
### Les deux agents se supervisent enfin eux-memes
|
||
|
||
`client_metrique` et `client_journal` etaient parmi les groupes sans sonde. Ils en ont une.
|
||
|
||
`metriques` interroge **l'endroit que Prometheus interroge**, pas le gestionnaire de
|
||
services : un node_exporter actif mais muet est vert pour systemd. Elle declare aussi les
|
||
dix collecteurs qui cherchent du materiel qu'une VM n'a pas (`zfs`, `mdadm`, `infiniband`…)
|
||
— ils echouent identiquement sur les sept machines, et *une sonde rouge partout est une
|
||
sonde qu'on cesse de lire.*
|
||
|
||
`journaux` ne demande pas si Alloy tourne : elle lit ce qu'il a du **jeter**, et le compare
|
||
au passage precedent — ce qui augmente est une perte en cours, ce qui stagne est une
|
||
cicatrice.
|
||
|
||
Elle a fait ses preuves le jour meme. Sur six machines :
|
||
|
||
Alloy: active (running) /-/ready: 200 dropped_entries_total: 50
|
||
|
||
Trois indicateurs verts, cinquante lignes perdues. La regle de frontiere manquait encore.
|
||
|
||
### Etat mesure
|
||
|
||
| | |
|
||
|---|---|
|
||
| `metriques` | **7/7 vert** |
|
||
| `journaux` | **1/7** — les six autres attendent la regle de frontiere, et le disent |
|
||
| cibles Prometheus | 2/8 pour la meme raison |
|
||
| `make prouver` | 64 OK, 0 echec |
|
||
| `ansible-lint` | 0 defaut, profil `production` |
|
||
|
||
**Reste a la main de l'exploitant** : `make frontiere-appliquer CONFIRMER=true`, et le
|
||
secret `vault_grafana_admin` a deposer dans `underlay.vault.yml`.
|
||
|
||
## 2026-09-10 (4) — Ce que l'hebergeur n'a pas le droit de VOIR (D-87)
|
||
|
||
Question posee : *« quels roles ne dois-je pas embarquer dans le site ? »*
|
||
|
||
La table de mutualisation de `filiation-emancipation.md` existait deja et repondait presque.
|
||
Mais mise a l'epreuve, **une de ses lignes contredisait ce qui tourne**.
|
||
|
||
### La ligne qui se contredisait
|
||
|
||
| observabilite | oui | l'hebergeur surveille ses locataires |
|
||
|
||
Or chaque ecosysteme a son propre Icinga, et celui du site ne voit que ses **sept**
|
||
machines (mesure). L'implementation avait raison, la doctrine avait tort.
|
||
|
||
Et surtout : cette case autorisait ce que la ligne du dessous interdit. **Les journaux
|
||
contiennent du CONTENU** — un mot de passe dans un message d'erreur, une donnee metier dans
|
||
une trace, qui a fait quoi et quand. Un hebergeur qui ingere les journaux de son locataire
|
||
en sait **plus** que s'il detenait son annuaire : *l'annuaire dit qui existe, les journaux
|
||
disent ce qu'ils font.*
|
||
|
||
Decoupee en trois :
|
||
|
||
disponibilite (est-ce debout ?) oui une VM tombee est un fait de la fabric
|
||
metriques (charge, disque) oui, reserve disent quand et combien, pas quoi
|
||
journaux NON contiennent le contenu
|
||
|
||
### Et la ligne PKI, « a trancher », est tranchee : non
|
||
|
||
Elle notait qu'*une AC intermediaire signee par l'hote est possible*. Elle l'est
|
||
techniquement — et c'est precisement ce qu'il ne faut pas faire.
|
||
|
||
Une intermediaire signee par l'hote lui donne le pouvoir d'emettre des certificats
|
||
**valides pour les noms du locataire**. Il peut alors se presenter comme n'importe lequel
|
||
de ses services, devant les propres machines du locataire — qui les accepteront, puisque
|
||
c'est exactement ce que la chaine de confiance leur demande de faire.
|
||
|
||
Meme pouvoir que l'annuaire, sous une forme **moins visible** : rien dans la configuration
|
||
du locataire, aucune trace de son cote, et la verification passe.
|
||
|
||
**Une PKI par ecosysteme, jamais derivee de l'hote.** C'est deja ce qui tourne ; la ligne
|
||
cesse de laisser la porte entrouverte.
|
||
|
||
### Le revers, mesure et assume
|
||
|
||
Le site ne porte **ni `client_journal` ni `client_metrique`**, et aucun `loki` ni
|
||
`prometheus`. Ses sept machines n'expedient rien nulle part : **l'hebergeur ne peut pas
|
||
lire ses propres journaux**.
|
||
|
||
C'est la consequence directe de la frontiere — a refuser de voir ceux des locataires, il
|
||
s'est prive des siens. Le remede n'est pas d'assouplir la regle, c'est de lui donner **sa
|
||
propre pile**, sans aucun lien avec celle d'un tenant.
|
||
|
||
### Ce que la mesure a confirme par ailleurs
|
||
|
||
Rien d'intime n'est au site aujourd'hui. `openldap`, `keycloak`, `oauth2_proxy`, `loki`,
|
||
`nextcloud`, `collabora`, `dovecot`, `rspamd`, `web_frontal`, `web_dorsal` : tous
|
||
**tenant seulement**. La frontiere etait tenue en pratique avant d'etre ecrite au net.
|
||
|
||
## 2026-09-10 (3) — Les cinq sondes qui manquaient le plus
|
||
|
||
**20 sondes sur 19 roles. 23 services distincts, 70 instances, 67 au vert.**
|
||
|
||
### D'abord une correction de compte
|
||
|
||
J'avais annonce « cinq groupes sans sonde ». Mesure : **vingt-sept**. J'avais compte ceux
|
||
que j'avais en tete, pas ceux que le depot contient. Il en reste vingt-deux.
|
||
|
||
### Les cinq posees, par ordre de degat silencieux
|
||
|
||
**`autorite` (step_ca)** — la plus urgente de l'ecosysteme. Nos certificats vivent 24 h :
|
||
une AC muette ne casse rien aujourd'hui, elle casse TOUT demain, d'un coup, sur les
|
||
vingt-et-une machines a la fois. Elle surveille aussi **l'expiration de la RACINE**, que
|
||
personne ne regarde jamais parce qu'elle vit des annees — releve : 3642 jours. Le jour ou
|
||
elle expire, toute la confiance interne tombe d'un bloc, et aucun renouvellement de
|
||
certificat d'hote n'y change rien.
|
||
|
||
**`base` (postgresql)** — une VRAIE requete, pas `pg_isready`. Celui-ci ouvre une connexion
|
||
et la ferme : il dit que le port repond, pas que la base sert. Une base en recuperation, en
|
||
lecture seule ou a court de connexions le passe et refuse tout travail. La sonde compte
|
||
aussi les connexions : a saturation, chaque application tombe en meme temps sans que la
|
||
base ait l'air morte.
|
||
|
||
**`annuaire` (openldap)** — elle COMPTE les entrees. Un annuaire vide repond `success` a
|
||
tout, et plus personne ne s'authentifie nulle part : c'est exactement le mensonge des
|
||
sauvegardes vides, vert et sans contenu.
|
||
|
||
**`zones` (powerdns)** — un autoritatif sans zone repond NXDOMAIN a tout, ce qui se lit
|
||
comme « ce nom n'existe pas ». La panne la plus trompeuse du DNS.
|
||
|
||
**`edge` (nginx)** — elle valide la configuration SUR DISQUE. nginx garde la derniere
|
||
configuration valide et continue de servir ; une configuration cassee ne se voit qu'au
|
||
prochain demarrage, c'est-a-dire au pire moment, souvent des mois plus tard.
|
||
|
||
Les cinq eprouvees vertes sur le sain, puis rouges PAR PARAMETRE — port ferme, seuil
|
||
impossible, port qui n'ecoute pas — sans toucher a un seul service.
|
||
|
||
### Ce qui reste, et pourquoi ce n'est pas le meme genre
|
||
|
||
Vingt-deux groupes. Ils ne sont pas de la meme nature :
|
||
|
||
- **couverts ailleurs** : `client_metrique` (par `collecte` chez Prometheus),
|
||
`client_backup` (par `sauvegarde`), `client_sante` (sa fraicheur EST son `ttl`) ;
|
||
- **chemins de report** : `client_smtp`, `client_artefacts`, `client_journal`,
|
||
`client_resolveur` — leur panne se voit deja par le silence de ce qu'ils portent ;
|
||
- **du site** : `serveur_cache_site`, `serveur_forge_site`, `serveur_backup_site`,
|
||
`serveur_resolveur_site`, `serveur_ops_site` — a poser depuis l'instance du site ;
|
||
- **applications et socle** : `redis`, `rspamd`, `collabora`, `web_frontal`,
|
||
`web_dorsal`, `oauth2_proxy`, `icingaweb2`, `backup`, `debian`, `durci`.
|
||
|
||
## 2026-09-10 (2) — `cache-apt` scindee, et le defaut que la scission a revele
|
||
|
||
### La scission
|
||
|
||
`cache-apt` fondait deux causes dont les DELAIS different : « ne repond pas » arrete tout
|
||
`apt` de l'ecosysteme — on agit dans la minute — tandis que « volume a 90 % » est un billet
|
||
pour demain. Les fondre obligeait soit a reveiller quelqu'un pour un disque, soit a traiter
|
||
une panne comme un billet.
|
||
|
||
Deux sondes desormais, chacune avec son etat, son historique et son acquittement. Prouve
|
||
qu'elles sont INDEPENDANTES, et c'etait tout l'enjeu :
|
||
|
||
port ferme -> cache-apt [2] CRITIQUE cache-apt-volume [0] OK
|
||
seuil impossible-> cache-apt [0] OK cache-apt-volume [1] AVERTISSEMENT
|
||
|
||
### Ce que la verification a revele : `moteur` aurait alarme PARCE QUE tout allait bien
|
||
|
||
En verifiant que les deux services arrivaient bien dans Icinga, un resultat pousse et
|
||
accepte (`code 200`) n'apparaissait pas en base. Hypothese testee et confirmee :
|
||
**IcingaDB n'ECRIT `service_state` QUE SUR CHANGEMENT D'ETAT.** Un OK identique repete ne
|
||
produit aucune ecriture ; un AVERTISSEMENT pousse ensuite est ecrit en 13 secondes.
|
||
|
||
Or la sonde `moteur`, ecrite quelques heures plus tot, lisait exactement
|
||
`max(last_update)` de `service_state` pour juger que « l'etat est frais ». Elle mesurait
|
||
donc le CHANGEMENT, pas la fraicheur.
|
||
|
||
**Consequence : sur un ecosysteme parfaitement stable — celui qu'on veut — plus rien ne
|
||
change, `last_update` vieillit, et la sonde serait passee en avertissement a 15 minutes
|
||
puis en critique a 90.** Une alarme qui se declenche PARCE QUE tout va bien, avec un delai
|
||
qui l'aurait rendue difficile a rattacher a sa cause.
|
||
|
||
C'est le piege que `docs/supervision-conception.md` interdit — *une alarme toujours allumee
|
||
apprend a ne plus regarder* — sous sa forme la plus sournoise : differee.
|
||
|
||
**La bonne source existait** : `icingadb_instance` porte le battement du synchroniseur,
|
||
ecrit en continu qu'il y ait ou non des changements. Releve a **1 seconde** sur un systeme
|
||
sain. Les seuils passent de 15/90 minutes a **1/5 minutes** : sur un battement, une minute
|
||
de silence est deja anormale.
|
||
|
||
Controle negatif rejoue par parametre.
|
||
|
||
### Etat
|
||
|
||
Dix-huit services, tous verts sauf `sauvegarde` a 7/9 — deux noeuds sans donnee a
|
||
emporter, deja connu et confirme.
|
||
|
||
## 2026-09-10 — Chaque role porte desormais sa sonde
|
||
|
||
**Quatorze sondes**, declarees dans `meta/supervision.yml`, deposees par le role qui
|
||
possede la verite, derivees en objets Icinga sans qu'une ligne soit ecrite a la main :
|
||
|
||
boites (dovecot) certificat (client_pki, 14/14) collaboration (nextcloud)
|
||
cache-apt (artefacts) collecte (prometheus) file-courriel (postfix)
|
||
forge (forgejo) identite (keycloak) ingestion (loki)
|
||
moteur (icinga) resolution (resolveur) runner (serveur_ops)
|
||
tableaux (grafana) voute (ops_tenant)
|
||
|
||
Les dix-neuf lignes `surveillance:` ecrites en prose et jamais executees commencent a
|
||
devenir des mesures.
|
||
|
||
### Deux principes que la premiere sonde a imposes
|
||
|
||
**Une sonde doit pouvoir etre mise en defaut PAR PARAMETRE.** Cible et seuils sont des
|
||
variables du role : on prouve le rouge avec un port ferme ou un seuil impossible, sur une
|
||
machine reelle, sans rien casser, aussi souvent qu'on veut. Une sonde qu'on ne peut
|
||
prouver qu'en cassant un service ne sera prouvee qu'une fois. Eprouve sur `cache-apt` :
|
||
vert, CRITIQUE par port ferme, AVERTISSEMENT par seuil, retour au vert.
|
||
|
||
**La sonde vit la ou vit la verite.** *« Ce noeud est-il collecte ? »* est une sonde de
|
||
`serveur_prometheus`, pas de `client_metrique` : une seule y voit les N noeuds, et surtout
|
||
elle voit le cas **silencieux** — celui qui a cesse d'etre collecte ne peut pas s'en
|
||
plaindre lui-meme.
|
||
|
||
### On demande au service ce qu'il pense de lui-meme
|
||
|
||
Quand il sait le dire : `/api/healthz` de Forgejo (base + cache), `/api/health` de Grafana,
|
||
`/ready` de Loki, `status.php` de Nextcloud, la decouverte OIDC de Keycloak. C'est plus
|
||
juste que tout critere invente de l'exterieur — une forge dont la base est tombee sert
|
||
encore ses pages et repond a `/api/v1/version`.
|
||
|
||
Et quand il ne sait pas : on va chercher la verite de terrain. `moteur` ne regarde ni le
|
||
service ni le port — il demande a la base **depuis combien de temps elle n'a pas ete
|
||
rafraichie**. C'est la lecon des sauvegardes appliquee a la supervision elle-meme : un
|
||
moteur vert dont la synchronisation est tombee affiche eternellement le dernier etat connu.
|
||
|
||
### Quatre fois j'ai ecrit la sonde avant de mesurer, quatre fois elle a eu tort
|
||
|
||
**La forge** : port 443 et chemin des depots INVENTES. Elle ecoute en 3000 derriere l'edge
|
||
et n'a legitimement AUCUN depot — elle est neuve. Deux cris sur un service sain.
|
||
|
||
**Loki** : conclu « panne persistante » sur deux lectures prises a quelques secondes
|
||
d'intervalle, juste apres un redemarrage. L'anneau etait `ACTIVE` et la reponse est passee
|
||
a `ready` moins d'une minute plus tard. *Deux mesures rapprochees ne distinguent pas un
|
||
etat d'un instant.* Le delai de stabilisation est desormais un AVERTISSEMENT nomme.
|
||
|
||
**Keycloak** : vise en 8443, il ecoute en 8080 derriere l'edge.
|
||
|
||
**Le runner** : `git` en root refuse les depots d'un autre proprietaire — la sonde aurait
|
||
rendu un echec qui parle de git au lieu de parler du depot.
|
||
|
||
À chaque fois le remede est le meme : **lire la verite du role, ne pas la supposer**.
|
||
|
||
### Et le meme piege Jinja, une seconde fois
|
||
|
||
`${#tableau[@]}` contient `{#`. Le remede etait deja au depot depuis `client_sante` ; je
|
||
l'ai reecrit au lieu de le chercher. La correction balaie desormais tous les gabarits de
|
||
sonde.
|
||
|
||
### Ce qui reste
|
||
|
||
`client_smtp`, `client_artefacts`, `client_journal`, `serveur_icingaweb2` et
|
||
`serveur_ops_site` n'ont pas encore la leur. Le premier lot couvre les services dont la
|
||
chute arrete l'ecosysteme ; ceux-la sont des chemins de report, dont la panne se voit
|
||
deja par le silence des sondes qu'ils portent.
|
||
|
||
## 2026-09-09 (9) — La supervision passe juste apres la PKI (D-86)
|
||
|
||
*On n'allume pas la lumiere une fois la maison finie.*
|
||
|
||
L'observabilite et le moteur de supervision etaient en couches 4 et 5 sur six — donc
|
||
deployes APRES presque tout ce qu'ils surveillent. Une reconstruction depuis zero est
|
||
pourtant le moment ou l'on a le plus besoin de voir, et c'etait le seul moment ou l'on ne
|
||
voyait rien.
|
||
|
||
1. socle serveur_debian, serveur_durci
|
||
2. pki_racine serveur_step_ca
|
||
3. pki_client client_pki
|
||
4. observabilite postgresql, prometheus, loki, grafana, icinga <- NOUVEAU
|
||
5. agents_supervision client_metrique, client_journal, client_sante <- NOUVEAU
|
||
6. services openldap, powerdns, resolveur, nginx, postfix...
|
||
7. apps keycloak, forgejo, icingaweb2, nextcloud...
|
||
8. agents client_smtp, client_backup, client_resolveur, client_artefacts
|
||
|
||
### Deplacer les serveurs sans les agents n'aurait rien change
|
||
|
||
C'est le point qui a decide de la forme : ce sont les AGENTS qui rapportent, et ils etaient
|
||
en derniere couche. Monter `prometheus` et `icinga` en laissant `client_metrique` et
|
||
`client_sante` a la fin aurait produit une supervision allumee et aveugle. Les deux
|
||
couches vont donc ensemble.
|
||
|
||
Des la couche 5, chaque hote expedie ses metriques, ses journaux et l'etat de ses unites
|
||
systemd. Tout ce qui se deploie ensuite est mesure PENDANT qu'on le construit.
|
||
|
||
### Ce que ca a coute, et ce qui reste tard a dessein
|
||
|
||
`serveur_postgresql` monte aussi : `icinga` l'exige, et lui n'exige rien. C'est le seul
|
||
entrainement.
|
||
|
||
Restent en couche 7 : **`icingaweb2` et `oauth2_proxy`**, qui reclament LDAP et Keycloak.
|
||
C'est la CONSOLE, pas la mesure — l'interface humaine peut attendre, l'etat non.
|
||
|
||
### Ce qui rend ce deplacement possible
|
||
|
||
Le DNS (`powerdns`, `resolveur`) reste en couche 6, donc APRES la supervision. Or `icinga`
|
||
joint sa base par un NOM, et `client_sante` pousse vers un NOM.
|
||
|
||
Ca tient parce que le **plancher `/etc/hosts`**, pose des la couche 1 par
|
||
`hosts_statiques`, porte deja les 34 entrees de l'ecosysteme — verifie sur `mon-01`. C'est
|
||
exactement ce pour quoi il existe : *« il ne s'installe pas, il rend installable »*. Sans
|
||
lui, cette decision serait impossible.
|
||
|
||
### La limite, dite franchement
|
||
|
||
Les **notifications** dependent de `client_smtp`, encore en derniere couche. Pendant une
|
||
reconstruction, l'etat est mesure et consultable — mais rien ne part par courriel avant la
|
||
fin. Le deplacer demanderait de monter `postfix` et son relais, ce qui entrainerait bien
|
||
plus que PostgreSQL.
|
||
|
||
Et ceci : **l'ordre est valide, pas eprouve**. P08 accepte (aucune arete en arriere), le
|
||
graphe des dependances accepte, `playbooks/site.yml` est regenere et passe le
|
||
`--syntax-check` sur ses 782 lignes de plan. Mais la seule preuve reelle d'un ordre de
|
||
reconstruction, c'est une RECONSTRUCTION — et celle-la se decide, elle ne se glisse pas
|
||
dans une soiree.
|
||
|
||
## 2026-09-09 (8) — L'hote de supervision saturait par sa propre demonstration
|
||
|
||
« mon-01 tape dans l'fond. » Il tapait, en effet.
|
||
|
||
load average: 5,10 sur 4 coeurs
|
||
8 processus check_disk, 70 a 99 % de CPU chacun, jusqu'a 28 minutes de vie
|
||
|
||
`check_disk` 2.4.0-3+deb13u1 ne rend jamais la main sur cet hote (etat `R`, il boucle).
|
||
Icinga en relancait un a CHAQUE intervalle pour le service `disk` de son hote de
|
||
DEMONSTRATION, et aucun ne mourait. **L'hote de supervision etait sature par la
|
||
configuration d'exemple livree avec le paquet.**
|
||
|
||
### Ce qui a ete retire, et pourquoi l'hote plutot que les services
|
||
|
||
`conf.d/hosts.conf` definit `object Host NodeName` — un `localhost` de demonstration avec
|
||
`vars.disks`, `vars.http_vhosts`, `vars.os`. Les `apply Service` s'y accrochent : `disk`,
|
||
`http`, `swap`, `apt`, `load`, `procs`, `users`, `ssh`, `icinga`. Aucun ne decrit cet
|
||
ecosysteme, et trois etaient **rouges en permanence** — `swap` sur une VM sans swap, `http`
|
||
sur un port ou rien n'ecoute, `apt` pour un paquet a mettre a jour.
|
||
|
||
On retire l'HOTE, pas les services : sans lui les `apply` ne s'accrochent a rien, et on ne
|
||
touche pas a un fichier que le paquet remplacera a la prochaine mise a jour.
|
||
|
||
**Et ca garde ce qui servait** : `apply Service "ping4"` vise tout hote ayant une adresse,
|
||
donc les notres. Il est vert 14/14 au tenant. Retirer `services.conf` l'aurait emporte
|
||
avec le reste.
|
||
|
||
avant : load 5,10 — 8 check_disk — 3 alarmes rouges permanentes
|
||
apres : load 0,77 — 0 check_disk — certificat 14/14, sante 14/14, ping4 14/14
|
||
|
||
### Trouve en verifiant : la supervision du SITE croyait tout mort
|
||
|
||
hotes UP : 1/7 (site) 14/14 (tenant)
|
||
|
||
Nos `object Host` sont verifies par `hostalive`, c'est-a-dire un ping. Le site est decoupe
|
||
en zones separees ; l'ICMP inter-zones n'etait declare nulle part, donc la frontiere
|
||
l'avalait — 100 % de perte, mesure. Et **Icinga SUPPRIME les notifications des services
|
||
d'un hote DOWN** : une supervision qui croit tout mort n'alerte plus de rien, tout en ayant
|
||
l'air de fonctionner.
|
||
|
||
Le flux est desormais declare des DEUX cotes (`serveur_icinga` en egress, `serveur_debian`
|
||
en ingress) — et le registre a refuse la premiere moitie seule, ce qui est exactement sa
|
||
raison d'etre. `CODES_ICMP` apprend `echo-request` (un TYPE, pas un code de
|
||
destination-unreachable). Les regles d'hote sont posees sur les 21 machines.
|
||
|
||
### Le generateur de la frontiere : corrige, et il cachait un second manque
|
||
|
||
**La cause exacte.** `devis_opnsense.py` traitait tout `port` non numerique comme
|
||
SYMBOLIQUE — a resoudre par le plan. C'est vrai pour `derive`, le port d'un service que
|
||
seul le plan connait. C'est FAUX pour l'ICMP : `echo-request` et `frag-needed` sont des
|
||
litteraux, pas des inconnues. Ils tombaient dans la branche « le plan ne resout pas », et
|
||
aucune regle n'etait emise.
|
||
|
||
Une ligne de condition, et le silence de toute une flotte.
|
||
|
||
**Ce que la correction a revele en plus.** Huit regles a creer, zero a retirer — et SIX
|
||
d'entre elles sont du **PMTUD**, absent lui aussi pour la meme raison. Le depot dit de ces
|
||
deux messages ICMP : *« Bloques, la connexion s'etablit, les petites requetes passent et
|
||
les grosses reponses restent suspendues — la panne la plus couteuse a diagnostiquer. »*
|
||
|
||
**CORRECTION (meme jour, apres mesure).** La premiere redaction de ce paragraphe disait que
|
||
cette panne « etait la, silencieuse, sur les sept machines de l'hebergeur ». **C'est faux,
|
||
et la mesure ne le soutient pas** :
|
||
|
||
site-mon-01 -> autre zone du site : MTU de chemin 1500
|
||
site-mon-01 -> Internet : MTU de chemin 1500
|
||
ops-01 (tenant, overlay EVPN) : MTU 1450, chemin vers Internet 1450
|
||
|
||
Le site est a **1500 de bout en bout**. Rien n'y etait donc casse entre zones : aucun lien
|
||
du chemin ne plafonne plus bas, donc aucun message « fragmentation necessaire » n'avait
|
||
lieu d'etre emis. La contrainte a 1450 vit chez les TENANTS, sur l'overlay — pas chez
|
||
l'hebergeur.
|
||
|
||
Les six regles restent un vrai manque : elles couvrent le site parlant vers l'EXTERIEUR,
|
||
ou un lien distant peut tres bien plafonner plus bas, et elles seraient necessaires le jour
|
||
ou une zone du site passerait sur l'overlay. Mais c'etait une **protection absente**, pas
|
||
une panne active — et decrire l'un comme l'autre est exactement le genre d'exageration que
|
||
ce fichier ne doit pas contenir. Une correction qu'on ne mesure pas se raconte toujours
|
||
plus grande qu'elle n'est.
|
||
|
||
**Ce que la regle emise autorise, exactement — et pourquoi on ne pretend pas mieux.**
|
||
`appliquer_opnsense` n'envoie `destination_port` que pour TCP et UDP : une regle ICMP ouvre
|
||
le PROTOCOLE entre deux pairs, pas le seul type declare. Le type reste porte par la regle
|
||
d'HOTE, que `resoudre_flux` emet precisement (`icmp type echo-request accept`). La
|
||
frontiere dit qui peut parler a qui ; l'hote dit ce qu'il accepte d'entendre. Emettre un
|
||
`icmptype` a la frontiere aurait ete plus fin — et aurait demande de traduire `frag-needed`
|
||
(un CODE de `destination-unreachable`) dans le vocabulaire de pf, au risque de casser le
|
||
PMTUD pour gagner de la precision sur une barriere qui n'est pas la derniere.
|
||
|
||
**Mesure apres application :**
|
||
|
||
ping site-mon-01 -> les six autres zones : 6/6 repondent
|
||
hotes UP : 1/7 -> 7/7
|
||
ping4 : 1/7 -> 7/7 certificat 7/7, sante 7/7
|
||
|
||
## 2026-09-09 (7) — Le contrat des sondes ETAIT celui de Nagios, sans le savoir
|
||
|
||
Question posee : « tu connais le paquet `monitoring-plugins` ? »
|
||
|
||
Oui — et le contrat que `docs/supervision-conception.md` decrivait quelques heures plus
|
||
tot EST le sien, mot pour mot : une ligne sur stdout, `0/1/2` en code de sortie. Je l'avais
|
||
donc **reinvente sans le nommer**, alors que `positionnement.md` dit exactement l'inverse :
|
||
*adopter aux seuils, ne pas reimplementer*.
|
||
|
||
Le document nomme desormais l'**API des greffons Nagios**, ajoute `3 = INCONNU` et la
|
||
partie `| metriques` qui manquaient, et dit la consequence : **un greffon standard EST une
|
||
sonde valide, sans la moindre colle**. Le paquet en fournit 54 — `check_disk`,
|
||
`check_load`, `check_procs`, `check_ntp_time`, `check_smtp`, `check_pgsql`... On n'ecrit du
|
||
shell que lorsque la verite a mesurer est propre a Set-OPS, comme
|
||
`client_pki/certificat`, qui compare l'empreinte SERVIE a celle du disque : aucun greffon
|
||
ne sait ca.
|
||
|
||
Le porteur separe maintenant le texte des metriques (`performance_data`), parce que c'est
|
||
ce que le contrat porte et qu'Icinga sait les tracer.
|
||
|
||
### Essayer un vrai greffon a revele DEUX defauts du porteur
|
||
|
||
**1. Un envoi refuse faisait taire toutes les sondes suivantes.** `rapporter` sortait en
|
||
`exit 1` : une sonde deposee mais non declaree — donc refusee par le filtre d'API —
|
||
supprimait le rapport de toutes les autres, `sante` comprise. Le tableau ne devenait pas
|
||
rouge, il devenait VIDE, et le `ttl` le perimait des heures plus tard sans dire pourquoi.
|
||
*Un rapporteur qui ne peut pas dire UNE chose doit quand meme dire les autres.*
|
||
|
||
**2. Aucun delai de garde sur les sondes.** Et ce n'est pas theorique :
|
||
|
||
check_disk 2.4.0-3+deb13u1 sur mon-01 : etat R, ne rend jamais la main
|
||
quels que soient ses arguments (-p /, sans -p, seuils en % ou en G)
|
||
|
||
Un greffon STANDARD, sur une machine saine, qui **boucle**. Sans delai de garde il figeait
|
||
le rapport entier, toutes les quinze minutes, indefiniment. Chaque sonde tourne desormais
|
||
sous `timeout 20` ; au-dela, on rapporte INCONNU en le disant.
|
||
|
||
*La lecon n'est pas « les greffons standards sont mauvais ».* C'est qu'adopter un standard
|
||
ne dispense pas de l'eprouver — et que le porteur doit survivre a une sonde qui se comporte
|
||
mal, standard ou maison.
|
||
|
||
### Trois voyants rouges permanents, sur l'hote de supervision
|
||
|
||
Constate en cherchant : la configuration d'exemple livree par Icinga surveille `localhost`
|
||
et crie en permanence sur `mon-01` —
|
||
|
||
swap : SWAP CRITICAL - 0% free (une VM sans swap)
|
||
http : connect to 127.0.0.1:80 (rien n'ecoute la)
|
||
apt : 1 package upgradable
|
||
|
||
Trois alarmes qui ne peuvent que rester rouges, dans le seul endroit qui doit rester
|
||
lisible. **Non corrige** — c'est une decision d'exploitation, pas une correction de code.
|
||
|
||
## 2026-09-09 (6) — La supervision se DECLARE dans le role, comme les flux
|
||
|
||
**64 preuves (P01-P64).** Nouveau document : `docs/supervision-conception.md`.
|
||
Premiere sonde : `client_pki/certificat`, **14/14 OK** sur la flotte.
|
||
|
||
### Le constat qui a decide
|
||
|
||
Mesure du depot sur lui-meme, au 2026-09-09 :
|
||
|
||
39 roles declarent leurs flux -> nftables + OPNsense, derives
|
||
32 declarent leur empreinte -> ressources des VM, derivees
|
||
32 declarent leur authentification -> habilitations, derivees
|
||
19 groupes declarent `surveillance:` -> RIEN
|
||
|
||
Les dix-neuf lignes `surveillance:` de `docs/dependances-groupes.yml` sont ecrites,
|
||
versionnees, relues — et **aucune n'etait executee**. Icinga surveillait deux choses.
|
||
|
||
C'est la classe d'echec de ce depot, en version documentaire : la carte dit ce qui est
|
||
surveille, et personne ne surveille. *Une intention ecrite n'est pas une mesure.*
|
||
|
||
### Le mecanisme
|
||
|
||
Un role declare ses sondes dans `meta/supervision.yml` (nom, `ttl`, raison) et **depose
|
||
lui-meme** son script dans `/usr/local/lib/setops/sondes/` — il connait ses chemins, ses
|
||
seuils, son consommateur. Le porteur (`client_sante`) fait tourner tout ce qui vit dans ce
|
||
repertoire et pousse un resultat passif par sonde ; il ne sait pas ce qu'elles mesurent, et
|
||
c'est le but. `serveur_icinga` derive les `object Service` ET **le filtre de permission
|
||
d'API** des memes declarations.
|
||
|
||
Ajouter une sonde ne demande de toucher ni au porteur, ni a Icinga. Comme un flux declare
|
||
devient une regle nftables.
|
||
|
||
### La sonde du certificat, et ce qu'elle mesure VRAIMENT
|
||
|
||
Nos certificats vivent **24 heures**. Une machine dont le renouvellement s'arrete ne casse
|
||
pas tout de suite : elle casse le LENDEMAIN, et tout ce qui parle TLS avec elle tombe
|
||
ensemble.
|
||
|
||
Ce qu'on ne mesure pas, et c'est delibere : *« le minuteur est-il actif ? »* — un minuteur
|
||
vert qui echoue chaque nuit est exactement le mensonge deja paye avec les sauvegardes.
|
||
*« le certificat existe-t-il ? »* — un certificat perime existe.
|
||
|
||
Ce qu'on mesure : les heures restantes sur le certificat REELLEMENT pose, la chaine
|
||
verifiee contre notre racine, et — quand un service le consomme — l'empreinte SERVIE
|
||
comparee a celle du disque.
|
||
|
||
### Trois obstacles, et le premier est le plus instructif
|
||
|
||
**La sonde a rendu 14 machines sur 14 en CRITIQUE — sur une PKI qui se portait tres bien.**
|
||
Elle utilisait `openssl verify -CAfile racine` ; or nos certificats d'hote sont signes par
|
||
un INTERMEDIAIRE, et seule la racine vit sur le disque. `step certificate verify`, l'outil
|
||
que le role installe deja, repond VALIDE.
|
||
|
||
*Une alarme toujours allumee ne vaut pas mieux qu'une alarme jamais allumee : elle apprend
|
||
a ne plus regarder.* Une sonde se prouve donc DEUX FOIS — verte sur le sain, rouge sur le
|
||
casse. Le document de conception porte la regle.
|
||
|
||
**Le filtre d'API etait ecrit avant la lecture des declarations.** Les services existaient,
|
||
et Icinga aurait refuse leurs resultats avec un « 404 No objects found » sur des objets
|
||
bien presents — une heure perdue sur ce message le 2026-09-02. La lecture est remontee
|
||
avant le compte d'API, avec le pourquoi ecrit la ou l'on serait tente de la redescendre.
|
||
|
||
**Mon controle negatif a casse un service reel.** Substituer le certificat d'hote par un
|
||
auto-signe a fait virer la sonde au rouge, comme voulu — et le script de synchronisation a
|
||
propage ce certificat vers `node_exporter`, dont la clef etait restee l'ancienne :
|
||
`tls: private key does not match public key`. Un service tombe pour eprouver une sonde. La
|
||
regle qui en decoule est au document : **un controle negatif se fait sur une copie**,
|
||
jamais en substituant l'artefact que d'autres consomment.
|
||
|
||
### Les quatre etats, tous eprouves
|
||
|
||
certificat absent -> [2] CRITIQUE
|
||
signe par une autre autorite -> [2] CRITIQUE (24 h restantes : c'est la CHAINE qui parle)
|
||
sous le seuil d'avertissement-> [1] AVERTISSEMENT
|
||
etat sain -> [0] OK
|
||
|
||
Puis relu dans IcingaDB : `certificat : 14/14 OK`, `sante : 14/14 OK`.
|
||
|
||
### P64 tient les deux bouts
|
||
|
||
Une sonde DECLAREE dont le script n'est pas depose donnerait un service qui n'a jamais de
|
||
resultat. Un script DEPOSE que rien ne declare serait refuse par le filtre d'API. Les deux
|
||
se rompent seuls, la preuve tient les deux — plus la presence d'une `raison` et d'un `ttl`.
|
||
Trois controles negatifs rejoues.
|
||
|
||
Ce qu'elle ne fait pas, et qu'aucune lecture statique ne fera : juger qu'une sonde MESURE
|
||
quelque chose. Ca reste au controle negatif de chacune, exige par le document et trace ici.
|
||
|
||
### Et une QUATRIEME fois la meme lecon : les seuils criaient trop tot
|
||
|
||
Deployee sur le site, la sonde a rendu **5/7** — deux machines en AVERTISSEMENT, sur des
|
||
certificats parfaitement sains.
|
||
|
||
Le seuil etait a 12 h. Or `cert-renewer@.service` porte
|
||
`ExecCondition=step certificate needs-renewal`, qui dit oui au TIERS RESTANT : **8 h** pour
|
||
un certificat de 24 h, reessaye toutes les 15 minutes. La sonde criait donc AVANT que le
|
||
mecanisme ne soit cense agir.
|
||
|
||
Les seuils sont desormais SOUS le point de renouvellement — 6 h (le renouvellement est du
|
||
depuis deux heures, huit fenetres manquees : ce n'est plus un hoquet) et 3 h. *Un seuil
|
||
au-dessus du point de renouvellement ne previent pas : il ment.*
|
||
|
||
Un seuil ne se choisit pas a l'intuition : il se DERIVE du moment ou le mecanisme surveille
|
||
est cense agir. C'est la meme faute que `openssl verify` quelques heures plus tot, sous une
|
||
autre forme — et c'est encore la flotte reelle qui l'a dite.
|
||
|
||
**Etat final : 14/14 au tenant, 7/7 au site**, sur `certificat` comme sur `sante`.
|
||
|
||
### Reste
|
||
|
||
Dix-huit groupes attendent encore leur sonde. Le mecanisme, lui, ne demande plus rien.
|
||
|
||
## 2026-09-09 (5) — Retrait d'une garde qui ne pouvait pas se declencher
|
||
|
||
`client_sante` portait une branche « aucun `serveur_icinga` : rien a poser », et un `when`
|
||
sur tout son bloc. **Ni l'une ni l'autre ne pouvait s'executer.**
|
||
|
||
Eprouve sur le modele public, dans une copie jetable : `instancier` ne pose une
|
||
integration UNIVERSELLE que si son serveur existe dans l'ecosysteme. Sans Icinga au plan,
|
||
le groupe `client_sante` est **absent** de l'inventaire genere — exactement comme
|
||
`client_metrique` et `client_journal` le sont deja. Le role n'est donc jamais appele sans
|
||
destinataire, et sa branche defensive etait du decor.
|
||
|
||
*Une garde qui ne peut pas se declencher n'est pas une garde : elle rassure sans rien
|
||
tenir.* Et elle coute deux fois — a la lecture, puis le jour ou l'on cherche pourquoi rien
|
||
n'a alerte.
|
||
|
||
Ce qui la remplace se declenche vraiment, et les deux cotes sont eprouves :
|
||
|
||
-e client_sante_icinga_hote='' -> FAILED (assertion sur l'hote)
|
||
-e client_sante_icinga_motdepasse='' -> FAILED (assertion sur le secret)
|
||
|
||
L'echec dit LEQUEL des deux manque. Le bloc, prive de sa condition, est aplati : douze
|
||
taches a plat au lieu de onze indentees sous un `when` toujours vrai.
|
||
|
||
Rejoue sur les deux flottes : **zero changement sur zero hote**. `make prouver` :
|
||
CONFORME, 63 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-09-09 (4) — `systemctl --failed` entre dans la supervision
|
||
|
||
**63 preuves. `make prouver` : CONFORME, 63 OK, 0 echec, 0 saute.**
|
||
Nouveau role `client_sante`, integration **universelle** (67 roles).
|
||
|
||
### Ce qu'il ferme
|
||
|
||
Le defaut trouve quelques heures plus tot : `openipmi.service` echouait a CHAQUE
|
||
demarrage sur les quatorze machines depuis le 2026-09-02, et `systemctl --failed` rendait
|
||
ZERO partout — non parce qu'elles allaient bien, mais parce qu'AUCUNE n'avait redemarre
|
||
depuis. Il a fallu qu'un humain redemarre une machine pour que le defaut existe aux yeux
|
||
de quelqu'un.
|
||
|
||
*Un controle qui ne peut echouer qu'au demarrage ne mesure rien tant que rien ne demarre.*
|
||
|
||
### PASSIF, et a duree de vie
|
||
|
||
Un controle ACTIF ne voit pas la machine MUETTE : si elle ne repond plus, la sonde echoue
|
||
et on met ca sur le compte du reseau. Ici c'est le NOEUD qui parle, et le `ttl` de son
|
||
envoi fait la fraicheur — sans nouvelle, Icinga perime le service tout seul. **Le silence
|
||
alerte autant que l'echec**, et le silence est precisement ce qui n'a alerte personne.
|
||
|
||
Le minuteur declenche **au demarrage** (`OnBootSec=2min`) autant que toutes les 15 min :
|
||
les echecs de cette famille NAISSENT au boot, et attendre le premier quart d'heure
|
||
laisserait une fenetre pendant laquelle la machine est en panne et le tableau au vert.
|
||
|
||
**CRITIQUE des la premiere unite**, jamais un seuil. Une unite en echec est soit un vrai
|
||
probleme, soit du bruit a retirer : dans les deux cas il faut agir. Un seuil ferait vivre
|
||
le bruit indefiniment — exactement ce qu'on vient de corriger.
|
||
|
||
Les tolerances se nomment **une par une** (`client_sante_unites_tolerees`, vide par
|
||
defaut). Jamais un motif large : un filtre qui cache une unite en cache d'autres, et on ne
|
||
s'en apercoit que le jour ou l'on cherche pourquoi rien n'a alerte.
|
||
|
||
### Le controle negatif
|
||
|
||
Une garantie qu'on n'a jamais vue echouer n'est pas une garantie. Unite factice posee sur
|
||
`obs-01`, rapport rejoue, etat relu dans IcingaDB :
|
||
|
||
obs-01 sante CRITICAL 1 unite(s) systemd en echec : setops-controle-negatif.service
|
||
(13 autres) sante OK Aucune unite systemd en echec.
|
||
|
||
Le verdict NOMME l'unite. Apres nettoyage : **14/14 OK**.
|
||
|
||
### Un conflit evite de justesse
|
||
|
||
`setops-sauvegardes.conf` definissait les `object Host`. Un second fichier de controle
|
||
aurait redefini les memes, et **Icinga refuse un objet en double** : la configuration
|
||
entiere aurait ete rejetee, donc AUCUNE supervision — en voulant en ajouter. Les hotes
|
||
vivent desormais dans `setops-hotes.conf`, definis UNE fois ; les fichiers de controle
|
||
n'attachent que des services. Verifie : 14 hotes, 14 services, zero doublon,
|
||
`icinga2 daemon -C` valide.
|
||
|
||
### Trois obstacles, et ce qu'ils apprennent
|
||
|
||
**`${#tableau[@]}` contient `{#`**, que Jinja lit comme un debut de commentaire — le rendu
|
||
echouait sur « Missing end of comment tag ». Le shell a besoin de `${#...}` ; c'est donc a
|
||
Jinja de s'ecarter (`#jinja2: comment_start_string:...` en tete du gabarit).
|
||
|
||
**Ma premiere sonde a traduit un refus en « 0 service ».** Le compte `setops-depot` ne peut
|
||
que POSER un resultat, pas lire — moindre privilege voulu. L'API rendait
|
||
`{"error":403,"status":"Missing permission: objects/query/service"}`, et mon script, qui
|
||
cherchait une clef `results`, a affiche « 0 service(s) sante ». *Encore un « echec » qui
|
||
ecrasait « permission ».* L'etat se lit dans IcingaDB, pas par ce compte.
|
||
|
||
**Et un echec `apt` transitoire** sur `mon-01` : `503 unexpected range` puis « Message has
|
||
been manipulated » sur la signature de `debian-security`. Alarmant a la lecture, local a
|
||
une machine (13 autres propres), et disparu au second essai — un hoquet du cache
|
||
apt-cacher-ng, pas un incident de signature. Mesure avant conclusion : les deux
|
||
mandataires servaient le meme contenu, au meme condensat.
|
||
|
||
### Le meme compte d'API, elargi de deux noms a trois
|
||
|
||
Un second compte serait plus pur — un secret par usage. Il exigerait une clef de voute de
|
||
plus dans CHAQUE ecosysteme, donc un geste manuel a chaque nouvel ecosysteme, pour une
|
||
portee identique : ce compte ne peut deja que poser un resultat passif, sur des services
|
||
NOMMES. Le filtre passe de `sauvegarde: *` / `sauvegarde` a ces deux-la plus `sante`. Aucun
|
||
pouvoir nouveau.
|
||
|
||
### Le SITE : fait, et il a fallu deux corrections pour que ca MARCHE
|
||
|
||
7 machines, 0 echec au deploiement — et **cinq rapports sur sept en `TimeoutError`**.
|
||
|
||
*Un playbook vert ne prouve pas qu'une chose fonctionne.* `make site-appliquer
|
||
GROUPE=client_sante` rendait 7/7, 0 echec, sur un flux qui ne passait pas. Seul l'essai
|
||
de bout en bout l'a dit.
|
||
|
||
**1. Le pair de la frontiere.** Le role client declarait son `egress` 5665, la politique
|
||
de sortie etait `accept`, la regle d'entree de l'hote autorisait bien la source — et les
|
||
paquets mouraient ENTRE les deux. La frontiere filtre le trafic inter-zones du site et ne
|
||
connait que ce que le registre lui dit ; or elle ne resout que les roles que les machines
|
||
PORTENT AU PLAN. Une integration universelle n'y figure pas : elle est derivee, pas
|
||
declaree. `pair: client_sante` produisait donc une regle est-ouest correcte et AUCUNE
|
||
regle a la frontiere.
|
||
|
||
Le pair est desormais `serveur_debian` — le vocabulaire du depot pour « tout noeud », que
|
||
le generateur de frontiere traite deja comme tel, et qui est exact au sens strict : tout
|
||
noeud rapporte sa sante. Six regles creees, zero retiree, une par patte de zone. Le meme
|
||
piege explique pourquoi le flux `client_backup` juste au-dessus ne suffisait pas seul :
|
||
c'est `serveur_backup`, role REEL, qui ouvrait la porte pour le depot.
|
||
|
||
**2. Le rapporteur s'accusait lui-meme.** Pendant l'heure ou le pare-feu bloquait,
|
||
`setops-sante.service` a echoue — et une fois debloque, cinq machines ont rapporte
|
||
CRITIQUE en citant... leur propre rapporteur. Le blocage corrige, l'accusation restait :
|
||
systemd garde l'etat `failed` jusqu'a un `reset-failed`. Un rapporteur qui trebuche une
|
||
fois s'accuserait indefiniment.
|
||
|
||
Sa propre unite est donc exclue du compte, et ce n'est pas se menager : **sa sante est
|
||
deja mesuree, et mieux, par la FRAICHEUR de ses envois**. S'il ne peut plus parler, le
|
||
`ttl` perime le service — ce qui se voit precisement quand il ne peut PAS ecrire, alors
|
||
que sa propre unite en echec ne se voit que quand il le peut. Se compter soi-meme, c'est
|
||
mesurer deux fois la meme chose, dont une fois mal.
|
||
|
||
**Etat final** : **7/7 au site, 14/14 au tenant**, tous OK. Controle negatif rejoue sur
|
||
les deux flottes.
|
||
|
||
## 2026-09-09 (3) — Le redemarrage a tenu, et il a montre autre chose
|
||
|
||
### Ce que le redemarrage prouve
|
||
|
||
`obs-01` a redemarre **avec son disque cloud-init retire de Proxmox** — donc sans paquet
|
||
ET sans source de donnees. Elle est revenue avec son adresse (`10.17.20.11/24`), sa
|
||
passerelle (`10.17.20.1`) et son resolveur (`10.0.34.11`). C'est la confirmation que les
|
||
mesures annoncaient : `/etc/network/interfaces.d/50-cloud-init` n'appartient a aucun
|
||
paquet, il survit, et `ifupdown` le relit au demarrage.
|
||
|
||
Le retrait de cloud-init est desormais **prouve par un demarrage reel**, pas seulement par
|
||
inference.
|
||
|
||
### Ce que le redemarrage a REVELE, et qui n'a rien a voir
|
||
|
||
Une unite en echec : `openipmi.service`.
|
||
|
||
openipmi[655]: Starting ipmi drivers ipmi failed!
|
||
systemd[1]: Failed to start openipmi.service
|
||
|
||
La chaine : `prometheus-node-exporter` **recommande**
|
||
`prometheus-node-exporter-collectors`, qui **recommande** `ipmitool`, qui **recommande**
|
||
`openipmi`. Trois recommandations en cascade, chacune raisonnable sur du metal. Dans une
|
||
VM il n'y a pas de BMC — `/dev/ipmi*` n'existe pas — et le script d'init echoue a chaque
|
||
demarrage.
|
||
|
||
**LE POINT N'EST PAS LE PAQUET, C'EST POURQUOI PERSONNE NE L'AVAIT VU.** `openipmi` est
|
||
arrive le **2026-09-02**, avec la reconstruction. `systemctl --failed` rendait pourtant
|
||
ZERO sur les quatorze machines — non parce qu'elles allaient bien, mais parce qu'aucune
|
||
n'avait **redemarre** depuis. Six jours et vingt heures pour `obs-01` (`last -x reboot` :
|
||
boot du 2026-09-02 19:24, jusqu'a aujourd'hui 16:10).
|
||
|
||
*Un controle qui ne peut echouer qu'au demarrage ne mesure rien tant que rien ne demarre.*
|
||
C'est la meme famille que le cheque vert sur un perimetre vide, avec le temps comme
|
||
perimetre.
|
||
|
||
Et le degat n'est pas cosmetique : une unite en echec **permanent** use le seul signal qui
|
||
devrait alerter. Le jour ou une vraie unite tombe, `systemctl --failed` rend « 2 » au lieu
|
||
de « 1 », et personne ne fait la difference.
|
||
|
||
### La correction, a la source et conditionnelle
|
||
|
||
`client_metrique` retire `ipmitool` et `openipmi` — mais **seulement dans une VM**
|
||
(`ansible_facts.virtualization_role == 'guest'`). Sur du metal le collecteur IPMI est
|
||
legitime : c'est la raison meme de la recommandation. Le role s'appuie sur ce qu'Ansible
|
||
SAIT de la machine, pas sur une supposition.
|
||
|
||
Ils ne sont que RECOMMANDES, donc les retirer n'emporte pas
|
||
`prometheus-node-exporter-collectors`, dont les collecteurs textfile servent vraiment.
|
||
|
||
Un handler efface l'etat `failed` laisse derriere : retirer le paquet ne l'efface pas,
|
||
systemd le garde jusqu'au prochain demarrage. Sur une flotte qui ne redemarre pas, ce
|
||
serait conserver par inadvertance exactement le bruit qu'on vient de supprimer.
|
||
|
||
Applique aux quatorze : `ipmi=0`, `collectors=1`, `node_exporter=active`, `echecs=0`
|
||
partout. Second passage : **zero changement sur zero hote** — idempotent.
|
||
|
||
### Ce qui reste ouvert
|
||
|
||
Rien dans le harnais ne regarde `systemctl --failed` sur la flotte. Ce defaut-ci a ete
|
||
trouve **parce qu'un humain a redemarre une machine**, pas parce qu'une mesure l'a dit.
|
||
Les treize autres portaient la meme unite condamnee, silencieuses.
|
||
|
||
## 2026-09-09 (2) — Le wiki d'`origin` : une garde qui bloquait au lieu de proteger
|
||
|
||
**Les deux forges servent desormais le meme wiki**, 26 pages, contenu identique au fichier
|
||
pres (`diff -rq` : aucune difference).
|
||
|
||
### Ce que je disais, et qui etait faux
|
||
|
||
« Il n'y a rien sur `origin` » — non. **Le depot y est, et a jour** : `git ls-remote` rend
|
||
exactement notre HEAD. C'est le WIKI qui etait vide. L'ecart entre « le depot est absent »
|
||
et « son wiki n'a pas de branche » est tout l'ecart entre une panne et une formalite, et je
|
||
l'avais recopie d'une session precedente sans le remesurer.
|
||
|
||
### La garde avait raison de refuser, et tort de s'arreter la
|
||
|
||
`make wiki-publier` refuse un wiki VIDE : sans commit il n'a aucune branche, et publier en
|
||
inventerait une — `master` sur ce poste, alors que le wiki en service vit sur `main`. Le
|
||
message disait « creer une premiere page dans l'interface Forgejo ». Or l'interface n'est
|
||
pas joignable depuis ce poste, et ce n'est pas une panne : la forge du site n'accepte le
|
||
443 que des machines qui DECLARENT le flux.
|
||
|
||
**Forgejo declare pourtant la reponse.** `GET /api/v1/repos/genome/set-ops-public` rend
|
||
`wiki_branch`. Interroge depuis `ops-01` — une machine qui a le flux — il repond `main`.
|
||
On ne devinait donc pas : on ne demandait pas.
|
||
|
||
`WIKI_BRANCHE=` a ete ajoute a la recette. Le refus reste le DEFAUT ; qui a la reponse peut
|
||
la donner. Les deux cotes sont eprouves : un wiki vide sans la variable est refuse, avec
|
||
`WIKI_BRANCHE=main` il est initialise sur `main` exactement.
|
||
|
||
### Au passage, une lecon d'instrument
|
||
|
||
Trois sondes fausses avant la bonne. Un `connect()` direct sur `10.0.33.11:443` depuis le
|
||
poste rend `TimeoutError` — route presente, paquets avales : une POLITIQUE, pas une route
|
||
manquante. Un tunnel par la frontiere ne repondait pas davantage. Et pendant ce temps
|
||
`git ls-remote` fonctionnait tres bien, parce que `~/.ssh/config` passe par un
|
||
`ProxyJump ansible@10.17.0.1` que la sonde ignorait.
|
||
|
||
L'instrument juste etait `ops-01` : la machine dont le flux vers la forge est DECLARE. Elle
|
||
repond `HTTP 200` en 443, et 22 muet — l'exact inverse du poste. *Verifier d'ou l'instrument
|
||
mesure*, encore une fois.
|
||
|
||
## 2026-09-09 — cloud-init nait avec la VM et ne lui survit pas
|
||
|
||
**63 preuves (P01-P63). `make prouver` : CONFORME, 62 OK, 0 echec, 1 saute.**
|
||
|
||
### Le second maitre
|
||
|
||
cloud-init n'est pas un logiciel d'installation : c'est une **source de verite externe**.
|
||
Il ne s'arrete pas apres la premiere seconde — il se reveille a CHAQUE demarrage et relit
|
||
le lecteur cloud-init attache par l'hyperviseur, lequel peut redefinir les comptes, les
|
||
cles SSH autorisees, les mots de passe et le reseau.
|
||
|
||
Sur une machine que le plan possede desormais, c'est un second maitre : le plan ne le
|
||
decrit pas, `make valider` ne le mesure pas, et il parle en premier.
|
||
|
||
Sa tache est pourtant finie a la premiere seconde. C'est precisement parce qu'il a REUSSI
|
||
a poser l'adresse, le nom et les cles d'hote qu'Ansible a pu entrer.
|
||
|
||
### Trois moities, et elles se defont separement
|
||
|
||
1. le **gabarit** le garde. Sans lui, un clone n'a ni adresse ni nom : il ne nait pas.
|
||
2. le **socle** ne l'installe plus. Le garder produisait un va-et-vient a chaque
|
||
deploiement — le socle installe, le durcissement retire, deux `changed` par passage,
|
||
l'idempotence perdue et `make valider` bruyant pour rien.
|
||
3. le **durcissement** le retire (`roles/cloud_init_retrait`, dernier role de
|
||
`serveur_durci`, apres que tout le reste soit pose).
|
||
|
||
Une seule des trois qui bouge et la decision devient son contraire en silence : un gabarit
|
||
sans cloud-init donne des VM mortes ; un socle qui le reinstalle rend le pouvoir a chaque
|
||
passage ; un durcissement qui ne le retire plus laisse le second maitre en place. **P63**
|
||
garde les trois, plus le contenu du role — un role vide passerait les trois premiers
|
||
controles sans rien fermer. Les quatre controles negatifs ont ete rejoues.
|
||
|
||
### Ce qui rend le retrait sur est MESURE, pas suppose
|
||
|
||
L'adresse d'une VM vit dans `/etc/network/interfaces.d/50-cloud-init`. Le risque evident
|
||
etait que le retrait l'emporte — une machine qui perd ce fichier ne se plaint pas : elle
|
||
repart au prochain demarrage sans adresse, et plus personne ne peut entrer pour le
|
||
constater. Mesure du 2026-09-09 sur `obs-01` :
|
||
|
||
dpkg -S /etc/network/interfaces.d/50-cloud-init -> aucun paquet ne le possede
|
||
/var/lib/dpkg/info/cloud-init.postrm -> ne nomme jamais interfaces.d
|
||
|
||
Le fichier survit donc, meme en `purge`, et `interfaces` fait toujours
|
||
`source interfaces.d/*`. Son en-tete annonce que les modifications « ne persistent pas au
|
||
redemarrage » : c'etait vrai TANT QUE cloud-init pouvait le reecrire. Le paquet parti,
|
||
plus personne ne le reecrit.
|
||
|
||
**Le role le verifie quand meme**, avant et apres, et n'accuse que si le retrait l'a
|
||
emporte — une machine qui n'a jamais eu ce fichier ne doit pas faire echouer le
|
||
durcissement. Essai a blanc sur `obs-01` : `cloud-init*` a retirer, configuration reseau
|
||
intacte, drapeau pose.
|
||
|
||
### Deux choix dits franchement
|
||
|
||
**`cloud-guest-utils` reste.** C'est `growpart` : ni service, ni port, ni source de
|
||
donnees. Le retirer ne fermerait aucune surface, et il faudrait le reinstaller au premier
|
||
agrandissement de disque.
|
||
|
||
**Les orphelins ne sont pas retires par defaut.** Le retrait laisse ~29 paquets qui
|
||
n'etaient la que pour cloud-init (`python3-jsonschema`, `python3-jinja2`,
|
||
`python3-oauthlib`...). Ce sont des bibliotheques : elles pesent, elles n'ouvrent rien.
|
||
`autoremove` calculerait exactement quoi retirer — a partir des drapeaux « installe
|
||
manuellement » de dpkg, et une machine dont ces drapeaux ont derive y perdrait autre
|
||
chose. Un durcissement ne doit pas pouvoir surprendre. `cloud_init_retrait_autoremove`
|
||
existe pour qui veut, en connaissance de cause.
|
||
|
||
### Et un drapeau, pour le retour par dependance
|
||
|
||
`cloud-init` peut revenir en recommandation d'un autre paquet.
|
||
`/etc/cloud/cloud-init.disabled` est lu par cloud-init lui-meme au demarrage et l'arrete
|
||
avant qu'il ne lise la moindre source de donnees.
|
||
|
||
### Ce que cette decision NE ferme PAS
|
||
|
||
Ajoute le meme jour, apres la question « cloud-init est-il vraiment une valeur ajoutee
|
||
ici ? ». La premiere redaction de D-85 se lisait comme une emancipation. Elle n'en est
|
||
pas une, et il valait mieux le dire que de laisser un lecteur presse en conclure trop.
|
||
|
||
**Retirer cloud-init n'ote AUCUN pouvoir a l'hebergeur.** `qemu-guest-agent` est au
|
||
gabarit — il doit y etre (P56 : c'est par lui que `creer-vm` confirme la materialisation
|
||
sans entrer chez le tenant). Releve du 2026-09-09 sur `edge-mta-01`, avec le jeton d'API
|
||
du site, les sous-chemins que l'API expose sur son dos :
|
||
|
||
exec · exec-status · file-read · file-write · set-user-password · shutdown · ...
|
||
|
||
C'est un pouvoir STRICTEMENT PLUS GRAND que le lecteur cloud-init, et il est deja la, sur
|
||
chaque VM vivante de la flotte.
|
||
|
||
Ce que D-85 ferme est donc precis et etroit :
|
||
|
||
1. une reapplication AUTOMATIQUE, a chaque demarrage, depuis un support que le plan ne
|
||
possede pas et qu'aucune preuve ne lit ;
|
||
2. le code de cloud-init lui-meme — un interpreteur Python complet execute en root au
|
||
demarrage, et ses ~29 dependances.
|
||
|
||
La mainmise d'un hyperviseur sur ses invites est une propriete de la VIRTUALISATION, pas
|
||
de cloud-init. Elle appelle sa propre decision, qui n'est pas prise.
|
||
|
||
### Le seuil ou le remplacer deviendrait juste
|
||
|
||
L'alternative existe et sa piece est deja au gabarit : ecrire l'adresse et la cle par
|
||
`agent/file-write` + `agent/exec` depuis l'hyperviseur, sans reseau. Aujourd'hui ce serait
|
||
REIMPLEMENTER UN STANDARD — ce que `positionnement.md` interdit — et echanger un mecanisme
|
||
eprouve contre du code maison au moment le plus fragile, dont le mode de panne est le
|
||
pire : une VM injoignable.
|
||
|
||
Le jour ou une premiere seconde ne pourra plus etre amorcee par Proxmox — autre
|
||
hyperviseur, metal nu, hebergeur sans API — ce chemin cesse d'etre une reimplementation et
|
||
devient LE CHEMIN PORTABLE. C'est l'axe emancipation que le depot suit deja. Le seuil est
|
||
nomme ici pour ne pas etre franchi sans le voir.
|
||
|
||
### Deploye le 2026-09-09 — 14/14
|
||
|
||
Applique a la flotte de Chezlepro apres confirmation explicite. `serveur_durci` :
|
||
**14 hotes, 0 echec, 0 injoignable**. Ordre suivi : `obs-01` seul d'abord, verifie, puis
|
||
les treize autres.
|
||
|
||
Releve sur les quatorze machines apres coup :
|
||
|
||
paquet=0 unites=0 reseau=oui drapeau=oui networking=enabled echecs=0
|
||
|
||
et, sur chacune, `ifquery` rend EXACTEMENT l'adresse vive (`10.17.20.11`, `10.17.21.11`...).
|
||
C'est le point : `ifquery` ne lit pas la memoire du systeme, il **relit le fichier** que le
|
||
retrait aurait pu emporter — donc il repond ce que le prochain demarrage fera.
|
||
|
||
Second passage sur `obs-01` : `changed=1`, et ce seul changement est la tache
|
||
`nftables_baseline` qui se declare toujours modifiee, anterieure a ce chantier. Le retrait
|
||
est idempotent.
|
||
|
||
`make valider` apres deploiement : **0 echec, 0 injoignable** sur les quatorze.
|
||
|
||
**CE QUI N'A PAS ETE PROUVE, ET IL FAUT LE DIRE.** Aucune machine n'a ete REDEMARREE. Le
|
||
redemarrage etait le controle que je voulais faire — la garde de securite du poste l'a
|
||
refuse, et on ne contourne pas une garde. Le chemin de demarrage a donc ete prouve
|
||
AUTREMENT, sans redemarrer : `networking.service` (ifupdown) est le service actif,
|
||
`systemd-networkd` est desactive, NetworkManager absent ; `ifquery` relit le fichier et
|
||
rend la bonne adresse ; zero unite cloud-init subsiste dans
|
||
`systemctl list-dependencies multi-user.target` ; zero unite en echec ; le nom d'hote et
|
||
les trois cles d'hote SSH persistent hors de cloud-init.
|
||
|
||
C'est fort, ce n'est pas un redemarrage. La confirmation finale tient en une ligne, a
|
||
lancer par un humain sur une machine de son choix.
|
||
|
||
### Le SITE : fait le 2026-09-09 — 7/7
|
||
|
||
`make site-appliquer GROUPE=serveur_durci` : **7 hotes, 0 echec, 0 injoignable**.
|
||
Configuration reseau intacte sur les sept, `ifquery` rend l'adresse attendue sur chacune
|
||
(`10.0.31.11`, `10.0.33.11`...), zero unite cloud-init restante, zero unite en echec.
|
||
|
||
Le site HEBERGE — c'est ce qui rendait l'operation plus lourde que chez le tenant. Verifie
|
||
apres coup, depuis les machines qui ont le flux : la forge repond `HTTP 200` et sert
|
||
toujours ce depot au bon commit ; le cache APT repond `HTTP 200` ; le DNS resout
|
||
`forge.genese.internal` ; step-ca rend `{"status":"ok"}`.
|
||
|
||
Le site ne portait pas le piege `openipmi` : il n'applique pas `client_metrique`.
|
||
|
||
*(Ce paragraphe remplace celui qui disait le changement « arme, pas applique » — et qui
|
||
disait aussi, a tort, qu'il fallait passer par `site-ops-01`. `make site-appliquer`
|
||
fonctionne depuis le poste.)*
|
||
|
||
### Trouve en verifiant : `site-backup-01` n'a jamais recu `client_pki`
|
||
|
||
Elle est DANS le groupe `client_pki` — et elle n'a ni `/etc/step`, ni minuterie de
|
||
renouvellement, ni meme la racine de l'AC dans `/usr/local/share/ca-certificates/`. Les
|
||
six autres machines du site ont leur certificat et leur minuterie quotidienne, qui tourne.
|
||
|
||
L'appartenance au groupe dit « couverte ». La machine dit le contraire. C'est la meme
|
||
famille que le reste : *un perimetre declare n'est pas un perimetre mesure.* Sans rapport
|
||
avec le retrait de cloud-init — trouve en le verifiant.
|
||
|
||
**Corrige le meme jour** : `make site-appliquer GROUPE=client_pki` — 7 hotes, 0 echec.
|
||
`site-backup-01` a recu quatorze changements : depot apt Smallstep, `step-cli`, STEPPATH,
|
||
mot de passe du provisioner, **etablissement de la confiance dans l'AC**, certificat
|
||
d'hote emis, unite et minuteur de renouvellement, actives.
|
||
|
||
Verifie apres coup sur les sept :
|
||
|
||
chaine = VALIDE (`step certificate verify` contre la racine locale)
|
||
racine = /etc/step/certs/root_ca.crt presente partout
|
||
timer = cert-renewer@<fqdn>.timer [enabled/active], prochaine passe ce soir
|
||
|
||
`site-pki-01` est la seule sans ancre dans `/usr/local/share/ca-certificates/`, et c'est
|
||
juste : elle PORTE l'autorite, elle ne s'enrole pas aupres d'elle-meme.
|
||
|
||
*Trois de mes sondes ont menti avant la bonne* : `is-enabled` sur un nom d'unite devine,
|
||
puis la colonne « service active » lue a la place de la minuterie — un service declenche
|
||
par minuteur est normalement `disabled`, ce qui se lit comme une panne quand on regarde la
|
||
mauvaise ligne. La question etait pourtant serieuse : sans minuteur, les certificats du
|
||
site expiraient dans 24 h.
|
||
|
||
Et le `changed=2` que les six autres machines rapportaient n'etait pas une
|
||
non-idempotence : c'etait le depot initial de `step-cli`. Rejoue sur `site-forge-01` :
|
||
**`changed=0`**.
|
||
|
||
### Ce qui restait a faire (historique)
|
||
|
||
`SITE-Chezlepro` est **une autre instance de ce depot** — meme
|
||
`playbooks/groupes/serveur_durci.yml`, memes roles. Ses sept machines actives
|
||
(`site-ops-01`, `site-cache-01`, `site-forge-01`, `site-pki-01`, `site-dns-01`,
|
||
`site-backup-01`, `site-mon-01`) sont durcies depuis le 2026-09-02 : leur prochain passage
|
||
de `serveur_durci` retirera cloud-init chez elles aussi, sans que personne ne le decide a
|
||
nouveau.
|
||
|
||
Ce n'est pas un oubli, c'est un fait a connaitre : le site se deploie depuis SON runner
|
||
(`site-ops-01`), pas depuis ce poste, et son inventaire n'est meme pas genere ici. Y aller
|
||
est un acte distinct, sur une machine distincte, et le site porte la forge qui sert ce
|
||
depot et le cache qui nourrit `apt` — un rayon d'action different.
|
||
|
||
## 2026-09-08 (4) — Les SIX registres ont un formulaire genere
|
||
|
||
**62 preuves. `make prouver` : CONFORME, 61 OK, 0 echec, 1 saute.**
|
||
`CHAMPS_ECRITS_A_LA_MAIN` est **vide** : plus un seul champ recopie a la main.
|
||
|
||
### L'epreuve qui compte
|
||
|
||
Ouvrir chaque vue et enregistrer **sans rien toucher** doit renvoyer au serveur
|
||
exactement le plan qu'on vient de lire. C'est ce qui separe un formulaire genere d'un
|
||
formulaire qui a l'air genere : un champ visible a l'ecran et perdu en silence a
|
||
l'enregistrement serait le pire des deux mondes.
|
||
|
||
/api/serveurs 14 entite(s) IDENTIQUE
|
||
/api/applications 25 entite(s) IDENTIQUE
|
||
/api/domaines 2 entite(s) IDENTIQUE
|
||
/api/bases 4 entite(s) IDENTIQUE
|
||
|
||
`test_rendu_gui.py` le mesure desormais a chaque `make prouver`.
|
||
|
||
### Trois defauts trouves en chemin
|
||
|
||
**Le formulaire annoncait des defauts inventes.** « 2048 » pour la memoire, « 2 » pour les
|
||
coeurs, « 16G » pour le disque. Il n'existe aucun defaut fixe : `deriver_ressources`
|
||
calcule la taille depuis les ROLES que l'hote porte — 1024 Mo et 1 coeur pour
|
||
`infra-pki-01`, 5632 et 4 pour `collab-01`. Un repere faux est pire qu'aucun : il fait
|
||
croire qu'on connait la valeur. Le schema nomme maintenant le champ derive
|
||
(`x-defaut-derive`), et le formulaire affiche la valeur REELLE de cet hote. De meme,
|
||
l'option vide d'un `<select>` dit le defaut qu'elle produira : « (défaut : asgard) ».
|
||
|
||
**Une SECONDE occurrence du defaut d'hier dormait.** `sourceDeValeurs` lisait encore
|
||
`data.nomenclature` — le meme `data` qui n'existe pas. Elle n'avait jamais leve parce que
|
||
la vue Serveurs, seule a emprunter cette source, avait encore un formulaire ecrit a la
|
||
main. Elle a leve **a la seconde** ou le generateur l'a prise, et c'est le banc de rendu
|
||
qui l'a dit.
|
||
|
||
Le banc ne voit que les chemins VIVANTS. `verifier_gui.py` fait donc desormais une
|
||
verification STATIQUE : une fonction qui lit `data.` sans le declarer ni le recevoir est
|
||
refusee. Elle voit aussi ce qui dort. Son premier essai a signale `dessinerReseau()` a
|
||
tort — `data` y est declare en second declarateur d'un `const` multiple ; corrige, parce
|
||
qu'un faux positif dans une garde finit toujours par la faire desactiver.
|
||
|
||
**La validation client s'accrochait a `data-v`**, pose a la main sur trois champs. Le
|
||
formulaire genere l'aurait perdu, et `querySelectorAll` aurait rendu une liste vide : la
|
||
validation serait passee au vert sur ZERO champ. Le generateur marque maintenant chaque
|
||
controle (`data-champ`), et la sauvegarde REFUSE si elle n'inspecte aucun champ — un
|
||
controle qui ne trouve rien ne dit pas « tout va bien ».
|
||
|
||
### Deux champs gardent leur editeur, et le schema le dit
|
||
|
||
`integrations` et `liens` ne sont PAS generes, volontairement. La matrice des integrations
|
||
montre aussi les universelles (non decochables) et les exemptions `sauf_role` : un champ
|
||
texte genere ferait lire un plan silencieux comme « cet hote n'est pas supervise »,
|
||
l'inverse exact de la politique. L'editeur de liens contraint le role a ce que le groupe
|
||
porteur accepte (`meta/liens.yml`). Le schema porte donc `x-editeur`, et le generateur
|
||
s'efface — au lieu de remplacer un editeur qui en sait plus que lui.
|
||
|
||
### La limite
|
||
|
||
Je n'ai toujours pas ouvert ces pages dans un navigateur. Le banc prouve qu'elles rendent
|
||
sans lever et qu'un aller-retour ne perd rien ; il ne dit rien de leur lisibilite.
|
||
|
||
## 2026-09-08 (3) — Quarante lignes de memoire que chaque « Sauvegarder » detruisait
|
||
|
||
**62 preuves (P01-P62). `make prouver` : CONFORME, 61 OK, 0 echec, 1 saute.**
|
||
Quatre registres sur six ont desormais un formulaire GENERE.
|
||
|
||
### Ce que j'ai trouve en voulant generer deux formulaires de plus
|
||
|
||
Avant de basculer les vues **Bases (serveurs de BD)** et **Domaines** sur le generateur,
|
||
j'ai confronte le schema a ce que les VALIDATEURS acceptent — pas seulement a ce que les
|
||
plans contiennent. Trois ecarts, et un quatrieme trouve en chemin.
|
||
|
||
**`edge` designe un GROUPE, pas un hote.** Le schema declarait `source_valeurs: serveurs`.
|
||
`instancier` compare pourtant cette valeur aux GROUPES d'un hote (`e.get("edge") in
|
||
groupes`) pour lui deriver ses SAN de certificat. Un formulaire genere aurait offert
|
||
`web-frontal-01`, qu'aucun hote n'aurait reconnu : aucun SAN, donc un certificat correct
|
||
sur un nom que personne ne peut appeler. C'est mot pour mot la panne du 2026-08-25, qu'une
|
||
liste mal choisie aurait reintroduite.
|
||
|
||
**`exposition` manquait au schema.** `valider_domaines` le valide entierement — une liste
|
||
de `{nom, cible, type}` — et le schema l'ignorait. Aucun plan ne s'en sert : P61 etait au
|
||
vert. Le formulaire genere n'aurait donc jamais pu offrir une fonctionnalite que le moteur
|
||
possede deja.
|
||
|
||
**`applications.liens` etait decrit `items: {type: object}`** — une liste d'objets sans
|
||
forme. Le moteur exige `vers` et `role`.
|
||
|
||
**`mail` faisait l'inverse** : offert par la vue Domaines depuis sa creation, decrit ici
|
||
comme un booleen, saisi la-bas comme du texte, et lu par RIEN. Retire.
|
||
|
||
D'ou **P62** : les champs qu'un validateur lit sur l'entite qu'il valide doivent tous etre
|
||
decrits. Elle separe l'entite du reste mecaniquement — un validateur lit son entite par
|
||
des variables LOCALES, et les autres registres par ses PARAMETRES (`srv.get("fonction")`
|
||
contre `nomenclature.get("fonctions")`). Aucune liste a tenir. Controle negatif rejoue.
|
||
|
||
### Et le degat qui etait deja la
|
||
|
||
En verifiant que le nouveau formulaire n'abimerait pas `domaines.yml`, j'ai mesure les
|
||
quatre ecrivains de registre sur les fichiers REELS :
|
||
|
||
domaines.yml 6 lignes de commentaire -> 3 (PERD 3)
|
||
applications.yml 27 -> 5 (PERD 22)
|
||
serveurs.yml 18 -> 3 (PERD 15)
|
||
bases-donnees.yml 4 -> 4 (intact — il n'en portait pas)
|
||
|
||
**Quarante lignes**, detruites par n'importe quel clic sur « Sauvegarder » dans les vues
|
||
Serveurs, Applications ou Domaines. Parmi elles, celle qui explique pourquoi `backup-01` a
|
||
ete retire — *« une supervision creuse est pire qu'aucune : elle est verte »* — et celle
|
||
qui dit dans quel ORDRE les deux roles du runner s'appliquent.
|
||
|
||
C'etait l'incident du 2026-08-18 (94 lignes perdues dans les fichiers d'intrants), **jamais
|
||
corrige pour les registres du plan** : `_fusion_chirurgicale` avait ete ecrite pour les
|
||
intrants seuls, et les quatre `ecrire_*` etaient restes au `safe_dump`. Ils passent tous
|
||
par `_ecrire_registre` maintenant. Aller-retour a vide : les quatre fichiers sont
|
||
**identiques a l'octet**. Une modification reelle ne touche que ses lignes.
|
||
`scripts/tests/test_ecriture_plan.py` le mesure sur les vrais fichiers, controle negatif
|
||
compris.
|
||
|
||
### Les deux formulaires
|
||
|
||
**Serveurs de BD** et **Domaines** sont generes, chargement et sauvegarde compris. Le
|
||
generateur a appris une forme de plus : la **liste d'objets** (`exposition`, `liens`), avec
|
||
son bouton d'ajout et le retrait par ligne. L'ajout passe par le meme setter que les
|
||
champs — on lui remet le tableau entier, serialise dans l'attribut — plutot que par une
|
||
fonction globale a resoudre au clic : ce qui marche sous le banc marche dans le navigateur.
|
||
|
||
### La limite
|
||
|
||
**Quatre registres sur six.** Restent `serveurs` et `applications`, les deux plus gros.
|
||
Et je n'ai toujours pas ouvert ces pages dans un navigateur.
|
||
|
||
## 2026-09-08 (2) — La vue Nomenclature, et deux fautes que mes bancs ne pouvaient pas voir
|
||
|
||
**61 preuves. `make prouver` : CONFORME, 60 OK, 0 echec, 1 saute.**
|
||
`couverture_gui verifier` : les 28 champs des plans reels sont editables — le dernier trou
|
||
est ferme.
|
||
|
||
### La vue
|
||
|
||
La nomenclature etait le seul registre que le GUI ne savait pas ecrire DU TOUT : ajouter une
|
||
fonction exigeait d'ouvrir le YAML. Elle a maintenant sa vue, et son formulaire est GENERE
|
||
depuis le schema — deuxieme registre sur six.
|
||
|
||
Elle n'est pas un registre comme les autres : elle ne decrit pas des objets, elle decrit la
|
||
REGLE dont VMID, VLAN, adresse et passerelle se derivent. D'ou trois partis pris :
|
||
|
||
- chaque fonction affiche **ce qu'elle derive** (zone, VLAN, sous-reseau, bloc d'hotes) et
|
||
les VM qui la portent — sans ca, changer un chiffre est un geste a l'aveugle ;
|
||
- `index` est montre mais PAS editable ici : le fichier dit lui-meme qu'il est RECU du site
|
||
et non decide par le tenant ;
|
||
- `valider_nomenclature` refuse de retirer une fonction encore portee par une VM, ou de
|
||
designer une zone non declaree — `deriver_nomenclature` rendrait `None` EN SILENCE.
|
||
|
||
### Les deux fautes, et pourquoi mes bancs ne les voyaient pas
|
||
|
||
**1. Le formulaire des bases, livre la veille, etait casse dans un navigateur.**
|
||
Il lisait `data.schema` — or il n'existe aucun `data` global dans cette page : c'est une
|
||
const locale de `charger()`. `ReferenceError` a l'ouverture. Pire dans `sauvegarderBases`,
|
||
ou `const data` est declare PLUS BAS dans la meme fonction : zone morte temporelle,
|
||
l'enregistrement jetait avant meme d'envoyer.
|
||
|
||
Je l'avais « eprouve » sous node — en PASSANT `data` a la fonction. Le banc reproduisait la
|
||
fonction, pas sa PORTEE. `verifier_gui.py`, lui, ne verifie que la syntaxe.
|
||
|
||
Remede : un global `schemaPlan`, et surtout `scripts/tests/test_rendu_gui.py`, qui charge le
|
||
JS entier dans un DOM simule, appelle `charger()` sur la VRAIE reponse de l'API, et dessine
|
||
les douze vues. Son controle negatif remet une reference absente et exige que le banc la
|
||
voie.
|
||
|
||
**2. Le schema decrivait `reservations` comme une table de zones.** Le fichier reel est un
|
||
bloc plat, et `underlay.py` lit `reservations.passerelle` a plat. P61 ne voyait rien : elle
|
||
comparait des NOMS aplatis, et `passerelle` existe des deux cotes — a des profondeurs
|
||
differentes. Un formulaire genere depuis cette description aurait offert « ajouter une zone »
|
||
et ecrit une forme que le moteur ne lit pas.
|
||
|
||
P61 compare desormais aussi la FORME : scalaire, bloc a clefs fixes, table a clefs libres.
|
||
Controle negatif rejoue, elle echoue.
|
||
|
||
### Ecrire dans la nomenclature sans deplacer un commentaire
|
||
|
||
Le fichier porte vingt-deux lignes qui disent pourquoi `index` est recu, et laquelle des
|
||
fonctions est le poste d'exploitation. `_fusion_chirurgicale` les gardait toutes — mais elle
|
||
remplace le BLOC entier des qu'une valeur change. Mesure : ajouter une fonction transformait
|
||
quinze entrees compactes en quarante-deux lignes, et HISSAIT le commentaire du poste
|
||
d'exploitation en tete du bloc, ou il affirmait que `collab` etait le poste d'exploitation.
|
||
|
||
Un commentaire deplace n'est pas laid : il est FAUX.
|
||
|
||
`_fusion_table` edite donc les tables LIGNE A LIGNE. Le diff d'un changement reel fait
|
||
desormais trois lignes, le style compact survit, et le commentaire garde son voisin.
|
||
`scripts/tests/test_nomenclature_ecriture.py` le fige, son controle negatif compris.
|
||
|
||
### Au passage
|
||
|
||
`sort_keys=True` triait le schema genere alphabetiquement — donc l'ordre des cases a
|
||
l'ecran. `reserve_max` passait avant `reserve_min`. L'ordre de `REGISTRES` est délibéré et
|
||
un dict Python litteral est deja stable : le tri ne servait a rien et desservait les deux
|
||
formulaires.
|
||
|
||
Et `_ecrire_index_nomenclature` ecrivait encore par `write_text` : oubliee au passage des
|
||
ecritures atomiques du 2026-09-06. Une coupure y laissait le seed a moitie ecrit,
|
||
c'est-a-dire tout l'adressage de l'ecosysteme.
|
||
|
||
### La limite, dite franchement
|
||
|
||
**DEUX registres sur six sont generes.** Les quatre autres formulaires restent ecrits a la
|
||
main. Et je n'ai toujours pas ouvert cette page dans un navigateur : le banc prouve qu'elle
|
||
rend sans lever, pas qu'elle est lisible.
|
||
|
||
## 2026-09-08 — La forme des registres cesse d'etre recopiee : elle est derivee
|
||
|
||
**61 preuves (P01-P61, dont une conditionnelle). `make prouver` : CONFORME, 60 OK, 0 echec,
|
||
1 saute.**
|
||
|
||
### Le probleme
|
||
|
||
La forme du plan etait ecrite TROIS FOIS : dans les constantes du moteur (`ETATS_SERVEUR`,
|
||
`PORTEES_BD`, `AUTORITES_DNS`), dans les formulaires du GUI, et dans la documentation. Rien
|
||
ne les tenait ensemble. Ajouter une portee de base de donnees demandait trois gestes, et le
|
||
troisieme s'oubliait sans qu'aucun controle ne s'en apercoive.
|
||
|
||
### Ce qui change
|
||
|
||
`scripts/schema_plan.py` (nouveau) derive un JSON Schema des SIX registres du plan
|
||
— `serveurs`, `applications`, `bases_donnees`, `serveurs_bd`, `domaines_publics`,
|
||
`nomenclature` — en IMPORTANT les enumerations du moteur plutot qu'en les recopiant. Le
|
||
resultat est versionne dans `docs/audit/schema-plan.json` : 42 champs, regenerable par
|
||
`make schema`. Il est versionne, et non recalcule a chaud, pour qu'une divergence se voie
|
||
dans un diff.
|
||
|
||
Le schema porte ce que JSON Schema seul ne dit pas : `x-source-valeurs` (liste fermee
|
||
alimentee a l'execution depuis l'inventaire), `x-source-selon` (liste dependant d'un autre
|
||
champ), `x-clef` (l'attribut qui identifie l'entite), `x-requis`.
|
||
|
||
**Le formulaire des bases de donnees du GUI n'est plus ecrit a la main.** Il est construit
|
||
au chargement depuis le schema servi par `/api/inventaire`. Le chemin de SAUVEGARDE aussi
|
||
derive du schema : les champs ecrits sont ceux que le schema declare, avec ses valeurs par
|
||
defaut — plus une liste de champs recopiee dans le JS.
|
||
|
||
`P61` garde l'ensemble : le schema doit couvrir tout ce que les plans REELS contiennent.
|
||
Son controle negatif : retirer un champ du schema le fait echouer.
|
||
|
||
### La limite, dite franchement
|
||
|
||
**UN registre sur six est genere.** Les cinq autres formulaires restent ecrits a la main.
|
||
|
||
Et le trou connu reste ouvert : `couverture_gui.py verifier` echoue toujours sur
|
||
`nomenclature.categorie` et `nomenclature.service` — deux champs presents dans les plans
|
||
reels que le GUI ne sait pas ecrire. Le schema les DECRIT deja ; c'est le passage de la vue
|
||
nomenclature au generateur qui fermera le trou, par construction.
|
||
|
||
Enfin : le formulaire genere a ete eprouve en rendant son HTML avec la vraie reponse de
|
||
l'API, pas dans un navigateur.
|
||
|
||
## 2026-09-06 — Tournee des 74 documents : ce que le depot disait de lui-meme avait vieilli
|
||
|
||
**57 preuves (P01-P57, dont une conditionnelle). `make prouver` : CONFORME, 56 OK, 0 echec,
|
||
1 saute.** Aucun comportement ne change. Ce qui change, c'est que les documents cessent de
|
||
decrire un depot qui n'existe plus.
|
||
|
||
### La lecon de methode, d'abord
|
||
|
||
La revision a commence par un BALAYAGE PAR MOTIFS — chemins morts, cibles `make` absentes,
|
||
comptes derives. Il a trouve une trentaine d'ecarts, et il a rate presque tout le reste. Un
|
||
motif ne voit que ce qui s'exprime en motif.
|
||
|
||
`make hote-planifier` en est l'exemple exact : la cible EXISTE, donc le controle passait au
|
||
vert. C'est une cible DEPRECIEE qui refuse et sort en 2 — recommandee par `AGENTS.md`, et
|
||
contredisant la REGLE D'OR du meme fichier trois ecrans plus haut. Il a fallu lire pour la
|
||
voir. D'ou la tournee : les 74 documents, un par un.
|
||
|
||
### Les affirmations franchement fausses
|
||
|
||
`AGENTS.md` — la source d'autorite — annoncait « pas encore execute contre des VM reelles ».
|
||
La flotte a ete rasee et remontee depuis zero le 2026-08-13, puis DEUX FOIS le 2026-09-02.
|
||
`docs/ecosysteme-chezlepro.md`, qui est le document montre a un client, portait la meme
|
||
phrase : il se sous-vendait gravement.
|
||
|
||
`docs/courriel-conception.md` s'ouvrait sur « Statut : CONCEPTION. Aucun role n'est encore
|
||
ecrit » — au-dessus de son propre §1 qui nomme les trois roles, deployes et prouves.
|
||
|
||
`docs/autorisation.md` se terminait sur « Rien n'est construit », alors que le meme document
|
||
rapporte des mesures DATEES prises sur le role en fonctionnement.
|
||
|
||
`docs/hebergeur-exploitation.md` disait « Rien n'est fait » d'un depot qui existe :
|
||
`SITE-Chezlepro`, avec son plan, ses sept VM et le symlink en place.
|
||
|
||
`docs/filiation-emancipation.md` se contredisait a deux ecrans de distance : une section
|
||
decrivait `make emancipation-prouver`, une autre affirmait que cet instrument n'existait pas.
|
||
|
||
### Les modeles decrits d'apres un monde anterieur
|
||
|
||
- **Le resolveur.** `dns-interne.md`, `integrations-vm.md`, deux unites du wiki et
|
||
`intrants-communs.md` decrivaient un Unbound par VM, en opt-in. Depuis le 2026-08-24,
|
||
`client_resolveur` N'INSTALLE PLUS RIEN et son integration est UNIVERSELLE. Trois
|
||
documents le donnaient meme en exemple d'integration *facultative* — l'inverse exact.
|
||
- **L'adressage.** `nomenclature-vm.md` decrivait un reseau unique `10.0.0.0/16`, des VLAN
|
||
11 a 15 et des VMID a cinq chiffres : le modele d'avant le multi-instance.
|
||
- **Le nommage SDN.** `sdn-evpn.md` annoncait `CHEZ17` / `chez174` ; le code produit `t17` /
|
||
`t17serv`. C'est le wiki qui avait raison.
|
||
- **La bascule D-77.** Trois documents la donnaient « en cours » ; `10.0.0.0/24` n'existe
|
||
plus depuis le 2026-08-22.
|
||
- **Le mecanisme de voute.** Cinq documents — dont le runbook de REPRISE — designaient un
|
||
`ANSIBLE_VAULT_PASSWORD_FILE` unique. Une voute, une cle depuis le 2026-08-28.
|
||
|
||
### Ce qui casse au premier essai
|
||
|
||
Le nom du gabarit dore etait faux a **quatre** endroits, dont la procedure qui le FABRIQUE
|
||
(`debian13-template`) et le critere de reussite R2 de l'epreuve de l'operateur independant.
|
||
Le defaut du code est `modeleSetOPS`, et le clonage cherche sa source PAR CE NOM.
|
||
|
||
`preparer-un-site-hebergeur.md` avertissait qu'une VM creee a la main serait detruite par
|
||
l'outil. C'est l'inverse : `raser` derive sa liste du plan, il ne la detruira JAMAIS — elle
|
||
survit sans DNS, sans certificat, sans sauvegarde, et son VMID n'est garde par aucune preuve.
|
||
|
||
Un mot de passe d'essai en clair dans `wiki/Courriel.md`, dans un depot public.
|
||
|
||
### Le nom de l'inventaire n'en est pas un
|
||
|
||
Douze chemins ecrivaient `instance/inventories/production/hosts.yml`, la REGLE D'OR
|
||
d'`AGENTS.md` comprise. Cet inventaire n'existe pas ici : la flotte dit `principal`. Mais le
|
||
modele public dit bien `production`, et le moteur CHERCHE le nom au lieu de l'imposer :
|
||
ecrire l'un des deux en dur etait faux pour la moitie des lecteurs.
|
||
|
||
### Deux preuves etendues, et une qui se trompait elle-meme
|
||
|
||
**P57** (comptes en prose) couvre desormais les GROUPES. Elle a signale dans la seconde qui
|
||
a suivi que `catalogue-services.md` annoncait « 30 groupes classes » la ou il y en a 40, et
|
||
« les 29 groupes » au-dessus d'un tableau qui en cite 40 — une contradiction A UNE LIGNE DE
|
||
DISTANCE.
|
||
|
||
**P29** confronte desormais le tableau de `docs/authentification.md` aux declarations
|
||
reelles. La ligne `sans-auth-humaine` annoncait 12 roles ; il y en a 21. La preuve lisait ces
|
||
declarations depuis le debut sans jamais regarder ce que le document en disait.
|
||
|
||
**Et P57 imposait un chiffre faux.** Elle mesurait `len(PREUVES)` = 56, mais le depot porte
|
||
**57** preuves : P16 est conditionnelle et vivait DANS `main()`, hors de tout comptage. Un
|
||
garde-fou qui fait respecter une erreur est pire qu'aucun garde-fou — il ajoute l'assurance
|
||
a l'erreur.
|
||
|
||
### Ce que la tournee laisse en place, et qu'aucune preuve ne tient
|
||
|
||
Deux comptes trouves a la main : le README annoncait cinq portes et en ouvrait six ;
|
||
`implanter-un-tenant-sur-un-site.md` renvoyait aux « huit lignes » d'une fiche qui en compte
|
||
dix. Et une lacune reelle, nommee dans `autorisation.md` : **rien ne garde les
|
||
`meta/acces.yml`** — ni qu'un service `web-sso` en porte un, ni que le groupe qu'il nomme
|
||
existe. P29 garde les POSITIONS d'authentification ; personne ne garde les HABILITATIONS.
|
||
|
||
## 2026-09-05 (2) — Rouvrir les cles : la cle USB doit se suffire a elle-meme
|
||
|
||
**56 preuves.** Les cles sont sorties du poste. Restait la moitie qui compte : savoir les
|
||
REMETTRE. Une sauvegarde qu'on ne sait pas rouvrir n'est pas une sauvegarde.
|
||
|
||
### Le piege, et pourquoi la procedure ne peut pas vivre dans le depot
|
||
|
||
Le jour ou l'on s'en sert, **le poste est mort**. Le depot Set-OPS est replique trois fois
|
||
— eregion, la forge du site, patient 0 — mais le CLONER demande la cle SSH, qui est
|
||
justement dans l'archive qu'on essaie d'ouvrir. Une procedure de restauration rangee dans
|
||
le depot serait donc inaccessible exactement quand elle sert.
|
||
|
||
`exporter_cles.py` depose donc, A COTE DE L'ARCHIVE : `restaurer_cles.py` et un
|
||
`LISEZ-MOI-RESTAURATION.txt`. La cle ne demande plus que `gpg`, `python3` et la phrase de
|
||
passe.
|
||
|
||
### Ce que le script fait de plus qu'un `tar -x`
|
||
|
||
- il remet chaque fichier a sa place selon son NOM, pas selon un chemin enregistre — on
|
||
restaure souvent sur une machine neuve, parfois sous un autre compte ;
|
||
- il repose les droits a **0600**. `ssh` REFUSE une cle privee que d'autres peuvent lire,
|
||
et son message ne dit pas qu'il s'agit d'un droit — une archive extraite depuis un
|
||
support FAT arrive toujours dans cet etat ;
|
||
- il **refuse d'ecraser** une cle deja presente, et regarde AVANT d'ecrire : refuser a
|
||
mi-parcours laisserait la moitie des cles en place et l'autre non, un etat que personne
|
||
ne sait diagnostiquer.
|
||
|
||
### Et si meme ce script ne tourne pas
|
||
|
||
Le LISEZ-MOI porte les trois commandes manuelles — `gpg`, `tar`, `chmod`. C'est le vrai
|
||
filet : un outil peut avoir un defaut, `gpg` et `tar` seront la.
|
||
|
||
### EPROUVE, PAS SUPPOSE
|
||
|
||
Cycle complet sur des fichiers factices : export vers une cle, poste neuf entierement
|
||
vide, restauration **depuis la cle seule**, puis comparaison.
|
||
|
||
IDENTIQUES — 4 empreintes sur 4
|
||
700 .ssh 600 .ssh/id_git_ed25519 600 .config/setops-vault-…
|
||
REFUS : ces cles existent deja ici — Rien n'a ete ecrit.
|
||
|
||
Le filet manuel a ete passe au meme test, separement : identiques, 4 sur 4.
|
||
|
||
### Le test le moins cher, a refaire souvent
|
||
|
||
make cles-restaurer ARCHIVE=/media/…/setops-cles-*.tar.gpg
|
||
|
||
Il refusera, puisque les cles sont en place — et **ce refus est la preuve** que l'archive
|
||
s'ouvre et que la phrase de passe est la bonne. A refaire apres chaque changement de cle.
|
||
|
||
## 2026-09-05 — 1 644 octets valent l'installation, et ils n'existaient qu'a un endroit
|
||
|
||
**56 preuves.** En cherchant par quoi reprendre, une mesure a change l'ordre des
|
||
priorites : le CODE de Set-OPS est replique trois fois — `eregion`, la forge du site,
|
||
patient 0 — et les voutes chiffrees y sont aussi. Le coffre est solide.
|
||
|
||
Les CLES qui l'ouvrent vivaient dans neuf fichiers de `~/.config` et `~/.ssh`, **1 644
|
||
octets au total, sans aucune copie ailleurs**. C'est le pire rapport valeur/fragilite de
|
||
l'installation : six cles de voute qui ouvrent tous les secrets — jetons Proxmox et
|
||
OPNsense, mots de passe de step-ca, des bases, et les `vault_restic_password` — plus les
|
||
cles SSH par lesquelles on entre partout.
|
||
|
||
Ce qu'on perdait avec le poste, sans exagerer la gravite :
|
||
|
||
- **le poste seul** : les mots de passe restic restent lisibles sur les machines vivantes
|
||
(`/etc/setops/restic.pass`) — recuperable, mais douloureux, et plus aucun deploiement
|
||
possible entre-temps ;
|
||
- **le poste et une machine** : l'etat de cette machine devient illisible ;
|
||
- **le poste et le site** : terminal.
|
||
|
||
### `make cles-recenser` et `make cles-exporter`
|
||
|
||
Le recensement montre ce qui sortirait sans rien ecrire — nom, taille, empreinte, **jamais
|
||
le contenu**. L'export met le tout dans une archive chiffree (AES256, phrase de passe
|
||
symetrique) sur un support choisi.
|
||
|
||
**A LANCER SOI-MEME.** `gpg` demande une phrase de passe : elle ne doit passer ni par un
|
||
journal, ni par le contexte d'un assistant. La cible le dit dans son propre commentaire.
|
||
|
||
ECRIRE PUIS RELIRE (D-68), applique a ce qui compte le plus : l'outil REDECHIFFRE ce
|
||
qu'il vient d'ecrire et compare les empreintes une a une. Une sauvegarde de cles qu'on
|
||
n'a pas rouverte n'est pas une sauvegarde, c'est un fichier dont on espere quelque chose.
|
||
|
||
### Trois refus, eprouves en les faisant echouer
|
||
|
||
- **destination dans l'infrastructure** — ces cles ouvrent les sauvegardes ; les y ranger
|
||
ferait un coffre dont la cle est a l'interieur. `~/Espace Chezlepro` et `/srv/restic`
|
||
sont refuses nommement ;
|
||
- **archive existante** — on n'ecrase pas une sauvegarde de cles : elle est peut-etre la
|
||
seule ;
|
||
- **archive illisible** — l'archive est SUPPRIMEE. Elle donnerait le sentiment d'etre
|
||
protege sans l'etre.
|
||
|
||
Les trois ont ete essayes sur des fichiers factices avant d'approcher les vraies cles —
|
||
et le premier essai du premier refus etait FAUX : le shell developpait `$HOME` avant que
|
||
je le remplace, l'instrument mesurait donc ailleurs que la cible. Reteste correctement,
|
||
le refus tire. Encore une fois : verifier d'ou l'instrument mesure.
|
||
|
||
### Ce que l'outil ne couvre pas, et qui reste a l'humain
|
||
|
||
La phrase de passe (perdue, l'archive est du bruit) et la seconde copie dans un autre
|
||
lieu. Un support unique dans un tiroir unique, c'est le probleme qu'on vient de fermer,
|
||
deplace de quelques metres. Ces deux gestes sont ecrits dans la sortie du script et dans
|
||
`docs/sortir-les-cles-du-poste.md` — pas en note de bas de page.
|
||
|
||
## 2026-09-03 — Les paquets non Debian passent par le cache : le dernier obstacle au hors-ligne
|
||
|
||
**56 preuves.** Trois depots tiers etaient configures sur la flotte — Smallstep, Icinga,
|
||
Grafana — **tous en HTTPS**. Or `client_artefacts` pose `Acquire::https::Proxy "DIRECT"`,
|
||
sans quoi le cache du site refuse les tunnels HTTPS (« 403 CONNECT denied ») et aucun
|
||
depot tiers n'est joignable.
|
||
|
||
Cette ligne est juste, et elle a ete posee pour une bonne raison. Sa CONSEQUENCE n'avait
|
||
pas ete vue : **ces trois depots contournent le cache par conception**, et chaque VM neuve
|
||
allait les chercher sur Internet a sa naissance.
|
||
|
||
`step-cli` (Smallstep) et `alloy` (Grafana) sont poses par des integrations
|
||
**universelles** : sans lien, une machine neuve n'obtenait ni son client d'autorite ni ses
|
||
metriques — elle n'entrait dans aucun flux chiffre. C'etait le dernier obstacle a une
|
||
reconstruction hors ligne, et il tenait dans un mot : `DIRECT`.
|
||
|
||
### Ce qui change
|
||
|
||
`make cacher-paquets` lit `paquets-tiers.yml`, va chercher l'index de chaque depot, tire
|
||
les 21 `.deb` aux **versions epinglees** et verifie l'empreinte SHA256 que l'index annonce.
|
||
Tout atterrit dans `~/.cache/setops/paquets/`, a cote de Forgejo, Keycloak et Nextcloud —
|
||
meme patron depuis toujours : telecharger une fois, verifier, deposer ensuite.
|
||
|
||
Le role partage `paquets_tiers` les depose et les installe **en un seul appel a apt** : apt
|
||
sait resoudre un ensemble de fichiers locaux qui se dependent mutuellement, la ou une
|
||
installation paquet par paquet echouerait sur l'ordre. Icinga en apporte dix-sept dans ce
|
||
cas.
|
||
|
||
Six roles y sont branches — `client_pki`, `client_journal`, `serveur_icinga`,
|
||
`serveur_icingaweb2`, `serveur_grafana`, `serveur_loki`. Chacun retombe sur le depot
|
||
distant pour **ce que le cache n'a pas fourni, et rien d'autre** : reinstaller ce que le
|
||
cache vient de poser ferait un `update_cache` inutile qui, hors ligne, echouerait APRES un
|
||
travail deja fait.
|
||
|
||
### La coupure, eprouvee pour de vrai
|
||
|
||
Sur `web-dorsal-01` : `packages.smallstep.com` renvoye vers `127.0.0.1`, `step-cli`
|
||
desinstalle, cache local efface. Le role rejoue :
|
||
|
||
step-cli : 0.30.6-1
|
||
installe depuis : step-cli_0.30.6-1_amd64_...deb
|
||
certificat present : 2
|
||
|
||
Zero echec. Le depot etait injoignable et la machine a obtenu son certificat.
|
||
|
||
### Deux defauts de mon propre outil, trouves en le construisant
|
||
|
||
**Smallstep sert son index NON COMPRESSE.** Icinga et Grafana servent `Packages.gz` ;
|
||
Smallstep sert `Packages` en clair et rend 404 sur le reste. La premiere version ne
|
||
demandait que `.gz` : le depot echouait, et `step-cli` — le paquet de CHAQUE machine — n'a
|
||
jamais ete mis en cache.
|
||
|
||
**Et le script rapportait « 20 tires, 20 declares ».** Un depot injoignable faisait
|
||
`continue` AVANT d'incrementer le total : il disparaissait du decompte, et l'echec se
|
||
lisait comme un succes. Une garde qui ne peut pas echouer ne garde rien — troisieme
|
||
occurrence cette semaine, cette fois dans un outil ecrit le jour meme.
|
||
|
||
Corrige, puis **eprouve en le faisant echouer** : depot rendu injoignable, le script rend
|
||
`INCOMPLET` et sort en 1 ; tout present, il sort en 0.
|
||
|
||
### Ce qui reste hors ligne
|
||
|
||
Deux dependances mineures, nommees pour qu'on sache : le temps (NTP externe, la derive est
|
||
lente) et l'expedition des alertes (le relais livre directement aux MX). La supervision
|
||
continuerait de VOIR sans pouvoir le DIRE.
|
||
|
||
## 2026-09-02 (6) — Deuxieme reconstruction de validation : une course que la premiere n'avait pas montree
|
||
|
||
**56 preuves. Reconstruction : 14/14 hotes, 0 echec. `make valider` : 0 echec sur 13
|
||
hotes.** Chezlepro a ete rasee une seconde fois et refaite depuis le gabarit minimal, pour
|
||
eprouver tout ce qui avait change depuis la veille.
|
||
|
||
### Ce que ce cycle a VALIDE
|
||
|
||
Les corrections de la veille ont toutes tenu en conditions reelles :
|
||
|
||
- **La garde de clonage** — `edge-mta-01`, `infra-mail-01` et `ops-01` ont clone « AVEC
|
||
AVERTISSEMENT ». Les trois auraient ete declarees perdues l'avant-veille ; le clonage
|
||
continue, et l'avertissement s'affiche au lieu d'etre avale.
|
||
- **La degradation de `client_artefacts`** — `infra-pki-01` et `infra-dns-01`, premieres
|
||
machines debout, sont restees sur le cache du SITE et l'ont dit. Elles ont bascule sur
|
||
celui du locataire dans la passe principale.
|
||
- **`enabled` a la frontiere, la delegation de zone, le durcissement du site** — aucun
|
||
incident.
|
||
|
||
### LE DEFAUT QUE SEULE UNE SECONDE RECONSTRUCTION POUVAIT MONTRER
|
||
|
||
`forge-01` bouclait :
|
||
|
||
migration[v14a_add-foreign-keys-collaboration] ... failure to delete inconsistent
|
||
records before foreign key sync: la relation « collaboration » n'existe pas
|
||
|
||
La base contenait **5 tables** avec `version=305` deja inscrite. A moitie faite.
|
||
|
||
**C'est une course.** Le role DEMARRE Forgejo, qui entame son initialisation de premier
|
||
lancement — creation du schema et migrations, plusieurs dizaines de secondes. Puis
|
||
`flush_handlers` le REDEMARRE, parce qu'`app.ini` vient de changer. Redemarre au milieu,
|
||
il laisse la version cible inscrite et le schema absent.
|
||
|
||
Il ne s'en releve jamais seul : `ORM engine initialization attempt #1/10, #2/10…`
|
||
indefiniment. Le service reste `active` — il n'a pas plante, il reessaie — et n'ecoute
|
||
jamais son port. Une machine verte qui ne sert rien.
|
||
|
||
**Pourquoi la premiere reconstruction ne l'avait pas vu.** Aux deploiements suivants la
|
||
base est deja migree, le premier demarrage est instantane, et le redemarrage ne tombe au
|
||
milieu de rien. Le defaut n'existe que sur une base VIERGE, et meme la il depend du
|
||
timing. Il a fallu deux reconstructions completes.
|
||
|
||
La correction attend la fin du premier demarrage AVANT tout redemarrage. Le schema a ete
|
||
remis a zero — il ne contenait rien — et Forgejo est monte du premier coup.
|
||
|
||
### La chaine de sauvegarde, prouvee sur une flotte entierement neuve
|
||
|
||
Neuf detenteurs d'etat ont depose sur le depot du SITE, puis chacun a verifie SON depot
|
||
distant et l'a rapporte a l'Icinga de l'ecosysteme :
|
||
|
||
collab-01 OK : instantane il y a 0 h, 108 fichier(s)
|
||
data-sql-01 OK : instantane il y a 0 h, 1 fichier(s)
|
||
edge-mta-01 OK : instantane il y a 0 h, 145 fichier(s)
|
||
forge-01 OK : instantane il y a 0 h, 29 fichier(s)
|
||
idm-01 OK : instantane il y a 0 h, 1 fichier(s)
|
||
infra-mail-01 OK : instantane il y a 0 h, 7 fichier(s)
|
||
infra-pki-01 OK : instantane il y a 0 h, 12 fichier(s)
|
||
web-dorsal-01 N'EMPORTE RIEN — a confirmer
|
||
web-frontal-01 N'EMPORTE RIEN — a confirmer
|
||
|
||
Le cycle complet — deposer chez l'hebergeur, verifier avec la cle qu'on est seul a
|
||
detenir, rapporter a sa propre supervision — tourne sur des machines nees il y a une
|
||
heure.
|
||
|
||
## 2026-09-02 (5) — La verification suit la cle : chaque noeud constate SON depot
|
||
|
||
**56 preuves. `make valider` : 0 echec sur 13 hotes**, test de restitution compris.
|
||
|
||
`backup-01` est retire du plan de Chezlepro et sa VM detruite. Elle ne gardait plus rien :
|
||
son `/srv/restic` ne contenait que les fichiers de demarrage du compte `restic`, aucun
|
||
depot, aucun instantane — et sa verification rapportait consciencieusement « tout va
|
||
bien ». **Une supervision creuse est pire qu'aucune : elle est verte.**
|
||
|
||
### Pourquoi la verification a change de main
|
||
|
||
`serveur_backup` verifiait pour tout le monde, et c'etait juste : un noeud sait qu'il a
|
||
LANCE sa sauvegarde, il ne sait pas qu'elle a ABOUTI — le depot etait le seul a voir ce
|
||
qui arrivait vraiment.
|
||
|
||
Depuis que les ecosystemes deposent chez leur HEBERGEUR, ce n'est plus vrai. Le site
|
||
heberge des octets chiffres COTE CLIENT, avec un mot de passe qui ne quitte pas la voute
|
||
du locataire : il ne peut ni les lire, ni les ouvrir, ni dire s'ils valent quelque chose.
|
||
C'est la propriete qui rend la mutualisation acceptable, et elle deplace la verification
|
||
chez le seul qui detient la cle — **le noeud lui-meme**.
|
||
|
||
Il verifie donc SON depot distant, pas le fait d'avoir lance sa sauvegarde. La nuance est
|
||
tout : une unite verte sur un depot vide est exactement ce qui a menti pendant un mois
|
||
(2026-07-03 -> 2026-08-11).
|
||
|
||
### Deux modeles, et le role sait desormais dans lequel il est
|
||
|
||
`serveur_icinga` exigeait un hote `serveur_backup` dans l'ecosysteme et attachait les
|
||
services `sauvegarde: <noeud>` a cet hote. Sans depot local, il refusait de se deployer.
|
||
|
||
Il se branche maintenant :
|
||
|
||
- **depot local** — il rapporte pour tous, les services vivent sur SON hote, et leur nom
|
||
dit de quel noeud on parle : `sauvegarde: idm-01` ;
|
||
- **pas de depot** — chaque noeud rapporte le sien, le service vit SUR LUI, et s'appelle
|
||
simplement `sauvegarde`. Repeter le nom donnerait « idm-01 / sauvegarde: idm-01 ». Et
|
||
c'est plus juste : la sauvegarde d'`idm-01` est un attribut d'`idm-01`.
|
||
|
||
Ce qui reste exige, c'est le secret d'API — sans lui, personne ne peut rien rapporter.
|
||
|
||
### Le 404 qui n'etait pas une absence
|
||
|
||
Les noeuds recevaient `{"error":404,"status":"No objects found."}` — le message d'un objet
|
||
ABSENT. `icinga2 object list` montrait pourtant `idm-01!sauvegarde` charge et vivant.
|
||
|
||
Le filtre du compte d'API portait `match("sauvegarde: *", service.name)` : la premiere
|
||
forme de nom seulement. C'etait la PERMISSION qui refusait, et elle le disait avec les
|
||
mots d'une absence. Le filtre accepte desormais les deux formes, sans s'elargir au-dela :
|
||
ce compte ne peut poser un resultat que sur un service de sauvegarde.
|
||
|
||
`serveur_icinga` declare aussi `ingress 5665` depuis `client_backup` — le pair ne
|
||
nommait que `serveur_backup`, qui n'existe plus chez ce locataire.
|
||
|
||
### Ce que la supervision dit maintenant, chez le locataire
|
||
|
||
collab-01 OK : instantane il y a 20 h, 108 fichier(s)
|
||
data-sql-01 OK : instantane il y a 20 h, 1 fichier(s)
|
||
edge-mta-01 OK : instantane il y a 20 h, 138 fichier(s)
|
||
forge-01 OK : instantane il y a 20 h, 29 fichier(s)
|
||
idm-01 OK : instantane il y a 20 h, 1 fichier(s)
|
||
infra-mail-01 OK : instantane il y a 20 h, 7 fichier(s)
|
||
infra-pki-01 OK : instantane il y a 20 h, 12 fichier(s)
|
||
web-dorsal-01 N'EMPORTE RIEN : instantane sans aucun fichier — a confirmer
|
||
web-frontal-01 N'EMPORTE RIEN : instantane sans aucun fichier — a confirmer
|
||
|
||
Les deux avertissements sont honnetes : ces repertoires sont reellement vides, aucune
|
||
webapp n'est deployee. La machine ne peut pas distinguer « les donnees ont disparu » de
|
||
« il n'y en a pas encore » — c'est a un humain de trancher, mais il doit le VOIR.
|
||
|
||
### Constat non corrige
|
||
|
||
Retirer une VM du plan ne la detruit pas : `make raser` ne connait que les hotes DU plan,
|
||
donc plus celle-ci. Il a fallu la detruire a la main sur l'hyperviseur. Un hote retire du
|
||
plan devient un orphelin qu'aucune cible ne ramasse.
|
||
|
||
Et Prometheus a continue de scruter son exportateur jusqu'a ce que `serveur_prometheus`
|
||
soit rejoue — `make valider` l'a attrape, ce qui est exactement son role.
|
||
|
||
## 2026-09-02 (4) — Le site a un temoin ; et une panne dormait depuis des semaines dans un mot
|
||
|
||
**56 preuves.** L'hebergeur a desormais sa propre supervision : `site-mon-01` (VLAN 36,
|
||
zone `site-supervision`) porte PostgreSQL, Icinga et un relais de courriel. Le depot de
|
||
sauvegarde lui rapporte, et **son premier verdict a ete un vrai defaut** — `site-mon-01`
|
||
n'avait jamais depose son propre etat. Corrige, les trois sont au vert :
|
||
|
||
sauvegarde: site-forge-01 -> OK : instantane il y a 5 h, 835 fichier(s)
|
||
sauvegarde: site-pki-01 -> OK : instantane il y a 5 h, 13 fichier(s)
|
||
sauvegarde: site-mon-01 -> OK : instantane il y a 0 h, 1 fichier(s)
|
||
|
||
`serveur_backup_verification_locale` repasse a `true` sur le depot du site. Ce drapeau ne
|
||
dit plus « on renonce » mais « verifie ce que tu peux ouvrir » :
|
||
`serveur_backup_noeuds_attendus` derive de l'inventaire OU TOURNE LE ROLE, donc des seules
|
||
machines du site, dont il detient le mot de passe restic. Il reste aveugle aux locataires,
|
||
et c'est le but.
|
||
|
||
### `serveur_postfix` gagne un mode `relais`
|
||
|
||
Le role est le serveur de courriel d'un ecosysteme : il exige un bind LDAP et un magasin
|
||
Dovecot. Un site n'heberge aucune boite — il lui faut EXPEDIER, et rien d'autre. Plutot
|
||
qu'un role jumeau (qui aurait duplique le certificat, sa resynchronisation, la resolution
|
||
dans le chroot et la validation), un mode : `complet` par defaut, `relais` pour le site.
|
||
|
||
Le site expedie DIRECTEMENT par sa frontiere. Emprunter le MTA d'un locataire ferait
|
||
dependre l'hebergeur d'un ecosysteme qu'il peut outvivre — une emancipation emporterait
|
||
son alerte avec elle.
|
||
|
||
### LA PANNE QUI DORMAIT DANS UN MOT : `disabled` au lieu de `enabled`
|
||
|
||
Le modele de routes d'OPNsense lit `enabled`. `appliquer_opnsense` lui envoyait
|
||
`disabled: "0"` — un champ qu'il IGNORE. `enabled` restait donc a son defaut, c'est-a-dire
|
||
ETEINT. **Chaque route creee par Set-OPS depuis l'origine l'etait desactivee.**
|
||
|
||
Pourquoi personne ne l'avait vu : une route eteinte EXISTE dans le modele. `frontiere-plan`
|
||
la comptait « posee » et annoncait « 15 routes inchange ». Elle n'etait simplement pas
|
||
installee dans la table de routage — et tant que le boitier n'avait pas ete recharge depuis
|
||
sa creation, le noyau gardait les routes ajoutees a la main lors de la mise en place. Tout
|
||
fonctionnait.
|
||
|
||
Le rechargement complet lance pour activer la patte du VLAN 36 a fait reprendre au noyau sa
|
||
table depuis le modele : **quatorze routes sur quinze ont disparu, et onze machines de
|
||
Chezlepro sont devenues injoignables** — le lendemain de sa reconstruction. Le devis, lui,
|
||
restait vert.
|
||
|
||
Trois corrections, parce qu'il y avait trois defauts empiles :
|
||
|
||
1. **La cause** : `enabled: "1"`.
|
||
2. **La cecite** : le plan lit desormais la TABLE DU NOYAU (`/api/diagnostics/interface/getRoutes`,
|
||
en GET, reponse en liste nue — sondee en POST puis en `{"rows": ...}`, elle rendait
|
||
« impossible de lire » sur une frontiere qui repondait tres bien) et signale toute route
|
||
declaree mais absente, ainsi que toute route presente mais eteinte.
|
||
3. **L'inaction** : la reconfiguration n'etait declenchee que `if routes_creer or
|
||
routes_retirer`. Aucun changement, donc aucune reparation : `appliquer` rendait « OK »
|
||
sur un routage casse. Elle se declenche maintenant aussi quand le noyau a perdu quelque
|
||
chose.
|
||
|
||
Et « rien a faire » ne s'affiche plus quand le noyau, lui, a du travail : le message disait
|
||
vrai du modele et faux du service.
|
||
|
||
### Un role du site peut enfin en appeler un autre
|
||
|
||
`serveur_icinga` declare `ingress 5665` depuis `serveur_backup`, et `serveur_backup`
|
||
l'`egress` en face. Les deux etaient justes, et la regle de frontiere n'existait pas : le
|
||
devis traitait tout `pair` nomme comme « l'exterieur » et sautait le flux. C'etait vrai
|
||
tant qu'un pair designait quelque chose de lointain — mais un pair peut nommer un role DU
|
||
SITE, pose dans une AUTRE ZONE, et depuis le decoupage en zones deux machines du site ne se
|
||
parlent qu'a travers la frontiere. Le depot pouvait joindre un Icinga du monde entier sauf
|
||
celui de son propre site.
|
||
|
||
### Le plan d'administration se DERIVE, il ne se recopie pas
|
||
|
||
L'intrant `nftables_admin_ssh` du site listait a la main les pattes de la frontiere, sous ce
|
||
commentaire : « oublier une seule de ces adresses rend une zone entiere inadministrable ».
|
||
Une zone a ete ajoutee le meme jour, la liste ne l'a pas suivie, et `site-mon-01` est
|
||
devenue injoignable des l'application de son pare-feu — rouverte par l'agent invite de
|
||
l'hyperviseur. La liste LIT desormais la carte ; l'intrant ne sert plus qu'aux voies
|
||
qu'elle ne peut pas connaitre.
|
||
|
||
### Ce qui reste ouvert
|
||
|
||
Le destinataire des alertes est encore `root@localhost` — le defaut d'Icinga. La
|
||
supervision voit et sait ; elle ne sait pas encore a QUI parler.
|
||
|
||
## 2026-09-02 (3) — Le site sauvegarde son propre etat, et la preuve le lui demande
|
||
|
||
**56 preuves.** L'hebergeur protegeait l'etat de tous ses locataires et pas le sien. Deux
|
||
choses qu'il detient et que personne ne peut regenerer :
|
||
|
||
- **la racine de son autorite de certification** (`/etc/step-ca`) — compromise, elle forge
|
||
tout nom ; perdue, il faut redistribuer la confiance a chaque machine de chaque
|
||
ecosysteme ;
|
||
- **la forge du genome** (`/var/lib/forgejo`) — les quatre depots dont tout descend. Ils
|
||
vivent aussi sur les postes et les runners, mais la forge est le seul endroit ou ils se
|
||
rejoignent.
|
||
|
||
Tout le reste est reconstructible par le code : le cache se re-remplit, la zone DNS se
|
||
regenere depuis le plan, les depots du runner vivent dans git.
|
||
|
||
**Preuve faite, pas annoncee.** Sauvegarde reelle sur les deux hotes, puis `restic check`
|
||
et restitution : 13 fichiers / 152 Ko pour l'AC — `secrets/root_ca_key` et
|
||
`secrets/intermediate_ca_key` compris — et 835 fichiers / 31 Mo pour la forge.
|
||
|
||
### Son propre compte, sur son propre depot
|
||
|
||
Le site depose avec SON identite (`backup_pubkey` au plan, moitie privee dans
|
||
`underlay.vault.yml`), sur le compte `restic` que `serveur_backup` lui cree — celui dont le
|
||
home est `/srv/restic/site`, a cote des comptes des locataires et separe d'eux par les
|
||
memes permissions. Celle des locataires ne lui sert a rien et ne doit pas lui servir.
|
||
|
||
La cible est DERIVEE du `expose` de l'application qui porte `serveur_backup_site` — le nom
|
||
du SERVICE, la meme source qu'un locataire utilise. Une adresse gravee dans
|
||
`client_backup_repo` rendrait le depot indeplacable.
|
||
|
||
### P36 lisait le plan de l'instance montee, donc jamais celui du site
|
||
|
||
La preuve qui exige qu'un detenteur d'etat porte `client_backup` ne regardait que
|
||
l'ecosysteme. L'hebergeur y echappait entierement — et c'est lui qui detient le plus.
|
||
|
||
Elle lit desormais les deux plans, avec le meme catalogue et la meme regle : ce qui se lit
|
||
statiquement se prouve statiquement, sinon on l'apprend le jour de la restauration (D-75).
|
||
`9 hote(s) de l'ecosysteme et 2 du site`.
|
||
|
||
**Verifiee en la faisant echouer** : l'integration retiree de `site-pki-01`, P36 tire et le
|
||
harnais passe NON CONFORME. Une garde qui ne peut pas echouer ne garde rien — cette
|
||
semaine en a deja produit deux.
|
||
|
||
### Ce qui reste ouvert, et qu'il faut savoir
|
||
|
||
**Personne ne verifie les sauvegardes du site.** `serveur_backup` rapporte l'etat reel des
|
||
instantanes a Icinga ; le site n'a pas de supervision, et son depot tourne avec
|
||
`serveur_backup_verification_locale: false` — pose pour les locataires, dont il ne peut pas
|
||
ouvrir les depots. Pour SES propres depots il le pourrait, mais il n'a personne a qui le
|
||
dire. La sauvegarde existe et se restaure ; c'est son SILENCE qui n'alerte pas encore.
|
||
|
||
## 2026-09-02 (2) — Les machines du site sont durcies : le commentaire disait vrai, le code n'en faisait que la moitie
|
||
|
||
**56 preuves.** Les six machines du SITE — racine de l'AC, forge du genome, cache
|
||
d'artefacts, resolveur, depot de sauvegarde, runner — ne recevaient QUE `serveur_debian`.
|
||
`serveur_durci` ne leur etait jamais applique : ni auditd, ni fail2ban, ni apparmor, ni
|
||
sysctl, ni pare-feu. Un inventaire de tenant met chaque machine dans les DEUX groupes
|
||
(`inventory_host.py`) ; celui du site n'en mettait aucune dans le second.
|
||
|
||
Le commentaire au-dessus de `GROUPE_SOCLE` affirmait pourtant : « elle veut le meme
|
||
durcissement SSH, les memes horloges, LE MEME PARE-FEU que n'importe quelle machine de la
|
||
flotte ». Personne ne pouvait voir l'ecart, puisque la documentation disait le contraire
|
||
de ce que le code faisait. Deuxieme fois en deux jours qu'un commentaire juste couvre une
|
||
implementation qui ne l'est pas.
|
||
|
||
**Trouve en cherchant autre chose** : un depot de sauvegarde refusait une cle valide, et le
|
||
fichier de durcissement SSH fautif datait d'une version abandonnee le 2026-08-09. Rien ne
|
||
l'avait jamais remplace parce que rien n'appliquait le role qui le remplace.
|
||
|
||
Etat obtenu, mesure sur les machines : auditd, fail2ban, apparmor et nftables actifs sur
|
||
les six, chacune avec son jeu de regles resolu, aucun service en echec.
|
||
|
||
### Le pare-feu du site n'avait aucune des trois pieces qui le rendent applicable
|
||
|
||
**1. Aucune regle n'etait generee pour le site.** `resoudre_flux.py nftables` lit un
|
||
`hosts.yml` ; le site a un inventaire DYNAMIQUE. Ses machines etaient donc invisibles pour
|
||
la generation. Deux absences se cachaient l'une l'autre : pas de regles, et pas de
|
||
pare-feu pour s'en apercevoir. D'ou `nftables-site`, qui traduit l'inventaire dynamique
|
||
vers la forme attendue plutot que de dupliquer la generation, et ecrit a cote du plan du
|
||
site. Branche sur `make flux`, pour que ca ne se demode pas.
|
||
|
||
**2. Le chemin du jeu de regles sortait du depot.** `nftables_baseline` derive son chemin
|
||
de `inventory_dir` — juste pour un tenant, faux pour un inventaire dynamique dont le
|
||
`inventory_dir` est `scripts/`. Le role serait tombe sur son repli : une politique `drop`
|
||
sans les regles resolues.
|
||
|
||
**3. Aucun plan d'administration.** `nftables_admin_ssh` est la seule chose qui empeche un
|
||
deploiement de couper la main qui le lance. Pour le site, il fallait comprendre que
|
||
l'exploitant administre PAR REBOND : la connexion est ouverte par la frontiere et arrive
|
||
avec l'adresse de sa patte DANS LA ZONE DE LA MACHINE VISEE, pas avec celle du poste.
|
||
Oublier une seule de ces adresses rend une zone entiere inadministrable. Le generateur
|
||
REFUSE desormais de produire des regles si cet intrant manque.
|
||
|
||
### Le defaut que la lecture a attrape avant l'application
|
||
|
||
Le jeu de regles produit pour `site-dns-01` autorisait `10.23.0.0/16` et `10.29.0.0/16` sur
|
||
le port 53 — et PAS `10.17.0.0/16`. `_supernets_voisins()` retire l'instance montee : juste
|
||
chez un tenant, ou nul n'est son propre voisin ; **faux du cote du site**, ou ca retire le
|
||
locataire qu'on pilote au moment ou l'on genere, c'est-a-dire presque toujours celui a qui
|
||
l'on tient le plus.
|
||
|
||
Chezlepro resout encore sur le resolveur du site. L'appliquer aurait prive de DNS les
|
||
quinze machines reconstruites la veille. C'est le meme piege que `site_inventaire`
|
||
documente deja pour les ACL du resolveur : il s'est retendu ici parce que la regle est
|
||
portee par la FONCTION, pas par le lieu qui l'appelle.
|
||
|
||
### Une valeur qui dependait de l'ordre des plays
|
||
|
||
`serveur_backup_site` desserrait `MaxStartups` a `60:30:200` — un depot de site voit la
|
||
somme des locataires. La valeur vivait dans son `meta`, donc ne portait que dans SON play.
|
||
Le jour ou `serveur_durci` a apporte `ssh_hardening` a toutes les machines du site, son
|
||
passage — le dernier — a remis le depot au defaut, sans rien signaler. Passee en host_var :
|
||
tout play qui applique le role la voit. Un reglage qui depend de l'ordre des plays est un
|
||
reglage qui reviendra en arriere.
|
||
|
||
### Verifie apres coup, pas seulement dans le journal
|
||
|
||
Depuis un locataire, a travers le nouveau pare-feu : le cache repond, la forge repond, le
|
||
depot rend `2 snapshots`, le resolveur du site resout. L'AC du site, elle, refuse — et
|
||
c'est correct : son flux declare `pair: flotte`, un locataire a la sienne.
|
||
|
||
## 2026-09-02 — Reconstruction de Chezlepro depuis zero : quatre defauts que seule une flotte rasee pouvait montrer
|
||
|
||
**56 preuves. `make valider` : 0 echec sur 13 hotes, test de restitution compris.**
|
||
|
||
Les quinze VM de Chezlepro ont ete detruites, disques compris, puis refaites depuis le
|
||
gabarit minimal — `make reconstruire` : creation des VM, AC et DNS montes completement
|
||
d'abord, puis toute la flotte par couches. Resultat : **15/15 hotes, 0 echec, 0
|
||
injoignable**, et les neuf detenteurs d'etat redeposent sur le depot du site.
|
||
|
||
Aucun des quatre defauts rencontres ne venait de la flotte ni du gabarit. Tous venaient de
|
||
gardes ou de derivations que rien n'avait jamais eprouvees, faute d'avoir jamais tout
|
||
reconstruit.
|
||
|
||
### 1. Un avertissement n'est pas un echec
|
||
|
||
Cinq VM sur quinze declarees perdues. Le journal Proxmox disait pourtant
|
||
`transferred 16.0 GiB of 16.0 GiB (100.00%)`, suivi de :
|
||
|
||
can't deactivate LV 'vm-9006-disk-1': Logical volume in use.
|
||
WARN: volume deactivation failed
|
||
|
||
Proxmox desactive le volume DU GABARIT apres un clonage, et n'y arrive pas tant qu'un
|
||
autre clonage parallele s'en sert — la consequence NORMALE de cloner un meme gabarit en
|
||
parallele, ce que `flotte-creer` fait justement pour aller vite. La garde n'acceptait que
|
||
`exitstatus == 'OK'`, or Proxmox rend trois formes : `OK`, `WARNINGS: n` pour une tache
|
||
ABOUTIE qui signale quelque chose, et un texte d'erreur pour un vrai echec.
|
||
|
||
Son message trompait en plus : « etat *stopped* » designe l'etat de la TACHE (terminee),
|
||
pas celui de la VM. On cherche un probleme de demarrage qui n'existe pas. Le message le
|
||
dit desormais. Et l'avertissement est AFFICHE au lieu d'etre avale.
|
||
|
||
### 2. Une garde qui se contredisait elle-meme
|
||
|
||
`_amorcer-socle` monte l'autorite en premier, comme il se doit. `infra-pki-01` est donc la
|
||
premiere machine debout — a un moment ou le cache du locataire, qui vit DANS la flotte
|
||
qu'on reconstruit, n'existe pas encore. `client_artefacts` arretait tout la.
|
||
|
||
Le plus parlant : **le commentaire du role decrivait deja le bon comportement** — « en
|
||
laissant le plancher en place, elles restent servies par le site : degrade, mais debout,
|
||
et reparable par un deploiement ». Le code faisait une `assert` qui arretait. Or ce que la
|
||
garde protege, c'est le plancher d'amorcage : il suffit de NE PAS l'ecraser. Arreter en
|
||
plus ne protege rien et rend la reconstruction impossible — aucun ordre de deploiement ne
|
||
pouvait la satisfaire, la contradiction etait dans la garde.
|
||
|
||
Elle degrade desormais, et le journal le DIT. La convergence a fonctionne dans la meme
|
||
passe : les quinze machines ont fini sur le cache de leur ecosysteme.
|
||
|
||
### 3. La racine nie notre TLD, et Unbound etend ce « non » — la vraie cause
|
||
|
||
`internal.` n'est pas delegue dans la racine, qui est SIGNEE : elle rend une preuve
|
||
NXDOMAIN validee pour ce TLD. `harden-below-nxdomain` (actif par defaut) tient ce « non »
|
||
pour prouve et repond NXDOMAIN pour TOUT nom sous `internal.` DEPUIS SON CACHE — sans
|
||
jamais interroger la `stub-zone` ni la `forward-zone`.
|
||
|
||
Le declencheur : n'importe quelle question sur un nom inexistant sous `internal.`, y
|
||
compris la zone d'UN AUTRE ecosysteme, que ce resolveur ne sert pas et va donc chercher a
|
||
la racine. Sur un resolveur partage par plusieurs locataires, ca arrive en permanence.
|
||
|
||
**Pourquoi ca a pris deux jours.** La panne parait intermittente : au redemarrage le cache
|
||
est vide, tout fonctionne, on conclut que c'est regle. Une seule requete l'eteint ensuite
|
||
pour des heures. Pire, le cache contenait EN MEME TEMPS la bonne reponse et un message
|
||
negatif pour le meme nom — c'est le message qui etait servi. Il a fallu lire le cache.
|
||
|
||
Le remede a d'abord ete mal identifie : `aggressive-nsec: no` avait semble marcher, parce
|
||
que le REDEMARRAGE qu'il imposait vidait le cache. C'est le vidage qui soignait. Mesure
|
||
qui tranche, cache vide puis une requete empoisonnante :
|
||
|
||
harden-below-nxdomain: no seul -> repond
|
||
aggressive-nsec: no seul -> ne repond pas
|
||
|
||
Ce qu'on perd : une protection anti-usurpation qui suppose que la racine dit vrai sur nos
|
||
noms. Elle ne le peut pas — nos zones n'y sont pas.
|
||
|
||
### 4. Un locataire doit savoir a qui demander la zone de son hebergeur
|
||
|
||
Un locataire depend de services du SITE par leur NOM : cache, forge, depot de sauvegarde,
|
||
autorite. Tant que ses machines pointaient sur le resolveur du site, ca marchait — par
|
||
accident. Reconstruites proprement, elles utilisent LEUR resolveur, qui ignorait cette
|
||
zone : `make valider` a echoue sur la RESTITUTION d'une sauvegarde. La sauvegarde etait
|
||
intacte ; c'est le chemin pour la NOMMER qui manquait.
|
||
|
||
D'ou `serveur_resolveur_zones_deleguees`, derive par `instancier`. **La premiere version
|
||
prenait `dns_amorcage` pour l'adresse du resolveur du site** — vrai chez Chezlepro, FAUX
|
||
chez Technolibre, dont l'amorcage pointe sur `9.9.9.9`. On aurait delegue la zone
|
||
souveraine de l'hebergeur a Quad9. **P03 l'a attrape avant tout deploiement**, en
|
||
comparant l'inventaire de CHAQUE instance a son plan. L'adresse vient desormais du plan du
|
||
site : la machine qui y porte `serveur_resolveur`.
|
||
|
||
Vide par defaut, et c'est le comportement d'un EMANCIPE : sans la carte de l'hebergeur, on
|
||
ne delegue plus rien, donc on ne nomme plus ses services. C'est ce qu'on veut CONSTATER
|
||
d'une emancipation, pas une panne a reparer.
|
||
|
||
### Au passage
|
||
|
||
`instancier` tentait encore `~/.config/setops-vault-pass`, le mot de passe UNIQUE d'avant
|
||
la separation des voutes du 2026-08-28 : `comparer` echouait en exit 4 sur un message qui
|
||
ne parlait que d'`ansible-inventory`. On ne pouvait donc plus voir ce qu'on changeait
|
||
avant de l'appliquer. Il derive desormais ses identites de `voutes.py`.
|
||
|
||
## 2026-09-01 — Le depot de sauvegarde du SITE : l'etat d'un locataire quitte enfin sa propre flotte
|
||
|
||
**56 preuves.** Chezlepro rangeait ses instantanes sur `backup-01`, une VM DE SA PROPRE
|
||
FLOTTE. Une sauvegarde rangee dans ce qu'elle protege ne protege rien : raser
|
||
l'ecosysteme pour le reconstruire, c'etait raser le filet avec. La reconstruction ne
|
||
pouvait donc pas etre tentee — le blocage n'etait pas technique, il etait structurel.
|
||
|
||
Le site a desormais son propre depot (`site-backup-01`, VLAN 35), et les neuf detenteurs
|
||
d'etat de Chezlepro y deposent. **Une restitution est sortie** : l'annuaire LDAP est
|
||
revenu lisible, `dc=chezlepro,dc=internal`, depuis un depot hors de la flotte.
|
||
|
||
### L'isolation entre locataires est celle du noyau, pas une convention
|
||
|
||
`serveur_backup` a un compte et une cle : juste pour un depot d'ecosysteme, faux pour un
|
||
depot de site. Une cle partagee aurait donne a chaque locataire la lecture — et
|
||
l'effacement — des instantanes des autres.
|
||
|
||
Un compte Unix par locataire, home en 0700, sa cle et rien qu'elle (`exclusive: true` :
|
||
le site est autorite sur qui entre chez lui). La racine partagee est a root en **0711** :
|
||
traversable, non listable — un locataire ne peut pas apprendre QUI sont ses voisins. Les
|
||
deux refus ont ete constates, pas supposes.
|
||
|
||
Ce que le site voit : des octets. restic chiffre CHEZ LE CLIENT, avec un mot de passe qui
|
||
reste dans la voute du locataire. Le site ne peut ni lire, ni ouvrir, ni reconstituer —
|
||
c'est ce qui rend la mutualisation acceptable, la meme frontiere que pour les voutes.
|
||
D'ou `serveur_backup_verification_locale: false` : la verification n'est pas supprimee,
|
||
elle est DEPLACEE chez celui qui detient la cle et sait ce qu'il a envoye.
|
||
|
||
### Quatre defauts que seul un deuxieme usage pouvait reveler
|
||
|
||
**1. La racine des depots ne peut etre le home de personne.** `/srv/restic` en 0700 au
|
||
compte `restic` : sshd a refuse `restic-chez` par `StrictModes`, qui exige que chaque
|
||
repertoire menant au home appartienne a root ou a l'utilisateur. Le message rendu etait
|
||
`Permission denied (publickey)` — celui d'une mauvaise cle. La cle etait la bonne ; c'est
|
||
le CHEMIN qui etait refuse, et rien dans ce message ne pouvait le dire.
|
||
|
||
**2. La racine nie notre TLD, et Unbound etend ce non a tout ce qui est dessous.**
|
||
`internal.` n'existe pas dans la racine, qui rend un NXDOMAIN **signe** (drapeau `ad`).
|
||
`harden-below-nxdomain` tient ce non pour prouve et fabrique un NXDOMAIN pour tout nom
|
||
sous `internal.`, **sans jamais interroger la `stub-zone`**. La delegation etait correcte,
|
||
l'autoritatif repondait juste, et pas une requete ne lui parvenait.
|
||
|
||
Ce que ca coutait : **aucun locataire ne pouvait nommer un service du site**. Chacun
|
||
gravait donc des ADRESSES dans sa configuration — le cache, le resolveur, la forge — et le
|
||
site devenait indeplacable un intrant a la fois. Le defaut ne s'est pas vu parce que les
|
||
machines du site ont le plancher `/etc/hosts` : le nom marchait partout ou on le testait,
|
||
et nulle part ou on en avait besoin. Remede : `local-zone transparent` sur notre zone —
|
||
le meme que pour les zones inverses, la meme cause.
|
||
|
||
**3. Le gabarit transporte des fichiers de durcissement perimes.** Dont
|
||
`20-chezlepro-hardening.conf`, celui-la meme que `ssh_hardening_fichiers_perimes` existe
|
||
pour retirer : fige dans l'image, donc herite par chaque VM clonee. Et **les machines du
|
||
SITE ne recoivent jamais `ssh_hardening`** — leur socle est `serveur_debian` seul, la ou
|
||
un locataire recoit aussi `serveur_durci`. Rien ne venait donc jamais le corriger.
|
||
|
||
**4. `MaxStartups` compte les connexions NON AUTHENTIFIEES.** Un depot d'ecosysteme en
|
||
voit une poignee ; celui d'un SITE en voit la somme de tous ses locataires, dont les
|
||
minuteurs se reveillent a la meme heure. Au-dela du seuil, sshd coupe AVANT la banniere :
|
||
le client rapporte `kex_exchange_identification: Connection reset by peer`, qui accuse le
|
||
reseau pour un refus applicatif. Douze sauvegardes simultanees, une perdue. Portee a
|
||
`60:30:200` sur le depot ; les douze repassent ensemble.
|
||
|
||
### Une patte de plus a la frontiere, et ce qu'elle a appris
|
||
|
||
Ajouter un segment au site n'avait aucune procedure ecrite. Il a fallu, dans l'ordre : le
|
||
declarer dans `underlay.yml`, creer le VLAN et l'interface sur OPNsense, ajouter le VLAN
|
||
au trunk du commutateur, une route sur le poste — et **inscrire la nouvelle patte dans
|
||
`opnsense_if_zones`**. Sans cette derniere ligne, le devis posait les regles du site sur
|
||
une patte ou son trafic ne passe pas : 16 regles creees, aucune ne correspondant jamais,
|
||
et rien ne le signalant.
|
||
|
||
### Constat non corrige, a decider
|
||
|
||
**Les machines du site ne sont pas durcies.** Pas d'auditd, pas de fail2ban, pas de
|
||
`nftables_baseline`, pas de `sysctl_hardening`, pas d'`unattended_upgrades` — alors
|
||
qu'elles portent la racine de l'AC, la forge du genome, et desormais les sauvegardes de
|
||
tous les locataires. `ssh_hardening` a ete pose sur le depot seul, la ou il bloquait.
|
||
Etendre `serveur_durci` a tout le site est un changement a mener pour lui-meme.
|
||
|
||
## 2026-09-01 — `q35` n'est pas un reglage : c'est la raison de la procedure manuelle
|
||
|
||
**56 preuves.** L'exploitant a explique pourquoi Set-OPS n'utilise pas l'image cloud
|
||
officielle de Debian — et c'est un constat **paye en anomalies**, qui n'etait ecrit nulle
|
||
part.
|
||
|
||
`genericcloud` est livree configuree pour **`i440fx`**, le defaut de Proxmox. La convertir
|
||
en `q35` apres coup ne change pas un parametre : ça **remplace le materiel virtuel sous un
|
||
systeme qui croit connaitre le sien**.
|
||
|
||
```
|
||
i440fx = PCI q35 = PCIe
|
||
la topologie des bus change, donc :
|
||
les noms d'interfaces predictibles suivent le chemin PCI et changent
|
||
les chemins de disques bougent
|
||
l'ordre d'enumeration des peripheriques n'est plus le meme
|
||
```
|
||
|
||
*La conversion n'est pas une correction — c'est une transplantation.* D'ou la regle : **une
|
||
machine nait `q35`, ou elle ne le sera jamais proprement**, et c'est l'installation depuis
|
||
l'ISO qui le garantit.
|
||
|
||
Ces deux lignes n'existaient que comme une ligne de tableau dans la procedure. Elles
|
||
portent desormais leur **pourquoi**, et le SITE les declare comme **donnees** — plus
|
||
seulement comme prose.
|
||
|
||
### `make gabarit-etat`
|
||
|
||
Il compare le gabarit reel a ce que le site declare de lui. *Controle negatif verifie :
|
||
declarer `i440fx` le fait echouer.*
|
||
|
||
**On verifie la SOURCE, pas chaque copie.** Ma premiere version gardait le clonage — mais
|
||
la propriete s'herite : verifier chaque clone coute a chaque creation sans rien dire de
|
||
plus que verifier le gabarit une fois.
|
||
|
||
*Et trois fois j'ai devine la forme de la reponse au lieu de la regarder — `regex_search` a
|
||
groupe qui rend `None`, `proxmox_vm_info` sans `config: current` qui ne rend que l'etat. La
|
||
garde a declare « ? » sur une VM parfaitement conforme. **Une garde qui crie toujours est
|
||
pire qu'aucune** : on apprend a l'ignorer, et le jour ou elle a raison plus personne ne la
|
||
lit.*
|
||
|
||
## 2026-09-01 — Le gabarit refabrique : minimal, et fabrique chez le SITE
|
||
|
||
**56 preuves.** Le gabarit dore est refait — VMID **9006**, `modeleSetOPS-minimal`, quatre
|
||
roles au lieu de dix-sept.
|
||
|
||
### Il se fabriquait dehors
|
||
|
||
*« Les ressources du SITE font autorite pour tous ses artefacts ; elles servent les tenants
|
||
jusqu'a ce qu'ils s'emancipent »* (decision de l'exploitant). Le gabarit en est un — c'est
|
||
de lui que descend **chaque VM de chaque tenant**.
|
||
|
||
Or `-i "<ip>,"` ne porte aucun `group_vars` : la fabrication n'avait ni mandataire ni
|
||
resolveur. L'ancien gabarit allait chercher ses paquets **chez Debian** et resolvait chez
|
||
**l'ancien LAN** (`192.168.10.10`, lu sur la VM 99998). Le site avait son cache et son
|
||
resolveur, et son propre artefact les ignorait.
|
||
|
||
La fabrication **derive** desormais ses ressources du plan du site et tourne sur le reseau
|
||
du genome. *Prouve : dix requetes de `10.0.33.31` servies par `site-cache-01`.*
|
||
|
||
### L'identite du gabarit vient du site, plus du tenant
|
||
|
||
`proxmox_clone_vmid_modele` vivait dans les `group_vars` de l'ecosysteme. **Deux tenants
|
||
pouvaient donc cloner deux gabarits differents sans que rien ne le dise**, et un tenant
|
||
decidait d'un objet qui n'est pas a lui.
|
||
|
||
*Meme mouvement que l'INDEX, tranche le 2026-08-25 : le site ALLOUE, le tenant RECOIT.*
|
||
|
||
### Deux pieges fermes en chemin
|
||
|
||
**Une valeur vide qui ecrasait la derivation.** `creer-vm` passait
|
||
`VMID_MODELE="$SETOPS_VMID_MODELE"` a `cloner-vm` — or `inventory_host.py` n'emet pas
|
||
cette variable. Elle valait le VIDE, et ce vide reprenait la main sur le site sans bruit.
|
||
|
||
**Le chemin du controleur contient une espace**, et `lookup('pipe', …)` le decoupait. Ce
|
||
depot l'a paye une troisieme fois — `ansible-galaxy` le 08-23, `git bundle` le 08-26, ici
|
||
le 09-01.
|
||
|
||
### Eprouve de bout en bout
|
||
|
||
```
|
||
VM clonee du gabarit minimal nom, adresse, machine-id neuf,
|
||
cle d'hote regeneree, agent invite actif
|
||
socle applique dessus changed=5, 0 echec, auditd compris
|
||
```
|
||
|
||
*VM d'essai retiree. L'ancien gabarit (99998) est garde et declare comme `precedent` : le
|
||
retirer est un geste, pas un effet, et il attend une reconstruction complete sur le neuf.*
|
||
|
||
## 2026-09-01 — Le gabarit etait un cache du socle, et il perimait sans le dire
|
||
|
||
**56 preuves.** Question de l'exploitant : le gabarit dore et cloud-init gagnent-ils leur
|
||
place, vu tout ce que Set-OPS fixe par ailleurs ? La mesure a repondu avant le
|
||
raisonnement.
|
||
|
||
**Le gabarit portait dix-sept roles — exactement ceux du socle et du durcissement**, que
|
||
le deploiement rejoue ensuite a l'identique. C'etait un **cache**, et comme tout cache il
|
||
perimait :
|
||
|
||
```
|
||
derniere recapture 2026-08-09
|
||
roles changes depuis common_packages · cloud_init · ssh_baseline
|
||
ssh_hardening · auditd · nftables_baseline
|
||
```
|
||
|
||
**Rien ne le signalait.** Aucune preuve du harnais ne regardait sa fraicheur, et le
|
||
deploiement masquait la derive en reappliquant tout — *donc personne ne pouvait la voir*.
|
||
C'est le defaut que D-81 a corrige pour la forge du genome, jamais traite ici.
|
||
|
||
### Il ne garde que ce qui doit exister avant qu'Ansible puisse agir
|
||
|
||
```
|
||
qemu_guest_agent l'agent repond AVANT SSH — c'est par lui que P52 confirme
|
||
cloud_init le seul chemin vers la premiere seconde : adresse, nom, cles
|
||
sudo_ansible le compte technique et son sudo — la porte par ou tout entre
|
||
ssh_baseline le serveur SSH, meme raison un cran plus bas
|
||
```
|
||
|
||
*Ce ne sont pas des choix d'efficacite, ce sont des conditions d'existence.*
|
||
|
||
**cloud-init, lui, ne peut pas etre retire** : sans lui une VM neuve n'a ni adresse ni cle,
|
||
donc personne ne peut l'atteindre pour lui en donner une. Il reste le seul chemin vers la
|
||
premiere seconde — au prix connu de reecrire `/etc/hosts` a chaque demarrage, ce que le
|
||
depot a deja du contourner.
|
||
|
||
### Ce que la reduction coute, et qui le couvre
|
||
|
||
Une VM neuve n'est plus durcie a la naissance. **Elle nait cependant derriere le pare-feu
|
||
de l'hyperviseur** — `policy_in=REJECT`, arme au clonage (verifie sur `obs-01`) : seuls
|
||
les flux declares l'atteignent, avant meme son premier paquet. *La fenetre d'exposition est
|
||
fermee par la fabric, pas par le gabarit* — mon objection initiale tombait devant la mesure.
|
||
|
||
### P56 garde les deux moities
|
||
|
||
Qu'il ne **regrossisse** pas : un role ajoute recree le cache, donc la peremption
|
||
invisible. Et que rien de retire ne soit **perdu** : un role absent du gabarit *et* du
|
||
socle disparaitrait de toutes les machines neuves — sans erreur, sans trace, et la panne
|
||
arriverait des mois plus tard sur une machine qu'on croyait durcie.
|
||
|
||
*Verifie : 14 retires, 14 repris, zero orphelin. Deux controles negatifs.*
|
||
|
||
## 2026-09-01 — `make emancipation-prouver` : couper, pas sonder
|
||
|
||
**55 preuves.** `docs/filiation-emancipation.md` decrivait quatre temps et n'en outillait
|
||
que trois. Le quatrieme est celui qu'on oublie — *« une emancipation non prouvee est une
|
||
emancipation non faite »*.
|
||
|
||
**Sonder ne prouve rien.** Verifier que le service local repond ne dit pas si l'amont sert
|
||
encore ; le depot le disait deja du cache : *« tant qu'internet repond, un `apt update` qui
|
||
reussit ne dit pas d'ou vient l'octet »*. L'instrument **coupe** donc l'amont, et refait
|
||
marcher la chose.
|
||
|
||
### Le meme essai rend les deux verdicts
|
||
|
||
```
|
||
coupe, la fonction marche -> ÉMANCIPÉ, et c'est prouvé
|
||
coupe, la fonction casse -> PAS ÉMANCIPÉ, dépendance prouvée RÉELLE
|
||
```
|
||
|
||
Le second n'est pas un echec de l'outil : c'est son **controle negatif**, rendu par la meme
|
||
commande. *Une preuve d'emancipation incapable de montrer la dependance qu'elle mesure ne
|
||
prouverait rien le jour ou elle passerait au vert.*
|
||
|
||
Un **temoin** precede la coupure — la fonction marchait-elle seulement avant ? Sans lui,
|
||
une panne preexistante se lirait comme une dependance.
|
||
|
||
### La coupure est garantie reversible
|
||
|
||
Une **table** `nftables` dediee, jamais une regle glissee dans une table existante : elle se
|
||
retire d'un geste et ne peut pas laisser d'etat partiel. Le bloc `always` la retire meme si
|
||
la mesure echoue ou si le play est interrompu, et une tache verifie ensuite qu'elle a bien
|
||
disparu.
|
||
|
||
### Mesure le jour de sa naissance
|
||
|
||
```
|
||
obs-01 / resolveur PAS ÉMANCIPÉ — plus aucune resolution des la coupure
|
||
forge-01 / artefacts ÉMANCIPÉ — apt installe, cache du site coupe
|
||
```
|
||
|
||
**Ce second verdict a ete doute avant d'etre cru** : `apt` aurait pu reussir en rejouant des
|
||
listes deja fraiches. Refait avec un dossier de listes **neuf**, amont coupe — il reussit
|
||
quand meme. Le cache sert vraiment son contenu.
|
||
|
||
## 2026-08-31 — D-82 : patient 0 n'est le parent de personne
|
||
|
||
**55 preuves.** Le dilemme ouvert le 2026-08-28 est referme, et ce sont les faits qui l'ont
|
||
tranche plus que le raisonnement. Trois lui ont retire ce role un par un — il fallait les
|
||
regarder **ensemble** pour le voir :
|
||
|
||
- **D-81** a donne l'autorite du genome a la forge du SITE. Son dernier lecteur, Chezlepro,
|
||
a ete corrige le 08-26 ; Technolibre le 08-31. *Il ne sert donc le genome a personne.*
|
||
- Le **denominateur commun** qu'il portait vit dans les modeles depuis le 08-24, sous le
|
||
nom `origine`. Ce n'est plus lui qu'on copie.
|
||
- **L'ancetre etait locataire de son enfant** : index 29 sur la fabric de `SITE-Chezlepro`,
|
||
qui descend de lui. *Il ne peut pas etre le chemin de reprise de son propre hote.*
|
||
|
||
Des trois issues posees mercredi, la deuxieme l'emporte — non parce qu'elle etait la plus
|
||
elegante, mais parce que les deux autres avaient cesse d'etre disponibles. **Et le depot
|
||
l'avait deja suivie sans le declarer** : `serveur_forge_site`, puis `serveur_cache_site`,
|
||
puis `serveur_resolveur_site`. Trois services pretes, un seul patron.
|
||
|
||
### Ce que ca ne regle pas, et qui est ecrit
|
||
|
||
Patient 0 existait pour **eliminer un point unique de defaillance** — `eregion`, hors
|
||
flotte, que Set-OPS ne deploie ni ne prouve. Il ne l'a pas elimine : **il a ete promu**. Le
|
||
poste y pousse, la forge du site en tire.
|
||
|
||
La dette a change de proprietaire, pas de nature ; elle appartient au SITE. *Un objectif
|
||
qu'on abandonne sans le dire devient un objectif qu'on croit atteint.*
|
||
|
||
**Ce qu'il garde** : sa place de pair dans la famille du genome, et sa forge de travail —
|
||
un ecosysteme garde la sienne meme quand il ne detient plus le genome de personne.
|
||
**Ce qu'il perd** : le rang.
|
||
|
||
### Une panne dormante, trouvee en reglant le cas
|
||
|
||
Technolibre chainait encore son cache sur patient 0. Le meme defaut a coute quinze machines
|
||
sans `apt` chez Chezlepro le jour ou ses VM ont ete eteintes ; chez Technolibre, qui n'a pas
|
||
encore de VM, il etait **dormant** et se serait reveille au premier montage.
|
||
|
||
## 2026-08-31 — La recette dependait de l'heure : un depot occupe n'est pas une sauvegarde cassee
|
||
|
||
**55 preuves. `make valider` : rc=0.** La reconstruction de Chezlepro est validee de bout
|
||
en bout — 16 cibles Prometheus UP, 6 vhosts HTTPS, un courriel **reellement remis** (envoi
|
||
→ LMTP → Maildir), et six depots restic **restaures et verifies**, dont l'annuaire dont on
|
||
prouve qu'il se rejoue (`slapadd -u`).
|
||
|
||
Un seul verdict rouge au premier passage : `data-sql-01`, la base. Et la meme commande,
|
||
rejouee a la main, restaurait ses cinq fichiers.
|
||
|
||
**`restic` verrouille son depot pendant qu'il ecrit.** Une recette qui croise la fenetre de
|
||
sauvegarde lit donc un echec de RESTAURATION la ou il n'y a qu'une attente — *verdict juste
|
||
sur l'instant, faux sur le fond, et dependant de l'heure a laquelle on l'a lancee.*
|
||
|
||
**On reessaie, mais on ne masque pas.** Trois tentatives espacees couvrent un verrou
|
||
d'ecriture ; au-dela l'echec est reel, et la recette rend desormais **la raison que restic
|
||
a donnee** au lieu d'un `-1` muet. Controle sur la machine — cle de dechiffrement retiree :
|
||
|
||
```
|
||
ECHEC — la RESTAURATION a échoué après 3 tentatives (snapshots=0)
|
||
— restic dit : Fatal: Resolving password failed
|
||
```
|
||
|
||
### Deux fautes a moi, en chemin
|
||
|
||
`[ "$f" -lt 0 ] && printf ...` : **la derniere commande d'un script decide de son code de
|
||
sortie**, et un test faux rend 1. La tache echouait donc exactement sur les machines ou la
|
||
restauration avait REUSSI — trois hotes verts declares en echec.
|
||
|
||
Puis la ligne `raison=` n'etait emise qu'en cas d'echec : le gabarit cherchait un champ
|
||
absent, rendait `None`, et explosait sur les **dix machines saines**. Elle est desormais
|
||
toujours emise, vide en cas de succes. *Un champ toujours present coute un octet et
|
||
supprime un cas.*
|
||
|
||
## 2026-08-31 — Deux filets : un cache mort ne rend plus un tenant irreparable
|
||
|
||
**55 preuves.** Le cache d'un tenant est **un maillon dont depend sa propre
|
||
reconstruction**. Toute la flotte y prend ses paquets ; s'il pointe vers un amont mort,
|
||
`apt` echoue sur les quinze machines, donc le socle echoue, donc le deploiement n'atteint
|
||
jamais la couche qui poserait le bon amont.
|
||
|
||
*Le correctif se retrouve dans la couche que la panne empeche d'atteindre* — et il faut
|
||
une main pour en sortir. C'est exactement ce qui s'est passe : l'amont pointait sur le
|
||
cache de patient 0, eteint la veille pour liberer de la RAM.
|
||
|
||
**`apt` rendait `503 Connection timeout` en citant l'adresse du cache LOCAL**, jamais
|
||
celle de l'amont manquant. La panne accusait le maillon visible.
|
||
|
||
### Deux filets, symetriques
|
||
|
||
`serveur_artefacts` sonde l'amont **avant** d'ecrire, et refuse s'il est muet : mieux vaut
|
||
un cache qui garde sa configuration precedente qu'un cache qu'on vient de rendre
|
||
inutilisable pour toute la flotte.
|
||
|
||
`client_artefacts` sonde la source **avant** de detourner `apt` vers elle. Ce fichier
|
||
ecrase le plancher d'amorcage pose par le socle — le poser sur un cache mort prive la
|
||
machine du cache du SITE, qui lui fonctionnait. En refusant, le plancher reste : la flotte
|
||
est degradee mais debout, et **reparable par un deploiement**.
|
||
|
||
### `ignore_errors` et non `failed_when: false` — la difference est toute la garde
|
||
|
||
`failed_when: false` **reecrit le verdict** : la tache n'est plus jamais `failed`, donc
|
||
`is not failed` est toujours vrai, donc l'assertion qui suit ne peut pas tirer. Ecrite
|
||
ainsi, ma premiere version a declare « joignable » un amont mesure **muet la seconde
|
||
d'avant**.
|
||
|
||
*Une garde qui ne peut pas echouer ne garde rien.* Deux controles verifies sur la machine :
|
||
amont eteint → refuse ; amont reel → pose.
|
||
|
||
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-31 — Trois unites en echec sur chaque machine, et personne ne les voyait
|
||
|
||
**55 preuves.** Le deploiement rendait `15/15, failed=0`. Les machines portaient chacune
|
||
**trois unites systemd en echec**. *Le rapport d'Ansible n'est pas l'etat d'une machine.*
|
||
|
||
### auditd : onze regles armees, personne pour collecter
|
||
|
||
Deux fichiers de regles identiques cohabitaient — `99-chezlepro.rules` et
|
||
`99-setops.rules`, vestige du renommage du role. `augenrules` **concatene** tout
|
||
`rules.d/`, et le noyau refuse la seconde occurrence :
|
||
|
||
```
|
||
Error sending add rule data request (Rule exists)
|
||
There was an error in line 16 of /etc/audit/audit.rules
|
||
```
|
||
|
||
`audit-rules` echoue, et `auditd` ne demarre pas — c'est sa dependance. **`auditctl -l`
|
||
affichait pourtant onze regles**, ce qui donne toutes les apparences d'un audit qui
|
||
fonctionne. L'audit etait arme dans le noyau et personne ne l'enregistrait.
|
||
|
||
Meme mue que `ssh_baseline`, meme registre — `auditd_fichiers_perimes`. Et
|
||
`failed_when: false` cachait le reste : *un service de securite qui ne demarre pas doit
|
||
se voir.*
|
||
|
||
### Les timers apt-daily : un etat d'echec residuel
|
||
|
||
Masquer le service pendant que son timer tourne lui fait perdre sa cible ; systemd le note
|
||
et le **garde**. `state: stopped` n'efface pas un etat `failed` — seul `reset-failed` le
|
||
fait.
|
||
|
||
Ce n'est pas cosmetique. Une supervision qui compte les unites en echec compte ces deux-la
|
||
**pour toujours**, et la vraie panne s'y noiera. Meme defaut que le journal de la frontiere
|
||
noye sous 982 000 entrees : *ce qui ment le plus n'est pas ce qui se tait, c'est ce qui crie
|
||
sans raison.*
|
||
|
||
### Un diagnostic faux, annule avant d'etre livre
|
||
|
||
J'avais lu `masked enabled` dans `list-unit-files` comme un etat contradictoire, conclu que
|
||
masquer empechait de desactiver, et bati une reparation pour le defaire. **Ces colonnes
|
||
sont ETAT puis PRESET** : masque, avec un prereglage constructeur active, est parfaitement
|
||
normal. Mesure sans ambiguite ensuite : `is-enabled=masked`, `is-active=failed`. Le
|
||
masquage etait juste ; seule la trace restait.
|
||
|
||
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-30 — Vingt-et-un plays : le resolveur ouvre la chaine, deux defauts tombent
|
||
|
||
**55 preuves.** Le deploiement depuis `ops-01` passe de **3 plays a 21**. Douze machines
|
||
sur quinze sont deployees completement (`ok=91` a `137`, `failed=0`). Le resolveur du site
|
||
a debloque tout ce qui suivait.
|
||
|
||
Trois echecs restants, deux causes — et les deux etaient de vrais defauts du moteur.
|
||
|
||
### Un port symbolique ecrit tel quel dans le pare-feu
|
||
|
||
```
|
||
/etc/nftables.conf:36: Error: Could not resolve service: Servname not supported
|
||
ip saddr { ... } udp dport derive accept # serveur_powerdns
|
||
```
|
||
|
||
`port: derive` dit *« ce port depend du deploiement »* — Forgejo ecoute 3000 derriere un
|
||
edge et 443 quand il sert son propre TLS ; PowerDNS 53 seul sur son hote et 5300 en
|
||
loopback derriere le resolveur. **Seul le plan sait lequel.** Le devis de la frontiere le
|
||
resout depuis le 2026-08-25 ; le generateur nftables, lui, ecrivait le mot.
|
||
|
||
Il resout desormais par le plan, et **n'emet rien** quand il ne peut pas — en le DISANT :
|
||
*un flux tu en silence est une porte qu'on croit ouverte.* Verifie que ca ne ferme rien :
|
||
`infra-dns-01` garde son 53, ouvert par `serveur_resolveur` ; l'omission de PowerDNS est
|
||
juste, puisqu'il ecoute en loopback derriere lui.
|
||
|
||
**Et la validation ne pose pas la meme question que l'emission.** Ma premiere garde
|
||
refusait tout port non numerique — elle a fait echouer P09, qui valide les roles **hors
|
||
instance**, la ou `derive` est parfaitement legitime. `PORTS_SYMBOLIQUES` nomme le
|
||
vocabulaire : le mot passe a la validation, jamais dans un fichier, et un mot inconnu reste
|
||
refuse des deux cotes. *Confondre les deux questions coute des deux cotes : un fichier
|
||
casse d'un cote, un role correct declare fautif de l'autre.*
|
||
|
||
### Recharger n'applique pas un changement d'ecoute — deuxieme fois
|
||
|
||
```
|
||
warning: ignoring inet_protocols parameter value change
|
||
warning: to change inet_protocols, stop and start Postfix
|
||
fatal: :::submission: Address family for hostname not supported
|
||
```
|
||
|
||
`main.cf` porte `inet_protocols`, que Postfix refuse de changer a chaud. Le `master` garde
|
||
`all`, tente d'ouvrir `:::submission` en IPv6, et meurt — **apres avoir accepte une
|
||
configuration valide**. `postfix check` ne dit rien, parce que la configuration EST valide :
|
||
c'est la TRANSITION qui ne l'est pas.
|
||
|
||
Meme famille que `nginx : restart si changement d'ecoute`. Ce qui vit dans le processus
|
||
maitre — protocoles, adresses, ports — exige qu'il reparte. `main.cf` notifie desormais le
|
||
redemarrage.
|
||
|
||
*Diagnostic corrige en chemin : j'ai d'abord accuse `postfix@-.service`, dont l'assertion
|
||
echouait sur `/etc/postfix-/main.cf`. C'etait MOI qui l'avais declenche en tentant un
|
||
demarrage manuel — `postfix.service` le declare en `Conflicts`. Le vrai journal etait
|
||
ailleurs.*
|
||
|
||
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-30 — La cle de signature est versionnee : une lignee qui se reproduit sans Internet
|
||
|
||
**55 preuves.** Le deploiement de Chezlepro depuis son propre runner a franchi le socle et
|
||
le durcissement sur les quinze machines, et s'est arrete sur une seule :
|
||
|
||
```
|
||
infra-pki-01 : Request failed: <urlopen error [Errno -3]
|
||
Echec temporaire dans la resolution du nom>
|
||
url: https://packages.smallstep.com/keys/apt/repo-signing-key.gpg
|
||
```
|
||
|
||
**Un tenant n'a pas de DNS sortant, et c'est voulu.** Il resout chez lui, sa requete ne
|
||
traverse jamais la frontiere. `apt` s'en sort par le mandataire du cache — qui resout a sa
|
||
place ; `get_url` n'a pas de mandataire. Mesure sur la machine :
|
||
|
||
```
|
||
resolv.conf 9.9.9.9, 149.112.112.112
|
||
DNS BLOQUE
|
||
443 sortant OK (par adresse)
|
||
apt via le cache OK
|
||
```
|
||
|
||
Les cinq reprises du role ne pouvaient rien : elles etaient ecrites pour un serveur
|
||
**intermittent** (mesure du 2026-08-23), pas pour une resolution fermee. *Une reprise ne
|
||
repare que ce qui est passager.*
|
||
|
||
### Une dette nommee, appelee
|
||
|
||
C'etait ecrit dans `client_artefacts` : *« les depots tiers en HTTPS continuent d'aller en
|
||
direct […] la seconde voie est propre et **reste a faire** — et le dire vaut mieux que le
|
||
laisser croire »*. Elle a ete appelee le jour ou un ecosysteme s'est reconstruit derriere
|
||
une frontiere qui fait son travail.
|
||
|
||
**La cle est desormais versionnee**, avec son empreinte et sa procedure de rafraichissement.
|
||
Meme idiome que les collections Ansible et les roues Python : le depot porte, la cible
|
||
n'ouvre rien. C'est ce qui rend une lignee reproductible **sans Internet**.
|
||
|
||
*Ce que ca coute, et qui est ecrit : une rotation amont n'est plus recuperee toute seule.
|
||
Elle ne passe pas inapercue pour autant — `apt` refuse alors le depot, bruyamment. Mieux
|
||
vaut un refus lisible qu'un telechargement qui reussit depuis n'importe ou.*
|
||
|
||
**Trouve en la recuperant** : l'URL publique **redirige** vers `pkgs.infra.smallstep.com`.
|
||
Sans suivre les redirections, on obtient un fichier VIDE que rien ne signale — un `curl -f`
|
||
sans `-L` rend zero octet et sort en succes. La procedure de mise a jour le dit, parce que
|
||
ce piege-la ne se voit qu'au deploiement suivant.
|
||
|
||
### Le correctif du pare-feu a tenu
|
||
|
||
Plus une seule suspension : les quinze machines ont franchi le durcissement (`ok=66`
|
||
chacune, `0 unreachable`) et repris la main apres s'etre armees. C'est la vraie nouvelle
|
||
de ce passage — le defaut d'hier soir ne se reproduit plus.
|
||
|
||
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-30 — Le terrain etait inoccupable : la cle du tenant nait avec ses machines
|
||
|
||
**55 preuves.** Le deploiement lance depuis `ops-01` s'est arrete au premier geste, sur les
|
||
quinze machines a la fois :
|
||
|
||
```
|
||
ops-01 -> toutes : Permission denied (publickey)
|
||
```
|
||
|
||
Les VM neuves n'acceptaient qu'une cle : celle de l'exploitant, posee par cloud-init. La
|
||
cle du runner **est** declaree au plan (`ssh_baseline_cles_admin`) — mais c'est le SOCLE
|
||
qui la depose, et le socle doit etre applique par quelqu'un qui peut deja entrer. *Boucle
|
||
fermee : le tenant recevait un terrain qu'il ne pouvait pas occuper.*
|
||
|
||
### Deux cles, deux portees, et la difference est toute l'architecture
|
||
|
||
```
|
||
la cle du SITE -> sur le SEUL runner du tenant l'insemination
|
||
la cle du TENANT -> sur TOUTES ses machines il va les configurer
|
||
```
|
||
|
||
**Poser une cle au clonage n'est pas entrer chez le tenant.** C'est un parametre de
|
||
creation, au meme titre que l'adresse ou le disque : le site ecrit les conditions de
|
||
NAISSANCE, il n'ouvre aucune session. La distinction n'est pas rhetorique — le site
|
||
n'obtient aucun acces sur ces machines, seul le runner du tenant en obtient un.
|
||
|
||
*C'est la forme que l'exploitant a tranchee : le site renseigne le seul runner, qui se
|
||
charge ensuite de toute sa flotte. Le plancher `/etc/hosts` suit le meme chemin — `ops-01`
|
||
a le sien depuis son insemination, il resout ses quinze voisines par leur nom (verifie :
|
||
`obs-01.chezlepro.internal:22` ouvert), et c'est lui qui posera le leur en appliquant le
|
||
socle. Le SITE n'a jamais a toucher une machine de tenant.*
|
||
|
||
### La revocation est honoree a la naissance
|
||
|
||
Une entree passee a `etat: absent` n'est pas reposee sur les VM neuves. Sans cette lecture,
|
||
une cle retiree de toute la flotte serait **ressuscitee sur chaque machine creee ensuite** —
|
||
une panne lente, silencieuse, et invisible au plan.
|
||
|
||
### P55 — le pouvoir se lit dans les cles, pas dans les intentions
|
||
|
||
Une VM recoit ses cles a la naissance, et personne ne relit un `authorized_keys` pose il y
|
||
a six mois. Si celle du site partait sur toute une flotte, l'hebergeur y gagnerait un acces
|
||
que rien ne declare. P55 verifie les deux moities : la cle du site ne nait que sur un
|
||
porteur de `serveur_ops_tenant`, et aucune machine ne reste sans celle de son tenant.
|
||
*C'est le pendant exact du flux d'insemination — une frontiere tenue a une seule couche
|
||
n'est pas tenue.* Deux controles negatifs verifies.
|
||
|
||
make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-30 — Le demarrage declarait en panne ce qui fonctionnait
|
||
|
||
**54 preuves.** Quinze VM de Chezlepro creees trois a la fois. L'une d'elles a depasse le
|
||
delai du module **pendant que Proxmox generait encore son ISO cloud-init** :
|
||
|
||
```
|
||
fatal: Reached timeout while waiting for starting VM.
|
||
Last line in task before timeout: 'generating cloud-init ISO'
|
||
```
|
||
|
||
Elle a demarre juste apres. Le module avait renonce, pas l'hyperviseur — et
|
||
`flotte-creer` a rendu *« au moins une VM n'a pas ete creee »* alors que **les quinze
|
||
tournaient**.
|
||
|
||
**Ce faux echec arrete une reconstruction.** `make reconstruire` enchaine `flotte-creer`
|
||
puis `deployer-tout` : la sequence se serait arretee la, sur une flotte complete et saine.
|
||
|
||
### Un delai reste un pari — on n'en fait plus un verdict
|
||
|
||
Deux corrections, et la seconde est la vraie. Le delai devient genereux, parce que la
|
||
generation d'ISO sur un stockage partage se met en file quand trois clonages tombent
|
||
ensemble. Mais son expiration ne conclut plus rien : **c'est l'hyperviseur qui juge**, en
|
||
repondant ce qu'il fait tourner.
|
||
|
||
C'est exactement le principe de P52, applique un cran plus tot — la, l'agent invite jugeait
|
||
la materialisation a la place d'une reponse SSH ; ici, l'API juge le demarrage a la place
|
||
d'un chronometre.
|
||
|
||
*Logique verifiee sur quatre cas : seul `running` passe ; VM arretee, VM introuvable et
|
||
reponse malformee echouent toutes. Une reponse vide ne passe pas en silence.* Rejoue sur
|
||
une VM deja en marche : idempotent.
|
||
|
||
### Ce que la creation de cette nuit n'a PAS prouve
|
||
|
||
Les quinze VM ont ete materialisees **depuis le poste**, pas depuis le runner du SITE. Le
|
||
chemin existait — `make inseminer` etait ecrit la veille — et il n'a pas servi. Rien de
|
||
cette nuit ne demontre donc que le runner du site sait materialiser une flotte : il a cree
|
||
une VM l'avant-veille, pas celles-ci.
|
||
|
||
*Ce n'est pas une faute de pouvoir : le poste detient legitimement la voute du site. C'est
|
||
une demonstration qui manque, et il faut le dire plutot que de laisser croire le contraire.*
|
||
|
||
make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-28 — Le puits avalait le plan d'administration
|
||
|
||
**54 preuves.** Le poste de l'exploitant ne joignait aucune machine de tenant. La chaîne,
|
||
mesurée saut par saut :
|
||
|
||
```
|
||
1. le poste 10.17.0.17 -> 10.17.19.41:22 route et source correctes
|
||
2. la frontiere rule 31/0(match): pass out vlan040 ELLE LAISSE PASSER
|
||
3. asgard vlan40 In ... 10.17.19.41.22 IL RECOIT
|
||
4. la VM (rien) IL N'ARRIVE JAMAIS
|
||
```
|
||
|
||
Et le noyau dit pourquoi — **la source est le discriminant, pas l'interface** :
|
||
|
||
```
|
||
ip route get 10.17.19.41 from 10.0.31.11 iif vlan40 -> dev vrf_t17
|
||
ip route get 10.17.19.41 from 10.17.0.17 iif vlan40 -> Invalid cross-device link
|
||
```
|
||
|
||
Parce que le **retour** est impossible : depuis le VRF du tenant, `10.17.0.17` tombe dans
|
||
`blackhole 10.17.0.0/16`.
|
||
|
||
### Le puits a ses raisons — c'est sa largeur qui était fausse
|
||
|
||
Sans lui, une adresse non attribuée du supernet sort par le défaut, revient par la
|
||
frontière **dans la table principale** et repart vers le réseau de gestion (mesuré le
|
||
2026-08-09). On n'y touche pas.
|
||
|
||
Mais il couvre la **bande basse**, là où D-77 place justement l'underlay d'un site. *Deux
|
||
règles justes séparément, contradictoires ensemble.*
|
||
|
||
### La correction est dérivée, pas écrite
|
||
|
||
Pour chaque tenant, les réseaux d'administration **qu'il déclare** (`nftables_admin_ssh`)
|
||
et qui tombent dans son propre supernet reçoivent une route vers la frontière, plus
|
||
spécifique que le puits. Ceux qui vivent dehors n'en ont pas besoin.
|
||
|
||
```
|
||
vrf_t17 ip route 10.17.0.0/24 ... l'admin de Chezlepro
|
||
vrf_t23 (rien) le sien est hors de son supernet
|
||
vrf_t29 ip route 10.29.19.41/32 ... l'admin de patient 0 : son propre ops-01
|
||
```
|
||
|
||
### Ce que ça répare au-delà de l'accès
|
||
|
||
Les règles `administration → tenant` de la frontière étaient **vraies et inapplicables à
|
||
la fois**. Elles correspondaient, elles laissaient passer, et le paquet mourait un saut
|
||
plus loin. *Un devis vert sur un chemin qui ne pouvait pas aboutir* — exactement le
|
||
« périmètre vide » que ce dépôt traque partout ailleurs.
|
||
|
||
*Trois instruments m'ont menti avant d'y arriver : une capture qui ne tournait pas, un
|
||
`ping` vers un ICMP que rien n'autorise, et un test TCP depuis la frontière dont la source
|
||
n'est pas dans l'IPSet de la VM. La mesure qui a tranché est `ip route get ... iif`,
|
||
comparée entre une source qui marche et une qui échoue.*
|
||
|
||
make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-28 — `make inseminer` : le geste sort de mes mains et entre dans le depot
|
||
|
||
**54 preuves.** L'insemination a d'abord ete conduite A LA MAIN depuis le runner du site —
|
||
donc hors du depot, donc sans preuve, ce que ce projet refuse. Elle a maintenant sa cible :
|
||
|
||
```
|
||
make inseminer TENANT=OPS-Chezlepro # l'hote se DERIVE
|
||
```
|
||
|
||
**`TENANT=` plutot que le symlink `instance`.** Le runner du SITE materialise et amorce
|
||
PLUSIEURS locataires ; pointer un lien global sur l'un d'eux le ferait se prendre pour ce
|
||
tenant. Il en **nomme** un par commande. C'est aussi ce qui borne le couplage que
|
||
`creer-vm` imposait en silence : rien ne disait sur quels tenants ce lien avait le droit de
|
||
pointer, ni jusqu'a quand.
|
||
|
||
**L'hote se derive, il ne se tape pas** : c'est celui qui porte `serveur_ops_tenant`. Meme
|
||
critere que le flux d'insemination et que la cle SSH du runner — *le meme mot borne les
|
||
trois pouvoirs*. Un ecosysteme qui n'en declare aucun est refuse, avec la raison :
|
||
« l'insemination vise LE RUNNER d'un tenant, jamais une machine ordinaire ».
|
||
|
||
### P54 — la ligne de partage, gardee la ou elle glisserait sans bruit
|
||
|
||
`COUCHES_INSEMINATION` vaut `serveur_debian serveur_ops`, et ce n'est pas une commodite :
|
||
**ce sont les deux seules couches qui ne reclament aucun secret**. Le site ne detient pas
|
||
la voute d'un tenant, et ne doit jamais la detenir.
|
||
|
||
Le jour ou l'on ajouterait une couche « pour aller un peu plus loin », le deploiement
|
||
echouerait chez le tenant sur une valeur vide — *un message qui ne dit pas qu'un pouvoir a
|
||
ete franchi*. P54 lit les roles reellement appliques et refuse toute citation de `vault_*`.
|
||
Controle negatif eloquent : ajouter `client_pki`, la couche **suivante**, la fait echouer,
|
||
parce qu'elle reclame le secret de l'autorite du tenant.
|
||
|
||
### Un garde-fou existant a intercepte une insemination mal dirigee
|
||
|
||
Premier essai sur un second tenant :
|
||
|
||
```
|
||
REFUS : SETOPS_INVENTAIRE designe un inventaire HORS de l'instance demandee
|
||
```
|
||
|
||
Le `make` parent **exporte** `SETOPS_INVENTAIRE`, derive de l'instance montee ; ma
|
||
resolution en heritait et visait l'inventaire d'un AUTRE ecosysteme. Le refus vient
|
||
d'`inventory_rules`, pas de la cible — et c'est exactement l'erreur qu'un runner de site,
|
||
qui sert plusieurs locataires, commettrait en silence. `env -u SETOPS_INVENTAIRE` ferme la
|
||
fuite.
|
||
|
||
*La cible dit aussi ce qu'elle ne fait pas : la machine amorcee n'est PAS armee, ni voute
|
||
ni cle, et l'armer est le geste d'un humain.*
|
||
|
||
make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-28 — `make sonder` : rapporter ce qui distingue, au lieu d'« échec »
|
||
|
||
**53 preuves.** Trois faux diagnostics en une journée, **tous dus à l'instrument, aucun au
|
||
composant**. `curl`, `bash /dev/tcp` et un `ping` nu écrasent quatre causes incompatibles
|
||
dans le même mot. L'information était là chaque fois ; l'outil la jetait.
|
||
|
||
```
|
||
ouvert, ~0 s le flux est déclaré et le service écoute
|
||
refusé (RST) une POLITIQUE interne refuse — immédiat
|
||
EHOSTUNREACH, ~3 s LA MACHINE N'EXISTE PAS (l'ARP a abandonné)
|
||
silence jusqu'au délai la FRONTIÈRE, muette par conception
|
||
```
|
||
|
||
Validé contre le réel depuis `ops-01` — trois des quatre cas, le quatrième attendant deux
|
||
machines vivantes dans un même tenant.
|
||
|
||
### Le même code d'erreur dit deux choses selon le délai
|
||
|
||
C'est la distinction qui a coûté le plus cher : `EHOSTUNREACH` **immédiat** veut dire « pas
|
||
de route » ; le **même code après trois secondes** veut dire « il n'y a pas de machine » —
|
||
c'est l'ARP qui renonce. Les confondre a fait appliquer un pare-feu pour réparer un vide.
|
||
`interpreter` est une fonction pure, donc gardée par un test qui exige que les deux ne se
|
||
lisent **jamais** pareil.
|
||
|
||
### La garde qui échouait du côté silencieux
|
||
|
||
L'outil dit par quelle **source** le paquet part, et refuse de conclure quand c'est une
|
||
passerelle anycast — le piège qui m'a fait déclarer muet un `REJECT` qui émettait bien ses
|
||
RST.
|
||
|
||
Ma première version rendait « pas anycast » quand `inventory_rules` était introuvable.
|
||
C'est-à-dire **exactement sur un hyperviseur où l'on a copié le seul fichier pour
|
||
diagnostiquer** — là où le piège se produit. Constaté à l'essai : l'avertissement s'est tu
|
||
sur `10.17.21.1`, qui *est* une passerelle anycast. *Une garde qui échoue doit crier, pas
|
||
se taire* : sans le moteur, elle retombe désormais sur une heuristique franchement
|
||
étiquetée `PASSERELLE PROBABLE (module absent)`.
|
||
|
||
### Et « non concluant » est un résultat
|
||
|
||
Quand la source est anycast et que la mesure est un silence, l'outil ne tranche pas — il
|
||
dit quoi faire à la place : *compter les paquets du côté qui refuse, ou sonder depuis une
|
||
VM*. C'est ce compteur qui a prouvé, lui, que le refus était instantané.
|
||
|
||
*TCP seulement, et c'est dit : UDP n'a pas de poignée, un silence y confond « ouvert » et
|
||
« filtré », et prétendre trancher serait le défaut qu'on corrige.*
|
||
|
||
make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-28 — P53 : l'interne refuse à voix haute, la bordure se tait
|
||
|
||
**53 preuves.** Décision de l'exploitant : `block` vers l'Internet, `reject` à l'intérieur.
|
||
*« Parce que c'est prudent. »*
|
||
|
||
Ce n'est pas le refus qui informe, c'est **ce qu'il fait au silence**. Sous `drop` partout,
|
||
un timeout voulait dire trois choses incompatibles — aucune machine, aucune route, ou une
|
||
politique. Quand la politique parle, il n'en reste qu'une.
|
||
|
||
```
|
||
nftables par hôte policy drop + reject with icmpx type admin-prohibited
|
||
pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique)
|
||
frontière OPNsense block — INCHANGÉ, et c'est la condition
|
||
```
|
||
|
||
**`admin-prohibited` et non `tcp reset`** : un RST est indiscernable d'un port fermé sans
|
||
service. Ce message-ci dit qu'une *politique* a refusé — la seule forme qui distingue « on
|
||
ne veut pas de toi » de « il n'y a rien ».
|
||
|
||
**La chaîne `forward` reste muette**, et ce n'est pas un oubli : elle porte le trafic qui
|
||
*traverse* l'hôte. Y répondre ferait parler cette machine **au nom d'une destination qui
|
||
n'est pas elle** — le défaut qu'on corrige dans `input`, déplacé d'un cran.
|
||
|
||
### Pourquoi c'est prudent, et pas seulement commode
|
||
|
||
**L'obscurité était déjà nulle à l'intérieur.** Chaque machine porte un `/etc/hosts`
|
||
généré qui liste toutes ses voisines avec leurs adresses. Se cacher de pairs qui ont déjà
|
||
notre adresse ne protège de rien — le `drop` coûtait du diagnostic sans rien acheter.
|
||
|
||
**Et la bordure protège le reject.** Rien d'indéclaré ne franchit le périmètre : ce reject
|
||
ne répond donc **jamais** à l'Internet. Mesure du 2026-08-27 à la frontière, qui explique
|
||
l'autre moitié du choix : 982 000 entrées par jour, dont 82 % un balayage contre le port
|
||
VNC. Y répondre serait un vecteur d'amplification, source usurpée comprise.
|
||
|
||
P53 garde les deux moitiés **ensemble**, parce que l'une sans l'autre est fausse : *une
|
||
bordure bavarde s'expose, un interne muet ment.* Trois contrôles négatifs vérifiés.
|
||
|
||
### La mesure a corrigé la mesure — deux fois
|
||
|
||
**Ma preuve était trop grossière.** Elle interdisait le littéral `DROP` dans le pare-feu
|
||
est-ouest, et a fait échouer un code **juste** :
|
||
`dc["politique"].upper() in ("DROP", "REJECT")` détecte une politique posée *au
|
||
datacenter*, où elle vaudrait pour tout le parc — un garde-fou, pas un réglage. *Une preuve
|
||
qui interdit un mot au lieu de mesurer une propriété finit par accuser ce qu'elle devrait
|
||
protéger.* Elle cherche désormais une **assignation en dur**, pas une occurrence.
|
||
|
||
**Et l'absence parlait déjà.** Mesuré depuis `ops-01` après application :
|
||
|
||
```
|
||
cache du site, déclaré OUVERT 0,00 s
|
||
machine INEXISTANTE OSError errno 113 EHOSTUNREACH 3,05 s
|
||
autre tenant, frontière TimeoutError 6,01 s
|
||
```
|
||
|
||
La passerelle anycast dit `EHOSTUNREACH` ; seule la frontière se tait. **Mes deux erreurs
|
||
de diagnostic de la journée ne venaient donc pas du `drop`, mais de ma sonde** — `curl` et
|
||
`bash /dev/tcp` écrasent `EHOSTUNREACH` et `timeout` dans un même « échec ». L'information
|
||
était là ; l'instrument la jetait. *Encore une fois, vérifier d'où l'on mesure avant
|
||
d'accuser ce qu'on mesure.*
|
||
|
||
Le `reject` garde toute sa valeur pour ce qu'aucun errno ne dit : la politique **d'hôte**
|
||
et **est-ouest**. Sa démonstration attend deux machines vivantes dans un même tenant —
|
||
Chezlepro n'en a qu'une.
|
||
|
||
**Appliqué** : 6 VM passées en `REJECT` (Chezlepro `ops-01`, les cinq de patient 0), 0
|
||
créée, 0 retirée. Le changement ne ferme rien — `REJECT` et `DROP` refusent tous deux,
|
||
l'un répond.
|
||
|
||
|
||
### Le REJECT est instantané — et c'est mon instrument qui disait le contraire
|
||
|
||
J'avais présenté « 3,05 s » comme le signal d'un refus. **C'était faux, et l'exploitant a
|
||
eu raison de le contester** : un REJECT répond en microsecondes. Ces 3 secondes étaient
|
||
l'ARP qui abandonne pour une machine inexistante — aucun pare-feu n'était impliqué.
|
||
|
||
Puis, sondé depuis l'hyperviseur, un port non déclaré a expiré à 8 s. La mesure qui tranche
|
||
n'est pas le délai mais le **compteur** :
|
||
|
||
```
|
||
compteur tcp-reset AVANT : 17
|
||
sonde vers ops-01:9999 (non déclaré) → TimeoutError en 4,001 s
|
||
compteur tcp-reset APRÈS : 21
|
||
```
|
||
|
||
**Quatre RST émis en quatre secondes**, un par retransmission du SYN. Le refus part
|
||
immédiatement, chaque fois. Ce qui manquait, c'était le retour :
|
||
|
||
```
|
||
ip route get 10.17.19.41 -> dev vrf_t17 src 10.17.21.1
|
||
```
|
||
|
||
`10.17.21.1` est une **passerelle anycast**. Le RST revient au nœud qui porte cette adresse
|
||
dans le VRF, pas à la socket émettrice. *Troisième instrument fautif de la journée, et
|
||
celui-ci était déjà documenté — « anycast n'est pas une source de test ».*
|
||
|
||
**Entre deux VM d'un tenant — le cas réel — la source est une adresse propre, et le
|
||
routage inter-zone ne la réécrit pas : le refus arrive instantanément.** L'artefact ne
|
||
vaut que depuis un hyperviseur.
|
||
|
||
*Ce qui manque n'est donc pas dans le pare-feu, c'est une sonde qui rapporte l'errno et le
|
||
délai au lieu de les écraser dans un « échec ».*
|
||
|
||
make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-28 — L'insémination aboutit : un runner de tenant, né d'un runner de site
|
||
|
||
**52 preuves.** `ops-01` existe, configurée de bout en bout **par le runner du SITE**, qui
|
||
n'a détenu aucun secret de Chezlepro :
|
||
|
||
```
|
||
/opt/setops Set-OPS-public OPS-Chezlepro SITE-Chezlepro venv
|
||
symlinks (D-80) instance -> OPS-Chezlepro underlay.yml -> SITE-Chezlepro/…
|
||
moteur au commit courant
|
||
sa paire SSH setops@ops-01.chezlepro.internal
|
||
voûte / clé AUCUNE
|
||
```
|
||
|
||
**Il s'arrête exactement là où il n'a pas la clé.** Ce n'est pas une règle qu'on lui
|
||
impose, c'est un fait : la voûte de Chezlepro ne vit dans aucun dépôt, et son mot de passe
|
||
n'est sur aucune de ses machines. L'armer reste le geste d'un humain, depuis **son** poste,
|
||
par **son** chemin — le site ne peut pas le faire à sa place, même s'il le voulait.
|
||
|
||
### Trois murs, et chacun a nommé une pièce manquante du terrain
|
||
|
||
Le socle bloquait sur `apt`, puis `serveur_ops` sur `pip`, puis sur le clonage du génome.
|
||
Trois échecs, trois couches du terrain que le SITE doit préparer et qui n'étaient pas
|
||
déclarées.
|
||
|
||
**`apt`** → la source d'artefacts d'amorçage. Le DNS sortant d'un tenant est fermé par
|
||
conception ; un mandataire résout ce que le client ne peut pas résoudre. Aucun flux à
|
||
ouvrir : le cache du site acceptait déjà les supernets des tenants.
|
||
|
||
**`pip`** → la roue hors-ligne, dernière dépendance qui sortait par elle-même. Les
|
||
collections suivaient l'idiome du dépôt depuis longtemps — *le contrôleur télécharge, la
|
||
cible n'ouvre rien* — `pip` non. `--no-index` **est** le point : sans lui, la réussite
|
||
dépendrait un jour d'un flux que ce tenant n'a pas le droit d'avoir, et une lignée
|
||
autonome deviendrait une lignée qui **a l'air** autonome.
|
||
|
||
**Le génome** → le marqueur `serveur_forge_site`, qui manquait. Le site rendait deux
|
||
services à ses locataires, les paquets et le génome ; seul le premier était déclaré. Une
|
||
dépendance écrite chez le consommateur, sans flux chez le fournisseur — invisible, parce
|
||
que la garde de matrice ne vérifie que les paires rôle→rôle.
|
||
|
||
### Les clés SSH suivent la même règle que les voûtes
|
||
|
||
Chaque runner a son jeu de clés ; sa publique vit dans l'`authorized_keys` des machines de
|
||
**son** tenant, et d'aucun autre. Le runner du SITE n'entre que sur `ops-01`, par le flux
|
||
d'insémination. *Le pouvoir d'entrer se borne au périmètre du runner qui le détient*, comme
|
||
le pouvoir d'ouvrir se borne à sa voûte.
|
||
|
||
Le véhicule existait déjà — `ssh_baseline_cles_admin`, avec son `etat` par entrée, donc
|
||
révocable sur toute la flotte. Chezlepro n'en déclarait aucune parce que son runner
|
||
n'existait pas encore.
|
||
|
||
### Ce que la mesure a corrigé, deux fois
|
||
|
||
`ops-01` est **la seule VM de Chezlepro qui existe** — les quatorze autres ne sont sur
|
||
aucun hyperviseur. Mes sondes vers le résolveur, le cache et la PKI du tenant mesuraient
|
||
donc une **absence**, que j'ai d'abord lue comme un pare-feu. *Un port muet ressemble à un
|
||
refus.*
|
||
|
||
Et l'accès que je croyais avoir cassé en appliquant le pare-feu est-ouest : aucune machine
|
||
de Chezlepro n'était joignable depuis le poste par ce rebond. `ops-01` l'était parce
|
||
qu'elle n'avait **aucun** groupe de pare-feu. Je n'ai pas créé une panne, j'ai retiré une
|
||
exception.
|
||
|
||
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-28 — Nourrir la première machine : le cache du site pendant la jeunesse
|
||
|
||
**52 preuves.** `ops-01` est la première machine de la reconstruction de Chezlepro, et le
|
||
socle y restait bloqué sur son premier `apt update`, sans message qui parle de réseau. La
|
||
mesure, depuis la machine :
|
||
|
||
```
|
||
sortie Internet TCP 443 OK
|
||
DNS UDP 53 -> 9.9.9.9 MUET
|
||
plancher /etc/hosts 15 machines du tenant, AUCUNE du site
|
||
```
|
||
|
||
**Le DNS sortant est fermé par conception** — un tenant résout chez lui, son DNS ne
|
||
traverse jamais la frontière. Or `apt` vise `deb.debian.org` **par son nom**, et le cache
|
||
de l'écosystème n'existe pas encore.
|
||
|
||
C'est le cas étroit que le plan du SITE nommait déjà, descendu d'un niveau : **la première
|
||
machine d'un écosystème n'a ni résolveur ni cache, et c'est elle qui doit les construire.**
|
||
|
||
### Un mandataire résout ce que le client ne peut pas résoudre
|
||
|
||
`apt` envoie au mandataire l'**URL absolue** ; c'est lui qui traduit le nom. La machine
|
||
n'a donc besoin d'aucun DNS pour installer ses paquets — seulement d'une **adresse**. D'où
|
||
`artefacts_amorcage`, symétrique exact de `dns_amorcage`, à la même place dans le socle.
|
||
|
||
**Aucun flux à ouvrir, et c'est la bonne surprise.** `serveur_cache_site` accepte déjà le
|
||
3142 depuis `voisins_site`, qui résout les **supernets** des tenants — donc toute machine
|
||
d'un locataire, pas seulement son cache. Vérifié depuis `ops-01` : *HTTP 200 en 0,158 s*,
|
||
la résolution faite par le cache.
|
||
|
||
### Ce n'est pas une rustine, c'est de la filiation
|
||
|
||
L'hébergeur nourrit la première machine de son locataire le temps qu'il monte son propre
|
||
cache. L'emprunt est **déclaré** — l'intrant nomme l'amont — et il s'efface :
|
||
`client_artefacts` écrit `00-setops-artefacts`, qui trie **après** ce fichier-ci, donc
|
||
gagne dès que l'écosystème a sa source. Le plancher reste dessous, comme `/etc/hosts`
|
||
reste sous le DNS.
|
||
|
||
*Retirer cette ligne le jour où le cache du tenant est debout est une **émancipation** — un
|
||
geste, pas un effet.* C'est le premier service à recevoir cette forme après la forge, et le
|
||
patron vaudra pour les sauvegardes, l'observabilité, le relais courriel.
|
||
|
||
### Deux diagnostics faux, corrigés par la mesure
|
||
|
||
**J'ai lu « BLOQUÉ » comme un pare-feu.** Les sondes vers le résolveur, le cache et la PKI
|
||
du tenant échouaient toutes — j'y ai vu un filtre, puis j'ai appliqué le pare-feu est-ouest
|
||
en croyant corriger. La vraie cause était une **absence** : `ops-01` est la seule VM de
|
||
Chezlepro qui existe, les quatorze autres ne sont sur aucun hyperviseur. *Un port muet
|
||
ressemble à un refus.*
|
||
|
||
**Et j'ai cru avoir cassé mon propre accès.** Après l'application du pare-feu, `ops-01` est
|
||
devenue injoignable depuis le poste — j'y ai vu une régression que j'aurais causée. Mesure :
|
||
**aucune** machine de Chezlepro n'est joignable depuis le poste par ce rebond. `ops-01`
|
||
l'était parce qu'elle n'avait *aucun* groupe de pare-feu. Je n'ai pas créé une panne, j'ai
|
||
retiré une exception — et le seul chemin qui reste vers elle est le chemin **déclaré**,
|
||
celui de l'insémination.
|
||
|
||
*Le nœud `192.168.11.42` est injoignable, à traiter séparément.*
|
||
|
||
### Le garde-fou du génome a refusé une histoire réécrite
|
||
|
||
`--amend` sur un commit déjà poussé : le runner a refusé l'avance non rapide, exactement
|
||
comme il doit. Les arbres étant identiques et seul le message différant, **c'est la version
|
||
de la forge qui fait foi** (D-81) — et l'explication vit ici, où elle a de toute façon sa
|
||
place.
|
||
|
||
make verifier : vert.
|
||
|
||
## 2026-08-28 — Armer un runner est un acte, pas un réglage
|
||
|
||
**52 preuves.** La séparation faite, la distribution devient sûre. `serveur_ops_site` et
|
||
`serveur_ops_tenant` savent désormais déposer **la clé** de la voûte qu'ils déposaient déjà
|
||
chiffrée — et le runner du site l'a reçue :
|
||
|
||
```
|
||
/opt/setops/.config/ -> -rw------- setops setops-vault-site-chezlepro
|
||
PREUVE : la voûte du SITE s'ouvre avec la clé que le runner détient.
|
||
```
|
||
|
||
**Une seule clé chez lui, et c'est tout ce qu'il peut ouvrir.** La fabric, pas un tenant.
|
||
|
||
### `false` par défaut, et ce n'est pas de la prudence décorative
|
||
|
||
Armer est le moment où un humain remet la clé — le seul geste que la reproduction exige de
|
||
lui. Un défaut à `true` armerait des machines sans que personne ne l'ait décidé. Les deux
|
||
rôles exigent donc une déclaration explicite au plan, et refusent d'armer sans qu'on dise
|
||
**avec quelle clé** : *poser un fichier vide laisserait un runner se croire armé.*
|
||
|
||
**Écrire, puis relire** — comme pour la voûte, mais contre la faute **symétrique**. Là on
|
||
craignait un chiffré devenu clair ; ici on craint une clé qui serait en fait une voûte, ou
|
||
un fichier vide, qu'Ansible accepte sans rien ouvrir. Le rôle mesure existence, taille et
|
||
mode après avoir écrit.
|
||
|
||
### Ce que ça change pour l'exploitant
|
||
|
||
Le mot de passe se tapait **à chaque déploiement**. Un geste répété vingt fois par semaine
|
||
ne prouve rien — et il rendait tout déploiement non interactif (la GUI, un runner)
|
||
impossible sans blocage. Il se remet désormais **une fois**, et c'est un événement daté.
|
||
|
||
*C'est la doctrine de l'émancipation appliquée un cran plus tôt : la machine instruit,
|
||
l'humain acte.*
|
||
|
||
### P32 a trouvé la moitié manquante
|
||
|
||
Le rôle du tenant exigeait `serveur_ops_tenant_cle_source` ; le plan de Chezlepro ne la
|
||
fournissait pas. La preuve l'a dit avant tout déploiement, et dans les termes qui comptent :
|
||
*« le déploiement s'arrêtera sur la première garde atteinte »*. `ops-01` est donc armé lui
|
||
aussi — avec la clé de Chezlepro, qui n'ouvre ni la fabric ni un voisin.
|
||
|
||
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute.
|
||
|
||
## 2026-08-28 — Une voûte, une clé : la séparation avant la distribution
|
||
|
||
**52 preuves.** Décision de l'exploitant : **chaque runner est maître de sa voûte et en
|
||
détient la clé.** C'est ce qui rend un runner autonome — et donc ce qui rend l'émancipation
|
||
atteignable. Mais elle exigeait un préalable, mesuré ce matin :
|
||
|
||
```
|
||
UN SEUL mot de passe ouvrait les SIX voûtes de la flotte
|
||
SITE-Chezlepro (la fabric) · OPS-Chezlepro · OPS-Chezlepro-lab (×2)
|
||
OPS-Patient0 · OPS-Technolibre
|
||
```
|
||
|
||
**Distribuer avant de séparer aurait été strictement pire que le statu quo.** Poser « la »
|
||
clé sur chaque runner, c'était rendre chaque runner capable d'ouvrir les autres :
|
||
compromettre le plus petit locataire donnait les secrets de l'hébergeur. *L'ordre n'est pas
|
||
un détail de mise en œuvre, c'est ce qui décide si la mesure protège ou expose.*
|
||
|
||
Cinq clés, une par écosystème, sous `~/.config/setops-vault-<dépôt-en-minuscules>`.
|
||
Vérification croisée après rechiffrement — la diagonale, et rien qu'elle :
|
||
|
||
```
|
||
voûte \ clé chezlepro lab patient0 site technolibre ANCIENNE
|
||
ops-chezlepro OUVRE . . . . refusée
|
||
ops-chezlepro-lab . OUVRE . . . refusée
|
||
ops-patient0 . . OUVRE . . refusée
|
||
site-chezlepro . . . OUVRE . refusée
|
||
ops-technolibre . . . . OUVRE refusée
|
||
```
|
||
|
||
### Un seul mot de passe ne pouvait plus suffire, et pas pour la raison qu'on croit
|
||
|
||
`cloner_vm_debian.yml` charge **deux voûtes dans la même exécution** : celle du tenant,
|
||
puis celle de l'underlay — les identifiants Proxmox appartiennent à l'hébergeur, le reste
|
||
au locataire. `ANSIBLE_VAULT_PASSWORD_FILE` n'en porte qu'une.
|
||
|
||
`ANSIBLE_VAULT_IDENTITY_LIST` en porte plusieurs et les essaie toutes sur un bloc chiffré
|
||
sans étiquette. **Un seul `export` dans le Makefile suffit donc pour les 28 appels à
|
||
`ansible-playbook`, sans en toucher un seul.** La liste se dérive de l'instance montée, de
|
||
son site et des dépôts frères (`scripts/voutes.py`).
|
||
|
||
*Ce qui borne le pouvoir n'est pas cette liste mais la **présence** des fichiers.* Sur le
|
||
poste de l'exploitant, toutes les clés sont là — c'est l'humain qui les détient toutes, et
|
||
des preuves comme P03 lisent les inventaires de tous les écosystèmes frères. Sur un runner,
|
||
une seule clé existe, et la même fonction n'en nomme qu'une. **Le code est identique, le
|
||
pouvoir ne l'est pas.**
|
||
|
||
### Deux preuves ont immédiatement dit ce qui manquait
|
||
|
||
**P03** (diff-vide de *toutes* les instances) est tombée dès la séparation : elle lit les
|
||
inventaires des écosystèmes voisins, donc il lui faut leurs clés. C'est elle qui a établi
|
||
que la liste devait couvrir le voisinage sur le poste — je l'avais d'abord limitée à
|
||
l'instance montée et à son site.
|
||
|
||
**P16** s'est mise à **sauter** : sa garde ne connaissait que `ANSIBLE_VAULT_PASSWORD_FILE`.
|
||
*Une preuve sautée se lit beaucoup trop facilement comme une preuve passée* — c'est le
|
||
même défaut que le vert sur un périmètre vide, en plus discret.
|
||
|
||
### Ce que ça coûte, et qui est assumé
|
||
|
||
Le chiffré et sa clé cohabiteront sur la même machine dès que les runners recevront la
|
||
leur. Voler le disque d'un runner suffira, là où ça ne donnait rien. Le pari tient parce
|
||
que le périmètre est borné : **le runner d'un tenant ne peut ouvrir que ce tenant.** Trois
|
||
contreparties en découlent — mode `0600`, la clé hors du chemin de sauvegarde ordinaire, et
|
||
un runner durci comme un détenteur d'état.
|
||
|
||
*Les six voûtes ont été sauvegardées telles quelles avant rechiffrement. L'ancienne clé
|
||
maîtresse n'ouvre plus rien et reste sur le poste : la retirer est une décision, pas un
|
||
nettoyage.*
|
||
|
||
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute — sans
|
||
`ANSIBLE_VAULT_PASSWORD_FILE`.
|
||
|
||
## 2026-08-28 — L'insémination aboutit, et trois tests qui ne gardaient rien
|
||
|
||
**52 preuves.** Le flux était déclaré et appliqué ; il manquait l'**identité**. Le runner
|
||
du SITE pouvait atteindre la porte de `ops-01` et n'avait pas de clé :
|
||
|
||
```
|
||
ansible@10.17.19.41: Permission denied (publickey)
|
||
```
|
||
|
||
**Le piège, et pourquoi il compte plus que le correctif.** La clé s'injecte par cloud-init,
|
||
**au clonage** — or `creer-vm` crée *toutes* les machines d'un tenant. L'injecter à chaque
|
||
clonage aurait donné à l'hébergeur un accès SSH à la **flotte entière de chaque
|
||
locataire**, en silence, sans qu'aucune règle ne le dise. *Ça aurait défait à la couche
|
||
identité ce que le pare-feu venait de borner à la couche réseau — et deux couches qui ne
|
||
déclarent pas la même politique, c'est une politique qu'on ne peut plus lire.*
|
||
|
||
**Le même critère gouverne les deux** : porter `serveur_ops_tenant`. `inventory_host.py`
|
||
émet `SETOPS_CLES_AMORCAGE` pour cet hôte, et une chaîne vide partout ailleurs. La clé
|
||
vient du **plan du SITE**, pas du disque local : on matérialise depuis le runner comme
|
||
depuis le poste, et un `lookup` local rendrait deux clés différentes selon qui agit.
|
||
|
||
Elle **s'ajoute**, elle ne remplace pas : l'exploitant garde son accès à la machine qu'il
|
||
vient de créer. C'est l'humain qui arme, pas le site.
|
||
|
||
### Deux couches mangeaient les espaces d'une clé publique, en silence
|
||
|
||
Une clé porte des espaces — type, matériel, commentaire. Proxmox répondait :
|
||
|
||
```
|
||
500 Internal Server Error: SSH public key validation error
|
||
```
|
||
|
||
Un message qui ne parle ni de `make`, ni de `shlex`, ni d'espaces. Ce qui l'a isolé n'est
|
||
pas la lecture du code mais un **contrôle** : rejouer `cloner-vm` *sans* la clé — la tâche
|
||
passe. Puis la mesure de ce qui arrivait vraiment au module : `"sshkeys": "ssh-ed25519"`.
|
||
**Le premier mot, rien d'autre.**
|
||
|
||
Deux causes, et j'ai accusé la mauvaise d'abord. Une variable `make` passée à un sous-make
|
||
est un chemin fragile pour une valeur à espaces — vrai, mais ce n'était pas ça. Le
|
||
coupable est `ansible-playbook -e clé=valeur`, qui découpe la chaîne **au shlex** : tout ce
|
||
qui suit le premier espace devient d'autres paires. D'où la forme retenue :
|
||
l'environnement pour le transport, et `-e '{"…": "…"}'` en **JSON** pour l'entrée dans
|
||
Ansible — le seul mode de `-e` qui ne découpe rien.
|
||
|
||
*Un scalaire YAML plié (`>-`) ne produit pas non plus de saut de ligne : `'\n'` y reste
|
||
deux caractères. Vérifié en base64 sur les trois formes candidates.*
|
||
|
||
### Trois tests qui ne gardaient rien
|
||
|
||
`test_inventory_host` déclare ses tests dans une **liste explicite**. Les deux que j'ai
|
||
écrits pour la nouvelle garde y étaient absents : définis, verts en apparence, **jamais
|
||
joués**. J'ai donc ajouté la garde qui manquait — la liste doit couvrir tout ce que le
|
||
fichier définit.
|
||
|
||
Elle a trouvé un **troisième** cas le jour même : `test_etiquette_vlan_repli_et_vide_explicite`,
|
||
jamais inscrit depuis sa création. Inscrit, il a levé un `KeyError` — il cherchait
|
||
`hotes_actifs` dans une fixture qui range ses hôtes sous `hotes_planifies`. Il gardait le
|
||
comportement SDN de l'étiquette VLAN, et il ne gardait rien. *Un test non inscrit est pire
|
||
qu'un test absent : on croit l'avoir.*
|
||
|
||
### La preuve, avec son contrôle négatif
|
||
|
||
```
|
||
runner du SITE -> ops-01 ops-01 10.17.19.41/24 ✔ entré
|
||
runner du SITE -> 10.17.19.21 Connection timed out ✘ refusé
|
||
```
|
||
|
||
Le chemin est large d'exactement une machine — et la première ligne est une **livraison**,
|
||
pas une poignée de main : `ops-01` s'est nommée.
|
||
|
||
*`ops-01` est née avant ce mécanisme ; sa clé a donc été posée une fois à la main. Toute VM
|
||
matérialisée à partir d'ici la reçoit de cloud-init, ou ne la reçoit pas — selon ce que le
|
||
plan dit d'elle.*
|
||
|
||
make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec.
|
||
|
||
## 2026-08-28 — Le lien d'insémination, déclaré — et un test rouge depuis trois jours
|
||
|
||
**52 preuves.** L'insémination avait un nom depuis ce matin ; elle n'avait pas de flux. Le
|
||
runner du SITE doit joindre le runner d'un tenant pour l'amorcer — socle, moteur, plan,
|
||
plancher de résolution — et ce chemin s'exerçait jusqu'ici sans être déclaré nulle part.
|
||
|
||
**Deux déclarations, aux deux bouts, et rien d'autre.**
|
||
|
||
```
|
||
serveur_ops_site egress 22/tcp -> serveur_ops_tenant
|
||
serveur_ops_tenant ingress 22/tcp <- runner_site partage: true
|
||
```
|
||
|
||
Étroit par construction : il vise le **groupe** `serveur_ops_tenant`, qu'un écosystème ne
|
||
pose que sur une machine. Déclaré au socle, il aurait ouvert le SSH du site vers toute la
|
||
flotte du tenant ; déclaré sur ce rôle, sa portée est celle du groupe — et le groupe est
|
||
écrit au plan du tenant.
|
||
|
||
L'en-tête de `roles/serveur_ops_site/meta/flux.yml` disait *« ce rôle n'entre JAMAIS chez
|
||
un tenant »*. C'était une frontière qu'on ne pouvait pas tenir : `creer-vm` exige
|
||
`_instance-requise`, et le runner du site avait déjà dû basculer son symlink `instance`
|
||
sur `OPS-Chezlepro` pour matérialiser ses VM. *Déclarer ne crée pas ce pouvoir — ça rend
|
||
limitable un pouvoir qui s'exerçait sans borne.*
|
||
|
||
Ce qui reste interdit n'est pas une règle mais un **fait** : le runner du SITE ne détient
|
||
pas la voûte d'un tenant — elle vit hors dépôt — et n'en connaît pas le mot de passe. Il
|
||
pose tout ce qui est public ; **l'humain arme**. Le pouvoir du site s'arrête là où il n'a
|
||
pas la clé.
|
||
|
||
### La règle est émise d'un seul côté, et ce n'est pas celui qu'on croit
|
||
|
||
Le paquet part du réseau de l'hébergeur vers la zone du tenant : il pénètre le pare-feu
|
||
**par la patte du site**, pas par le lien de transit. La règle appartient donc au côté
|
||
SITE du devis. L'émettre aussi depuis la déclaration `ingress` du tenant aurait produit
|
||
une seconde règle attachée à la mauvaise interface — *jamais évaluée, impossible à
|
||
distinguer d'une règle utile, et comptée comme telle par tout ce qui audite ce devis.*
|
||
|
||
La déclaration du tenant n'est pas perdue : c'est elle qui pose la règle `nftables` sur la
|
||
machine visée, et elle seule. Une ligne, sur un seul hôte, dont la source **dérive du plan
|
||
du site** :
|
||
|
||
```
|
||
ip saddr { 10.0.31.11 } tcp dport 22 accept # serveur_ops_tenant: Insémination…
|
||
```
|
||
|
||
*Écrire cette adresse à la main aurait survécu au prochain déplacement du runner sans que
|
||
rien ne le dise — le site a déjà déplacé ses machines une fois, le 2026-08-25.*
|
||
|
||
**Plan de la frontière : 2 objets à créer, 0 à retirer, 126 inchangés.** Rien n'est appliqué.
|
||
|
||
### P41 appliquée au plan du site
|
||
|
||
`resoudre_flux` avait besoin du plan du SITE à son tour, que `devis_opnsense` portait en
|
||
privé. Les trois lecteurs déménagent dans `underlay` — le module qui sait déjà où vit
|
||
l'hébergeur — et l'appelant délègue. *Deux recensements du même objet finissent toujours
|
||
par diverger, et la divergence se lit « tout va bien ».*
|
||
|
||
**Deux gardes ont fait leur travail au passage**, et méritent d'être nommées : P33 a refusé
|
||
`ingress 22` sur un hôte qui porte déjà le sshd du socle — la réponse était `partage: true`,
|
||
le mot qui distingue *ouvrir une écoute* d'*autoriser une source de plus sur celle d'un
|
||
autre*, exactement comme `serveur_backup`. Et le devis a refusé d'émettre une règle vers un
|
||
alias vide plutôt que d'en poser une sans destination.
|
||
|
||
### Un test rouge depuis trois jours, que personne ne pouvait voir
|
||
|
||
`test_adressage_derive` construisait un site avec un `index`. Or **un SITE n'a pas
|
||
d'index** — `add94f2` l'a retiré le 2026-08-25, remplacé par `bande_basse_de`, porté par
|
||
chaque *réseau* et non par le site. Le test est resté rouge du 25 au 28.
|
||
|
||
Personne ne l'a vu parce que **le geste quotidien est `make prouver`, qui ne joue pas les
|
||
tests** — `make verifier` le fait, et il n'avait pas été rejoué. *Un test rouge que rien ne
|
||
lit ne garde plus rien.* Remis sur le contrat actuel, et sa contrepartie ajoutée : un
|
||
réseau qui ne **dit pas** de quel tenant il occupe la bande basse n'a droit à aucun
|
||
chevauchement — ne pas déclarer ne peut pas valoir permission.
|
||
|
||
make verifier : vert de bout en bout. make prouver : CONFORME, 52 OK, 0 echec.
|
||
|
||
## 2026-08-28 — L'insémination : la parenté est la seule exception au zéro-confiance
|
||
|
||
**52 preuves.** Entre tenants, la frontière refuse tout ce qui n'est pas déclaré. Mais un
|
||
écosystème neuf **ne peut pas s'amorcer lui-même** : quelqu'un doit poser sa première
|
||
machine. Ce geste n'avait pas de nom, et un geste sans nom ne se borne pas.
|
||
|
||
```
|
||
le SITE matérialise VNets, VM, routes, flux — le terrain
|
||
le SITE amorce ops-01 la première machine, et elle seule
|
||
le TENANT achève ses autres machines, ses rôles, ses intégrations
|
||
```
|
||
|
||
**Le partage d'intelligence qui le rend possible**, et qui est plus net que « le SITE ignore
|
||
les rôles » : les deux runners portent le même moteur, et n'en consomment pas la même face.
|
||
Le SITE lit `roles/*/meta/flux.yml` — ce que les rôles exigent du **réseau** — et en tire le
|
||
SDN, le pare-feu Proxmox, la frontière. Le tenant lit `tasks/`, `templates/`,
|
||
`meta/integration.yml` — ce qu'ils exigent de la **machine**. *C'est ce partage qui permet
|
||
au SITE de poser les bonnes règles sans jamais lire la configuration d'un tenant ni détenir
|
||
sa voûte.*
|
||
|
||
### Le couplage existait déjà, sans être déclaré
|
||
|
||
`make creer-vm` exige `_instance-requise`. Pour matérialiser les VM de Chezlepro, le runner
|
||
du SITE a donc dû basculer son symlink `instance` sur le dépôt de Chezlepro — alors que son
|
||
plan pose `serveur_ops_instance: ""` et que la doctrine dit qu'un SITE n'a pas de plan monté
|
||
par ce lien. Mesuré sur la machine :
|
||
|
||
```
|
||
/opt/setops/Set-OPS-public/instance -> ../OPS-Chezlepro (27 août 21:52)
|
||
```
|
||
|
||
Rien ne borne sur quels tenants ce lien porte, ni jusqu'à quand. *Le déclarer, c'est le
|
||
rendre limitable ; le taire ne l'a jamais empêché.*
|
||
|
||
### L'émancipation exige la gouverne d'un humain
|
||
|
||
J'avais proposé une **condition d'extinction mesurée** : le lien se ferme quand la machine
|
||
constate que le tenant se reproduit sans son parent. C'était l'erreur symétrique de celle
|
||
qu'on corrigeait, et ce document disait déjà pourquoi — *« chaque émancipation est une
|
||
décision du client, jamais une rupture technique »*.
|
||
|
||
**Le fruit de l'émancipation est livré à quelqu'un.** Sans quelqu'un pour le recevoir, il
|
||
n'y a pas de livraison : seulement un lien qui tombe. *Une émancipation qui se déclencherait
|
||
toute seule ne serait pas une émancipation, ce serait une expulsion.*
|
||
|
||
D'où quatre lignes qui ne se confondent pas : le moteur porte le **lien**, la machine
|
||
produit le **constat d'aptitude** — elle instruit, elle n'agit pas —, **l'humain** pose
|
||
l'**acte** sous `CONFIRMER=true`, et la machine **prouve** ensuite que le lien est coupé.
|
||
C'est la forme des autres gestes lourds d'ici : `raser` exige son nom d'instance, le mot de
|
||
passe de la voûte se tape à l'exécution. **L'émancipation est le deuxième objet de cette
|
||
liste.**
|
||
|
||
### Deux limites nommées plutôt que tues
|
||
|
||
**Qui est le parent ?** D-81 donne l'autorité du génome à la forge du SITE. Conséquence non
|
||
écrite : patient 0, dont la raison d'être est *« porter les dépôts qui fabriquent les
|
||
écosystèmes, et les servir à ses enfants »*, n'a plus un seul lecteur — Chezlepro était le
|
||
dernier, corrigé le 26. Et la machine hors flotte qu'il existe pour remplacer alimente
|
||
désormais la forge qui fait autorité : le point unique de défaillance n'a pas été éliminé,
|
||
**il a été promu**. Trois issues sont posées dans le document ; aucune n'est tranchée, et
|
||
aucune ne bloque la reconstruction en cours.
|
||
|
||
**La séparation des voûtes est organisationnelle, pas cryptographique.** Les fichiers sont
|
||
bien séparés — vérifié sur `site-ops-01`, qui ne détient que `underlay.vault.yml`. Mais un
|
||
**seul mot de passe** ouvre la voûte du site et celles des tenants. « Deux pouvoirs, aucun
|
||
omnipotent » décrit la répartition des fichiers, pas celle des clés.
|
||
|
||
*Un document qui nomme ce qu'il n'a pas encore résolu vaut mieux qu'un document dont on
|
||
découvrira le trou en s'appuyant dessus.*
|
||
|
||
## 2026-08-27 — P52 : matérialiser n'exige pas d'entrer chez le tenant
|
||
|
||
**52 preuves.** `creer-vm` confirmait son succès en attendant une réponse SSH. Le runner du
|
||
SITE matérialise le terrain de **tous** les tenants, mais la frontière lui refuse d'entrer
|
||
chez eux — c'est le sens même de leur isolation. La première VM qu'il a créée a donc été
|
||
déclarée en échec après 600 secondes alors qu'elle tournait, avec l'adresse exacte que le
|
||
plan lui destinait :
|
||
|
||
```
|
||
Attente de SSH sur ops-01 ............ ECHEC: injoignable apres 600s.
|
||
```
|
||
|
||
**La tentation était d'ouvrir le SSH du runner vers tous les tenants.** Ça aurait réparé la
|
||
mesure en détruisant ce qu'elle protège : une machine capable d'entrer chez chaque locataire
|
||
est précisément ce que cette architecture refuse d'avoir.
|
||
|
||
L'agent invité répond sans rien ouvrir — l'API des hyperviseurs est déjà le flux par lequel
|
||
la VM vient d'être créée, donc qui peut la créer peut la voir naître — et il **prouve
|
||
davantage**. « Quelque chose écoute sur le port 22 » ne dit ni quel système a démarré, ni si
|
||
cloud-init a posé la bonne adresse. Sur `ops-01` :
|
||
|
||
```
|
||
10.17.19.41 portee sur asgard — Debian GNU/Linux 13 (trixie) 6.12.101
|
||
```
|
||
|
||
Ses échecs distinguent deux causes très différentes : « la machine démarre mais rapporte une
|
||
**autre** adresse — cloud-init, ou le pont sur lequel elle est posée », et « introuvable sur
|
||
la fabric ».
|
||
|
||
**Attendre la disponibilité a changé de main.** Guetter cloud-init et la libération de dpkg
|
||
appartient à qui va **configurer** : `deployer` le fait désormais, là où il se contentait
|
||
d'un `ping` unique. Il y gagne ce qu'il n'avait pas — attendre le verrou APT, faute de quoi
|
||
la première couche échouait dessus. Contrepartie assumée : un nom d'hôte erroné patiente au
|
||
lieu d'échouer vite ; échouer vite interdirait de chaîner création et déploiement, le geste
|
||
central d'une reconstruction.
|
||
|
||
*Une preuve qui exige un pouvoir que l'architecture refuse ne mesure pas le système : elle
|
||
mesure ce qu'il faudrait casser pour la satisfaire.*
|
||
|
||
## 2026-08-27 — P51 : quatre défauts de portabilité, révélés par le runner
|
||
|
||
**51 preuves.** Première matérialisation d'une VM de tenant depuis le runner du SITE. Elle a
|
||
échoué quatre fois d'affilée, sur quatre dépendances que le moteur ne déclarait nulle part.
|
||
Chacune fonctionnait chez le mainteneur pour une raison **différente**, et aucune de ces
|
||
raisons n'existe sur une autre machine.
|
||
|
||
**1. Versions des collections.** `requirements.yml` les nommait sans les épingler. Le runner
|
||
a reçu `community.general` 13.3.0 quand le poste porte la 10.3.0 — et la 11 a retiré les
|
||
modules Proxmox de cette collection (ils vivent désormais dans `community.proxmox`).
|
||
|
||
```
|
||
ERROR! couldn't resolve module/action 'community.general.proxmox_pool'
|
||
```
|
||
|
||
*Une dépendance non épinglée n'est pas une dépendance, c'est un pari sur l'état d'Internet à
|
||
la date du déploiement.*
|
||
|
||
**2. Installation en avant.** `ansible-galaxy` ne rétrograde pas : déclarer la 10.3.0 ne
|
||
suffisait pas à défaire une 13.3.0 déjà posée. `--force`. Un poste peut être en **avance**,
|
||
pas seulement en retard, et le dépôt doit faire autorité dans les deux sens.
|
||
|
||
**3. Emplacement.** Le `Makefile` pose `ANSIBLE_HOME ?= $(CURDIR)/.ansible` : sous `make`,
|
||
Ansible ne lit **que** `<moteur>/.ansible/collections`. Le rôle déposait dans
|
||
`<racine>/.ansible/collections` — à côté, jamais lu. Invisible chez le mainteneur, où les
|
||
collections viennent du paquet système, toujours dans le chemin quel que soit `ANSIBLE_HOME`.
|
||
Et le garde-fou d'installation suivait le **dépôt des archives** : changer la destination ne
|
||
le déclenchait pas. Il mesure désormais la destination — même piège qu'au 2026-08-24, déplacé
|
||
d'un cran.
|
||
|
||
**4. Bibliothèques Python.** Le venv recevait `ansible-core` et `pyyaml`, écrits en dur. Les
|
||
modules Proxmox tournent `delegate_to: localhost` et exigent `proxmoxer` **sur le
|
||
contrôleur**.
|
||
|
||
```
|
||
La bibliotheque Python proxmoxer est absente
|
||
```
|
||
|
||
Né d'ici : `requirements-python.txt`, avec sa frontière écrite — il ne déclare **que** ce qui
|
||
tourne sur le contrôleur. `python-ldap` et `psycopg2` s'exécutent sur leurs cibles ; le poste
|
||
du mainteneur ne les a pas et la flotte se déploie, ce qui prouve que la ligne est au bon
|
||
endroit.
|
||
|
||
**P51** garde les trois faiblesses de cette famille : une dépendance utilisée sans être
|
||
déclarée (le défaut du 2026-08-24, `ansible.posix`), une dépendance déclarée sans version, et
|
||
`proxmoxer` absent alors que des playbooks Proxmox existent. La preuve ne lit que les fichiers
|
||
de **tâches** : un `defaults/main.yml` porte `net.ipv4.ip_forward`, qu'un motif trop large
|
||
prendrait pour un module.
|
||
|
||
**Résultat :** `ops-01` matérialisée par le runner du site. L'agent invité rapporte
|
||
`eth0 10.17.19.41/24`, Debian 13 trixie — l'adressage dérivé du plan, appliqué.
|
||
|
||
**Migration connue, pas faite :** passer les quatre modules Proxmox à `community.proxmox`
|
||
permettra de suivre `community.general` au-delà de la 11.
|
||
|
||
*Un seul exécutant masque ces écarts indéfiniment ; un second les révèle tous en une soirée.
|
||
Même mécanique que le second tenant en août.*
|
||
|
||
## 2026-08-27 — P50 : le devis de la frontière sait refuser SANS consigner
|
||
|
||
**50 preuves.** L'outil ne savait qu'**autoriser** — `"action": "pass"` était en dur dans
|
||
l'émetteur. Une règle de silence ne pouvait donc pas naître du dépôt, et j'en avais posé deux
|
||
à la main sur le boîtier : exactement ce que ce projet refuse.
|
||
|
||
**Ce qui l'a motivé.** Le journal de la frontière écrivait 982 000 entrées par jour, dont
|
||
82 % un balayage Internet contre le port VNC et le reste du bavardage de découverte du réseau
|
||
local. Sa fenêtre utile était tombée à **quarante-quatre secondes**. J'y ai cherché la trace
|
||
d'un flux du site vers les hyperviseurs, je n'ai rien trouvé, et j'en ai conclu à tort
|
||
qu'aucune règle ne bloquait. **Un journal noyé ment aussi sûrement qu'un journal mort.**
|
||
Mesuré après déclaration : ~20 700/jour.
|
||
|
||
Une règle de silence ne change **aucun** comportement : ce qu'elle vise était déjà refusé par
|
||
le défaut. Elle ne supprime qu'une trace que personne ne lira.
|
||
|
||
**Trois pièces.** `cle_regle` accepte une action sans changer d'un octet la clé des règles
|
||
`pass` déjà posées — l'ajout naïf d'un champ les aurait toutes détruites pour les recréer à
|
||
l'identique, sur la frontière, en production ; le plan l'a confirmé : 7 à créer, 0 à retirer,
|
||
121 inchangées. `_corps_regle` lit l'action, la consignation et la **séquence** depuis le
|
||
devis : la séquence est ce qui rend un `block` sûr, puisque OPNsense évalue en `quick` et
|
||
qu'un blocage large émis avant les `pass` fermerait courrier, web et accès distant.
|
||
`devis_opnsense` lit `opnsense_silences` et en fabrique règles et alias, **motif compris** —
|
||
une règle `block` muette sans raison écrite est indiscernable d'un oubli.
|
||
|
||
P50 garde ces deux dangers : un silence en séquence 1 échoue, un silence sans motif échoue.
|
||
|
||
## 2026-08-26 — P49 : le registre des flux avait dérivé sans bruit
|
||
|
||
**48 preuves.** `docs/registre-flux.md` est **généré** depuis les `roles/*/meta/flux.yml`,
|
||
et c'est le document qu'un humain lit pour savoir ce que le pare-feu laisse passer. Son
|
||
**existence** était vérifiée depuis longtemps ; sa **fraîcheur** ne l'était pas.
|
||
|
||
Il avait dérivé : la garde d'administration y portait encore `10.0.0.0/24` alors que le
|
||
réseau d'administration vaut `10.17.0.0/24`, deux flux `client_resolveur` ajoutés depuis
|
||
n'y figuraient pas, et un hôte manquait des listes de sources. **Un lecteur y aurait lu un
|
||
pare-feu qui n'existe plus.**
|
||
|
||
L'inventaire avait déjà sa garde — **P03**, le diff-vide du plan. Le registre des flux est
|
||
le même genre d'artefact : généré, versionné, lu par un humain. Il lui manquait la même.
|
||
`generer_registre` étant une fonction pure, P49 la rejoue **en mémoire** et compare — une
|
||
preuve qui répare ce qu'elle mesure ne mesure plus rien.
|
||
|
||
*Contrôle négatif idéal, et il ne s'invente pas : la version commitée elle-même. Restaurée,
|
||
la preuve échoue ; régénérée, elle passe.*
|
||
|
||
### Ce que cette découverte corrige aussi dans ma tête
|
||
|
||
J'avais d'abord décrit le symptôme comme « le runner salit ses propres clones » — une
|
||
contradiction structurelle entre un dépôt-clone et un répertoire de travail. C'était faux,
|
||
et la question de l'exploitant l'a mis au jour. Régénérer un artefact **doit** produire un
|
||
diff quand les sources ont changé ; ce qui manquait n'était pas une architecture, c'était
|
||
une garde. *Un symptôme observé depuis un seul endroit ressemble toujours à une propriété
|
||
de cet endroit.*
|
||
|
||
## 2026-08-26 — `serveur_ops_tenant` : le runner d'un tenant reçoit enfin sa voûte
|
||
|
||
**47 preuves.** La doctrine des runners décrit trois portées depuis le 2026-08-22 :
|
||
|
||
```
|
||
calculer plan → inventaire aucune voûte serveur_ops
|
||
configurer rôles sur ses machines voûte du TENANT ← revendiquée, jamais reçue
|
||
matérialiser créer/détruire des VM voûte du SITE serveur_ops_site
|
||
```
|
||
|
||
La deuxième ligne était un trou. **Rien ne déposait jamais la voûte d'un tenant sur son
|
||
runner.** Un runner pouvait dériver son inventaire et ne rien pouvoir en faire : chaque
|
||
rôle qui demande un secret échouait sur son assertion, et l'échec ne disait pas qu'il
|
||
manquait un *fichier*, seulement que les valeurs étaient vides.
|
||
|
||
Découvert en préparant la reconstruction de Chezlepro. Le runner du site peut matérialiser
|
||
ses quinze machines ; il ne peut pas les configurer, parce que Chezlepro a sa **propre**
|
||
autorité de certification — donc `client_pki` y réclame un secret de Chezlepro. Le runner
|
||
du site ne l'a pas, et **ne doit pas l'avoir** : c'est la ligne qui rend l'hébergement
|
||
mutualisé défendable.
|
||
|
||
`serveur_ops_tenant` est le symétrique exact de `serveur_ops_site` : un **marqueur**, pas
|
||
un installateur. Il dépose la voûte `decrypt: false`, puis **relit l'en-tête** du fichier
|
||
déposé — une voûte déchiffrée par accident est une fuite silencieuse. Le dossier
|
||
d'inventaire (`principal` ou `production`) se **découvre** sur la machine.
|
||
|
||
*Pourquoi un rôle à part et non une option : donner sa voûte à un runner est un pouvoir,
|
||
pas un réglage. Le déclarer au plan force à répondre à « cette machine a-t-elle le droit de
|
||
configurer cet écosystème ? » — une option activée par défaut y répondrait à notre place.*
|
||
|
||
### Chezlepro lisait encore son génome chez patient 0
|
||
|
||
Trouvé au passage, et bloquant pour la reconstruction : `serveur_ops_forge_amont` pointait
|
||
sur `10.29.16.11` — la forge de patient 0, l'amont d'avant que le site ait la sienne.
|
||
**D-81** a tranché depuis. Laissé tel quel, Chezlepro se serait reconstruit depuis un
|
||
moteur périmé, sur un réseau que le découpage en zones a de toute façon déplacé.
|
||
|
||
Corrigé vers la forge du site, **par son IP** — `forge.genese.internal` appartient à la
|
||
zone souveraine du site, et un tenant ne résout que la sienne ; le certificat servi porte
|
||
bien `IP Address:10.0.33.11`. La racine de l'AC du site est désormais versionnée à côté de
|
||
la carte de la fabric à laquelle elle appartient, et son chemin se **dérive** du symlink
|
||
`underlay.yml` — il vaut donc depuis le poste comme depuis un runner.
|
||
|
||
## 2026-08-26 — Le runner du site travaille, et le génome remonte chez lui
|
||
|
||
**46 preuves.** `serveur_ops` et `serveur_ops_site` sont déployés sur `site-ops-01` : le
|
||
moteur, les plans des trois tenants, les collections hors ligne, et la voûte de l'underlay
|
||
déposée **chiffrée**. Le site a son runner.
|
||
|
||
### Deux formes de runner, et une seule était prévue
|
||
|
||
`serveur_ops` exigeait un dépôt `role: instance` et un `serveur_ops_instance`. C'est la
|
||
forme d'un runner de **tenant** : il pilote un écosystème, monte son plan par le lien
|
||
`instance`, détient sa voûte.
|
||
|
||
Un runner de **site** ne pilote rien — il **matérialise** le terrain que d'autres
|
||
occuperont, et son inventaire est dynamique. Son plan déclarait donc ses dépôts de tenants
|
||
en `role: tenant`, à dessein et avec ses raisons écrites, et le rôle le refusait au nom
|
||
d'une exigence qui ne le concernait pas. Il exige désormais que le runner pilote
|
||
*quelque chose*, sans prescrire quoi : un `instance`, ou un `hebergeur`.
|
||
|
||
Et il posait le lien quand même : `serveur_ops_instance: ""` donnait `instance ->
|
||
/opt/setops/`, un répertoire qui n'est l'écosystème de personne. *Un lien qui existe et ne
|
||
désigne rien est pire qu'un lien absent : tout ce qui le suit croit avoir trouvé une
|
||
instance.*
|
||
|
||
### Le certificat couvre les noms du service, pas seulement ceux de la machine
|
||
|
||
`client_pki` ne mettait dans ses SAN que le FQDN, le nom court et l'IP. Le clonage du
|
||
génome a buté dessus : *« certificate subject name (site-forge-01.genese.internal) does not
|
||
match target hostname 'forge.genese.internal' »*. Le nom déclaré `expose:` était publié
|
||
partout — plancher, zone DNS — et couvert nulle part.
|
||
|
||
Les expositions **que cet hôte sert réellement** entrent maintenant dans le certificat.
|
||
Un certificat qui revendiquerait le nom d'un service rendu ailleurs serait une usurpation,
|
||
pas une commodité.
|
||
|
||
### `make genome-pousser` — le runner pousse, le poste ne route pas
|
||
|
||
La forge du génome vit **dans** le site, sur un réseau que le poste de l'exploitant ne
|
||
route pas. On ne perce pas un chemin pour lui : on lui retire le rôle. Le poste emballe les
|
||
commits dans un `git bundle` — un fichier, vérifiable, qui ne demande aucun réseau — et le
|
||
runner vérifie, avance **en avance rapide seulement**, puis pousse avec **sa** clé.
|
||
|
||
Ce qui est poussé se **dérive** de `serveur_ops_depots` : les dépôts que le runner porte et
|
||
dont une copie existe à côté du moteur. Six, ici. `DEPOT=<nom>` en cible un seul.
|
||
|
||
Trois pièges rencontrés, tous inscrits dans le code :
|
||
|
||
- l'outil ne peut pas être supposé présent sur le runner — il ne recevrait cette version
|
||
qu'**après** la poussée qu'elle sert à faire. Le contrôleur le porte ;
|
||
- la branche n'est pas toujours `main` — deux dépôts vivent sur `master`, et le plan le
|
||
déclarait déjà ;
|
||
- le dossier des dépôts frères contient un espace : `argv` et non `cmd`.
|
||
|
||
Le juge n'est pas le journal du playbook mais **la forge** : on lui demande son API ce
|
||
qu'elle porte, et on refuse si ça diffère de ce que porte le runner.
|
||
|
||
*Pourquoi ça comptait : la forge du site était restée quatre commits derrière le poste,
|
||
sans que rien ne le signale — dont le correctif qui désarme le pare-feu Proxmox. Un
|
||
écosystème qui se reproduit depuis une forge en retard reproduit ses défauts.*
|
||
|
||
### P48 — la carte d'orientation ne peut plus mentir
|
||
|
||
`docs/carte-set-ops.md` est l'index du **mainteneur** : l'ordre de lecture du corpus, et
|
||
surtout le catalogue des **mécanismes transverses** avec, pour chacun, **où il vit dans le
|
||
code**. Son but est écrit en toutes lettres : *« ne plus re-déterrer ce qui existe »*.
|
||
|
||
Elle n'avait aucune garde, alors que `catalogue-services.md` a la sienne depuis P38. Ses
|
||
**sept** chiffres étaient faux — 54 rôles annoncés contre 60, 34 documents contre 38, 15
|
||
pièces d'audit contre 27, 70 décisions contre 78.
|
||
|
||
Le défaut coûteux n'est pourtant pas là. C'est le **pointeur mort** : la carte dit où vit
|
||
un mécanisme, quelqu'un ne l'y trouve pas, et le réimplémente à côté — exactement la panne
|
||
qu'elle existe pour prévenir. *Aucun de ces nombres ne fait travailler personne ; mais un
|
||
document dont les faits vérifiables sont faux cesse d'être consulté, et c'est alors ses
|
||
pointeurs qu'on perd.*
|
||
|
||
P48 vérifie les deux : **84 chemins** cités existent, et **7 chiffres** correspondent à la
|
||
mesure. Quatre distinctions ont dû être écrites pour qu'elle ne soit pas fausse dans
|
||
l'autre sens — un gabarit de nom (`preuve-<date>.md`) décrit une forme, pas un fichier ; un
|
||
chemin hors dépôt (`~/.config/setops-vault-pass`) vit sur le poste de l'exploitant ; un
|
||
fragment (`meta/acces.yml`) vaut comme suffixe ; et un artefact **généré** (`hosts.yml`) n'a
|
||
pas à exister dans le moteur. Les quatre sont nommées dans le code plutôt que sautées en
|
||
silence.
|
||
|
||
*Le tableau « Le dépôt en chiffres » remplace les comptes en prose : ce qu'on n'entretient
|
||
pas, on ne l'affirme pas — ou bien on le fait recompter.*
|
||
|
||
### D-81 — la forge du site fait autorité, et `make genome-etat` le vérifie
|
||
|
||
Décision de l'exploitant : **la forge du SITE fait autorité pour le génome.** Toute autre
|
||
copie — y compris celle d'où le moteur a été poussé jusqu'ici — est un **miroir**.
|
||
|
||
Une autorité qu'on ne vérifie pas est une autorité qu'on suppose. `make genome-etat`
|
||
confronte donc, dépôt par dépôt, ce que le poste porte à ce que la forge porte — et
|
||
**refuse** en cas d'écart plutôt que de le signaler. *Un écart connu et toléré redevient un
|
||
écart oublié, et la commande qui le corrige tient en trois mots.* Il dit aussi quand la
|
||
copie locale n'est pas propre : des commits pas encore faits sont une autre forme de
|
||
retard.
|
||
|
||
Mesuré au passage, et traité plutôt qu'ignoré : **le premier contact avec la forge échoue
|
||
une fois sur six** — poignée TLS expirée, puis cinq réponses de suite. Ce n'est pas le
|
||
chemin, qui est prouvé ; c'est l'acceptation TLS après un temps d'inactivité. Les deux
|
||
cibles réessaient.
|
||
|
||
## 2026-08-25 — Quatre zones inverses, et rien de plus
|
||
|
||
**47 preuves.** Le site ne servait aucun PTR. `serveur_powerdns_zone_inverse` dérivait
|
||
d'un supernet `/16` — la forme d'un **tenant**, qui tire tout de son index. Un site ne
|
||
dérive pas : il déclare plusieurs `/24` et n'a pas de supernet unique, si bien que la
|
||
dérivation rendait une chaîne vide et qu'aucune zone n'était générée.
|
||
|
||
Le site revendique désormais **exactement ce qu'il occupe** :
|
||
|
||
```
|
||
31.0.10.in-addr.arpa 32.0.10.in-addr.arpa
|
||
33.0.10.in-addr.arpa 34.0.10.in-addr.arpa
|
||
```
|
||
|
||
Revendiquer `0.10.in-addr.arpa` d'un seul geste aurait été plus simple et faux : cette
|
||
zone couvre aussi la frontière, le transit et les hyperviseurs, qui ne sont pas à lui.
|
||
*Une autorité qu'on s'attribue sans l'exercer est une panne différée.*
|
||
|
||
La dérivation vit dans un **filtre** (`zones_inverses`) parce qu'elle a deux appelants :
|
||
`serveur_powerdns` écrit ces zones, `serveur_resolveur` les délègue à l'autoritatif. Deux
|
||
calculs séparés finiraient par diverger, et la divergence ne se verrait qu'au premier PTR
|
||
interrogé. Le nom d'une zone dit sa profondeur — trois étiquettes numériques valent un
|
||
`/24`, deux valent un `/16` — et le modèle en déduit seul la forme du PTR.
|
||
|
||
### Deux chemins morts trouvés en route
|
||
|
||
Les zones étaient servies, et personne ne les demandait. `serveur_resolveur` déléguait la
|
||
zone directe par une `stub-zone` mais pas les inverses : `dig -x` rendait vide depuis les
|
||
cinq machines alors que la même requête posée directement à l'autoritatif répondait juste.
|
||
*Un service correct derrière un chemin que rien n'emprunte.*
|
||
|
||
Puis, les stubs posés, Unbound répondait toujours **NXDOMAIN avec le drapeau `aa`** — une
|
||
réponse *autoritaire*, sans jamais consulter le stub. Il embarque des `local-zone` pour
|
||
tout l'espace RFC1918 inversé. `nodefault` n'y change rien : ce mode n'agit que si le nom
|
||
correspond exactement à une zone par défaut, et la sienne est `10.in-addr.arpa`, le `/8`
|
||
entier. C'est `transparent` qui laisse la requête suivre son cours — pour nos quatre zones
|
||
seulement, là où `unblock-lan-zones` aurait ouvert tout l'espace privé.
|
||
|
||
Rien ne distinguait ce blocage d'une absence : le même NXDOMAIN qu'un nom qui n'existe pas.
|
||
|
||
### Vérification
|
||
|
||
Les cinq PTR résolvent depuis les cinq hôtes par le résolveur, les huit noms directs par
|
||
le plancher **et** par le DNS, et les deux rôles sont idempotents (`changed=0`).
|
||
|
||
**P47** évalue la dérivation sur cinq cas — un site à quatre zones, un tenant à une, deux
|
||
machines d'un même `/24` qui n'en font qu'une, aucune adresse, une adresse illisible.
|
||
|
||
## 2026-08-25 — Le plancher survit au redémarrage, et la zone dit les vraies adresses
|
||
|
||
**46 preuves.** Le découpage du site en quatre zones a déplacé cinq machines ; ni le
|
||
plancher `/etc/hosts` ni la zone DNS n'avaient suivi. Quatre défauts, tous dans le moteur.
|
||
|
||
### Le plancher était effacé à chaque démarrage — et le correctif d'hier n'en était pas un
|
||
|
||
`hosts_statiques` posait `/etc/cloud/cloud.cfg.d/99-setops-hosts.cfg` avec
|
||
`manage_etc_hosts: false`, pendant que `cloud_init` posait `99_setops.cfg` avec `true`.
|
||
Dans `cloud.cfg.d` l'ordre est **lexical** et le dernier gagne : `-` vaut 0x2D, `_` vaut
|
||
0x5F — le `true` l'emportait. On a donc d'abord retiré la clé de `cloud_init` (le rôle qui
|
||
**possède** le fichier décide), puis renommé notre fragment `zz-` pour passer après le
|
||
`99_chezlepro.cfg` du gabarit doré.
|
||
|
||
**Et ça ne suffisait toujours pas.** Redémarrage d'épreuve : le plancher, encore effacé.
|
||
La cause réelle est ailleurs — Proxmox inscrit `manage_etc_hosts: true` dans la
|
||
**user-data** de son lecteur cloud-init, et la user-data prime sur `cloud.cfg.d` tout
|
||
entier. Aucun fragment ne pouvait gagner ; renommer pour parler en dernier ne servait à
|
||
rien, le dernier mot n'appartenant pas à ce répertoire.
|
||
|
||
Ce que cloud-init régénère, il le régénère depuis `hosts.debian.tmpl` — le gabarit le
|
||
documente lui-même. `hosts_statiques` le pose donc désormais avec le **même contenu** que
|
||
`/etc/hosts`, et une garde compare les deux à chaque passage. Redémarrage d'épreuve : les
|
||
neuf entrées sont là.
|
||
|
||
*La garde précédente affirmait « conforme » en mesurant l'ordre lexical — vrai, et sans
|
||
rapport avec ce qui se passait. Une garde qui mesure la mauvaise chose est pire
|
||
qu'aucune.*
|
||
|
||
### La zone DNS ne publiait pas les noms de service
|
||
|
||
`forge.genese.internal` et `pki.genese.internal` — des noms que les certificats portent et
|
||
que les clients appellent — n'avaient **aucun enregistrement**. Seul le plancher savait
|
||
les résoudre. Trois causes empilées :
|
||
|
||
- le plan du site coupait `serveur_powerdns_publier_expositions`, au motif que « le site
|
||
n'a pas d'edge » — ce qui confondait *public* et *exposé* ;
|
||
- `expositions_des_applications` rendait `domaine: None` faute de `domaines.yml`, et le
|
||
modèle de zone écarte les expositions dont le domaine n'est pas la zone. Le repli existe
|
||
désormais : **sans domaine public déclaré, le domaine est celui que porte le FQDN** —
|
||
symétrique du repli déjà écrit pour `edge` ;
|
||
- `serveur_powerdns` exigeait les deux registres et échouait si `domaines.yml` manquait —
|
||
le même tout-ou-rien que `hosts_statiques` avait corrigé le même jour.
|
||
|
||
Puis `named-checkzone` a refusé la zone : `dns.genese.internal` héritait d'un `CNAME` par
|
||
défaut du rôle **et** d'un `A` par exposition. La garde a bien joué son rôle — elle a
|
||
arrêté une zone cassée avant qu'elle soit servie. Le plan l'emporte désormais sur le
|
||
défaut du rôle.
|
||
|
||
*Un service ne doit pas dépendre d'un plancher pour être joignable : le plancher est un
|
||
filet, pas le sol.*
|
||
|
||
### Vérification
|
||
|
||
Les huit noms — cinq machines et trois services — résolvent vers les bonnes adresses
|
||
depuis les cinq hôtes, **par le plancher et par le DNS**, et le plancher survit au
|
||
redémarrage.
|
||
|
||
**Reste nommé, pas corrigé** : la zone INVERSE. `serveur_powerdns_zone_inverse` dérive
|
||
d'un supernet `/16` — la forme d'un tenant. Un site déclare plusieurs `/24` et n'a pas de
|
||
supernet unique : aucune zone inverse n'est générée, et les machines du site restent
|
||
anonymes à l'envers.
|
||
|
||
## 2026-08-25 — Le pare-feu Proxmox ne s'arme que dans le SDN
|
||
|
||
**45 preuves.** Le découpage du site en quatre zones a révélé un défaut qui dormait dans
|
||
le moteur : `firewall=1` sur l'interface d'une VM, posé sur un **pont classique**, jette
|
||
le retour des flux en épingle.
|
||
|
||
Le mécanisme. `firewall=1` fait passer tout le trafic ponté par conntrack. Sur un VNet
|
||
SDN c'est sans conséquence : en EVPN le routage inter-VNet se fait dans le VRF, **sur le
|
||
nœud**, et le flux ne quitte jamais l'hyperviseur. Sur un pont classique routé par une
|
||
frontière externe, deux VM du **même nœud** dans deux VLAN différents ne se parlent qu'en
|
||
épingle : la trame sort par le lien physique, la frontière la route, elle revient sur le
|
||
même pont. La même table conntrack voit alors les deux moitiés de la connexion, classe le
|
||
retour `INVALID`, et `PVEFW-FORWARD` le jette.
|
||
|
||
La mesure, et c'est elle qui a tranché :
|
||
|
||
```
|
||
ops(asgard) -> pki(asgard) 0/8 dns(gandalf) -> cache(gandalf) 0/8
|
||
ops(asgard) -> forge(vishnu) 6/8 dns(gandalf) -> pki(asgard) 6/8
|
||
ops(asgard) -> cache(gandalf) 8/8 dns(gandalf) -> forge(vishnu) 8/8
|
||
```
|
||
|
||
**Toutes** les paires intra-nœud échouent, **toutes** les paires inter-nœuds passent.
|
||
Douze tentatives faisaient monter le compteur `ctstate INVALID` de **+112** sur asgard et
|
||
**+116** sur gandalf ; `firewall=0` posé, il ne bouge plus — 0 sur les deux.
|
||
|
||
Ce qui rend le défaut coûteux, c'est son déguisement : la poignée TCP **aboutit** (SYN et
|
||
SYN-ACK créent l'état), et ce sont les paquets de **données** qui disparaissent. L'AC le
|
||
disait dans son propre journal — `TLS handshake error … i/o timeout`, elle accepte la
|
||
connexion et attend un `ClientHello` qui n'arrive jamais. On accuse le MTU, la frontière,
|
||
la zone, le port. Écartés un par un, par la mesure : règles identiques champ par champ,
|
||
alias corrects, assignation des interfaces confirmée, ARP et routes saines, IPS désactivé,
|
||
shaper vide, NAT source limité à `wan`, MTU à 1500 de bout en bout et la taille sans effet
|
||
(100 octets se perdent comme 1460).
|
||
|
||
**La règle, désormais dans le moteur** : le pare-feu Proxmox ne s'arme que sur un VNet
|
||
SDN. L'intention déclarée ne suffit pas — un tenant déclare
|
||
`proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme valeur *par
|
||
défaut*, chaque hôte la remplaçant par son VNet dérivé ; l'hôte qui retombe sur `vmbr1`
|
||
naîtrait armé sur un pont classique. `cloner_vm_debian.yml` croise donc l'intention avec
|
||
le pont **réellement utilisé**, et le dit quand il désarme.
|
||
|
||
**P45** évalue l'expression du playbook sur quatre cas plutôt que d'en lire le texte, dont
|
||
un qui **doit rendre vrai** — sans lui, une expression constamment fausse passerait la
|
||
preuve sans rien garantir.
|
||
|
||
*Le site a révélé ce défaut parce qu'il a été le premier à porter plusieurs zones. Ce
|
||
n'est pas une particularité du site : un tenant dérive ses zones du même principe.*
|
||
|
||
## 2026-08-25 — Le SITE en quatre zones, et l'ordre inscrit dans les intégrations
|
||
|
||
**44 preuves.** Le site n'est plus un `/24` plat : une zone par **nature d'autorité**,
|
||
chacune son VLAN et sa patte sur la frontière.
|
||
|
||
```
|
||
opt3 vlan031 10.0.31.0/24 pilotage site-ops-01 voûte underlay, API Proxmox + OPNsense
|
||
opt4 vlan032 10.0.32.0/24 autorite site-pki-01 racine de confiance
|
||
opt5 vlan033 10.0.33.0/24 genome site-forge-01, site-cache-01
|
||
opt6 vlan034 10.0.34.0/24 service site-dns-01
|
||
```
|
||
|
||
L'inversion que ça corrige, mesurée : les cinq VM du site n'avaient **aucun** filtrage
|
||
est-ouest — `enable=None`, `policy_in=None`, zéro règle — contre `enable=1 policy_in=DROP`
|
||
sur une machine de tenant. **La machine la plus autoritaire du système était la moins
|
||
protégée.** Le filtrage est nord-sud, par choix de l'exploitant : un seul point de police,
|
||
un seul langage, un seul devis — plutôt que deux politiques à tenir d'accord.
|
||
|
||
Le devis passe de 90 à **117 règles**, et c'est tout le sens du découpage : *ce qui était
|
||
gratuit devient policé*. Chaque flux `flotte` produit une règle **par zone source**, avec
|
||
une destination nommée — le rôle visé, jamais la zone entière.
|
||
|
||
### L'ordre fait partie de l'intégration
|
||
|
||
`client_pki` s'exécutait sur tous les hôtes en parallèle. Sur celui qui porte l'autorité,
|
||
le rôle réémet le certificat de `step-ca` et **recharge le service** ; les quatre autres
|
||
l'interrogeaient dans cette fenêtre et échouaient ensemble sur `TLS handshake timeout` —
|
||
un message qui accuse le réseau alors que la cause est une course de dix secondes.
|
||
|
||
Ce n'est pas propre à la PKI. Chaque intégration déclare désormais sa dépendance dans
|
||
`meta/integration.yml`, les playbooks de groupe en sont le **miroir généré**, et **P44**
|
||
refuse l'écart. Quatre contrôles négatifs : play unique, second play qui n'exclut pas le
|
||
serveur, `any_errors_fatal` retiré, déclaration absente.
|
||
|
||
`serveur: ~` est une réponse, pas un oubli — `client_metrique` pose un exportateur que
|
||
Prometheus vient *lire*. Toutes les intégrations ne dépendent pas d'un service debout.
|
||
|
||
*Le cas dangereux n'est pas le certificat mais le **résolveur** : un hôte basculé sur un
|
||
résolveur pas encore prêt devient muet, et le runner qui devrait le réparer tombe avec lui.*
|
||
|
||
### Le plancher avant le premier `apt`
|
||
|
||
`hosts_statiques` venait après `common_packages`. Or apt vise le cache **par son nom** —
|
||
ce qui est voulu. Le jour où les adresses changent, la boucle se referme : apt échoue faute
|
||
de résoudre, donc le socle n'atteint jamais le plancher, donc le plancher reste périmé.
|
||
Cinq machines bloquées, `/etc/hosts` vide. Le plancher **ne s'installe pas, il rend
|
||
installable** — il passe donc avant.
|
||
|
||
### `dns_amorcage` se dérive
|
||
|
||
Écrit en dur, il a eu tort deux fois : la frontière d'abord, puis une adresse que le
|
||
découpage a déplacée. Il se lit maintenant sur l'hôte qui porte `serveur_resolveur`.
|
||
|
||
Et l'ACL du résolveur dérivait de `setops_supernet` — juste tant que le site était plat.
|
||
Découpé, il refusait trois zones sur quatre, *et la panne ressemble à un DNS mort alors
|
||
que c'est une autorisation*.
|
||
|
||
### Ce que je me suis trompé à croire
|
||
|
||
J'ai diagnostiqué un trou noir de MTU et déclaré 1450 sur les quatre zones. **C'était
|
||
faux.** Il n'y a pas de VXLAN sur ce chemin — il sert aux zones EVPN des tenants — et tout
|
||
est à 1500 de bout en bout, vérifié sur `igb1`, les `vlan03x`, `bond3`, `vmbr3` et les
|
||
cartes. Ma mesure (`1500 → échec`, `1300 → 200`) était réelle, mon interprétation ne
|
||
l'était pas : je venais de débrancher la carte à chaud en modifiant `net0`, et chaque
|
||
`ip link set mtu` **reconfigurait l'interface** au passage. C'est la reconfiguration qui
|
||
débloquait, pas la valeur. Les VM sont en `mtu=1`, héritant du pont, à 1500.
|
||
|
||
## 2026-08-25 — P43 : la frontière voit-elle les machines du site ?
|
||
|
||
**43 preuves.** Celle-ci couvre ce qui a failli coûter 36 objets ce soir : le devis de la
|
||
frontière avait cessé de voir le site, et proposait de retirer tous ses alias et toutes
|
||
ses règles. La frontière aurait laissé tomber la forge du génome, le cache racine et le
|
||
runner du site, d'un seul `CONFIRMER=true`.
|
||
|
||
Rien ne l'avait signalé — harnais vert, `ansible-lint` vert. Seule la lecture manuelle du
|
||
plan avant application l'a attrapé.
|
||
|
||
### La première version était inutile, et c'est instructif
|
||
|
||
Elle lisait le plan par `devis_opnsense._machines_du_plan_site()` — **la fonction même
|
||
dont la panne était à détecter**. Éprouvée sur la régression réelle, elle ne criait pas :
|
||
elle **se taisait**. Les deux voyaient le vide, et la preuve concluait « rien à prouver ».
|
||
|
||
> Une preuve qui partage la source de ce qu'elle vérifie ne vérifie rien.
|
||
|
||
C'est la même erreur d'instrument qui a coûté quatre faux diagnostics cette session : le
|
||
VPN pris pour la frontière, `ping` pour du TCP, l'`overview` d'OPNsense pour ses
|
||
assignations. Ici elle était logée dans la preuve elle-même.
|
||
|
||
La version retenue lit `plan/serveurs.yml` et `plan/applications.yml` **directement**, et
|
||
confronte au devis produit. Éprouvée sur la régression réelle : elle refuse, et nomme la
|
||
cause — *« `SETOPS_SITE` est absent ou vide, alors que le plan déclare 5 machines »*.
|
||
|
||
## 2026-08-25 — Le site résout chez lui, et le socle cesse de le défaire
|
||
|
||
**Mise au point de l'exploitant** : la frontière OPNsense n'a pas de service DNS actif et
|
||
géré. Un Unbound y *tourne* — il répondait, ce qui m'a induit en erreur — mais un
|
||
processus n'est pas un service. La délégation de zone que j'y avais posée est retirée :
|
||
on ne règle pas ce que rien ne déclare ni ne prouve.
|
||
|
||
**Et surtout, le site a son propre DNS.** `dns_amorcage` pointait encore sur la frontière
|
||
(`10.0.3.1`), valeur d'un moment où `site-dns-01` n'existait pas. Elle a survécu à sa
|
||
raison d'être. Il vaut désormais `10.0.3.51`.
|
||
|
||
*Reste un cas étroit, nommé plutôt que résolu : la machine qui PORTE le DNS ne peut pas
|
||
résoudre chez elle avant de l'avoir installé. Un site bâti depuis zéro doit créer et
|
||
déployer `site-dns-01` en premier.*
|
||
|
||
### Le devis de la frontière ne voyait plus le site
|
||
|
||
En déplaçant le plan hors de `underlay.yml`, j'ai vidé `machines()` sans rebrancher
|
||
`devis_opnsense`. Il proposait de **retirer 36 objets** — tous les alias et toutes les
|
||
règles du site. Aucune preuve ne couvre le devis de la frontière du site, donc
|
||
`make prouver` restait vert : **c'est le plan avant application qui l'a attrapé**, et rien
|
||
d'autre ne l'aurait fait.
|
||
|
||
### Le socle défaisait la bascule du résolveur
|
||
|
||
Sa garde ne protégeait que l'hôte du résolveur (`127.0.0.1`). Partout ailleurs il
|
||
réécrivait `/etc/resolv.conf` avec `dns_amorcage`, annulant `client_resolveur` à chaque
|
||
passage.
|
||
|
||
Invisible chez un tenant : `dns_amorcage` y vaut l'adresse du résolveur du tenant, donc
|
||
réécrire remettait la même valeur. **La coïncidence masquait le défaut.** Sur le site les
|
||
deux ont divergé quelques heures — et ça ne s'est vu qu'en retirant la règle de pare-feu
|
||
vers la frontière : les cinq machines ont perdu la résolution d'un coup.
|
||
|
||
L'amorçage ne s'applique plus que si rien de sensé n'est en place : ni la loopback, ni un
|
||
résolveur déclaré de l'écosystème. Vérifié — le socle repasse `changed=0` et les cinq
|
||
machines restent sur `10.0.3.51`.
|
||
|
||
## 2026-08-25 — Le génome vit sur la forge du SITE
|
||
|
||
Six dépôts, 606 commits, chaque tête confrontée entre le poste et la forge : **identique**.
|
||
Le génome ne dépend plus d'un écosystème pour se reproduire.
|
||
|
||
L'ancienne forge de patient 0 devient un second remote, nommé `patient0` — elle n'est pas
|
||
remplacée. Le README de patient 0 exige que le génome vive en **au moins trois endroits
|
||
sans coordination** : *« n'importe quel survivant réamorce les autres. Une famille, pas un
|
||
maître. »* La forge du site en fait un quatrième.
|
||
|
||
### On vérifie l'état, on ne fait pas confiance au drapeau
|
||
|
||
Toute l'API de la forge rendait **403**. Ni 401, ni un message de droits : jeton valide,
|
||
identité reconnue, requête rejetée. On cherche un scope, une politique, une capacité — et
|
||
la cause est un drapeau sur le compte, `must-change-password`.
|
||
|
||
Le rôle passait pourtant **déjà** `--must-change-password=false`, corrigé le 2026-08-23
|
||
sur la première forge de patient 0. **Forgejo 16 a ignoré l'option** : le compte est né
|
||
avec le drapeau posé malgré elle. La correction d'il y a deux jours ne protégeait plus
|
||
rien, et rien ne le disait.
|
||
|
||
D'où la forme du remède : le rôle ne demande plus à la création de bien se comporter, il
|
||
**constate l'état voulu et le rétablit** — une tâche idempotente, sans effet quand la
|
||
création a tenu parole. C'est la même leçon que le `changed=` d'Ansible qui ne prouve pas
|
||
un service : *vérifier l'état, pas l'intention*.
|
||
|
||
Le jeton d'amorçage est créé localement par la CLI : un mot de passe d'administrateur n'a
|
||
pas à circuler pour créer six dépôts.
|
||
|
||
## 2026-08-25 — Un SITE a bien un plan
|
||
|
||
J'ai passé la journée à répéter qu'un SITE n'est pas un plan. **C'était faux**, et le
|
||
dépôt le démontrait à mesure : j'ai reconstruit un plan morceau par morceau dans
|
||
`underlay.yml` — machines, services, variables, intégrations — sans jamais le nommer.
|
||
|
||
Ce qui est vrai, c'est qu'un site ne **dérive** pas. Un tenant tire tout de son `index` ;
|
||
un site déclare ses adresses, parce qu'il *est* le terrain. Il ne passe donc pas par
|
||
`instancier` et ne partage pas la superclasse du tenant. Mais « ne pas dériver » n'est
|
||
pas « ne pas avoir de plan » — et faute d'avoir fait la distinction, chaque manque est
|
||
arrivé par surprise au lieu d'être prévisible.
|
||
|
||
```
|
||
SITE-Chezlepro/
|
||
plan/10-intrants.yml domaine, organisation, rebond, clé, amorçage, exemptions
|
||
plan/serveurs.yml 5 machines — adressage DÉCLARÉ, pas dérivé
|
||
plan/applications.yml 7 services, leur placement, et leurs `expose:`
|
||
underlay.yml la fabric seule : ce qui est MESURÉ
|
||
```
|
||
|
||
`underlay.yml` décrit ce qui est **mesuré**, le plan ce qui est **voulu**. Les groupes
|
||
viennent du registre des applications, comme chez un tenant. Les gardes ont suivi le plan
|
||
— une garde loin de ce qu'elle garde finit par garder autre chose — et quatre contrôles
|
||
négatifs les éprouvent, dont une collision d'IP avec la frontière.
|
||
|
||
### Ce que le site a fait tomber, et qu'aucun tenant ne pouvait révéler
|
||
|
||
Le site est le premier écosystème qui n'a que le strict nécessaire : pas de plan (jusqu'à
|
||
aujourd'hui), pas de Keycloak, pas de PostgreSQL, pas d'edge, pas de rôles colocalisés.
|
||
|
||
| défaut | pourquoi il était invisible |
|
||
|---|---|
|
||
| `client_pki` codait `infra-pki-01` **en dur** | la nomenclature d'un tenant nomme toujours sa PKI ainsi — le littéral avait raison par coïncidence |
|
||
| aucun moyen pour un service non-root de lire la clé | nginx, Postfix, Dovecot lisent en root avant de déprivilégier ; Forgejo tourne en `git` d'emblée |
|
||
| le certificat — **public par nature** — restait en `0600` | même cause |
|
||
| l'unité Forgejo n'avait pas d'`ExecReload` | chez un tenant c'est nginx qui consomme le cert, et il sait se recharger |
|
||
| le rôle Forgejo ne savait pas servir TLS | il y avait toujours un edge devant |
|
||
| le certificat ne couvrait pas le nom de **service** | `expose:` n'existait pas, faute de plan |
|
||
| chargement des registres en **tout-ou-rien** | `domaines.yml` existe toujours chez un tenant |
|
||
| le flux déclarait `3000` en dur | vrai tant qu'il y avait toujours un edge |
|
||
|
||
*Le message d'erreur accusait presque toujours autre chose : le DNS quand c'était un nom
|
||
faux, une permission de fichier quand c'était un port privilégié, rien du tout quand le
|
||
plancher s'écrivait vide.*
|
||
|
||
### `ROOT_URL` est gravée dans les URL de clonage
|
||
|
||
La forge servait son TLS sur `3000` — le port qu'elle écoute *derrière* un edge. Coût
|
||
réel : chaque écosystème descendant aurait porté `:3000` dans son `origin`, pour toujours.
|
||
Elle sert désormais sur **443**, `ROOT_URL = https://forge.genese.internal/`, avec
|
||
`CAP_NET_BIND_SERVICE` dans son unité — Forgejo tournant en `git` ne peut pas se lier à un
|
||
port privilégié autrement, et l'échec parlait de « permission denied » sur le *port*.
|
||
|
||
### Sans edge déclaré, le service se sert lui-même
|
||
|
||
`expositions_des_applications` résolvait l'edge par le **domaine**. Sans `domaines.yml`,
|
||
elle rendait `edge: None`, le gabarit cherchait `groups[None]` et écrivait un plancher
|
||
**sans alias, sans erreur**. Le repli dit une vérité générale, et corrige aussi un silence
|
||
chez les tenants dont un domaine ne déclare pas d'edge.
|
||
|
||
Chaîne complète, vérifiée depuis le réseau : `expose:` → `/etc/hosts` → SAN du certificat
|
||
→ `Verify return code: 0 (ok)` → `https://forge.genese.internal/ → 200`.
|
||
|
||
## 2026-08-25 — Un SITE n'a pas d'index : le vestige est retiré
|
||
|
||
Le site portait `index: 17`. Ça n'a jamais voulu dire « voici mon adressage » — un site
|
||
ne dérive rien, ses machines vivent sur un réseau de fabric (`10.0.3.0/24`), hors de toute
|
||
dérivation. Ça voulait dire **une seule chose** : *quel supernet de tenant est le mien*,
|
||
pour autoriser un réseau du site à en occuper la bande basse (D-77).
|
||
|
||
C'était un reste de la coïncidence hébergeur↔tenant chez Chezlepro, qui est les deux à la
|
||
fois. Ailleurs, ça aurait induit en erreur : un hébergeur qui n'héberge pas son propre
|
||
écosystème n'a aucun supernet à lui, et devait quand même inventer un index.
|
||
|
||
**L'exception se déclare désormais sur le réseau qui en a besoin**, et elle **nomme** le
|
||
tenant dont elle occupe la bande basse :
|
||
|
||
```yaml
|
||
- nom: management
|
||
segment_physique: true
|
||
bande_basse_de: OPS-Chezlepro
|
||
sous_reseau: 10.17.0.0/24
|
||
```
|
||
|
||
Trois gains. C'est **plus vrai** — un seul réseau est concerné, pas le site entier. C'est
|
||
**plus lisible** — le lecteur voit pourquoi ce `/24` habite un `/16` qui n'est pas le sien.
|
||
Et c'est **vérifiable** : le nom se résout dans le registre `tenants:`, donc une faute de
|
||
frappe est refusée au lieu de passer silencieusement.
|
||
|
||
Quatre contrôles négatifs, tous refusés : un tenant inconnu, l'exception absente alors que
|
||
le chevauchement existe, un débordement sur la bande des zones, et — le cas subtil — le
|
||
**mauvais** tenant nommé.
|
||
|
||
## 2026-08-25 — Le SITE devient un écosystème complet : PKI, DNS, forge
|
||
|
||
Le site portait **trois services sur les sept** que le modèle `origine` appelle le plus
|
||
petit écosystème complet. Ce n'était pas un choix : j'ai bâti le strict minimum pour y
|
||
déplacer la forge, signalé une fois que « le site n'a ni PKI ni DNS », puis continué. La
|
||
décision était celle de l'exploitant, et je l'avais tranchée par omission.
|
||
|
||
Ce que ça coûtait : **la forge servait le génome en clair** — le code qui fabrique tous
|
||
les écosystèmes — et aucune machine du site n'avait de certificat, donc aucun chiffrement
|
||
est-ouest. La doctrine zéro-confiance l'interdit frontalement.
|
||
|
||
`site-pki-01` (step-ca) et `site-dns-01` (PowerDNS + résolveur, colocalisés) rejoignent
|
||
les trois autres. Cinq machines, réparties sur les trois nœuds.
|
||
|
||
### Quatre défauts que le site a fait tomber
|
||
|
||
Le site est le **premier endroit qui n'a que le strict nécessaire** — pas de plan, pas de
|
||
Keycloak, pas de PostgreSQL, pas de rôles colocalisés. Chaque manque a révélé une
|
||
dépendance implicite qu'aucun tenant ne pouvait exposer :
|
||
|
||
| défaut | où il se cachait |
|
||
|---|---|
|
||
| DNS bloqué par notre propre default-deny | un tenant a son résolveur *dans* son réseau et ne traverse jamais la frontière |
|
||
| `serveur_cache_site` sans dépendance déclarée | colocalisé avec `serveur_artefacts` chez patient 0 |
|
||
| OIDC résolu même désactivé | il y a toujours un Keycloak chez un tenant |
|
||
| plancher `/etc/hosts` vide | `hotes_actifs` n'existait pas dans l'inventaire du site |
|
||
|
||
**Le DNS du site est dérivé, pas écrit à la main** : le devis lit `site.dns_amorcage` et
|
||
émet `SETOPS_SITE → SETOPS_RESOLVEUR_SITE` en 53/udp et 53/tcp. Destination **déclarée**,
|
||
jamais `any` — ouvrir le 53 vers le monde aurait été une sortie DNS non policée.
|
||
|
||
*La panne ne ressemblait pas à un pare-feu : `resolv.conf` correct, Unbound qui écoute sur
|
||
toutes les interfaces, le port qui « répondait » à un test TCP naïf. On a cherché du côté
|
||
du résolveur pendant que c'était le filtre.*
|
||
|
||
**`serveur_cache_site` déclare enfin sa dépendance.** Il n'installe rien — il marque un
|
||
cache comme racine de chaîne et lit pour ça les variables de `serveur_artefacts`. Or les
|
||
défauts d'un rôle ne sont en portée que *dans le play qui l'inclut*, et chaque groupe est
|
||
appliqué par son propre playbook. Déclarer les deux rôles sur la machine ne suffisait pas ;
|
||
une dépendance de rôle règle l'ordre **et** la portée.
|
||
|
||
**On ne résout pas un fournisseur d'identité qu'on n'utilisera pas.** `resoudre_idp`
|
||
partait inconditionnellement et exigeait un plan. `serveur_forgejo_oidc_actif` dérivait
|
||
déjà de la présence de Keycloak : la résolution suit désormais l'usage.
|
||
|
||
### Une machine du site peut se configurer
|
||
|
||
Un tenant configure ses services par son plan. Le site n'en a pas — c'est tout le sens de
|
||
« un SITE n'est pas un plan » — mais ses machines ont quand même des choix à faire. D'où
|
||
une section `variables:` par machine, appliquée **en dernier** : ce qu'une machine déclare
|
||
d'elle-même prime sur ce que le site déclare pour toutes. La forge y déclare
|
||
`serveur_forgejo_bd: sqlite`, le DNS `serveur_powerdns_publier_expositions: false`.
|
||
|
||
### Vérifié, pas supposé
|
||
|
||
`systemd` disait `active` et la racine existait — mais **rien n'écoutait sur 443** :
|
||
step-ca sert sur **8443**. Le `changed=` d'Ansible n'est pas une preuve de service. La
|
||
zone souveraine résout (`site-forge-01.genese.internal → 10.0.3.31`) et la récursion
|
||
marche, mesurées depuis la machine.
|
||
|
||
## 2026-08-25 — C'est le SITE qui détermine l'index d'un tenant
|
||
|
||
**SITE et OPS sont deux classes distinctes.** Le site décide de la *fabric* et du *réseau*
|
||
et **produit les intrants** ; le tenant les consomme et n'a d'intelligence que sur ses
|
||
*applications*, leur configuration et leurs intégrations. Une valeur réseau qu'un OPS
|
||
**décide** est une valeur mal placée.
|
||
|
||
Or l'index est l'intrant réseau par excellence : de lui descendent le supernet
|
||
`10.<index>.0.0/16`, les sous-réseaux de zone, les VLAN, les VMID, les noms de VNet SDN.
|
||
Un tenant qui le décide décide du plan d'adressage de la fabric.
|
||
|
||
### Ce qui n'allait pas
|
||
|
||
Il était déclaré **deux fois** — dans `plan/nomenclature.yml` du tenant *et* dans
|
||
l'underlay du site. Deux sources de vérité pour le même nombre, dans deux classes
|
||
différentes. Et le site ne déclarait même pas **qui il héberge** : la clé `tenants:`
|
||
existait dans le code, avec une docstring qui décrivait précisément le danger, mais
|
||
n'était écrite nulle part — la découverte se faisait par balayage des dossiers frères,
|
||
c'est-à-dire par accident du système de fichiers.
|
||
|
||
### Le sens de la flèche change, pas les valeurs
|
||
|
||
`tenants:` devient un **registre d'allocation** : `OPS-Chezlepro: 17`,
|
||
`OPS-Technolibre: 23`, `OPS-Patient0: 29`. Le site alloue, le tenant reçoit, et la
|
||
nomenclature du tenant n'est plus que la copie vérifiable d'une décision prise ailleurs.
|
||
|
||
Quatre gardes neuves, chacune éprouvée par un contrôle négatif : une allocation qui
|
||
contredit le plan du tenant, deux tenants sur le même index, une allocation qui n'est pas
|
||
un entier, un tenant nommé qui n'existe pas. Elles vivent dans `underlay.valider()`, donc
|
||
sous **P23** — aucune preuve à ajouter.
|
||
|
||
La forme héritée (simple liste de noms) reste acceptée : elle dit « ces tenants sont ici »
|
||
sans rien allouer, pour un site qui n'a pas encore migré.
|
||
|
||
### Et l'émancipation
|
||
|
||
Un tenant qui part chez un autre hébergeur **reçoit un index neuf de son nouveau site**.
|
||
L'index n'est pas une propriété du tenant : c'est une **place sur une fabric**.
|
||
|
||
## 2026-08-25 — La vue Réseau du panneau était vide : un `KeyError` deux couches plus bas
|
||
|
||
`segment_physique: true` — introduit le matin même pour dire qu'un réseau n'a pas
|
||
d'étiquette VLAN — retire la clé `vlan`. Or `devis_reseau.py` écrivait `r['vlan']` en une
|
||
vingtaine d'endroits.
|
||
|
||
Le premier segment non étiqueté a levé un `KeyError`, `/api/devis-reseau` a rendu une 500,
|
||
et **la vue Réseau de l'interface est restée vide**. Une panne qui ne ressemble à rien, à
|
||
deux couches de sa cause — c'est le genre qu'on cherche partout sauf là où elle est.
|
||
|
||
**Filtré à la source, une seule fois.** Colmater les vingt appels aurait laissé le
|
||
vingt-et-unième. `devis_reseau` définit désormais son propre `reseaux_de_fabric` qui écarte
|
||
les réseaux sans étiquette, et les dix appels y passent : ce fichier ne configure que des
|
||
commutateurs, et un commutateur n'a rien à dire d'un segment qui ne l'atteint pas.
|
||
|
||
Le bloc de gestion d'un switch échappait au filtre — il lit son réseau directement. Quand
|
||
ce réseau n'est pas étiqueté, il n'existe aucune `interface VlanN` : l'adresse appartient à
|
||
l'interface de gestion native du boîtier, dont le nom dépend du modèle. Le devis **le dit**
|
||
plutôt que d'inventer une syntaxe — un devis qui promet une commande fausse est pire qu'un
|
||
devis qui se tait.
|
||
|
||
*Au passage, ça expose une incohérence de la carte : `bifrost-3` et `bifrost-4` déclarent
|
||
leur adresse de gestion sur `management`, un segment qui ne les traverse pas. Ces deux
|
||
adresses sont d'ailleurs celles de l'ancien plan et n'ont jamais répondu.*
|
||
|
||
Les quatre devis du panneau — switches, frontière, SDN, pare-feu est-ouest — repassent.
|
||
|
||
## 2026-08-25 — Un renommage de rôle laissait son ancien fichier aux commandes
|
||
|
||
Mesuré sur `forge-01`, par l'agent invité — donc sans dépendre du réseau. Quatre
|
||
drop-ins SSH coexistent là où il devrait y en avoir deux :
|
||
|
||
```
|
||
10-chezlepro.conf 10-setops.conf
|
||
20-chezlepro-hardening.conf 20-setops-hardening.conf
|
||
```
|
||
|
||
Les rôles se sont appelés `chezlepro` avant de porter le nom du moteur. Le renommage a
|
||
changé le fichier **déposé** sans retirer le précédent. Et les paires ne disent pas la même
|
||
chose : `MaxSessions 2` contre `10`, `MaxStartups 5:30:20` contre `10:30:60`.
|
||
|
||
**C'est l'ancien qui gagne.** `sshd` retient la *première* valeur rencontrée pour chaque
|
||
mot-clé, et lit les drop-ins dans l'ordre lexical : `20-chezlepro-hardening.conf` passe
|
||
avant `20-setops-hardening.conf`. Une configuration qu'on croit avoir remplacée reste donc
|
||
aux commandes, en silence — et sa trace se lirait un jour comme une lenteur inexplicable
|
||
pendant un déploiement parallèle, cherchée partout sauf là.
|
||
|
||
`ssh_baseline` et `ssh_hardening` retirent désormais chacun le fichier de la nomenclature
|
||
qu'il a abandonnée, **avant** de déposer le sien, et notifient la même validation. La liste
|
||
est déclarative (`ssh_baseline_fichiers_perimes`, `ssh_hardening_fichiers_perimes`) et ne
|
||
contient que des noms qu'on a réellement déposés un jour : supprimer un fichier qu'on n'a
|
||
jamais écrit serait effacer la configuration de quelqu'un d'autre.
|
||
|
||
**Pas encore éprouvé.** Les seules machines portant les anciens fichiers sont celles de
|
||
patient 0, actuellement hors d'atteinte depuis le poste — leur écosystème est sain, c'est
|
||
le chemin d'entrée qui est coupé. Le correctif est écrit et validé, il reste à le voir
|
||
retirer un fichier pour de vrai.
|
||
|
||
## 2026-08-25 — Le clonage inter-nœuds : quatre défauts que la première VM ailleurs a révélés
|
||
|
||
Toutes les VM naissaient jusqu'ici sur le nœud du gabarit, puis migraient. `site-cache-01`
|
||
est la première à être placée ailleurs — et elle a fait tomber quatre défauts enchaînés du
|
||
moteur de clonage. Aucun n'était visible avant, et aucun ne disait son nom.
|
||
|
||
**1. Le clonage visait le nœud de destination.** L'URL était
|
||
`nodes/{{ proxmox_clone_noeud }}/qemu/<gabarit>/clone`, alors que l'API veut le nœud qui
|
||
**détient** le modèle ; la destination se dit par `target`. Le gabarit vit sur asgard,
|
||
la VM allait sur gandalf → `500 Configuration file 'nodes/gandalf/qemu-server/99998.conf'
|
||
does not exist`. Le nœud du gabarit se découvre désormais dans l'inventaire du cluster.
|
||
|
||
**2. Le refus de l'API était avalé.** `failed_when: false` + `no_log: true` protégeaient
|
||
l'en-tête d'authentification, mais masquaient aussi le 500. Une assertion le relève
|
||
maintenant, message de l'API compris — le masque reste, l'erreur sort.
|
||
|
||
**3. L'attente cherchait la tâche au mauvais endroit.** Elle interrogeait
|
||
`nodes/<destination>/tasks/<UPID>` ; un clonage inter-nœuds s'exécute sur le nœud du
|
||
gabarit. L'UPID n'existait pas là, l'API répondait en erreur, `until` n'était jamais
|
||
satisfait : **60 tentatives × 10 s pour un clonage terminé en 87 s**, puis la suite qui
|
||
reprend sans un mot. Un UPID s'écrit `UPID:<nœud>:<pid>:…` — le nœud y est déjà, on le lit
|
||
là plutôt que de le redemander à une variable.
|
||
|
||
**4. La garde d'après-attente ne pouvait pas échouer.** `exitstatus | default('OK')`
|
||
faisait passer pour un succès une attente qui n'avait **rien observé** : sans `json.data`,
|
||
pas d'`exitstatus`, donc « OK ». Elle exige désormais d'avoir vu la tâche s'arrêter, et
|
||
bien s'arrêter.
|
||
|
||
### La taille des disques porte toujours son unité
|
||
|
||
`disque: 40` produisait un `resize` à « 40 » que Proxmox lisait comme un **rétrécissement**
|
||
(`shrinking disks is not supported`). Le playbook tolère cette erreur — à raison, un disque
|
||
déjà assez grand n'est pas une panne — donc la machine naissait avec les 16 Go du gabarit
|
||
au lieu de 40, **sans que rien ne le dise**. Les plans des tenants écrivent `40G` depuis
|
||
toujours ; le site écrit pareil, et un entier nu se voit rattacher son unité.
|
||
|
||
### Le site déclare ce qu'il matérialise
|
||
|
||
Le playbook prenait le gabarit et le stockage dans les group_vars du **tenant actif** :
|
||
`make site-creer` aurait cloné depuis un modèle différent selon le symlink `instance`,
|
||
sans rien dire. `materialisation:` (vmid du gabarit, stockage, `clone_complet`) vit
|
||
désormais dans l'underlay du site, et `site-creer` les passe explicitement.
|
||
|
||
### Ce que le chronomètre a dit
|
||
|
||
Le clonage réel : **59 s et 87 s** pour 3,3 Gio effectivement alloués (le gabarit en
|
||
déclare 16). Ce n'était donc pas le disque du modèle qui coûtait — c'était les dix minutes
|
||
d'attente aveugle du défaut n° 3. Le clone lié reste le vrai levier (`rbd clone` depuis
|
||
`@__base__`, instantané), mais il exige `CephNVMe` en destination : `TrueNAS` est du LVM
|
||
épais, sans copy-on-write.
|
||
|
||
## 2026-08-25 — La frontière voit le site : `fabric`, alias et règles
|
||
|
||
### Un mot manquait au vocabulaire des flux
|
||
|
||
Le runner de SITE déclarait son API Proxmox en `pair: externe`. Or « externe » se rend
|
||
par `!SETOPS_INTERNES` — *tout sauf les espaces privés* — et les hyperviseurs **sont** en
|
||
RFC 1918. La règle sortante les aurait exclus **tout en ayant l'air d'ouvrir le flux**.
|
||
Un flux qui a l'air ouvert et qui ne l'est pas est pire qu'un flux fermé : il ne se
|
||
cherche pas.
|
||
|
||
D'où `fabric` : le matériel de l'hébergeur — hyperviseurs, frontière, commutateurs. Ce
|
||
n'est ni `flotte` (les machines d'un écosystème) ni `voisins_site` (les tenants d'à côté),
|
||
c'est le **socle** sur lequel les uns et les autres reposent. Le seul à s'y adresser est
|
||
le runner du site, et c'est tout son objet.
|
||
|
||
### Deux flux qui n'existaient pas
|
||
|
||
**Le cache racine ne pouvait rien aller chercher.** `serveur_cache_site` ne déclarait
|
||
qu'un `ingress 3142` : la chaîne entière se terminait sur un cache vide. Ça ne se serait
|
||
vu qu'au premier `apt update` d'un écosystème neuf — c'est-à-dire au pire moment.
|
||
|
||
**Le runner de site n'avait que l'API.** Déplacer un disque, poser un pont, lire une
|
||
configuration réseau passe par le shell du nœud ; ouvrir les flux du tenant qu'on
|
||
matérialise passe par l'API de la frontière. Trois pouvoirs distincts, déclarés à part.
|
||
|
||
### Ce que le devis ignorait
|
||
|
||
Les machines du site ne sont dans **aucun** plan de tenant : la boucle du devis ne
|
||
pouvait pas les voir, et il rendait zéro règle pour elles **sans rien signaler**. Un
|
||
devis muet sur une machine qui existe n'est pas un devis.
|
||
|
||
Le devis émet désormais `SETOPS_SITE` (le réseau), `SETOPS_FABRIC` (ce que le runner
|
||
pilote), `SETOPS_ADMIN_SITE` (seule source du SSH) et un alias par rôle du site. Leur
|
||
trafic arrive par `opt2`, **pas** par le lien de transit — une règle posée sur la mauvaise
|
||
interface ne correspond jamais, et c'est la panne la plus silencieuse de cette couche. Le
|
||
SSH de gestion, lui, arrive par `lan`. Plus le NAT sortant du site : son réseau est
|
||
directement attaché, mais le mode automatique d'OPNsense a été quitté en déclarant les
|
||
tenants à la main — compter sur un mode qu'on a soi-même quitté se paierait au premier
|
||
téléchargement.
|
||
|
||
**Le socle s'applique aussi au site.** `site_inventaire.py` range ses machines dans
|
||
`serveur_debian` : même durcissement SSH, mêmes horloges, mêmes règles que n'importe
|
||
quelle machine de la flotte. L'oublier aurait donné à la machine la plus puissante du
|
||
site la protection la plus faible.
|
||
|
||
### Patient 0 rend ses responsabilités de site
|
||
|
||
> **Corrigé le 2026-08-25.** Cette section affirmait que « patient 0 est un tenant comme
|
||
> les autres : c'est même tout ce qu'il prouve ». **C'est faux**, et le texte est corrigé
|
||
> plutôt qu'effacé. Un tenant ordinaire existe pour ses gens ; patient 0 n'héberge que la
|
||
> **lignée**. Il est l'écosystème d'origine et le détenteur du génome, et il le reste.
|
||
> Le geste était juste, la raison ne l'était pas — et une doctrine juste appuyée sur une
|
||
> raison fausse finit toujours par se retourner.
|
||
|
||
Son plan déclarait encore `serveur_ops_site` et `serveur_cache_site`. Il les tenait non
|
||
par nature mais **faute d'un SITE capable de les porter** : à l'époque, un site n'était
|
||
qu'un fichier de carte, sans machines à lui.
|
||
|
||
Le SITE existe désormais comme objet à part entière — machines déclarées dans son
|
||
underlay, sur leur propre réseau de fabric, avec leur voûte et leur runner. Matérialiser
|
||
et servir le cache aux voisins lui reviennent. Porter la lignée reste chez patient 0.
|
||
|
||
Bilan au devis : **26 règles à créer, 5 à retirer** (dont les trois périmées de patient 0).
|
||
|
||
## 2026-08-24 — Un SITE n'est pas un plan : ses machines vivent dans l'underlay
|
||
|
||
J'avais d'abord fait entrer le site dans le générateur des tenants, avec une branche
|
||
« si l'adresse est déclarée, elle gagne ». **Cette branche est retirée.**
|
||
|
||
Le symptôme était visible tout de suite. Un tenant se **dérive** : de son seul `index`
|
||
descendent son supernet, ses VLAN, ses VMID, ses VNet SDN. Un site ne dérive de rien —
|
||
il n'a pas d'index, il **est** le terrain sur lequel les tenants dérivent. Les faire
|
||
passer par la même moulinette donnait un `nomenclature.yml` de site réduit à une coquille
|
||
vide, et un site exclu des devis par **absence** d'index plutôt que par nature. Une
|
||
exclusion fondée sur un manque casse au premier ajout innocent.
|
||
|
||
Les machines de l'hébergeur se déclarent donc dans `underlay.yml`, à côté des switches et
|
||
des hyperviseurs qui les portent : même carte, même fichier, mêmes secrets. Ce qui se
|
||
partage entre un site et un tenant, ce sont les **rôles**, pas la forme du plan.
|
||
|
||
Six gardes neuves, chacune éprouvée par un contrôle négatif : réseau inconnu, réseau sans
|
||
pont, nœud qui n'est pas un hyperviseur déclaré, IP hors sous-réseau, IP déjà prise,
|
||
vmid absent ou en double, machine sans service.
|
||
|
||
### Ce que la mesure a corrigé
|
||
|
||
**Le réseau d'administration `10.17.0.0/24` n'a pas d'étiquette VLAN**, et n'en a jamais
|
||
eu. `vlan: 10` était une supposition héritée, et elle était fausse : la frontière le porte
|
||
sur `igb0`, un port physique à elle. Il existe bien un VLAN 10 sur `vmbr1`, mais c'est
|
||
l'**ancien** plan `10.0.0.0/24`, aujourd'hui vide — deux choses différentes que le même
|
||
chiffre confondait. D'où `segment_physique: true`.
|
||
|
||
**Aucun pont d'hyperviseur ne touche ce segment** : trois sondes non persistantes depuis
|
||
asgard (bond0 étiqueté 10, bond3 étiqueté 10, vmbr1 non étiqueté) sont restées muettes,
|
||
témoin positif réussi dans le même script. Une VM y naîtrait sourde. Le validateur refuse
|
||
désormais toute machine déclarée sur un réseau sans `pont:`.
|
||
|
||
*Le premier jeu de sondes avait pour cible la frontière, et concluait faux : elle ne
|
||
répond pas à une source qu'elle ne connaît pas — son default-deny travaillait. C'est le
|
||
témoin qui l'a révélé, pas la sonde.*
|
||
|
||
**Les machines du site vivent sur `site-services`** — VLAN 30, `10.0.3.0/24`, pont
|
||
`vmbr3` (bond3, créé sur les trois nœuds), passerelle `10.0.3.1` portée par la frontière
|
||
sur son interface OPT2. `site-ops-01` en `.11`, `site-cache-01` en `.21`.
|
||
|
||
*Le repli intermédiaire était `grappe-controle` (`192.168.11.0/24`) : porté par un vrai
|
||
pont, mais de l'hérité — des adresses sans avenir, derrière une passerelle qui n'est même
|
||
pas la frontière. Ancrer le site là aurait figé les deux défauts dans la carte. Le VLAN 30
|
||
lève les deux : adressage de fabric cohérent avec le transit (vlan 40 → `10.0.4.0/24`), et
|
||
sortie **par la frontière**, donc policée.*
|
||
|
||
### Le chemin qui matérialise un site
|
||
|
||
`site_machines.py` traduit une déclaration d'underlay en les mêmes `SETOPS_*` que
|
||
`make cloner-vm` consomme déjà : le clone reste le seul chemin éprouvé, il n'y en a pas
|
||
deux. `site_inventaire.py` est un inventaire **dynamique** — un site ne dérivant de rien,
|
||
sa déclaration *est* déjà sa forme finale, et un `hosts.yml` généré ne rendrait rien de
|
||
plus inspectable. Les groupes sont les services : `playbooks/groupes/<rôle>.yml` trouve
|
||
ses hôtes sans qu'on câble quoi que ce soit.
|
||
|
||
Quatre cibles, indépendantes de toute instance montée — le site existe avant tout tenant
|
||
et doit pouvoir naître quand aucun n'est encore là : `site-decrire`, `site-inventaire`,
|
||
`site-creer` (`CONFIRMER=true`), `site-appliquer GROUPE=…`.
|
||
|
||
*Le premier garde-fou de `site-appliquer` refusait un `GROUPE` vide — il ne pouvait
|
||
jamais se déclencher, `GROUPE` ayant un défaut global. Le vrai risque, observé en le
|
||
testant : un playbook de tenant lancé contre l'inventaire du site n'y trouve aucun hôte,
|
||
n'a rien à faire, et sort avec 0 — un succès qui n'a rien fait. La cible refuse désormais
|
||
tout groupe absent de cet inventaire-ci.*
|
||
|
||
**`passerelle_amont`**, rôle d'hôte neuf : un routeur qui existe mais que nous
|
||
n'administrons pas. Le déclarer n'est pas l'adopter — c'est distinguer une passerelle
|
||
étrangère d'une passerelle fantôme.
|
||
|
||
## 2026-08-24 — Quinze invites pour un seul mot de passe
|
||
|
||
`flotte-creer` appelait `make creer-vm` **une fois par hôte**, et chaque appel ajoutait
|
||
`--ask-vault-pass`. Quinze machines, donc quinze invites — **quatre en parallèle**, avec la
|
||
sortie redirigée vers des journaux : l'exploitant est harcelé par des invites qu'il ne voit
|
||
même pas.
|
||
|
||
Mesuré en reconstruisant Chezlepro. Ce n'est pas une fatalité de l'outil, c'est une faute
|
||
de l'outil.
|
||
|
||
### L'idiome existait déjà — je ne l'avais pas cherché
|
||
|
||
`deployer-tout` demande **une seule fois** depuis longtemps, et refuse proprement quand
|
||
personne n'est au clavier. Ma première correction inventait une **seconde** façon de
|
||
manipuler un secret, à côté de la première.
|
||
|
||
C'est exactement la faute que P41 combat : deux résolutions d'une même question finissent
|
||
par diverger, et pour un secret la divergence ne se remarque qu'après.
|
||
|
||
Les deux cibles partagent désormais **un seul bloc**, `VAULT_UNE_FOIS` : lecture unique,
|
||
fichier `mktemp` en `0600`, `trap` au retrait, et refus explicite en entrée non
|
||
interactive.
|
||
|
||
### Une garde que ma première version n'avait pas
|
||
|
||
`-t 0` : ne demander que si un humain est au clavier. Sans elle, un appel non interactif —
|
||
CI, ordonnanceur, agent — resterait **bloqué sur une invite que personne ne lit**. Je m'en
|
||
suis aperçu en vérifiant ma propre correction : la commande a pendu deux minutes.
|
||
|
||
Vérifié : `flotte-creer` sans mot de passe et sans terminal refuse maintenant en une
|
||
ligne, au lieu d'attendre indéfiniment.
|
||
|
||
## 2026-08-24 — Le dénominateur extrait : `origine` devient un modèle
|
||
|
||
La hiérarchie se lit maintenant sans ambiguïté :
|
||
|
||
```
|
||
Set-OPS le moteur — méthodes, rôles, preuves
|
||
SITE-xxx le terrain — UN par hébergeur
|
||
Set-OPS-Modeles les plans-types, dont `origine` est le dénominateur commun
|
||
OPS-xxx les écosystèmes réels : un modèle, posé sur un SITE
|
||
```
|
||
|
||
Chezlepro et Technolibre sont **frères** — deux OPS au même niveau.
|
||
|
||
### Patient 0 conflait trois rôles
|
||
|
||
La mesure l'a montré : il portait le dénominateur commun **et** un `index: 29`, une voûte
|
||
réelle, une parenté, cinq VM en marche — quatre choses qu'un modèle n'a pas.
|
||
|
||
```
|
||
socle public pki · edge · mail · dns
|
||
patient 0 pki · edge · dns · forge · ops
|
||
+ artefacts + cache_site + ops_site + resolveur
|
||
```
|
||
|
||
Trois rôles, dont **un seul est générique** :
|
||
|
||
| rôle | où il vit désormais |
|
||
|---|---|
|
||
| le dénominateur commun | le modèle **`origine`** |
|
||
| l'écosystème de l'hébergeur de `SITE-Chezlepro` | reste dans `OPS-Patient0` |
|
||
| le détenteur du génome | reste dans `OPS-Patient0` |
|
||
|
||
### Ce que `origine` ne porte pas
|
||
|
||
**`serveur_ops_site` et `serveur_cache_site`** — les rôles de l'hébergeur. Un modèle qui
|
||
les porterait donnerait à chaque client un pouvoir sur ses voisins.
|
||
|
||
**Ni index réel, ni voûte, ni parenté.** Un modèle est une recette : `index: 1` est un
|
||
gabarit que l'instanciation remplace par le seed dont tout l'adressage dérive.
|
||
|
||
C'est la même séparation que pour les runners et pour les voûtes — distinguer **la recette**
|
||
de **la machine qui l'applique**.
|
||
|
||
### Et le même défaut, trouvé une fois de plus
|
||
|
||
Les six modèles privés portaient encore `setops_plan_dir: instance/plan` — le lien du
|
||
moteur, en dur. Corrigé chez les instances et le modèle public il y a deux jours, il avait
|
||
survécu ici. Un déploiement lancé par `SETOPS_INSTANCE` y aurait lu le plan d'une **autre**
|
||
instance, comme l'edge de patient 0 avait publié les noms de Chezlepro.
|
||
|
||
Les sept modèles génèrent leur inventaire.
|
||
|
||
## 2026-08-24 — Les deux pare-feu appliqués, et un `derive` qui n'était compris que d'un côté
|
||
|
||
```
|
||
frontiere a creer : 0 | a retirer : 0 | inchange : 65 + 15 routes
|
||
est-ouest a creer : 0 | a mettre a jour : 0 | a retirer : 0
|
||
```
|
||
|
||
Flotte vérifiée après coup, sur les cinq hôtes : DNS interne, Internet, `apt` sans erreur,
|
||
et le cache d'artefacts joignable (`HTTP 200`).
|
||
|
||
### Un port `derive` que seul un générateur sur deux savait lire
|
||
|
||
J'avais déclaré le port de PowerDNS `derive` — il vaut 53 seul sur son hôte, 5300 derrière
|
||
le résolveur. `verifier_ports` traite depuis toujours un port non numérique comme « pas une
|
||
écoute fixe ». Le générateur est-ouest, lui, l'envoyait **tel quel** à l'API Proxmox :
|
||
|
||
```
|
||
dport: derive -> 400 « invalid format - invalid port 'derive' » six règles refusées
|
||
```
|
||
|
||
Une même notion, comprise d'un côté et pas de l'autre. Elle l'est désormais des deux.
|
||
|
||
**Et sauter est la bonne réponse, pas un contournement** : depuis que le résolveur du
|
||
tenant est la seule porte, l'autoritatif n'écoute que sur `127.0.0.1:5300`. Aucune règle
|
||
est-ouest n'a d'objet pour lui — les trois groupes `t*-srv-powerdns` sont devenus périmés
|
||
et ont été retirés.
|
||
|
||
**Mais un flux qu'on n'applique pas doit se voir.** Le devis recense et affiche les ports
|
||
sautés, avec la raison. Sans cette note, sauter proprement serait devenu un trou
|
||
silencieux — la faute que ce dépôt traque sous tous ses déguisements.
|
||
|
||
### Le même piège qu'à la frontière, une heure plus tôt
|
||
|
||
Là aussi le premier essai a signalé des échecs, et là aussi une partie du travail était
|
||
déjà écrite : le devis suivant est passé de « 6 à créer, 10 à mettre à jour » à « 0 à
|
||
créer, 1 à mettre à jour ». Un refus partiel n'est pas un refus.
|
||
|
||
## 2026-08-24 — La frontière appliquée, et une contrainte qui n'était écrite nulle part
|
||
|
||
`a creer : 0 | a retirer : 0 | inchange : 65 + 15 routes` — le devis est clos. La flotte de
|
||
patient 0 vérifiée après coup : DNS interne, Internet et `apt` sans erreur sur les cinq.
|
||
|
||
### OPNsense refuse un nom d'alias de 32 caractères ou plus
|
||
|
||
Le schéma `SETOPS_<tenant>_<ROLE>` ne laisse que 17 caractères au rôle :
|
||
|
||
```
|
||
SETOPS_PATI29_SERVEUR_ARTEFACTS_SITE 36 refusé
|
||
SETOPS_PATI29_SRV_ARTEFACTS_SITE 32 refusé encore, d'un caractère
|
||
SETOPS_PATI29_SRV_CACHE_SITE 28 passe
|
||
```
|
||
|
||
La contrainte n'était **écrite nulle part**, et elle s'est manifestée à l'**application**,
|
||
pas au devis. Un devis qui promet un objet que la cible rejettera n'est pas un devis :
|
||
`nom_alias` abrège désormais (`SERVEUR_` → `SRV_`, comme le pare-feu est-ouest) et **refuse
|
||
bruyamment** si le nom déborde encore. Le rôle est renommé `serveur_cache_site`.
|
||
|
||
### « RIEN N'EST APPLIQUÉ » voulait dire autre chose que ce que j'ai lu
|
||
|
||
Le premier essai a signalé cinq échecs et affiché *« RIEN N'EST APPLIQUÉ, la config reste
|
||
en attente »*. J'ai d'abord compris que le boîtier n'avait pas été touché. Le devis
|
||
suivant disait autre chose : 62 objets inchangés au lieu de 49.
|
||
|
||
Les objets **étaient bien écrits** dans la configuration ; c'est le **rechargement** qui
|
||
n'avait pas eu lieu — « en attente » au sens d'OPNsense. Une différence qui compte : entre
|
||
les deux, la configuration contenait des objets que le pare-feu en marche ignorait encore.
|
||
|
||
### Et la garde du boîtier injoignable a servi
|
||
|
||
Une tentative a échoué sur une poignée TLS expirée — le premier paquet après une pause,
|
||
mesuré tout au long de la journée. Le code a **refusé de lire un boîtier injoignable comme
|
||
un boîtier vide**, au lieu de proposer de tout recréer. C'est exactement ce pour quoi cette
|
||
garde avait été écrite.
|
||
|
||
## 2026-08-24 — Le chaînage des caches, et la règle qui appartient au SITE
|
||
|
||
Le runner de SITE est le seul à toucher la configuration système du matériel. Sa
|
||
responsabilité est de **préparer le terrain** — VNets, routes, flux — pour que le plan
|
||
d'un OPS puisse tenir. Ensuite le runner du tenant fait la suite.
|
||
|
||
Cette mise au point tranche une question restée ouverte : **une règle inter-tenant
|
||
n'appartient ni à l'émetteur ni au récepteur, mais au site**. Ce n'est pas « Chezlepro
|
||
ouvre une porte chez patient 0 » — c'est le site qui autorise un flux entre deux de ses
|
||
tenants.
|
||
|
||
### Ce que je croyais, et qui était faux
|
||
|
||
J'avais annoncé que le générateur construisait « tenant par tenant, sur les interfaces de
|
||
ce tenant », et que des règles croisées changeraient sa forme. **Faux** : il fait une seule
|
||
passe sur tous les tenants du site, et il n'y a qu'**une interface de transit**. Les
|
||
tenants s'y distinguent par leur **alias source**, pas par leur interface.
|
||
|
||
### Deux silences fermés dans le générateur
|
||
|
||
`flux_frontiere()` ne retenait que les flux `externe`. Or deux tenants d'une même fabric
|
||
vivent sur des VLAN distincts, **routés par la frontière** : leur trafic la traverse, donc
|
||
elle doit le porter. Sans ça, le mot `voisins_site` était accepté par la validation et
|
||
rendait **zéro règle** — un flux déclaré que personne n'applique.
|
||
|
||
Et un `egress` vers un voisin recevait `!SETOPS_INTERNES` en destination, comme tout flux
|
||
sortant — c'est-à-dire **le port ouvert vers l'Internet**. La destination est désormais
|
||
**nommée**.
|
||
|
||
### Deux rôles, parce que les flux sont statiques
|
||
|
||
Un rôle unique aurait dû déclarer l'ingress inter-tenant pour **tous** les caches. Le devis
|
||
l'a montré avant toute application :
|
||
|
||
```
|
||
+ TENANT_PATI29 -> CHEZ17_SERVEUR_ARTEFACTS un maillage complet,
|
||
+ TENANT_TECH23 -> CHEZ17_SERVEUR_ARTEFACTS chaque cache acceptant chaque autre
|
||
```
|
||
|
||
`serveur_artefacts_site` porte donc l'ingress, `serveur_artefacts` l'egress vers son amont.
|
||
Résultat au devis : **l'ingress ne vise plus que le cache du site**.
|
||
|
||
### Ce que ça donne
|
||
|
||
```
|
||
VM du tenant -> cache du tenant -> cache du SITE -> Debian
|
||
```
|
||
|
||
Debian téléchargé **une fois pour toute la fabric**. Un seul flux nouveau par écosystème,
|
||
entre deux caches — jamais d'une VM vers le cache d'un voisin. Et le cache du site ne voit
|
||
que des requêtes **agrégées** : jamais quelle machine installe quoi.
|
||
|
||
Vider `serveur_artefacts_amont`, c'est s'émanciper.
|
||
|
||
### Ce qui reste large, et que je ne cache pas
|
||
|
||
L'`egress` autorise chaque cache de tenant à joindre le 3142 de **tous** ses voisins, alors
|
||
qu'un seul lui sert d'amont. La déclaration étant statique, le rôle ne sait pas lequel de
|
||
ses voisins est le cache du site. La portée effective reste juste — seul le cache du site
|
||
accepte — mais la règle sortante est plus permissive que nécessaire.
|
||
|
||
**Rien n'a été appliqué.** Le devis se lit avant.
|
||
|
||
## 2026-08-24 — `voisins_site` : le vocabulaire d'un flux entre tenants, et le mur qu'il révèle
|
||
|
||
Le registre des flux connaissait `flotte` (mon écosystème), `edge`, `admin` et `externe`.
|
||
Il ne savait pas dire **« les autres tenants de ma fabric »** — et `ingress` + `externe`
|
||
signifie *depuis l'Internet* : déclarer ainsi un cache partagé l'aurait **publié au monde**.
|
||
|
||
`voisins_site` existe maintenant dans `resoudre_flux`. Il rend des CIDR — les supernets
|
||
des tenants que **ce site** héberge — et réutilise `devis_reseau.decouvrir_du_site()`
|
||
plutôt que d'en écrire un second recensement. Deux listes de tenants finiraient par
|
||
diverger, et la divergence se lirait « tout va bien ».
|
||
|
||
Vérifié : depuis patient 0, il rend `10.17.0.0/16` et `10.23.0.0/16`. Le lab, `federe:
|
||
false`, est correctement exclu.
|
||
|
||
**Une faute évitée de justesse.** Ma première version lisait un `federe` absent comme
|
||
« non fédéré » et excluait Chezlepro et Technolibre — leur nomenclature est antérieure à
|
||
cette clé. `devis_reseau` dit `n.get("federe", True)` : **l'absence vaut fédéré**. Réutiliser
|
||
sa découverte plutôt que d'en réécrire une a fermé le piège au passage.
|
||
|
||
### Ce qui n'est PAS fait, et pourquoi
|
||
|
||
Le chaînage des caches — chaque écosystème garde le sien, qui prend celui de l'hébergeur
|
||
comme amont, pour que Debian ne soit téléchargé qu'une fois par site — **n'est pas livré**.
|
||
|
||
Le vocabulaire est accepté, mais **aucun générateur ne le rend** : `make frontiere-plan`
|
||
produit zéro règle pour le port 3142 entre tenants. Un flux déclaré que personne
|
||
n'applique est précisément le piège que ce dépôt traque, alors les déclarations ont été
|
||
retirées plutôt que laissées à moitié.
|
||
|
||
La difficulté est structurelle, pas cosmétique. Les règles d'OPNsense s'évaluent sur
|
||
l'**interface d'arrivée** : un paquet venant de Chezlepro vers le cache de patient 0 arrive
|
||
sur l'interface *de Chezlepro*, donc la règle doit être posée **là**. Or le générateur
|
||
construit ses règles tenant par tenant, sur les interfaces de ce tenant. Émettre des règles
|
||
croisées change la forme de ce qu'il produit — et ses propres commentaires répètent qu'une
|
||
règle posée sur la mauvaise interface ne correspond jamais à un paquet.
|
||
|
||
Ce qui reste en place : le mot `voisins_site`, éprouvé ; et `serveur_artefacts_amont`, la
|
||
capacité de chaînage côté rôle. Il manque le rendu, dans les deux générateurs de pare-feu.
|
||
|
||
## 2026-08-24 — Un résolveur par tenant, et non plus un par machine
|
||
|
||
`client_unbound` posait un Unbound sur **chaque VM**. C'était étanche, et c'était N démons
|
||
identiques pour un service unique.
|
||
|
||
**Mesure prise avant de décider** : ~21 Mo de RSS par VM, et **28 à 189 requêtes** servies
|
||
depuis le démarrage. Le gain de cache mutualisé est donc négligeable ; ce qu'on récupère,
|
||
c'est un démon au lieu de cinq — et **un endroit à regarder** au lieu de cinq.
|
||
|
||
### Pourquoi pas sur la frontière
|
||
|
||
C'était la proposition, et elle aurait été la plus économe. Mais un résolveur partagé par
|
||
tout le SITE devrait connaître la zone interne de **chaque tenant** : sans vues par réseau
|
||
soigneusement réglées, le tenant A résoudrait les noms du tenant B. Et un tenant dont le
|
||
résolveur vit chez l'hébergeur **ne peut plus s'émanciper avec**.
|
||
|
||
La récursion est générique ; **la zone interne ne l'est pas**. C'est ce qui fait du
|
||
résolveur un service de tenant, et non de fabric.
|
||
|
||
### La cohabitation avec l'autoritatif
|
||
|
||
Les deux vivent sur `infra-dns-01` et voudraient le port 53. Le partage est **dérivé, pas
|
||
déclaré** — l'exploitant n'a pas à se souvenir de déplacer un port parce qu'il a ajouté un
|
||
rôle :
|
||
|
||
```
|
||
résolveur (Unbound) 10.29.19.11:53 + 127.0.0.1:53 la seule porte
|
||
autoritatif (PowerDNS) 127.0.0.1:5300 derrière lui
|
||
```
|
||
|
||
Le port de PowerDNS est déclaré `derive` dans son `meta/flux.yml` — `verifier_ports` traite
|
||
un port non numérique comme « pas une écoute fixe », ce qui est exactement le cas : les
|
||
deux lient le port 53, mais sur des **adresses** différentes.
|
||
|
||
### Deux pièges, dont un que le harnais seul a vu
|
||
|
||
**Unbound refuse d'interroger une loopback.** `do-not-query-localhost` vaut `yes` d'origine
|
||
— une protection contre les boucles. Or l'autoritatif vit désormais sur `127.0.0.1:5300`,
|
||
derrière le résolveur. Sans la lever, **toute la zone souveraine rendait SERVFAIL**.
|
||
|
||
**Et la panne était masquée par le plancher.** `getent hosts forge.genese.internal`
|
||
répondait `10.29.16.11` sur les cinq machines — c'était `/etc/hosts`, pas le DNS. J'ai
|
||
d'abord conclu que la résolution fonctionnait. Seul un `dig` explicite a montré le
|
||
SERVFAIL. Le plancher fait son travail — mais il rend une panne DNS invisible à qui
|
||
mesure avec le mauvais instrument.
|
||
|
||
C'est la tâche de validation du rôle qui a refusé de se dire satisfaite, et elle avait
|
||
raison contre moi.
|
||
|
||
### Le client sait se retirer
|
||
|
||
`client_resolveur` **désinstalle** l'Unbound local après avoir basculé — jamais avant,
|
||
sinon l'hôte perdrait toute résolution au milieu du play. L'hôte qui porte le résolveur est
|
||
épargné : c'est le même paquet.
|
||
|
||
Sans cette tâche, le rôle aurait changé le `resolv.conf` sans rien économiser, et la raison
|
||
même du changement aurait été perdue.
|
||
|
||
### Le renommage
|
||
|
||
`client_unbound` n'installant plus Unbound, son nom mentait. Il devient `client_resolveur`
|
||
— une vingtaine de fichiers, quatre plans d'instance et six modèles. Le harnais a rattrapé
|
||
chaque oubli : playbook homonyme manquant, inventaires en écart, plan de recette périmé,
|
||
README absent.
|
||
|
||
## 2026-08-24 — Collabora en natif : la dernière exception conteneurisée tombe
|
||
|
||
`serveur_collabora` lançait `collabora/code` dans Docker — **seule exception** de la
|
||
flotte au principe fondateur : logiciel libre en natif, systemd + paquets + nginx. Elle
|
||
traînait une dépendance entière, `community.docker`, qui n'était même pas déclarée dans
|
||
`requirements.yml` et donc absente de tout runner.
|
||
|
||
**Plus aucun rôle du dépôt n'a besoin de Docker.** La collection est retirée.
|
||
|
||
### Éprouver l'outil avant le rôle
|
||
|
||
Sur une Debian 13.6 réelle, sans rien installer :
|
||
|
||
```
|
||
apt-get install --simulate coolwsd -> résout jusqu'à « Conf coolwsd (26.04.3.1-1) »
|
||
libgcc1 (exigé par le paquet) -> fourni par libgcc-s1 sur trixie
|
||
le dépôt CODE-deb -> PLAT : Packages et Release à la racine, pas de dists/
|
||
```
|
||
|
||
Le paquet livre aussi `/etc/nginx/snippets/coolwsd.conf` et un profil AppArmor — deux
|
||
choses que cette flotte sait exploiter, contrairement à une image opaque.
|
||
|
||
### La configuration par surcharge
|
||
|
||
Le `coolwsd.xml` livré fait **439 lignes richement commentées**. Le remplacer par un
|
||
gabarit maison obligerait à suivre son évolution amont et ferait perdre ses explications.
|
||
`coolwsd` acceptant des surcharges `--o:<clé>=<valeur>`, le rôle pose un **fragment
|
||
systemd** : la configuration de Set-OPS tient en un fichier, celle du paquet reste intacte.
|
||
|
||
### La console d'administration est fermée
|
||
|
||
Elle expose un formulaire hors de tout SSO, avec un secret à créer, faire tourner et
|
||
surveiller — pour une fonction dont personne n'a besoin. Même raisonnement que
|
||
`serveur_forgejo_connexion_locale`.
|
||
|
||
### Trois outils du harnais ont eu raison, et l'un avait tort
|
||
|
||
**`voute.py` lisait les commentaires.** Expliquer en commentaire « pour ouvrir cette
|
||
option, fournir `vault_x` » suffisait à rendre `vault_x` obligatoire dans le gabarit ET
|
||
dans la voûte réelle. Documenter le nom d'une clef ne doit pas l'exiger : le scanner
|
||
ignore désormais ce qui suit un `#`. Contrôle négatif fait — une référence réelle, dans du
|
||
code, reste exigée.
|
||
|
||
**Le message d'erreur d'un `assert` comptait comme une référence.** Il nommait la clef de
|
||
voûte ; il nomme maintenant la variable que l'exploitant règle, ce qui est plus utile de
|
||
toute façon.
|
||
|
||
**Et `verifier_intrants.py` avait raison.** Un `assert` de la forme `var | length > 0`
|
||
déclare un **contrat** — et l'outil ignore délibérément les `when:`, parce qu'un intrant
|
||
exigé sous condition reste un intrant exigé. Écrite en deux morceaux, ma garde promettait
|
||
un secret pour une console fermée. Réécrite en **implication** — *si ouverte, alors un mot
|
||
de passe* — elle dit ce qu'elle veut vraiment dire.
|
||
|
||
### Ce qui n'est pas prouvé
|
||
|
||
L'**installabilité** est établie. Le **fonctionnement** ne l'est pas : aucun écosystème de
|
||
la flotte ne fait tourner Collabora aujourd'hui. La première mise en service devra
|
||
vérifier l'édition d'un document de bout en bout, depuis Nextcloud.
|
||
|
||
## 2026-08-24 — La clé du runner, et trois silences fermés en chemin
|
||
|
||
Le poste d'exploitation fabriquait sa propre paire SSH depuis son premier déploiement, et
|
||
**n'avait jamais pu joindre un seul hôte**. Deux verrous, pas un : la clé n'était autorisée
|
||
nulle part, et `nftables_admin_ssh` ne listait pas son adresse. Les deux vont ensemble —
|
||
c'est tout le sens de P24, un seul intrant pour la flotte et la frontière.
|
||
|
||
`ssh_baseline` gère désormais les clés d'administration déclarées au plan. **Chaque entrée
|
||
porte son `etat`** : passer à `absent` révoque sur toute la flotte au prochain passage. Une
|
||
liste purement additive ne sait pas retirer, et « révoquer » voudrait alors dire se
|
||
connecter à la main sur chaque machine.
|
||
|
||
Pas d'`exclusive: true` : il effacerait la clé posée par cloud-init si le plan ne la
|
||
reprenait pas, et fermerait la flotte à tout le monde d'un seul déploiement. Le prix
|
||
assumé : une clé posée hors du plan n'est pas détectée ici.
|
||
|
||
Vérifié depuis `ops-01` : les cinq hôtes répondent leur FQDN.
|
||
|
||
### Cloud-init effaçait le plancher de résolution à chaque démarrage
|
||
|
||
`manage_etc_hosts: true` traîne dans le gabarit doré. À chaque boot, cloud-init régénère
|
||
`/etc/hosts` depuis son propre modèle et **efface le plancher dérivé de l'inventaire** —
|
||
la seule façon qu'a une machine de nommer ses voisines sans DNS.
|
||
|
||
Constaté sur `infra-dns-01`, redémarré lors d'un déplacement de disque : six entrées de
|
||
flotte perdues. Le défaut s'est manifesté deux jours plus tard sous la forme d'un
|
||
`apt update` qui ne résolvait plus le cache d'artefacts — un message qui ne parlait pas du
|
||
tout du vrai problème. `infra-edge-01` avait subi le même sort et s'était fait réparer
|
||
sans qu'on le sache, par un redéploiement.
|
||
|
||
`hosts_statiques` dépose maintenant un fragment `cloud.cfg.d` qui neutralise cette
|
||
réécriture. Les cinq hôtes : six entrées, `manage_etc_hosts: false`, `apt` sans erreur.
|
||
|
||
### Trois collections utilisées, aucune déclarée
|
||
|
||
`requirements.yml` ne nommait que `community.postgresql` et `community.general`. Or les
|
||
rôles utilisent aussi **`ansible.posix`** (`serveur_backup`) et **`community.docker`**
|
||
(`serveur_collabora`). Ça marchait chez le mainteneur, où un gros lot est installé — et
|
||
échouait partout ailleurs.
|
||
|
||
**Le runner n'a que ce que ce fichier déclare** : il n'aurait pas pu déployer les
|
||
sauvegardes. C'est le genre d'écart qu'on ne voit qu'en portant le moteur sur une autre
|
||
machine.
|
||
|
||
À trancher : `community.docker` contredit le principe « zéro conteneur ». La dépendance
|
||
est déclarée parce qu'elle **existe** — mieux vaut une contradiction visible qu'un rôle qui
|
||
échoue en silence — mais la question reste ouverte : Collabora en natif, ou l'exception
|
||
assumée ?
|
||
|
||
### Et le cache suivait la mauvaise chose
|
||
|
||
Le téléchargement des collections était gardé par `creates: <cache>/requirements.yml` :
|
||
une collection **ajoutée** au fichier n'aurait jamais été récupérée, le témoin existant
|
||
déjà. Le cache est désormais indexé sur l'**empreinte** du fichier — changer une ligne
|
||
crée un cache neuf, donc un téléchargement.
|
||
|
||
### Un `when` en écrase un autre
|
||
|
||
En ajoutant une condition, j'en ai laissé une seconde juste en dessous. YAML garde la
|
||
dernière clé et jette l'autre, **en silence** : `not ansible_check_mode` avait disparu.
|
||
`ansible-lint` l'a vu ; personne d'autre ne l'aurait vu. Les conditions vont dans une liste.
|
||
|
||
## 2026-08-24 — Deux runners, deux pouvoirs, aucun omnipotent
|
||
|
||
Le travail d'un runner se divise en trois, et la ligne de partage est **celle des voûtes** :
|
||
|
||
```
|
||
calculer plan -> inventaire aucune voûte portée TENANT
|
||
configurer rôles sur ses machines voûte du TENANT portée TENANT
|
||
matérialiser créer/détruire des VM voûte du SITE portée FABRIC
|
||
```
|
||
|
||
Un runner **par tenant** qui matérialiserait mettrait la voûte du SITE en N exemplaires —
|
||
le secret le plus dangereux du système, recopié autant de fois qu'il y a de locataires.
|
||
|
||
Un runner **unique** qui ferait tout devrait entrer en SSH chez tous les tenants, donc
|
||
traverser le default-deny inter-tenant — et rendrait l'**émancipation impossible** : un
|
||
écosystème dont le runner appartient à l'hébergeur ne peut plus se rebâtir sans lui.
|
||
|
||
D'où `serveur_ops_site`, additif : il crée des VM vides et n'entre **jamais** chez un
|
||
tenant ; `serveur_ops` habille des machines et ne touche **jamais** la fabric. Réservé à
|
||
l'écosystème de l'hébergeur — un tenant ordinaire qui le déclarerait s'arrogerait un
|
||
pouvoir sur ses voisins.
|
||
|
||
### La voûte, de droit plutôt que par emprunt
|
||
|
||
La cérémonie du prêt que j'avais bricolée en ligne de commande masquait une pièce
|
||
manquante. Le rôle dépose maintenant la voûte du SITE, **chiffrée**, en `0600` — et le mot
|
||
de passe n'est toujours pas stocké : le Makefile ajoute `--ask-vault-pass` quand aucun
|
||
fichier n'est défini.
|
||
|
||
### Une garde née d'une faute
|
||
|
||
`decrypt: false` est obligatoire sur la copie : sans lui, Ansible **déchiffre** la source
|
||
quand il détient le mot de passe. Mesuré le jour même, en la déposant à la main — 776
|
||
octets en clair au lieu de 3465 chiffrés, sur une machine où ils n'avaient rien à faire.
|
||
|
||
Le rôle **relit l'en-tête après avoir écrit** et refuse si le fichier n'est pas chiffré.
|
||
Contrôle négatif fait : pointé sur un fichier en clair, il échoue ; rétabli ensuite, la
|
||
voûte déposée est bien `$ANSIBLE_VAULT`, 3465 octets, `0600`.
|
||
|
||
Une fuite silencieuse aurait été le pire des cas : le fichier existe, le rôle se dit
|
||
satisfait, et les clés du cluster dorment en clair.
|
||
|
||
## 2026-08-24 — Filiation, mutualisation, émancipation : nommer un motif que le moteur avait déjà
|
||
|
||
Un écosystème ne naît pas autoportant. Il lui faut une forge pour lire son génome, une
|
||
source d'artefacts pour ses paquets, un dépôt pour ses sauvegardes — et rien de tout cela
|
||
n'existe la première seconde. Il **emprunte** donc à son hôte.
|
||
|
||
Le moteur avait rencontré ce besoin **trois fois sans le reconnaître** :
|
||
|
||
```
|
||
client_artefacts_actif dérivé : « une source existe-t-elle chez moi ? »
|
||
serveur_ops_forge_externe « je lis mon génome ailleurs »
|
||
client_backup_cible patient 0 sauvegarde chez eregion — mutualisé, en production
|
||
```
|
||
|
||
Trois astuces, une seule notion — désormais déclarée dans `docs/filiation-emancipation.md`.
|
||
|
||
### Ce que ça change pour les modèles
|
||
|
||
Quatre modèles sur six n'ont pas de forge : `collaboration`, `identite`, `observabilite`,
|
||
`presence-web`. On pouvait lire ça comme une lacune — leur `ops-01` n'aurait rien à cloner.
|
||
C'en est une autre : ce sont des écosystèmes **au premier âge**, qui lisent leur génome
|
||
chez leur hôte. L'ajout d'une forge n'est pas une correction, c'est une **émancipation**.
|
||
|
||
Les quatre le déclarent maintenant explicitement. `forge` et `integral` restent émancipés
|
||
par défaut.
|
||
|
||
**Et personne ne l'aurait vu** : le harnais ne regarde pas les modèles privés — P17 n'en
|
||
découvre qu'un seul, celui du dépôt public. Encore un vert sur un périmètre vide.
|
||
|
||
### Une promesse sans substance
|
||
|
||
`serveur_ops_forge_externe` était citée par `docs/dependances-groupes.yml` **et** par le
|
||
README du rôle, et **définie nulle part**. L'exemption qu'elle portait ne pouvait donc
|
||
jamais s'appliquer : le registre refusait un écosystème sans forge tout en documentant
|
||
comment l'autoriser. La variable existe maintenant, avec son amont obligatoire — lever le
|
||
drapeau sans nommer chez qui l'on emprunte, c'est ne rien déclarer du tout.
|
||
|
||
### Ce que l'émancipation devra prouver
|
||
|
||
Trois temps, dont le dernier est celui qu'on oublie :
|
||
|
||
1. **déclarer** — lever le drapeau, déployer le service chez soi ;
|
||
2. **migrer l'état** — génome, sauvegardes, certificats ;
|
||
3. **prouver que le lien est coupé.**
|
||
|
||
Sans le troisième, on croirait s'être émancipé en restant dépendant sans le savoir. Une
|
||
émancipation non prouvée est une émancipation non faite. **L'instrument de cette preuve
|
||
n'existe pas encore** — c'est la prochaine pièce.
|
||
|
||
### Ce que ça ouvre, côté commerce
|
||
|
||
L'hébergeur ne vend plus seulement des machines : il vend **l'abri pendant la jeunesse**.
|
||
Chaque service mutualisé est une ligne de facture ; chaque émancipation est une décision du
|
||
client, jamais une rupture technique. Pour une clientèle d'OBNL et de coopératives : on
|
||
entre à bas coût, on grandit vers l'autonomie, et **on n'est jamais captif** — le génome
|
||
est chez soi dès le premier jour.
|
||
|
||
## 2026-08-24 — Le poste d'exploitation entre dans les modèles, et un silence de plus est fermé
|
||
|
||
`serveur_ops` n'existait que chez patient 0. Aucun modèle ne le portait — donc **aucun
|
||
écosystème livré n'aurait su se relire lui-même** : il aurait détenu son génome en
|
||
dépendant, pour l'exécuter, de la machine de quelqu'un d'autre. Les six modèles déclarent
|
||
désormais la fonction `ops`, la machine `ops-01` et son application.
|
||
|
||
### Un défaut du rôle, corrigé avant qu'il ne serve
|
||
|
||
Les valeurs par défaut de `serveur_ops` nommaient **patient 0** :
|
||
|
||
```yaml
|
||
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 :
|
||
|
||
```text
|
||
la STRUCTURE se reconstruit depuis la forge
|
||
les SECRETS se restaurent depuis la sauvegarde (restic)
|
||
```
|
||
|
||
Deux sources distinctes, qu'un même incident n'atteint pas ensemble.
|
||
|
||
### Sa clé doit être autorisée à la main
|
||
|
||
Le poste fabrique sa propre paire, distincte de celle du mainteneur : deux exploitants,
|
||
deux révocations possibles. Tant que sa clé publique n'est pas portée aux intrants SSH du
|
||
plan, il ne joint aucun hôte. C'est volontairement un geste humain — donner à une machine
|
||
le droit d'entrer partout mérite une décision, pas un effet de bord.
|
||
|
||
### Le harnais a écrit la moitié de ce rôle
|
||
|
||
Le rôle écrit, `make prouver` a rendu **36 OK, 5 échecs**, tous sur la pièce neuve :
|
||
|
||
```
|
||
P08 aucune couche de deploiement -> couches-deploiement.yml
|
||
P09 pair de flux inconnu 'hyperviseur' -> vocabulaire : edge|flotte|externe|...
|
||
P29 aucune declaration d'authentification -> meta/authentification.yml
|
||
P31 aucun README -> roles/serveur_ops/README.md
|
||
P38 le catalogue ne le nomme pas -> docs/catalogue-services.md
|
||
```
|
||
|
||
Aucun de ces cinq oublis n'aurait empêché le rôle de fonctionner. Tous les cinq
|
||
l'auraient rendu invisible à la carte, au graphe, à la politique de pare-feu et au
|
||
lecteur. Le harnais ne vérifie pas que le code marche : il vérifie que le dépôt sait
|
||
encore ce qu'il contient. Retour à **41 OK, 0 échec**.
|
||
|
||
Au passage, `pair: hyperviseur` a été refusé à juste titre : l'hyperviseur n'est pas dans
|
||
l'écosystème, il est de l'autre côté de la frontière — donc `externe`. C'est le seul flux
|
||
par lequel un écosystème peut en engendrer un autre.
|
||
|
||
### Ce que le poste a révélé en naissant
|
||
|
||
Une machine neuve est un révélateur : elle traverse tout le moteur sans rien hériter d'un
|
||
état antérieur. `ops-01` a buté sur cinq obstacles, dont deux étaient des défauts réels et
|
||
silencieux du dépôt.
|
||
|
||
**Le plan lu n'était pas celui déployé.** `setops_plan_dir` valait
|
||
`{{ playbook_dir }}/../../instance/plan` — le lien `instance` du moteur, **en dur**. Neuf
|
||
rôles lisent cette variable. En déployant patient 0 par `SETOPS_INSTANCE`, ils lisaient
|
||
donc le plan de **Chezlepro**. Conséquences constatées sur les machines :
|
||
|
||
```
|
||
/etc/hosts de ops-01 : auth.chezlepro.internal, forge.chezlepro.internal…
|
||
nginx de l'edge : server_name forge.chezlepro.internal;
|
||
```
|
||
|
||
Un écosystème publiait les noms d'un autre. La variable est désormais ancrée sur
|
||
`{{ inventory_dir }}/../../plan` : le plan qui a **engendré** cet inventaire. Les deux ne
|
||
peuvent plus se contredire, et l'expression reste juste par le symlink comme par
|
||
`SETOPS_INSTANCE`. Corrigé dans les quatre instances et dans le modèle public.
|
||
|
||
**L'edge publiait derrière un certificat auto-signé.** Trois instances sur quatre
|
||
portaient `group_vars/serveur_nginx.yml` — les SAN d'exposition, le chemin du certificat
|
||
step-ca, le rechargement de nginx. La quatrième, plus récente, ne l'avait pas ; le modèle
|
||
public non plus. Résultat : `ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem` devant
|
||
la forge de patient 0. Le service répondait, la page s'affichait après un avertissement,
|
||
`make prouver` était vert. Le premier à refuser fut `git clone` — et il avait raison.
|
||
|
||
C'est un oubli de recopie, pas un bug : un câblage reproduit à la main finit toujours par
|
||
manquer quelque part. D'où **P42 — « L'edge porte les noms qu'il publie »**, qui le
|
||
réclame désormais pour chaque écosystème déclarant un edge. Contrôle négatif fait : le
|
||
fichier retiré, la preuve passe au rouge.
|
||
|
||
**Et trois obstacles d'exécution**, sans mystère mais instructifs :
|
||
|
||
- la frontière ne connaissait pas `ops-01` — normal, il est neuf : `+ alias
|
||
SETOPS_PATI29_SERVEUR_OPS`, `+ SETOPS_PATI29_CLIENT_UNBOUND`, 0 retrait ;
|
||
- `ansible-galaxy` ne joint pas `galaxy.ansible.com` depuis l'overlay, et **c'est très
|
||
bien ainsi**. Ouvrir une règle vers un serveur étranger pour qu'un écosystème sache se
|
||
reconstruire aurait été la mauvaise réponse. Le contrôleur télécharge dans son cache et
|
||
pousse par SSH — même idiome que `serveur_forgejo` devant son propre tiers. Effet de
|
||
bord recherché : le poste devient déployable **hors ligne** ;
|
||
- le chemin du contrôleur contient une espace (« Espace Chezlepro/… ») et `command: cmd:`
|
||
la lit comme un séparateur. Le message d'erreur ne parlait pas du tout du vrai problème.
|
||
Forme `argv` désormais.
|
||
|
||
### La preuve
|
||
|
||
Depuis `ops-01`, sous son propre compte :
|
||
|
||
```
|
||
8e125b7 plancher : nommer ce qui est hors de l'ecosysteme le moteur
|
||
8c4a39b amont : declarer par quelle adresse patient 0 … son plan
|
||
etiquette v2026.08.21 — SSH SIGNATURE presente
|
||
make aide -> « Set-OPS — moteur d ecosystemes numeriques souverains »
|
||
```
|
||
|
||
L'écosystème lit son propre génome, depuis sa propre forge, avec son propre Ansible.
|
||
|
||
### Le 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 :
|
||
|
||
```yaml
|
||
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
|
||
|
||
```yaml
|
||
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](wiki/Filiation-signatures-et-témoins.md)
|
||
— 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](wiki/Le-réseau-des-tenants.md).
|
||
Dix-sept des quarante termes y vivaient sans domicile. Elle raconte le chemin dans l'ordre
|
||
où les problèmes se sont posés : deux clients sur un même câble → le VLAN → ses deux
|
||
limites → l'encapsulation → pourquoi 1450 → qui distribue les enveloppes → et le VRF, qui
|
||
n'est pas une interdiction mais une **ignorance structurelle**.
|
||
|
||
### P39, et ce qu'elle avoue ne pas savoir faire
|
||
|
||
Elle vérifie trois choses : chaque terme du jargon a une entrée ; chaque lien du glossaire
|
||
mène à une page qui existe ; chaque page du wiki est atteignable depuis la navigation.
|
||
|
||
La liste des termes est **déclarée**, et c'est un choix mesuré. La dérivation automatique a
|
||
été essayée : 153 acronymes dans le wiki et le README, dont la moitié sont des mots
|
||
français en capitales — `AUCUNE`, `AVANT`, `TOUS`. Un contrôle qui exige une entrée de
|
||
glossaire pour « AUCUNE » finit désactivé, et une preuve désactivée ne garde rien.
|
||
|
||
Éprouvée en négatif contre le glossaire d'avant : **49 termes manquants**, nommés un par
|
||
un.
|
||
|
||
`make verifier` : 39 OK, 0 échec, 0 sauté.
|
||
|
||
> **Ce que la preuve ne mesurera jamais.** Qu'une explication soit *bonne*. Elle compte des
|
||
> entrées ; elle ne sait pas si on comprend. Ça, seul un lecteur peut le dire — et c'est
|
||
> précisément le lecteur qu'on cherche à ne pas perdre.
|
||
|
||
|
||
## 2026-08-20 — Le devis d'avant-vol validait le mauvais réseau
|
||
|
||
Remarque de l'exploitant, en préparant patient 0 : *« le pont ne me semble pas approprié
|
||
du tout, depuis qu'on crée des VNets pour des tenants. »* Il avait raison, et le défaut
|
||
était plus grave que cosmétique.
|
||
|
||
`make placement-plan` confrontait `proxmox_clone_pont` — `vmbr1` — au cluster. Or ce
|
||
n'est **pas** là que les VM de la flotte atterrissent : `instancier` pose dans chaque hôte
|
||
le pont **dérivé** de sa zone (le VNet du tenant), et `make creer-vm` le passe au clone en
|
||
écrasant ce défaut. `vmbr1` n'est que le repli des clones **manuels**, hors plan.
|
||
|
||
Le devis mesurait donc un objet qui ne sert pas, et ne mesurait pas celui qui sert.
|
||
|
||
### Ce que ça donnait sur patient 0
|
||
|
||
```
|
||
avant : pont vmbr1 existe → CONFORME
|
||
après : reseaux VM t29appl, t29donn, t29fron, t29serv INTROUVABLE
|
||
-> VNet(s) absent(s) : ... — passer `make sdn-appliquer` AVANT de creer les VM
|
||
```
|
||
|
||
Aucun des quatre VNets de patient 0 n'existe sur le cluster. Le devis d'avant-vol disait
|
||
« conforme » à un tenant dont les VM n'auraient eu nulle part où naître.
|
||
|
||
D-80 avait pourtant été corrigée le 13 août : la liaison de placement est **nœud, stockage
|
||
et gabarit** — le pont se dérive. Le devis, lui, continuait de compter quatre objets et de
|
||
nommer le mauvais. Une doctrine corrigée dans un document ne se propage pas toute seule
|
||
dans le code qui l'applique.
|
||
|
||
### Ce qu'il mesure maintenant
|
||
|
||
Les réseaux **où les VM atterriront** : en `sdn`, les VNets dérivés confrontés à
|
||
`/cluster/sdn/vnets` ; en `switch`, les ponts du nœud retenu. Avec, quand il en manque, le
|
||
geste exact qui répare.
|
||
|
||
Non-régression vérifiée sur l'écosystème de référence : ses six VNets existent, verdict
|
||
conforme, code de sortie 0. Quatre tests, Cluster simulé, aucun réseau touché.
|
||
|
||
> **Le vert le plus dangereux est celui qui porte sur un objet voisin du bon.** Ici tout
|
||
> était vrai — `vmbr1` existe bel et bien — et la conclusion était fausse. C'est la
|
||
> quatrième fois cette semaine : la frontière qui poliçait les tenants d'un autre site,
|
||
> P03 qui comparait à l'inventaire d'une autre instance, le catalogue qui décrivait un
|
||
> moteur d'il y a quatre mois, et maintenant un devis qui contrôle un pont que la flotte
|
||
> n'utilise pas.
|
||
|
||
|
||
## 2026-08-20 — Le devis d'avant-vol mourait au lieu de parler
|
||
|
||
Soir de reconstruction, VPN pas encore monté. `make placement-plan` — le devis qu'on lance
|
||
**avant** quarante minutes de déploiement, pour savoir si le terrain est bon :
|
||
|
||
```
|
||
AttributeError: 'str' object has no attribute 'get'
|
||
```
|
||
|
||
Dix lignes de trace Python pour dire *« le nom `asgard` ne se résout pas d'ici »*.
|
||
|
||
En panne, `Cluster.__call__` rend `{"_erreur": "…"}` — un **dict**. Le devis l'itérait
|
||
comme une liste, et un dict itéré rend ses **clés** : d'où un `str` là où le code
|
||
attendait un objet. `Cluster.rate()` existait précisément pour ça, et n'était appelé
|
||
nulle part ici. Les quatre appels passent désormais par une garde qui nomme la cause,
|
||
l'hôte interrogé et le geste à tenter.
|
||
|
||
### Et le devis mesurait le mauvais tenant
|
||
|
||
`placement_du_tenant()` lisait `instance/` **en dur** : viser patient 0 avec
|
||
`SETOPS_INSTANCE` mesurait en silence le placement de l'instance montée. Le verdict était
|
||
juste — pour l'autre tenant. Les deux portaient les mêmes quatre valeurs, ce qui est
|
||
exactement la circonstance où l'erreur ne se voit pas.
|
||
|
||
C'est la **huitième** résolution d'inventaire ou d'instance codée en dur trouvée en trois
|
||
jours. À ce compte, ce n'est plus une série de bogues : c'est une pièce manquante.
|
||
|
||
Au passage, l'en-tête annonçait « tenant *instance* » — le nom du lien, pas celui du
|
||
tenant. Un devis doit nommer ce qu'il a mesuré.
|
||
|
||
### Trois tests, aucun réseau touché
|
||
|
||
`test_devis_placement.py` simule le Cluster : une panne devient un refus lisible (cause,
|
||
hôte, geste), une réponse qui n'est pas une liste est refusée elle aussi — c'est le cas
|
||
silencieux, celui qui franchirait la première garde —, et le cas nominal traverse sans
|
||
gêne. Branchés sur `make test`.
|
||
|
||
> **Ce qu'on répare ici n'est pas une exception, c'est un message.** Un outil de
|
||
> diagnostic qui échoue en langage machine transforme une panne de trente secondes (monter
|
||
> le VPN) en une demi-heure de fouille. Le pire moment pour ça est celui où on l'utilise :
|
||
> quand quelque chose ne va déjà pas.
|
||
|
||
|
||
## 2026-08-20 — Le harnais ne se déclenchait que par mémoire
|
||
|
||
Trente-huit preuves, des tests, un lint — et **rien** ne les exécutait sans qu'un humain
|
||
tape `make`. Le meilleur atout du dépôt dépendait de ne pas oublier. Il a maintenant une
|
||
CI (`.forgejo/workflows/verifier.yml`) et une cible qui la rejoue à l'identique :
|
||
`make ci`.
|
||
|
||
### Ce que la CI a trouvé avant d'exister
|
||
|
||
Écrire le workflow supposait de répondre à une question jamais posée : **est-ce qu'un
|
||
dépôt public, seul, se tient ?** Réponse mesurée sur un clone nu : non, à cinq endroits.
|
||
|
||
| | |
|
||
|---|---|
|
||
| `make instancier` | échouait sur le modèle public — **le tout premier geste du QUICKSTART** |
|
||
| P32 | exigeait les intrants d'oauth2-proxy d'une instance qui ne le déploie pas |
|
||
| P24 | le modèle ne déclarait aucun réseau d'administration |
|
||
| P33 | `verifier_ports.py` codait `principal/` en dur |
|
||
| P32, P24 (bis) | lisaient le symlink `instance/` au lieu de `SETOPS_INSTANCE` |
|
||
|
||
**Toutes de la même famille** — celle de P03 avant-hier : une résolution d'inventaire
|
||
recopiée, une variable d'environnement qui déborde de sa portée. Le dépôt en compte
|
||
**sept** ; deux de plus ont été corrigées ici, et le commentaire de la septième le dit
|
||
plutôt que de le taire.
|
||
|
||
### Celle qui comptait le plus
|
||
|
||
P32 parcourait les 54 rôles sans regarder ce que l'instance déploie. Elle passait sur
|
||
l'écosystème de référence **parce qu'il porte tout**. La conséquence dépassait le modèle :
|
||
les modèles sont des **offres**, et toute offre plus petite que l'écosystème complet —
|
||
c'est-à-dire toute offre réelle — échouait son propre harnais, pour des services qu'elle
|
||
ne vend pas. Le périmètre juste se lit du plan : les groupes de l'inventaire, puis les
|
||
rôles que leur playbook compose.
|
||
|
||
### Ce que `make ci` ne fait pas
|
||
|
||
Il ne touche **aucun symlink**. Le modèle public est monté comme instance jetable, visé
|
||
par `SETOPS_INSTANCE` / `SETOPS_UNDERLAY`, et détruit en sortant — ton instance reste
|
||
montée pendant l'exécution. Deux détails, mesurés parce que devinés faux d'abord :
|
||
|
||
- l'instance jetable est un **dossier frère**, pas un `/tmp` : la fédération se découvre
|
||
par les dossiers frères, et ailleurs quatre preuves tombent en disant « aucun tenant
|
||
fédéré découvert » ;
|
||
- `SETOPS_UNDERLAY` n'est posé **que pour la vérification**, jamais pour l'application —
|
||
sinon l'inventaire est écrit avec une fabric et régénéré avec une autre, et la commande
|
||
fabrique elle-même l'écart qu'elle dénonce.
|
||
|
||
### Le résultat
|
||
|
||
```
|
||
clone nu, aucune instance, aucun frère → make ci : 38 OK, 0 échec, 0 sauté
|
||
dépôt de l'exploitant, 3 instances → make ci : 38 OK, 0 échec, 0 sauté
|
||
```
|
||
|
||
Et une dernière chose, qui dit bien où on en est : **le lint du dépôt a refusé mon propre
|
||
fichier de CI** avant qu'il ne tourne une seule fois — `on:` que YAML lit comme le booléen
|
||
vrai. Le harnais mordait déjà.
|
||
|
||
> **Ce que le vert de cette CI dira, et ce qu'il ne dira pas.** Que le moteur et son
|
||
> modèle public se tiennent — pas que la flotte va bien. Aucune VM n'est jointe, aucune
|
||
> voûte n'entre là. La santé de la flotte reste une question qu'on pose sur le poste de
|
||
> l'exploitant, avec `make prouver`. C'est écrit en tête du workflow, pour que personne
|
||
> ne lise ce vert pour plus qu'il ne vaut.
|
||
|
||
|
||
## 2026-08-19 — La carte des services avait quatre mois de retard sur le moteur
|
||
|
||
`catalogue-services.md` est le document qu'on lit pour savoir **ce que Set-OPS fait** :
|
||
l'hébergeur d'un second site, un futur client, un mainteneur qui arrive. Il annonçait
|
||
comme *« capacités futures encore à implémenter »* la collaboration et la couche web —
|
||
dont les rôles existent et dont les hôtes sont **actifs**.
|
||
|
||
Vérifié rôle par rôle contre `roles/`, ce que le document disait de faux :
|
||
|
||
| Ce qu'il annonçait | La réalité |
|
||
|---|---|
|
||
| collaboration « à implémenter » | `serveur_nextcloud` (533 lignes) + `serveur_collabora`, `collab-01` actif |
|
||
| couche web « à implémenter » | `serveur_web_frontal` / `serveur_web_dorsal`, codifiés depuis les spikes du 5 juillet |
|
||
| fédération LDAP « pas automatisée » | `serveur_keycloak/tasks/federation-ldap.yml` |
|
||
| Keycloak « pas exposé » | `expose: auth.<domaine>` au plan |
|
||
| `infra-mail-01` : « Sendmail MTA » | Dovecot — Sendmail est retiré depuis le 4 juillet |
|
||
| `client_supervision` | n'a **jamais** existé : ni rôle, ni playbook |
|
||
| rôles `nextcloud`, `metriques`… | une colonne décorative : le rôle porte le nom du **groupe** |
|
||
|
||
Et **neuf rôles vivants** n'apparaissaient dans aucune table — le socle, toute la pile
|
||
courriel, les sauvegardes, Icinga Web 2, oauth2-proxy, Unbound. Deux d'entre eux
|
||
(`serveur_backup`, `client_backup`) n'étaient nommés **nulle part** dans le document.
|
||
|
||
### Ce que P31 ne pouvait pas voir
|
||
|
||
La preuve de documentation vérifie que chaque script, cible `make` et rôle est **nommé et
|
||
atteignable**. Elle ne dit rien de la justesse d'un document. **Une carte peut être
|
||
complète et périmée** — celle-ci l'était depuis la consolidation du 3 juillet.
|
||
|
||
### P38 — la table fait foi, dans les deux sens
|
||
|
||
```
|
||
tout rôle serveur_*/client_* doit figurer dans une LIGNE DE TABLE
|
||
tout groupe cité dans une table doit exister (rôle, ou playbook de groupe)
|
||
```
|
||
|
||
Le premier sens seul aurait été trop faible : la pile courriel **était** racontée en
|
||
prose, et invisible pour qui lit le catalogue comme un index — c'est-à-dire tout le monde.
|
||
Le second attrape les cases inventées, qui survivent des mois parce qu'une case de table
|
||
ressemble à un fait.
|
||
|
||
Deux exemptions, nommées pour rester des choix : la **prose** peut citer des rôles retirés
|
||
(`serveur_sendmail`, `client_dns`, `client_ldap`) — sinon on ne peut plus écrire d'où l'on
|
||
vient ; et `serveur_durci` est accepté comme **groupe sans rôle homonyme**, son playbook
|
||
composant onze rôles de durcissement.
|
||
|
||
**Éprouvée en négatif** : rejouée contre la version d'avant les corrections, elle échoue en
|
||
nommant les neuf rôles absents et les trois cases fantômes. `make prouver` : **38 OK, 0
|
||
échec, 0 sauté**.
|
||
|
||
> **Ce que la preuve ne mesure pas, et ne mesurera pas.** Qu'un service soit dit
|
||
> « éprouvé » à bon droit se juge en revue, contre le CHANGELOG. Le tableau des capacités
|
||
> dit maintenant lui-même où il s'arrête : la reconstruction prouve qu'une machine nue
|
||
> atteint l'état voulu, elle ne dit rien de la tenue sous charge ni des mises à jour. Et
|
||
> pour Nextcloud, l'usage réel — déposer un fichier, éditer à deux — n'est **pas** consigné
|
||
> comme preuve ; le document le dit désormais au lieu de le laisser supposer.
|
||
|
||
|
||
## 2026-08-18 — `make underlay` confronte les tenants déclarés aux dossiers réels
|
||
|
||
Suite immédiate du filtre de portée : `underlay.tenants` nomme des **dossiers frères**.
|
||
Une faute de frappe y était invisible — le tenant disparaissait simplement des trois
|
||
devis du site, qui restaient « conformes » sur ce qu'il en restait.
|
||
|
||
Sur un site à **un seul tenant** — le cas de la prochaine implantation — la faute de
|
||
frappe rend un devis **vide** : une frontière sans règle, un commutateur sans VLAN. Et
|
||
rien dans le mot « conforme » ne dirait qu'on vient de dessiner le vide.
|
||
|
||
L'écart est entièrement lisible sans toucher au matériel : d'un côté une liste de noms,
|
||
de l'autre les dossiers présents. Il se dit donc à `make underlay` (D-75), pas au moment
|
||
où l'on pousse dans un boîtier. Quatre situations, quatre messages distincts :
|
||
|
||
```
|
||
dossier absent → aucun dossier frere de ce nom (attendu : …/OPS-Fantome)
|
||
dossier sans nomenclature → dossier present, mais sans plan/nomenclature.yml
|
||
nomenclature non fédérée → `index` absent, `categories` vide ou `federe: false`
|
||
plus rien ne correspond → les devis de ce site n'auraient rien a poser
|
||
```
|
||
|
||
### Ce qu'un gabarit ne doit surtout pas subir
|
||
|
||
Un **modèle** décrit du matériel, pas un site déployé : il ne peut nommer aucun tenant
|
||
réel. Sans garde, tout modèle portant un exemple de `tenants` échouerait chez quiconque
|
||
n'a pas ce dossier — et P17 (« tous les modèles valident ») deviendrait rouge sur la
|
||
machine du voisin. La distinction existait déjà dans le code : `modeles.py` passe des
|
||
repères de tenants **explicites**, ce qui dit « gabarit » ; le site, lui, les laisse
|
||
dériver. La vérification ne s'applique qu'au second cas.
|
||
|
||
Trois tests ajoutés à `test_adressage_derive.py` — le nom introuvable, la clé absente, et
|
||
le gabarit épargné — avec un nom volontairement absurde pour qu'aucun test ne dépende
|
||
des dossiers de la machine qui l'exécute. `make test` 15 + 9 ; `prouver` 37/37.
|
||
|
||
|
||
## 2026-08-18 — Les trois devis d'un site partagent enfin la même portée
|
||
|
||
Le 14 août, la frontière a appris qu'elle ne police que les tenants de **son** site. Le
|
||
commit le disait lui-même : *« même hypothèse ailleurs, non corrigée — `devis_sdn` et
|
||
`devis_reseau` partent du même `decouvrir()`. À traiter quand ils serviront sur un second
|
||
site. »* C'est fait avant, pas pendant.
|
||
|
||
Les trois devis équipent le **matériel d'un site** :
|
||
|
||
| devis | ce qu'il pose | ce qu'un tenant d'ailleurs y ajoutait |
|
||
|---|---|---|
|
||
| `devis_opnsense` | règles et routes de la frontière | des routes vers des sous-réseaux inexistants |
|
||
| `devis_reseau` | VLAN, SVI, routes du commutateur | des VLAN qu'aucune VM ne peuplera |
|
||
| `devis_sdn` | zones et VNets EVPN de l'hyperviseur | des zones sans machine |
|
||
|
||
Aucun de ces objets ne fait de mal visible : le matériel les accepte, ils ne
|
||
correspondent jamais à rien, et rien ne les signale. C'est la définition même du chèque
|
||
vert sur un périmètre vide — sauf qu'ici, il faut le lire à l'envers : une politique qui
|
||
a l'air complète et ne protège rien.
|
||
|
||
### Une seule fonction, au lieu d'un filtre recopié trois fois
|
||
|
||
`devis_reseau.decouvrir_du_site()` — `decouvrir()` restreint par `underlay.tenants`, avec
|
||
la doctrine écrite une fois pour les trois. Le filtre inline de `devis_opnsense` est
|
||
retiré au profit d'elle. `admin_tous_tenants()` la suit : le routeur d'un site n'a aucune
|
||
raison de savoir revenir vers le plan de gestion d'un tenant qu'il ne porte pas.
|
||
|
||
Éprouvé dans les trois situations qui comptent :
|
||
|
||
```
|
||
underlay sans la clé → ['OPS-Chezlepro', 'OPS-Technolibre'] (identique à avant)
|
||
underlay du second site → ['OPS-Technolibre']
|
||
un nom qu'aucun dossier ne fournit → ATTENTION, et le reste est retenu
|
||
le filtre ne retient rien → refus, code 1 (jamais un devis vide)
|
||
```
|
||
|
||
**Sans effet sur le site actuel** : l'underlay de Chezlepro ne déclare pas `tenants`, et
|
||
clé absente = toute la fédération. `prouver` 37/37, `make test` inchangé.
|
||
|
||
### Et la clé est enfin documentée
|
||
|
||
C'était le vrai trou : `underlay.tenants` existait depuis le 14 et n'apparaissait **ni**
|
||
dans `underlay.yml.example` **ni** dans l'annexe du runbook d'implantation. Un exploitant
|
||
montant un second site ne pouvait pas la découvrir — il aurait posé la politique du
|
||
premier tenant chez le second, et le seul symptôme aurait été un silence.
|
||
|
||
> **Traiter la deuxième occurrence quand on nomme la première.** Le défaut de portée
|
||
> était écrit noir sur blanc dans le commit du 14, avec la liste des endroits où il
|
||
> restait. Quatre jours plus tard, le coût de le finir est d'une heure ; sur place, il
|
||
> aurait coûté une visite.
|
||
|
||
|
||
## 2026-08-18 — Le panneau d'intrants effaçait la mémoire écrite du dépôt
|
||
|
||
Un enregistrement du panneau « Intrants de base », à 13:48, a emporté **94 lignes de
|
||
commentaire** dans quatre fichiers (129 → 35 ; ce qui reste est l'en-tête que le panneau
|
||
réécrit lui-même). Dont celle-ci, juste au-dessus de la valeur qu'on venait de changer :
|
||
|
||
```yaml
|
||
# POURQUOI PAS ENCORE 10.17.0.0/24 (essayé puis retiré le 2026-08-12) : `devis_opnsense`
|
||
# dérive l'interface d'une règle de l'ATTACHEMENT RÉEL de sa source (D-61)… un second
|
||
# CIDR est classé « distant », et la règle atterrit sur `wan` où elle ne peut JAMAIS
|
||
# correspondre.
|
||
- 10.0.0.0/24
|
||
```
|
||
|
||
Ces phrases sont la seule trace de raisonnements qu'aucun code ne redit. `safe_dump` les
|
||
efface toutes, à chaque sauvegarde, en retriant les clés au passage — un diff illisible
|
||
par-dessus le marché.
|
||
|
||
### Le dépôt connaissait déjà le geste juste
|
||
|
||
`_ecrire_intrants_fabric` (underlay.yml) et `_ecrire_index_nomenclature` remplacent **la
|
||
ligne**, sans toucher au reste ; leur commentaire dit même *« un `safe_dump` les
|
||
effacerait toutes »*. Les quatre fichiers d'intrants, eux, n'avaient jamais reçu ce
|
||
traitement. Ce qui manquait pour l'étendre : savoir remplacer une valeur de **liste**, qui
|
||
tient sur plusieurs lignes.
|
||
|
||
`_fusion_chirurgicale` le fait, sur trois règles :
|
||
|
||
| | |
|
||
|---|---|
|
||
| une clé dont la valeur ne change pas | **n'est pas réécrite** — zéro bruit au diff |
|
||
| les commentaires internes à un bloc remplacé | **conservés**, jamais jugés |
|
||
| une clé absente du fichier | ajoutée **à la fin**, jamais insérée au hasard |
|
||
|
||
La deuxième règle mérite d'être assumée : une explication devenue fausse survit à la
|
||
valeur qu'elle explique. C'est voulu. Corriger une phrase est un geste humain ; l'effacer
|
||
parce qu'un champ a bougé, non. Le même principe que la fusion des clés posée le 10 août :
|
||
*un panneau qui ne connaît pas une valeur n'a pas le droit de la détruire.*
|
||
|
||
### Éprouvé sur le fichier réel, pas sur un exemple
|
||
|
||
L'enregistrement du 13:48 rejoué sur la version d'avant, tirée de git :
|
||
|
||
```
|
||
lignes 41 -> 41
|
||
commentaires 30 -> 30 perdus : 0
|
||
diff 1 ligne - 10.0.0.0/24 → + 10.17.0.0/24
|
||
```
|
||
|
||
Neuf tests dans `scripts/tests/test_gui_intrants.py`, branchés sur `make test` : le
|
||
commentaire qui survit au changement qu'il explique, la liste multi-lignes remplacée, la
|
||
clé inchangée non reformatée, la clé que le panneau ignore, la clé nouvelle,
|
||
l'idempotence, le garde-fou des clés sensibles, et la création d'un fichier neuf.
|
||
|
||
> **Deux fois le même geste destructeur, sur le même chemin.** Le 10 août ce panneau
|
||
> perdait des **clés** (`dns_amorcage`, `amorcage_acces_courriel` — une VM qui naît sans
|
||
> résolution) ; le 18, des **commentaires**. La première fois avait valu une fusion, pas
|
||
> un test. C'est le test qui manquait.
|
||
|
||
|
||
## 2026-08-18 — P03 mesurait toutes les instances contre l'inventaire d'**une seule**
|
||
|
||
Trouvé en validant une simple mise à jour du CHANGELOG. Deux invocations de la même
|
||
preuve, deux verdicts :
|
||
|
||
```
|
||
make prouver NON CONFORME — « lab : 17 hôtes avec écart »
|
||
python3 scripts/prouver.py CONFORME 37/37
|
||
```
|
||
|
||
Le lab n'avait aucun écart : `SETOPS_INSTANCE=…lab instancier comparer --strict` dit
|
||
**DIFF VIDE**. C'est l'instrument qui mesurait ailleurs.
|
||
|
||
### Deux variables désignent la cible, et c'est la seconde qui gagne
|
||
|
||
```
|
||
Makefile:13 export SETOPS_INVENTAIRE → instance/inventories/principal/hosts.yml
|
||
instancier.py:68 SETOPS_INVENTAIRE FORCE la cible, par-dessus SETOPS_INSTANCE
|
||
prouver.py:505 env = {**os.environ, "SETOPS_INSTANCE": str(chemin)} ← rien de retiré
|
||
```
|
||
|
||
P03 générait donc le plan de **chaque** instance fédérée et le comparait à l'inventaire
|
||
appliqué de la **seule** instance active. D'où un rouge sur un lab sain.
|
||
|
||
### Le rouge n'était pas le problème — le vert l'était
|
||
|
||
Sous `make`, l'inventaire appliqué de lab et de Technolibre **n'était jamais lu**. Or P03
|
||
a été écrite le 2026-08-12 pour exactement cet angle : un tenant qu'on ne regarde pas —
|
||
parce qu'il n'a aucune VM, précisément — imposant ses vieilles adresses au pare-feu
|
||
partagé. **La preuve était aveugle au cas pour lequel elle existe**, quand on l'invoque de
|
||
la façon documentée. Les rapports du 13 et du 14 sortent de cette invocation-là.
|
||
|
||
La signature était visible sans lire une ligne de code : sous `make prouver`, les
|
||
`hosts.genere.yml` de lab et de Technolibre **ne bougeaient pas** — tout était écrit dans
|
||
le répertoire de Chezlepro.
|
||
|
||
### Corrigé aux cinq sites, et rendu bruyant
|
||
|
||
`env.pop("SETOPS_INVENTAIRE", None)` partout où l'on redirige `SETOPS_INSTANCE` : P03 et
|
||
P15 (`prouver.py`), et les trois applicateurs `appliquer_opnsense` / `appliquer_proxmox_fw`
|
||
/ `appliquer_sdn`, qui pointent `SETOPS_INSTANCE` vers l'**hébergeur**. Ces trois-là sont
|
||
sans effet tant qu'hébergeur et tenant actif coïncident — c'est-à-dire **jusqu'au second
|
||
site**. Le geste correct existait déjà dans le dépôt (`modeles.py:96`) ; il n'avait
|
||
simplement jamais été repris.
|
||
|
||
Et pour que la classe cesse d'être silencieuse, `inventory_rules.inventaire_force()`
|
||
**refuse** une cible hors de l'instance visée, en nommant les deux valeurs :
|
||
|
||
```
|
||
REFUS : SETOPS_INVENTAIRE designe un inventaire HORS de l'instance demandee.
|
||
SETOPS_INSTANCE …/OPS-Chezlepro-lab
|
||
SETOPS_INVENTAIRE …/OPS-Chezlepro/inventories/principal/hosts.yml
|
||
```
|
||
|
||
Éprouvée dans les deux sens : la contradiction sort en code 1, une cible légitime **dans**
|
||
l'instance passe. Branchée sur les quatre résolutions de `_inventaire` (`instancier`,
|
||
`serveurs`, `applications`, `config_proxmox`). Le GUI garde la sienne : il ne redirige
|
||
jamais `SETOPS_INSTANCE` pour un fils, et sa résolution suit le symlink à chaque requête.
|
||
|
||
**`make prouver` : 37 OK, 0 échec, 0 sauté** — et cette fois les `hosts.genere.yml` des
|
||
trois instances portent l'horodatage du passage, preuve que chacune a été lue chez elle.
|
||
|
||
> **Vérifier d'où l'instrument mesure.** Le dépôt porte déjà la règle ; c'est ici la
|
||
> quatrième fois qu'elle paye. Une preuve qui change de verdict selon qu'on l'appelle par
|
||
> `make` ou à la main ne mesurait pas ce qu'elle annonçait dans au moins un des deux cas.
|
||
|
||
|
||
## 2026-08-14 — Une frontière ne police que les tenants de **son** site
|
||
|
||
Premier `make frontiere-plan` sur le second site. Le devis voulait poser sur la frontière
|
||
de Technolibre les règles **et** les routes de **Chezlepro** : trente objets de plus, dont
|
||
six routes vers des sous-réseaux `10.17.x` qui n'existent pas là-bas.
|
||
|
||
### Le défaut est de portée, et il est silencieux
|
||
|
||
Les devis partaient de `devis_reseau.decouvrir()`, qui rend **toute la fédération** — tout
|
||
dossier frère portant une nomenclature avec un index. C'était juste tant qu'il n'y avait
|
||
qu'un site : l'hébergeur unique portait bien tous les tenants. Dès le second, c'est faux.
|
||
|
||
Et rien ne l'aurait dit. Le boîtier aurait **accepté** ces trente objets ; aucun n'aurait
|
||
jamais correspondu à un paquet ; aucune erreur, aucun avertissement. Une politique qui a
|
||
l'air complète et ne protège rien — encore le **chèque vert sur un périmètre vide**.
|
||
|
||
### Le correctif : l'hébergeur nomme ce qu'il porte
|
||
|
||
```yaml
|
||
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`](docs/implanter-un-tenant-sur-un-site.md)
|
||
omettait `index`. Sans cette clé, `make underlay` refuse le réseau de gestion en le prenant
|
||
pour le supernet d'un **autre** site : message déroutant, cause triviale. Trouvé en s'en
|
||
servant, moins de vingt-quatre heures après l'avoir écrit.
|
||
|
||
`prouver` 37/37, `make test` 0.
|
||
|
||
> **Ce qui marche avec un seul écosystème n'est pas prouvé.** Comme les six défauts moteur
|
||
> qu'avait révélés le second tenant, celui-ci n'existait que parce qu'un second **site**
|
||
> existe enfin. Une hypothèse implicite ne se voit qu'au moment où elle cesse d'être vraie.
|
||
|
||
|
||
## 2026-08-13 — La frontière poste en formulaire encodé : `hasPost()` et le `failed` nu
|
||
|
||
Mesuré sur un OPNsense **24.7** (version ancienne, mise à jour depuis) : **toute écriture**
|
||
du moteur y échouait. Alias, règles, NAT, routes — `make frontiere-appliquer` n'aurait rien
|
||
posé sur ce boîtier. Ce n'est donc pas un défaut universel du moteur : il écrit correctement
|
||
sur la frontière de Chezlepro, plus récente. C'est un problème de **compatibilité**, et le
|
||
correctif vaut surtout comme garantie de **portabilité** — le jour où l'on arrive sur un
|
||
site dont on ne choisit pas le firmware, ce qui est exactement le cas.
|
||
|
||
### Le symptôme mérite d'être retenu, lui, quelle que soit la version
|
||
|
||
Le contrôleur d'OPNsense lit ses champs avec `hasPost(<racine>)`. Si le corps arrive en
|
||
`application/json` et que le boîtier ne le décompose pas en variables de POST, ce test est
|
||
**faux** :
|
||
|
||
```
|
||
HTTP 200 {"result":"failed"} ← nu : aucune redirection, aucune validation
|
||
```
|
||
|
||
Le contrôleur ne dit pas quel champ manque, **parce que de son point de vue il n'y avait
|
||
aucun champ**.
|
||
|
||
Trois fausses pistes avant la bonne : valeur invalide (un corps **vide** échouait pareil),
|
||
racine de payload erronée (elle était juste), privilèges de la clé (la lecture passait). Ce
|
||
qui a tranché : *un corps vide aurait dû produire des validations.* Leur absence disait que
|
||
le contrôleur n'avait rien reçu.
|
||
|
||
### Le correctif
|
||
|
||
`Frontiere` poste désormais `racine[champ]=valeur` — la forme que poste **l'interface web
|
||
elle-même**. Aucune version d'OPNsense ne la refuse, alors que le JSON dépend du boîtier.
|
||
Tous les corps du moteur sont des dicts plats de chaînes (alias, `rule`, `route`) : un seul
|
||
niveau d'imbrication suffit.
|
||
|
||
**Éprouvé sur le boîtier, dans les deux sens** : `addItem` d'un alias sonde → `saved`,
|
||
`delItem` → `deleted`, aucune trace laissée, lecture intacte (11 alias). **Reste ouvert** :
|
||
ré-éprouver après la mise à jour, pour vérifier que le formulaire reste bon sur la version
|
||
récente.
|
||
|
||
> **Un `failed` sans validation n'est pas un refus, c'est une absence.** Quand un contrôleur
|
||
> ne nomme aucun champ fautif, cesser de chercher le mauvais champ : il n'a rien reçu.
|
||
|
||
|
||
## 2026-08-13 — Implanter un tenant sur un site neuf : le runbook de l'intervention
|
||
|
||
Le dépôt couvrait déjà « l'hébergeur prépare son matériel » et « un tenant **vivant** change
|
||
d'hébergeur, sans coupure ». Il manquait le troisième cas, qui est celui qu'on s'apprête à
|
||
faire : un tenant dont le plan existe déjà **prend corps sur un site qui n'a jamais rien
|
||
porté**. Rien à migrer, rien à interrompre — donc ni la séquence de gel/bascule de
|
||
`migration-tenant.md`, ni le bootstrap du QUICKSTART.
|
||
|
||
[`docs/implanter-un-tenant-sur-un-site.md`](docs/implanter-un-tenant-sur-un-site.md),
|
||
**six phases**, chacune fermée par une commande qui **interroge le système** :
|
||
|
||
```
|
||
0 bureau index du site = index du tenant, dépôt hébergeur, gabarit, voûte
|
||
1 reconnaître make underlay, make placement-plan
|
||
2 frontière make frontiere-plan
|
||
3 gabarit convertir en template — un clone de VM vivante en ferait 14 copies
|
||
4 composer les DEUX symlinks (D-80) + les 4 valeurs de placement
|
||
5 matérialiser make sdn-appliquer, puis make reconstruire
|
||
6 recette devis, restauration éprouvée, supervision
|
||
```
|
||
|
||
### Ce que le runbook porte et qu'aucun autre document ne dit
|
||
|
||
- **Un site neuf se bâtit d'emblée dans l'adressage cible** (D-77/D-78). Le site historique
|
||
est encore en `10.0.x` et migrera par les runbooks §6 ; sur un site vierge, la cible ne
|
||
coûte rien. Deux sites en `10.0.0.0/24` rendraient la **reprise mutuelle impossible** :
|
||
deux plans de gestion identiques ne peuvent pas s'atteindre.
|
||
- **L'ordre de `reconstruire`, ligne à ligne**, dont le piège `make flux` : sans lui,
|
||
nftables tombe en `policy drop` **sans aucune règle** — la flotte monte, SSH répond, et
|
||
tout le reste est mur (trouvé le 2026-08-10).
|
||
- **Un site à un seul nœud** : ce nœud est aussi nœud de sortie, aucun pont ne peut être
|
||
partiel, aucune haute disponibilité. À dire, pas à laisser supposer.
|
||
- **PVE 9 n'a jamais été éprouvé ici** : tout écart est inconnu jusqu'à mesure.
|
||
- **« Ce qui n'est PAS fait en repartant »** — resserrer le compte d'API, le lien
|
||
inter-sites, le gabarit des deux côtés, la voûte hors de son propre site.
|
||
|
||
**En annexe**, les squelettes d'`underlay.yml` et de `proxmox-hebergeur.yml` pour un site à
|
||
un nœud, à remplir depuis la **reconnaissance** et jamais de mémoire — c'est en recopiant
|
||
des listes chez chaque tenant qu'elles avaient divergé.
|
||
|
||
Sixième porte dans la table de routage du README. Les 21 cibles `make` citées ont été
|
||
vérifiées comme existantes. `prouver` 37/37 (dont P34 : 40 documents déclarent leur
|
||
lecteur), `make test` 0.
|
||
|
||
> **La règle qui commande tout l'ordre du document** : *rien n'est fait tant que ce n'est
|
||
> pas mesuré sur place.* Une valeur transmise par courriel, une liste relevée dans
|
||
> l'interface web, un `vmbr` cité de mémoire — chacun des trois a déjà produit une panne ici.
|
||
|
||
|
||
## 2026-08-13 — La machine d'épreuve jetable existait, et n'était écrite nulle part
|
||
|
||
L'exploitant a découvert par hasard, en creusant l'intrant « pont réseau », qu'on peut
|
||
fabriquer une VM **hors du plan**. Vérification : `cloner-vm` n'était mentionné qu'**une
|
||
fois** dans tout le dépôt, comme note de plomberie dans `dimensionnement-ressources.md`.
|
||
L'usage, lui, n'était nulle part.
|
||
|
||
### Une doctrine sans son instrument
|
||
|
||
Le dépôt porte déjà la règle *« éprouver l'outil avant d'écrire le rôle qui l'enveloppe »* —
|
||
elle a évité les bugs de premier déploiement de rspamd et tranché le pivot Stalwart →
|
||
Postfix/Dovecot. Mais **l'instrument de cette règle n'était pas nommé**.
|
||
|
||
```
|
||
make cloner-vm HOTE=essai-nginx VMID=99123 PONT_PROXMOX=t17appl \
|
||
ADRESSE_IP=10.17.21.99 CIDR=24 PASSERELLE=10.17.21.1
|
||
```
|
||
|
||
Une Debian issue du gabarit doré, sur le réseau choisi, en deux minutes, sans toucher au
|
||
plan. C'est ainsi que `modeleSetOPS` a lui-même été recapturé.
|
||
|
||
### Documenter la discipline, pas seulement la capacité
|
||
|
||
Une telle VM est **nue** — et c'est à la fois l'intérêt et le danger :
|
||
|
||
| | |
|
||
|---|---|
|
||
| l'inventaire | ne la contient pas |
|
||
| **`make raser`** | ne la détruira **jamais** — il dérive du plan |
|
||
| DNS, certificat, sauvegarde, pare-feu, nftables | aucun |
|
||
| son VMID | gardé par aucune preuve contre une collision |
|
||
|
||
Elle ne disparaît que si on la détruit soi-même. Un VMID oublié squatte le cluster sans
|
||
que rien ne le signale — c'est très exactement ainsi qu'un pont disparu a survécu dix jours
|
||
dans une déclaration, le matin même.
|
||
|
||
Écrit à trois endroits, pour trois lecteurs : le **geste** dans `vm-lifecycle.md` §4bis, la
|
||
**capacité** dans `pouvoirs-set-ops.md` (qui évalue le moteur), et le **réflexe** dans la
|
||
discipline de `carte-set-ops.md` (qui modifie le moteur).
|
||
|
||
> **Le symptôme valait le diagnostic.** Découvrir une capacité de son propre outil par
|
||
> accident, en creusant autre chose, est le signe qu'elle manquait à la documentation —
|
||
> pas au code.
|
||
|
||
|
||
## 2026-08-13 — Le « pont réseau » n'était pas un réglage, et l'intitulé le dit maintenant
|
||
|
||
Question de l'exploitant après la découverte de `vmbr3` : *à quoi sert l'intrant « pont
|
||
réseau » ?* Mesuré, et la réponse est : **à presque rien**.
|
||
|
||
```
|
||
proxmox_pont 14 occurrences dans l'inventaire → DÉRIVÉ par hôte (le VNet de sa zone)
|
||
proxmox_noeud 0 → proxmox_clone_noeud est la vraie valeur
|
||
proxmox_stockage 0 → proxmox_clone_stockage est la vraie valeur
|
||
```
|
||
|
||
`instancier` pose le VNet de chaque zone dans `proxmox_pont`, et l'hôte l'emporte sur le
|
||
défaut. **`proxmox_clone_pont` n'est donc consulté que par un `make cloner-vm` manuel**,
|
||
hors flotte — utile pour dépanner, sans effet sur les quatorze VM du plan.
|
||
|
||
C'est exactement pourquoi `vmbr3` a pu y être faux **dix jours** sans que rien ne bronche.
|
||
Et c'est le pire genre d'intrant : on le voit dans le GUI, on le corrige, on redéploie, et
|
||
rien ne change.
|
||
|
||
L'intitulé dit désormais sa portée — *« Pont réseau — clones manuels seulement (les VM du
|
||
plan reçoivent le VNet de leur zone) »*. Un intrant dont on comprend la portée cesse
|
||
d'être un piège.
|
||
|
||
### D-80 corrigée
|
||
|
||
J'y avais écrit « trois clés : nœud, stockage, pont ». **Faux pour le pont.** La liaison de
|
||
placement réelle est **nœud, stockage et gabarit** ; le pont se dérive comme le reste.
|
||
|
||
> **Vérifier avant d'énumérer.** J'avais listé les clés de placement en lisant le fichier
|
||
> du tenant, sans regarder lesquelles sont réellement consultées. Deux le sont, une ne
|
||
> l'est pas — et c'est celle qui était fausse.
|
||
|
||
|
||
## 2026-08-13 — `make placement-plan` : et le gabarit, justement
|
||
|
||
J'avais écarté le gabarit de P37 — « objet du cluster, pas une liste déclarée, donc pas
|
||
vérifiable statiquement ». L'exploitant a relevé que ce n'était pas une raison de ne pas
|
||
le vérifier : ça déplace la question du **statique** vers le **devis**.
|
||
|
||
`devis_placement.py` interroge donc le cluster et confronte **les quatre** valeurs :
|
||
|
||
```
|
||
noeud le nœud existe-t-il
|
||
stockage existe-t-il ET accepte-t-il le contenu `images`
|
||
pont existe-t-il SUR LE NŒUD retenu (un pont partiel est un piège)
|
||
gabarit le VMID existe-t-il ET porte-t-il `template=1`
|
||
```
|
||
|
||
Ce dernier contrôle compte : cloner une VM ordinaire *fonctionnerait*, et produirait
|
||
quatorze copies d'une machine vivante.
|
||
|
||
### Au premier passage, il a trouvé une déclaration périmée
|
||
|
||
```
|
||
ponts réels sur les 3 nœuds vmbr0, vmbr1, vmbr2
|
||
proxmox-hebergeur.yml vmbr0, vmbr1, vmbr2, vmbr3
|
||
```
|
||
|
||
**`vmbr3` n'existe plus** — disparu au passage du transport VXLAN sur les interfaces VLAN.
|
||
La déclaration du 3 août a survécu à sa disparition, et **P37 la validait** : elle valide
|
||
la déclaration, pas le cluster.
|
||
|
||
Invisible jusqu'ici parce que `flotte-creer` surcharge le pont avec le VNet dérivé de
|
||
chaque zone — le défaut n'aurait mordu que sur un `make cloner-vm` manuel. Corrigé des
|
||
deux côtés : la liste de l'hébergeur, et le défaut des trois tenants vers **`vmbr1`**, que
|
||
le cluster nomme lui-même « VM (5Gig) ».
|
||
|
||
> **Ce que ça démontre.** P37 et ce devis ne se remplacent pas : l'un garde la cohérence
|
||
> entre deux déclarations, l'autre confronte la déclaration au réel. Il fallait les deux
|
||
> pour voir un pont qui n'existait plus depuis dix jours.
|
||
|
||
Et P31 a refusé le script tant qu'aucune cible `make` ne l'atteignait.
|
||
|
||
|
||
## 2026-08-13 — D-80 : un tenant est agnostique de son underlay, à trois clés près
|
||
|
||
Formulation de l'exploitant, meilleure que celle du dépôt. Le commentaire disait *« la
|
||
fabric reste celle de l'hébergeur, quel que soit le tenant actif »* — vrai, mais centré
|
||
sur l'hébergeur. Le cadrage juste est centré sur le **tenant** : les deux symlinks
|
||
composent **deux axes indépendants**.
|
||
|
||
```
|
||
instance -> quel tenant
|
||
underlay.yml -> sur quelle fabric
|
||
```
|
||
|
||
Tout l'adressage d'un tenant dérive du seed `index` : son plan se déplace d'une fabric à
|
||
l'autre **sans y toucher**. Ce qui ne se déplace pas tient en **trois clés** :
|
||
|
||
```
|
||
proxmox_clone_noeud proxmox_clone_stockage proxmox_clone_pont
|
||
```
|
||
|
||
Elles vivent côté tenant — c'est lui qui choisit où se poser — mais elles **nomment des
|
||
objets de l'hébergeur**. Trois, pas trente : c'est ce qui sépare « portable » de
|
||
« théoriquement portable ».
|
||
|
||
### P37 — le placement est confronté à ce que l'hébergeur offre
|
||
|
||
L'écart est entièrement lisible : le tenant déclare trois noms, l'hébergeur déclare ses
|
||
listes dans `proxmox-hebergeur.yml`, trouvé par dérivation du symlink `underlay.yml`. Un
|
||
nom absent est un écart **statique** (D-75). Sans cette preuve, une faute de frappe ne se
|
||
découvre qu'au premier clone — après quarante minutes de déploiement.
|
||
|
||
Éprouvée en négatif sur les trois clés : un nœud, un stockage et un pont inexistants sont
|
||
refusés, avec la liste de ce qui est réellement offert.
|
||
|
||
**Non vérifié, et dit comme tel** : le gabarit (`_vmid_modele`) est un objet du cluster,
|
||
pas une liste déclarée. Seul le cluster peut dire s'il existe.
|
||
|
||
|
||
## 2026-08-13 — Reconstruction **d'un trait** : 43 groupes, 0 échec, 37 minutes
|
||
|
||
```
|
||
DEBUT 08:05:17 FIN 08:42:11 code=0
|
||
43 groupes 0 fatal 0 hote en echec
|
||
```
|
||
|
||
Chezlepro rasé puis reconstruit **sans une seule intervention**. Les sept correctifs de la
|
||
nuit tiennent sur une flotte entièrement neuve. Sept devis CONFORME, `prouver` 36/36,
|
||
`make test` 0, neuf sauvegardes réussies et cinq sans objet.
|
||
|
||
### La bataille contre `NodeName` n'avait qu'une cause
|
||
|
||
Toute la lutte d'hier soir — aligner `NodeName` avant `api setup`, après, dériver de
|
||
l'inventaire, puis adopter le nom court — visait un **symptôme**.
|
||
|
||
`icinga2 api setup` écrit `NodeName` d'après `hostname -f`. Tant que `/etc/hosts` plaçait
|
||
le nom court en premier, `hostname -f` mentait et le certificat devenait invérifiable.
|
||
**Depuis que le FQDN est en tête, Icinga s'émet spontanément un certificat `CN` et `SAN`
|
||
= FQDN.** Il n'y avait rien à forcer : il suffisait que la machine sache comment elle
|
||
s'appelle.
|
||
|
||
Le contournement par le nom court est donc **retiré** — il était devenu faux dès que la
|
||
cause réelle a été corrigée. Le commentaire du rôle dit maintenant la causalité, pas ma
|
||
fausse piste.
|
||
|
||
> **Ce que ça enseigne sur le diagnostic.** J'ai passé trois heures à corriger un
|
||
> symptôme visible (le nom du certificat) alors que la cause vivait deux couches plus bas
|
||
> et affectait toute la flotte. Le signe qui aurait dû alerter : *chaque* correction était
|
||
> effacée au passage suivant. Un correctif qu'on doit reposer sans cesse ne corrige pas.
|
||
|
||
### Une dette notée, pas traitée
|
||
|
||
Le clone de `mon-01` a dépassé `proxmox_clone_timeout` (600 s) pendant la génération de
|
||
son ISO cloud-init — la VM était debout quelques minutes plus tard. Quatre clones complets
|
||
simultanés saturent le stockage. À régler par le délai ou le parallélisme, pas en urgence.
|
||
|
||
|
||
## 2026-08-13 — Reconstruction complète en `10.17.x.x` : sept défauts, tous invisibles avant
|
||
|
||
Chezlepro reconstruit **depuis zéro** sur l'adressage dérivé sans décalage. Sept défauts
|
||
sont tombés, et **aucun n'était détectable sur une flotte déjà debout** — c'est tout
|
||
l'argument de la reconstruction comme preuve.
|
||
|
||
| # | Défaut | Pourquoi il était invisible |
|
||
|---|---|---|
|
||
| 1 | aucune route vers le tenant sur le poste | posée à la main un jour, jamais persistée ; disparue à la réactivation de l'interface |
|
||
| 2 | `backup-01` exigeait l'AC d'Icinga | l'AC existait déjà sur l'ancienne flotte |
|
||
| 3 | ma correction inversait les couches | **refusée par P08** avant d'entrer au dépôt |
|
||
| 4 | base Forgejo à moitié initialisée | séquelle de l'arrêt du défaut n°2 |
|
||
| 5 | **`/etc/hosts` : nom court avant le FQDN** | `hostname -f` faux sur **les quatorze machines, depuis toujours** |
|
||
| 6 | API Icinga jamais activée | la garde `creates:` d'`api setup` la saute dès que le fichier existe |
|
||
| 7 | restic refuse tout le lot si un chemin manque | `/srv/web` n'existe qu'après la première webapp |
|
||
|
||
### Le cinquième dépassait Icinga
|
||
|
||
`/etc/hosts` déclarait `10.17.18.21 backup-01 backup-01.chezlepro.internal`. Or
|
||
`hostname -f` rend le **premier** nom : toute la flotte se croyait appelée `mon-01`, jamais
|
||
`mon-01.chezlepro.internal`. Conséquence visible sur Icinga (certificat `CN=mon-01`
|
||
invérifiable en appelant par le FQDN) — mais le même piège attendait Postfix (`myhostname`),
|
||
les journaux et tout ce qui se nomme ainsi. **FQDN d'abord, nom court en alias.**
|
||
|
||
### Ce qu'on ne corrige pas : la convention d'Icinga
|
||
|
||
`icinga2 api setup` **réécrit** `NodeName` d'après le nom court et nomme ses certificats
|
||
d'après lui. Aligné avant, il est écrasé ; aligné après, les certificats portent le mauvais
|
||
nom. On a essayé les deux. **On adopte donc sa convention** : le dépôt appelle l'API par le
|
||
nom court, celui que le certificat porte, résolu par le plancher `/etc/hosts`.
|
||
|
||
`serveur_icinga_node_name` dérive désormais de l'**inventaire** et non d'`ansible_fqdn` —
|
||
un fait qui dépend du résolveur de la machine, et qui a rendu `mon-01` alors que le FQDN
|
||
était correct.
|
||
|
||
> **Une leçon presque comique.** Le commentaire écrit pour avertir qu'une séquence
|
||
> accolade-dièse casse les gabarits Jinja… contenait cette séquence, et cassait le gabarit.
|
||
> Neuf hôtes en échec pour un avertissement mal rédigé.
|
||
|
||
### Verdict
|
||
|
||
**Sept devis CONFORME**, `prouver` 36/36, `make test` 0. Neuf sauvegardes réussies, cinq
|
||
sans objet, et la supervision reçoit les rapports du dépôt avec vérification du pair.
|
||
|
||
|
||
## 2026-08-12 — D-79 : l'hyperviseur filtre encore en iptables *legacy*
|
||
|
||
Question de l'exploitant : *« pourquoi les règles de sécurité au pare-feu global, alors
|
||
que chaque VNet peut en porter ? »* La réponse honnête a demandé trois corrections
|
||
successives — et la dernière était la bonne.
|
||
|
||
### Ce que j'avais tort d'affirmer
|
||
|
||
**« Une règle de VNet serait trop grossière. »** Faux : elle porte `source`, `dest`,
|
||
`dport`, `proto`. Éprouvé — règle créée, relue, retirée. L'exploitant avait raison.
|
||
|
||
**« C'était un arbitrage. »** Faux, et pire : **zéro mention** du pare-feu SDN dans le
|
||
dépôt — ni doc, ni script, ni CHANGELOG. Ce n'était pas un choix, c'était une omission,
|
||
présentée comme un raisonnement.
|
||
|
||
**« Sur Debian 12, `iptables` c'est `iptables-nft`, donc c'est déjà du nftables. »** Faux
|
||
ici. Mesuré sur `asgard` : `iptables v1.8.9 (legacy)`. Proxmox force l'alternative sur
|
||
legacy, parce que `pve-firewall` dépend de l'ancien sous-système. **Le défaut d'une
|
||
distribution n'est pas une mesure.**
|
||
|
||
### Ce que la mesure établit
|
||
|
||
```
|
||
asgard iptables v1.8.9 (legacy) nftables v1.0.6 installé, inutilisé par Proxmox
|
||
proxmox-firewall 0.7.1 INSTALLÉ, absent des services en cours
|
||
pve-firewall 5.1.3 en service
|
||
node firewall enable: 0 cluster firewall enable: 1
|
||
VNet fw type/action/proto/source/dest/dport + policy_forward ∈ {ACCEPT, DROP}
|
||
```
|
||
|
||
Le pare-feu de VNet est **entièrement expressif** — et **implémenté par le seul moteur
|
||
nftables**. Posée aujourd'hui, une règle serait acceptée, stockée, visible dans
|
||
l'interface, et n'appliquerait rien. C'est le motif de la journée, une quatrième fois.
|
||
|
||
### Trois gains dans le même geste
|
||
|
||
Activer `proxmox-firewall` fait passer le moteur de xtables à **nftables natif** (la
|
||
doctrine « tout est nftables », que les invités respectent déjà et que l'hyperviseur
|
||
trahissait), rend le pare-feu de VNet **réel**, et donne des règles qui **survivent à la
|
||
reconstruction** — là où les 38 groupes par VM doivent être ré-attachés à chaque cycle.
|
||
|
||
Ce n'est donc pas une entorse à évaluer : c'est une **dette à rembourser**.
|
||
|
||
**Différé** après la reconstruction, sur un nœud d'abord, avec preuve de blocage et
|
||
contrôle négatif. Deux réserves consignées : le jeu de règles chargé n'a pas été inspecté
|
||
(`nft list tables` exige root, le compte `ansible` ne l'a pas), et aucun blocage réel n'a
|
||
été éprouvé — aucune VM n'était en service.
|
||
|
||
## 2026-08-12 — Technolibre 11→23, lab 1→13 : sortir des plages qu'occupait le matériel
|
||
|
||
Le retrait du décalage de `+10` a déplacé les tenants vers `10.<index>`. Ces plages
|
||
n'étaient pas vierges : les hyperviseurs portent des **interfaces VLAN héritées** que le
|
||
plan ne connaît pas.
|
||
|
||
```
|
||
asgard vlan5 = 10.11.5.41 vlan6 = 10.11.6.41 vlan7 = 10.11.7.41 ← dans 10.11 = Technolibre
|
||
vlan1110 = 10.1.110.254 ← dans 10.1 = lab
|
||
```
|
||
|
||
**Changer l'index plutôt que déloger le matériel** : Technolibre passe à **23**, le lab à
|
||
**13**. `10.23` et `10.13` sont libres partout — frontière, poste de l'exploitant, dépôt —
|
||
et les VLAN dérivés (1231-1236, 1131-1136) ne croisent rien.
|
||
|
||
### Les devis ne suppriment jamais ce qu'ils ne possèdent pas
|
||
|
||
Cette garde est juste — un devis ne doit pas détruire la zone d'un autre — mais elle laisse
|
||
des orphelins quand un tenant **change de nom dérivé** :
|
||
|
||
| Objet | Sort du devis | Retiré explicitement |
|
||
|---|---|---|
|
||
| Zone SDN `t11` + 6 VNets + 6 sous-réseaux | *« zone hors devis, LAISSÉE INTACTE »* | oui |
|
||
| 18 groupes de sécurité `t11-*` (31 règles) | *« à retirer : 0 »* | oui |
|
||
|
||
Un VNet ne se supprime pas tant qu'il porte un sous-réseau, ni un groupe tant qu'il porte
|
||
une règle : l'ordre est **contenu d'abord, contenant ensuite**.
|
||
|
||
### Vérifié sur le réel, pas sur les devis
|
||
|
||
```
|
||
SDN zones t17, t23 — 12 VNets, 12 sous-réseaux, aucun t11
|
||
Pare-feu 19 groupes t17, 17 groupes t23, aucun t11
|
||
Frontière 10.0 (underlay), 10.17, 10.23 — aucun 10.11, 10.21, 10.27
|
||
```
|
||
|
||
Les trois devis répondent *« rien à faire »*, `prouver` et `make test` à 0.
|
||
|
||
> **Ce qui reste, et qui n'est pas une anomalie** : `vlan5/6/7` et `vlan1110` vivent
|
||
> toujours sur les hyperviseurs. Ils n'appartiennent à aucun plan et **ne collisionnent
|
||
> plus** — c'était le but. Les déloger est un geste séparé, à faire avec le reste du
|
||
> déplacement physique.
|
||
|
||
## 2026-08-12 — D-78 : destination ou chemin, et le VLAN 50 pour éviter une collision
|
||
|
||
D-77 sortait déjà le stockage de l'espace dérivé, mais par une **exception nommée** plutôt
|
||
que par une règle. Une question de l'exploitant — *« le VLAN 40 n'a aucune VM, pourquoi ne
|
||
pas lui donner une adresse non routée ? »* — a fait apparaître le critère juste.
|
||
|
||
Ce n'est pas « y a-t-il des machines dedans » : le VLAN de gestion n'en a pas plus qu'un
|
||
autre. C'est :
|
||
|
||
> **Ce réseau est-il jamais une *destination*, ou seulement un *chemin* ?**
|
||
|
||
| Réseau | Rôle réel | Adressage |
|
||
|---|---|---|
|
||
| **Gestion** (10) | le poste, un VPN, demain le lien inter-sites doivent l'**atteindre** | `10.<index>.0.0/24` — **unique** |
|
||
| Transit (40) | rien que des prochains sauts, deux extrémités adjacentes | `192.168.40.0/24` |
|
||
| Transport VXLAN (**50**) | VTEP ↔ VTEP, destination de rien | `192.168.50.0/24` |
|
||
| Stockage (20/30/31) | baie ↔ hyperviseurs | `192.168.20/30/31.0/24` |
|
||
|
||
Sur six réseaux, **cinq** cessent d'exiger la moindre coordination entre deux hébergeurs —
|
||
et « unique » redevient signifiant : seul ce qui doit l'être l'est.
|
||
|
||
### L'os : le VLAN 11 aurait collisionné
|
||
|
||
Sous la règle `192.168.<vlan>`, le transport VXLAN (VLAN 11) aurait produit
|
||
`192.168.11.0/24` — **déjà occupé** par la gestion des hyperviseurs sur `vmbr0`,
|
||
passerelle `.254`. Trouvé par l'exploitant avant écriture.
|
||
|
||
Le VLAN 11 se libérera lorsque cette gestion rejoindra `10.<index>.0.x` — mais **faire
|
||
dépendre un plan d'adressage de l'ordre d'une migration** est le genre de dette qui se paie
|
||
un an plus tard. Le transport passe donc au **VLAN 50** : libre, `192.168.50.0/24` libre,
|
||
et ne dépend de rien. Vérifié : rien ne code le 11 en dur, c'est de la donnée d'underlay.
|
||
|
||
### Ce qui n'est PAS fait, et pourquoi
|
||
|
||
`underlay.yml` décrit le matériel **tel qu'il est**. Y écrire les nouvelles plages avant le
|
||
déplacement physique le ferait mentir : P23 validerait une fiction et les devis émettraient
|
||
une configuration pour un état inexistant. La décision est consignée ; les adresses
|
||
changeront **avec** le matériel, dans l'ordre du runbook §6.
|
||
|
||
**Un site neuf, lui, se monte directement au schéma final** — le document de préparation
|
||
d'un site hébergeur porte déjà le VLAN 50 et les `192.168`.
|
||
|
||
## 2026-08-12 — P03 regarde TOUTES les instances, et trouve une collision au premier essai
|
||
|
||
P03 ne vérifiait la fraîcheur de l'inventaire que pour l'instance **active** — laissant un
|
||
tenant qu'on ne regarde pas imposer ses adresses à la frontière partagée. Elle boucle
|
||
désormais sur les instances **découvertes**, chacune vérifiée avec `SETOPS_INSTANCE`.
|
||
|
||
**Au premier passage, elle a trouvé une troisième instance périmée** — et une collision
|
||
franche que personne n'avait vue :
|
||
|
||
```
|
||
OPS-Chezlepro-lab (index 1) applique : 10.11.18.21 ← ancienne derivation (10+1)
|
||
OPS-Technolibre (index 11) derive : 10.11.x.x ← nouvelle derivation
|
||
```
|
||
|
||
Le lab occupait **exactement la plage désormais attribuée à Technolibre**. Sans cette
|
||
preuve, la collision serait apparue le jour où les deux auraient tourné ensemble — c'est
|
||
`P21` qui garde les index, rien ne gardait les inventaires *appliqués*.
|
||
|
||
Les trois instances sont maintenant alignées : `10.1` (lab), `10.11` (Technolibre),
|
||
`10.17` (Chezlepro).
|
||
|
||
> **Ce que la preuve ne fait pas** : vérifier que le boîtier porte ce que le devis dit —
|
||
> c'est `make frontiere-plan`. Elle garde l'**intrant** de ce devis, pas sa sortie. Les
|
||
> deux sont nécessaires, et c'est l'intrant qui manquait.
|
||
|
||
## 2026-08-12 — Un tenant périmé injecte ses vieilles adresses dans le pare-feu partagé
|
||
|
||
Après le renumérotage de Chezlepro, la frontière portait encore **17 adresses `10.21.x`**
|
||
— l'ancienne plage de **Technolibre**. Et `frontiere-plan` répondait *« la frontière dit
|
||
déjà ce que le devis dit »*.
|
||
|
||
Les deux étaient vrais. La frontière construit deux natures d'objets :
|
||
|
||
| Objet | Source | Comportement |
|
||
|---|---|---|
|
||
| `SETOPS_TENANT_<T>` (réseau) | **la formule** (`sous_reseau_de`) | s'est recalculé seul → `10.11` |
|
||
| `SETOPS_<T>_SERVEUR_*` (hôtes) | **le `hosts.yml` du tenant** | est resté à `10.21` |
|
||
|
||
Le devis lisait donc *fidèlement* une entrée périmée, et l'annonçait conforme. **Le
|
||
contrôle n'était pas faux — son intrant l'était.**
|
||
|
||
### Ce que ça révèle
|
||
|
||
La frontière est **partagée entre tous les tenants**, mais `P03` ne vérifie la fraîcheur
|
||
de l'inventaire que pour l'instance **active**. Un tenant qu'on ne regarde pas — parce
|
||
qu'il n'a aucune VM, précisément — continue d'imposer ses adresses au pare-feu de tout le
|
||
monde, sans qu'aucune preuve ne s'en aperçoive.
|
||
|
||
Corrigé en régénérant l'inventaire de Technolibre puis en réappliquant. Vérifié non pas
|
||
sur le devis mais sur la **configuration réelle du boîtier**, téléchargée et relue : il ne
|
||
reste que `10.0` (underlay), `10.11` et `10.17`. Zéro `10.21`, zéro `10.27`.
|
||
|
||
> **Ce qui manque encore** : rien ne prouve que *chaque* tenant fédéré a un inventaire à
|
||
> jour. P03 devrait boucler sur les instances découvertes, pas seulement sur l'active.
|
||
|
||
## 2026-08-12 — P03 peut enfin échouer, et elle échoue
|
||
|
||
`instancier.py comparer` affichait l'écart puis renvoyait **toujours `0`**. La preuve P03
|
||
« Diff-vide du plan » ne pouvait donc pas échouer : elle a passé au vert pendant que
|
||
quatorze hôtes divergeaient du plan.
|
||
|
||
Deux appelants, deux besoins — d'où un mode plutôt qu'un changement de comportement :
|
||
|
||
| Appelant | Attente |
|
||
|---|---|
|
||
| `make instancier` | **inspection** : voir le diff avant de décider. Un code d'erreur y transformerait la lecture en panne |
|
||
| **P03** | **affirmation** : « le plan reproduit l'inventaire ». Sans `--strict`, elle n'affirmait rien |
|
||
|
||
**`prouver` sort désormais à 1**, et c'est exact : depuis le retrait du décalage de `+10`,
|
||
le plan dérive `10.17.x.x` tandis que l'inventaire appliqué — et les quatorze VM qui
|
||
tournent — portent `10.27.x.x`. Le dépôt est sciemment dans cet état jusqu'au
|
||
renumérotage. Une preuve rouge qui dit vrai vaut mieux qu'une verte qui ne regarde rien.
|
||
|
||
## 2026-08-12 — L'index est borné : `10.300.0.0/16` n'est pas un réseau
|
||
|
||
Rien ne bornait `index`. `supernet_de(300)` rendait `"10.300.0.0/16"` — **une chaîne qui
|
||
ressemble à un réseau**. Elle traverse tout le moteur sans bruit et n'échoue qu'au premier
|
||
`ip_network()` qui la lit, très loin de l'index fautif.
|
||
|
||
La borne est posée **à la source** (`valider_index`), pas dans un validateur de plan :
|
||
toutes les fonctions dérivées y passent — `supernet_de`, `base3_de`, `vlan_de` — donc
|
||
aucune ne peut fabriquer une adresse invalide, d'où qu'on l'appelle : plan, GUI, devis ou
|
||
test.
|
||
|
||
Elle protège **un second plafond, moins visible** : à l'index 255 le VLAN vaut `3550+zone`,
|
||
sous les 4094 du 802.1Q. Un index à trois chiffres débordait aussi là.
|
||
|
||
Et une garde statique dans le contrôle de fédération (**P21**), qui nomme le dépôt fautif
|
||
au lieu de laisser l'erreur remonter d'une bibliothèque.
|
||
|
||
### Le test a trouvé ce que la relecture n'avait pas vu
|
||
|
||
`valider_index` n'attrapait que `TypeError` et `ValueError`. Or `int(float('inf'))` lève
|
||
**`OverflowError`** : un infini flottant passait la garde en la faisant planter au lieu de
|
||
la faire refuser. Corrigé — et c'est le cas de test qui l'a levé, pas ma relecture.
|
||
|
||
`test_underlay_bande_basse.py` devient `test_adressage_derive.py` : il ne parlait plus
|
||
seulement de la bande basse. **12 cas**, dont les refus.
|
||
|
||
> **Un piège de structure, au passage.** Les nouveaux cas, ajoutés après le bloc
|
||
> `if __name__ == "__main__":`, ne s'exécutaient pas — le bloc tourne avant que les
|
||
> fonctions suivantes ne soient définies, et le compte affichait tranquillement « 7 tests »
|
||
> au lieu de 12. Un harnais qui compte ses propres tests doit être lu : *sept* était la
|
||
> bonne réponse à la mauvaise question.
|
||
|
||
## 2026-08-12 — Le décalage de `+10` est retiré : l'index se lit dans l'adresse
|
||
|
||
`supernet_de(index)` rendait `10.(10+index).0.0/16`. **Personne ne savait plus pourquoi** —
|
||
ni le commentaire de la constante, ni le wiki de l'adressage, ni le commit fondateur
|
||
`36a882b` ne le justifiaient. Trois endroits consultés, zéro raison écrite.
|
||
|
||
Ses deux effets constatés :
|
||
|
||
- il **réservait `10.0`–`10.9`** sous la plage tenant. Utile tant que l'underlay vivait
|
||
là — mais **D-77 l'a fait entrer dans la bande basse de son propre `/16`**, ce qui a vidé
|
||
cette réserve de son rôle la veille ;
|
||
- il éloignait le premier tenant de `10.0.0.0/16`, la plage la plus répandue en réseau
|
||
domestique. **Ce risque revient donc aux index bas, et c'est assumé** : choisir un index,
|
||
c'est choisir sa plage — autant que ce soit lisible.
|
||
|
||
En échange, **l'index se lit directement dans l'adresse** — index 17 → `10.17.x.x` — et le
|
||
plafond passe de 245 à 255 écosystèmes fédérés.
|
||
|
||
| Instance | Avant | Après |
|
||
|---|---|---|
|
||
| Chezlepro (17) | `10.27.0.0/16` | `10.17.0.0/16` |
|
||
| Technolibre (11) | `10.21.0.0/16` | `10.11.0.0/16` |
|
||
| lab (1) | `10.11.0.0/16` | `10.1.0.0/16` |
|
||
|
||
Documentation alignée partout : les trois pages du wiki, `multi-instances.md` (dont le
|
||
plafond et l'exemple, devenus faux arithmétiquement), `sdn-evpn.md`, le libellé de la GUI,
|
||
la docstring d'`underlay.py`, D-77, et le document de préparation d'un site hébergeur. Les
|
||
**constats de terrain datés** — incidents dans les commentaires, `CHANGELOG`, rapports
|
||
d'audit — sont laissés tels quels : ce sont des mesures, pas des formules.
|
||
|
||
### Ce que ce commit ne fait PAS
|
||
|
||
Il ne renumérote rien. Il change ce que le plan **dérive** ; l'inventaire appliqué, lui,
|
||
porte toujours `10.27.x.x`, et les quatorze VM tournent dessus.
|
||
|
||
```
|
||
14 hote(s) avec ecart — ansible_host, proxmox_passerelle, setops_supernet
|
||
```
|
||
|
||
Appliquer cet inventaire **sans reconstruire la flotte la rendrait injoignable** : Ansible
|
||
chercherait des machines à des adresses que personne ne porte. Le renumérotage est une
|
||
opération à part — `instancier-appliquer`, puis SDN, puis reconstruction, puis frontière —
|
||
à mener à froid.
|
||
|
||
### Au passage : une preuve qui ne peut pas échouer
|
||
|
||
**P03 « Diff-vide du plan » ne prouve rien.** `instancier.py comparer` affiche l'écart puis
|
||
renvoie **toujours `0`** : la preuve passe quel que soit le nombre d'hôtes divergents. Elle
|
||
aurait dû crier ici, sur quatorze. Non corrigé dans ce commit — le corriger ferait échouer
|
||
`prouver` jusqu'au renumérotage, ce qui est exact mais bloquerait tout le reste. À traiter
|
||
**avec** le renumérotage, pas avant.
|
||
|
||
## 2026-08-12 — P23 outille D-77 : la bande basse devient une règle, pas une convention
|
||
|
||
D-77 disait où l'underlay doit vivre. Rien ne le vérifiait — et une convention qu'on
|
||
n'outille pas pourrit en silence (D-70). C'est exactement ce qui a laissé la sauvegarde
|
||
vide pendant un mois.
|
||
|
||
**Le contrôle disait l'inverse de la décision.** `underlay.py` refusait *tout*
|
||
chevauchement avec un supernet tenant. Il fallait le rendre plus **fin**, pas plus strict :
|
||
|
||
| Situation | Verdict |
|
||
|---|---|
|
||
| dans **son propre** supernet, bande basse | conforme — c'est la règle |
|
||
| dans son propre supernet, bande **haute** | **refusé** — collision avec ses propres zones |
|
||
| dans le supernet d'un **autre** site | **refusé** — les deux ne pourront jamais être reliés |
|
||
| hors de tout supernet (`10.0.x`, `192.168.x`) | conforme — héritage, et stockage |
|
||
|
||
La frontière est **dérivée** de `OCTET_ZONE`, jamais écrite en dur : déplacer la règle des
|
||
zones déplace la borne avec elle. Le site déclare son `index` dans `underlay.yml` ; sans
|
||
lui, on retombe sur la règle stricte d'avant D-77 — le comportement sûr pour un underlay
|
||
qui n'a pas encore migré. **Chezlepro reste donc conforme aujourd'hui**, en `10.0.x`.
|
||
|
||
### Le piège que le test attrape
|
||
|
||
Un préfixe peut **commencer** dans la bande basse et déborder : `10.21.0.0/19` couvre les
|
||
octets 0 à 31. Une vérification qui ne regarderait que le premier octet le laisserait
|
||
passer. La borne est donc évaluée sur **toute l'étendue** du préfixe.
|
||
|
||
Deux de mes propres cas d'épreuve étaient mal choisis — `10.21.14.0/23` et `10.21.12.0/21`
|
||
se normalisent entièrement dans la bande basse, et « conforme » y était la bonne réponse.
|
||
Il a fallu construire un préfixe qui franchit réellement la frontière pour éprouver la
|
||
garde.
|
||
|
||
`scripts/tests/test_underlay_bande_basse.py` — 7 cas, câblé dans `make test`.
|
||
|
||
## 2026-08-12 — `modeleSetOPS`, et une porte pour l'hébergeur
|
||
|
||
### Le gabarit portait le nom du mauvais propriétaire
|
||
|
||
`modeleChezlepro` était déclaré par les **trois** instances — Chezlepro, Technolibre et le
|
||
lab — qui pointaient déjà toutes sur le **même** VMID 99998. Le commentaire du rôle
|
||
affirmait pourtant « chaque tenant a SON golden template » : c'était faux depuis
|
||
longtemps, et personne ne pouvait le voir en lisant un seul fichier.
|
||
|
||
Renommé **`modeleSetOPS`** — sur le cluster et dans les trois instances. Le gabarit est un
|
||
artefact du **moteur**, pas d'un tenant, et le nom d'un tenant sur le gabarit d'un autre
|
||
était un piège qui n'attendait qu'un troisième hébergeur pour se refermer. Les scripts
|
||
d'amorçage (`model_creer.py`, `config_proxmox.py`) proposaient encore
|
||
`modele-debian13` : alignés eux aussi.
|
||
|
||
Sans risque : le clonage se fait **par VMID** depuis la correction du 2026-08-10 — le nom
|
||
ne sert plus qu'à l'affichage et aux vérifications. Rien n'empêche un tenant d'en désigner
|
||
un autre ; il change le champ **et** le VMID.
|
||
|
||
### Une porte de plus dans l'aiguillage : l'hébergeur
|
||
|
||
`docs/preparer-un-site-hebergeur.md` — pour quelqu'un qui **prête son matériel** sans rien
|
||
connaître de Set-OPS. Il ne décrit que ce que la machine ne peut pas deviner : le plan
|
||
d'adressage à respecter, la frontière, le stockage, l'hyperviseur, le gabarit, et la liste
|
||
exacte de ce qu'il doit transmettre en retour.
|
||
|
||
Écrit à partir du dépôt, pas de conventions générales : les VLAN et MTU viennent
|
||
d'`underlay.yml`, le bloc par tenant de `inventory_rules.supernet_de()`, les privilèges du
|
||
jeton de `config-proxmox.md`, et le dimensionnement (**~460 Go, ~37 Go de RAM pour
|
||
quatorze VM**) d'une mesure sur la flotte vivante.
|
||
|
||
Deux avertissements y sont écrits parce qu'ils ont déjà coûté cher ici : **un gabarit
|
||
personnalisé recopie son identité dans chaque clone**, et **un blocage contourné en
|
||
silence se paie en heures** — la panne est alors cherchée au mauvais endroit.
|
||
|
||
Le rôle `Administrator` sur le jeton Proxmox y est recommandé **et signalé comme tel**,
|
||
avec le minimum documenté en regard : mieux vaut un privilège large assumé et resserré
|
||
ensuite qu'un privilège serré qu'on élargit en panique au milieu d'un déploiement.
|
||
|
||
## 2026-08-12 — La donnée revient : restauration éprouvée, pas seulement sauvegarde
|
||
|
||
On savait que la donnée partait et arrivait. On ne savait pas qu'elle **revenait** — et
|
||
c'est le seul test qui compte le jour venu.
|
||
|
||
### Les trois charges critiques, éprouvées pour de vrai
|
||
|
||
| Charge | Preuve | Résultat |
|
||
|---|---|---|
|
||
| **Clés de l'AC** (`infra-pki-01`) | restauration + comparaison **octet pour octet** avec le vivant | 12 fichiers, **11 identiques**, `root_ca_key` et `intermediate_ca_key` compris |
|
||
| **Annuaire** (`idm-01`) | `slapadd -u` (essai à blanc) sur le LDIF restauré | **rejouable**, 7 entrées dont `uid=sysadmin` |
|
||
| **Bases** (`data-sql-01`) | section `forgejo` **rejouée** dans une base d'épreuve | **0 erreur, 130 tables**, comptes réels (`forgejo-admin`, `sysadmin`) |
|
||
|
||
Le seul écart sur l'AC est `db/000000.vlog` — le journal badger de step-ca, qui avance à
|
||
chaque émission de certificat. Attendu, pas un défaut.
|
||
|
||
**Contrôles négatifs, parce qu'un test qui dit toujours oui ne teste rien** : un LDIF
|
||
volontairement corrompu fait sortir `slapadd` en 1 ; la garde SQL a refusé une section mal
|
||
découpée (voir ci-dessous). Production vérifiée intacte après le rejeu.
|
||
|
||
### Le piège de `pg_dumpall`, trouvé par la garde
|
||
|
||
`pg_dumpall` écrit `CREATE DATABASE <suivante>` **avant** le `\connect` correspondant.
|
||
Découper « du `\connect X` au `\connect` suivant » emporte donc un ordre visant une
|
||
**autre** base. Ma première découpe l'a fait ; la garde a refusé de rejouer. Sans elle, un
|
||
essai de restauration aurait touché `icingadb`. Consigné dans `runbooks-exploitation.md` §5.
|
||
|
||
### La recette ne ment plus
|
||
|
||
`playbooks/valider.yml` exigeait une restauration de **tous** les nœuds `client_backup` —
|
||
elle échouait donc sur ceux qui ne détiennent légitimement rien, et sur les dépôts vides.
|
||
Elle distingue désormais quatre verdicts : `OK`, `À CONFIRMER` (restauré mais vide),
|
||
`SANS OBJET`, `ÉCHEC` (la restauration elle-même). **Alignés sur ceux de la supervision** :
|
||
deux verdicts opposés sur le même fait apprendraient à en ignorer un.
|
||
|
||
Elle prouve en outre que l'annuaire restauré est **rejouable**, pas seulement présent.
|
||
|
||
`make valider` : **0 échec** sur toute la flotte.
|
||
|
||
## 2026-08-12 — Le `curl -k` est mort, mais pas comme prévu
|
||
|
||
Objectif : que le rapporteur **vérifie** le pair en appelant l'API Icinga, au lieu de
|
||
sauter la vérification. C'est fait — et la manière a été imposée par la mesure, pas par
|
||
le plan.
|
||
|
||
### Servir un certificat step-ca sur l'API est IMPOSSIBLE
|
||
|
||
La tentative était directe : `client_pki` dépose déjà sur `mon-01` un certificat portant
|
||
`serverAuth` + `clientAuth` et le bon SAN. Il suffisait de le faire servir. Icinga le
|
||
refuse, et le dit lui-même :
|
||
|
||
```
|
||
information/ApiListener: Our certificate will expire soon, but we own the CA. Renewing.
|
||
```
|
||
|
||
Icinga renouvelle tout certificat expirant **sous 30 jours**. Les certificats Set-OPS
|
||
vivent **24 h**. Possédant une AC, il ré-émet donc avec la sienne — écrasant le nôtre à
|
||
chaque démarrage. Ce n'est pas réparable par configuration : c'est une collision entre
|
||
deux politiques de PKI, et la nôtre (certificats courts) n'est pas négociable.
|
||
|
||
Deux découvertes en chemin, toutes deux par le garde-fou `icinga2 daemon -C` ajouté au
|
||
rôle — qui a **arrêté le déploiement avant** de redémarrer la supervision :
|
||
|
||
- `cert_path` / `key_path` / `ca_path` sont **dépréciés depuis 2.8** ; les poser réveille
|
||
un chemin de code hérité qui exige en plus un objet `Endpoint`.
|
||
- L'identité de l'API est le **CN du certificat**. `NodeName` valait `mon-01` : le
|
||
certificat auto-émis portait donc `SAN=mon-01` alors qu'on appelle par le FQDN, et
|
||
**aucune** vérification n'aurait pu réussir. `NodeName` est désormais aligné sur le FQDN.
|
||
|
||
### Ce qu'on fait à la place
|
||
|
||
Icinga garde son AC — un domaine de confiance **fermé**, ce qui est légitime — et
|
||
`backup-01` vérifie le pair **contre cette AC-là**, récupérée depuis `mon-01` au
|
||
déploiement. Le pair est authentifié ; seule la racine diffère. Le `-k` a disparu, ce qui
|
||
était le vrai problème.
|
||
|
||
**Contrôle négatif, parce qu'une vérification qu'on ne teste pas est un ornement** : avec
|
||
la mauvaise AC (celle de step-ca), `curl` refuse — `unable to get local issuer
|
||
certificate`. Avec la bonne, les neuf rapports passent.
|
||
|
||
### Et la sous-AC step-ca ?
|
||
|
||
Écartée, et pas par prudence de principe. Elle poserait sur l'hôte de supervision une clé
|
||
capable d'**émettre** pour n'importe quel nom de l'écosystème — alors qu'on a justement
|
||
choisi le sens du flux (le dépôt parle à la supervision, jamais l'inverse) pour que
|
||
compromettre `mon-01` ne donne rien. Une AC isolée pour un domaine isolé est le bon
|
||
design, pas une entorse à la souveraineté.
|
||
|
||
## 2026-08-11 — Les sauvegardes sont surveillées, et c'est le dépôt qui parle
|
||
|
||
Icinga ne surveillait **rien** : aucun objet `Host` ni `Service` de Set-OPS, seulement la
|
||
configuration Debian d'origine pointant sur `localhost`.
|
||
|
||
### On ne supervise pas l'unité — on supervise ce qui est arrivé
|
||
|
||
Superviser `setops-sauvegarde.service` aurait reproduit le défaut du jour même : l'unité
|
||
était **verte** sur onze nœuds pendant qu'elle n'emportait rien. Le nœud sait qu'il a
|
||
*lancé* sa sauvegarde ; il ne sait pas qu'elle est *arrivée*. Seul le dépôt le voit.
|
||
|
||
`backup-01` évalue donc ses dépôts restic et **pousse un résultat passif par nœud** vers
|
||
l'API Icinga. Trois critères, parce qu'un seul suffit à mentir :
|
||
|
||
| Critère | Ce qu'il attrape |
|
||
|---|---|
|
||
| l'instantané **existe** | la sauvegarde n'arrive pas |
|
||
| il est **récent** (26 h / 50 h) | elle a cessé d'arriver |
|
||
| il contient **au moins un fichier** | elle arrive mais ne porte rien |
|
||
|
||
Le sens du flux est délibéré : le dépôt parle à la supervision, jamais l'inverse. Un seul
|
||
flux nouveau, et compromettre `mon-01` ne donne aucun accès aux sauvegardes.
|
||
|
||
### Le silence alerte
|
||
|
||
Le `ttl` de 6 h porté par chaque envoi fait la fraîcheur : si le rapporteur se tait,
|
||
Icinga périme les services tout seul. **C'est le silence qui a laissé le défaut vivre un
|
||
mois** — il devait devenir la première chose qui alerte. Le rapporteur, lui, refuse
|
||
d'avaler ses propres échecs et sort en erreur.
|
||
|
||
### Deux erreurs de conception, corrigées par la mesure
|
||
|
||
**Le corps `--data-urlencode` était refusé** en `Bad Request` : l'API veut du JSON. Le flux,
|
||
le TLS et l'authentification fonctionnaient — seule la charge était perdue. Sans lecture du
|
||
journal d'Icinga, un `curl` silencieux aurait été pris pour un succès.
|
||
|
||
**Le seuil « vide » en octets était faux.** Il signalait `idm-01` (2 363 octets) alors qu'un
|
||
export LDIF d'un annuaire à un compte pèse légitimement cela. « Vide » se mesure en
|
||
**fichiers**, pas en taille : zéro fichier, c'est exact quelle que soit la taille. Et le
|
||
verdict est un **avertissement**, pas un critique — la machine ne peut pas distinguer « les
|
||
données ont disparu » de « il n'y en a pas encore », mais l'humain doit le voir.
|
||
|
||
### Mesuré de bout en bout
|
||
|
||
```
|
||
idm-01 OK 1 fichier, 2 363 o (l'annuaire) infra-mail-01 AVERT. aucun fichier
|
||
infra-pki-01 OK 20 621 o (les clés de l'AC) web-frontal-01 AVERT. aucun fichier
|
||
collab-01 OK 67 129 221 o web-dorsal-01 AVERT. aucun fichier
|
||
data-sql-01 OK 1 085 158 o (toutes les bases)
|
||
```
|
||
|
||
**Réserve assumée** : le rapporteur appelle l'API en `curl -k`. L'API Icinga présente le
|
||
certificat de sa propre AC (`icinga2 api setup`), pas celui de step-ca — la liaison est
|
||
chiffrée mais le pair n'est pas vérifié. C'est la réserve connue sur `5665`, et elle reste
|
||
ouverte.
|
||
|
||
## 2026-08-11 — La sauvegarde emporte enfin quelque chose
|
||
|
||
**Correction de l'entrée précédente** : j'y attribuais le défaut à la reconstruction
|
||
from-zero. C'est faux. Le commit fondateur `7476a54` (2026-07-03) le disait lui-même —
|
||
*« Reste : jobs Tier 1 (pg_dump/slapcat/vmail/forgejo) »*. Ces jeux n'ont jamais été
|
||
écrits. Le Tier 0 était prouvé sur `infra-pki-01`, mais l'hôte a ensuite perdu son
|
||
intégration `client_backup` sans que rien ne le dise.
|
||
|
||
### Le catalogue vit dans le rôle, dérivé de l'appartenance aux groupes
|
||
|
||
C'est le rôle qui **possède** la donnée qui dit comment la sortir. `client_backup_jobs` est
|
||
l'intersection du catalogue et des `group_names` du nœud : un tenant qui déplace un service
|
||
emporte sa sauvegarde avec lui, sans rien redéclarer. On sauvegarde l'**état non
|
||
régénérable** — ni les zones PowerDNS ni les tableaux de bord Grafana n'y figurent, ils se
|
||
redéploient.
|
||
|
||
Les chemins ne peuvent pas référencer les defaults du rôle propriétaire : `make deployer`
|
||
déroule un play par groupe, et ceux de `serveur_forgejo` ne sont pas chargés pendant le play
|
||
de `client_backup`. D'où la forme `var | default(littéral)`.
|
||
|
||
### L'unité qui ment est retirée, pas rendue bloquante
|
||
|
||
Refuser le déploiement d'un nœud sans jeu aurait cassé `infra-edge-01`, `infra-dns-01` et
|
||
`mon-01`, qui ne détiennent légitimement rien. Le défaut n'était pas là : il était dans le
|
||
timer qui échouait chaque nuit en donnant l'apparence d'une sauvegarde. Le rôle installe
|
||
donc la sauvegarde **si et seulement si** un jeu s'applique, et **retire** celle qui
|
||
existerait. *Une sauvegarde qui ne sauvegarde rien est pire que pas de sauvegarde : elle
|
||
rassure.*
|
||
|
||
### P36 — tout détenteur d'état porte une sauvegarde
|
||
|
||
L'écart était lisible dans le plan depuis un mois (D-75). La preuve lit les groupes
|
||
détenteurs dans `client_backup_catalogue` : ajouter un rôle au catalogue étend la preuve du
|
||
même geste. Elle a immédiatement attrapé `infra-pki-01`, corrigé au plan.
|
||
|
||
### Mesuré, hors-nœud
|
||
|
||
| Hôte | Emporté |
|
||
|---|---|
|
||
| `collab-01` | 64,0 MiB · 272 fichiers |
|
||
| `edge-mta-01` | 4,4 MiB · 139 (bayes rspamd appris) |
|
||
| `data-sql-01` | 1,0 MiB · `pg_dumpall` de **toutes** les bases |
|
||
| `forge-01` | 26,4 KiB · 68 |
|
||
| `infra-pki-01` | 20,1 KiB · 21 — **les clés de l'AC** |
|
||
| `idm-01` | 2,3 KiB · 5 — l'annuaire par `slapcat` |
|
||
| `infra-mail-01`, `web-frontal-01`, `web-dorsal-01` | vides, et c'est exact : `/var/vmail`, `/srv/web` et `/srv/webapp` n'ont rien depuis la reconstruction du 2026-08-10 |
|
||
|
||
9 hôtes, 9 `success`, 5 hôtes sans sauvegarde parce qu'ils ne détiennent rien.
|
||
|
||
**Ce qui reste** : rien ne surveille encore l'unité. C'est ce silence qui a laissé le défaut
|
||
vivre un mois — Icinga devrait voir une unité systemd en échec.
|
||
|
||
## 2026-08-11 — En consignant l'effet du rasage, la sauvegarde s'est révélée vide
|
||
|
||
Il s'agissait d'écrire une conséquence connue : raser l'hôte qui porte `openldap` détruit
|
||
l'annuaire, donc le compte `sysadmin` est recréé depuis le jeton de la voûte et le
|
||
changement forcé est réarmé. Mesuré après le rasage de `idm-01` : `pwdReset: TRUE`, et le
|
||
mot de passe choisi par l'exploitant n'existe plus.
|
||
|
||
La perte réelle est ailleurs, et le §2 la rendait prévisible : les appartenances ne sont
|
||
**jamais réconciliées**. Set-OPS crée *un* compte. Tout ce que l'exploitant a construit
|
||
depuis est détruit et ne sera pas recréé — contrepartie exacte du régime qui protège ces
|
||
décisions du prochain `make deployer`.
|
||
|
||
### Le contrôle qui devait rattraper ça ne fonctionne pas
|
||
|
||
En cherchant où pointer pour la restauration, mesure sur les 14 hôtes :
|
||
|
||
| Hôtes | État |
|
||
|---|---|
|
||
| 11 (dont `idm-01`, `data-sql-01`, `forge-01`, `collab-01`) | `setops-sauvegarde.service` **en échec chaque nuit** — `Fatal: nothing to backup` |
|
||
| `infra-pki-01`, `obs-01`, `backup-01` | **aucune sauvegarde déployée** — et `infra-pki-01` porte les clés de l'AC |
|
||
|
||
`client_backup_jobs` vaut `[]` par défaut et **rien ne le surcharge** dans l'instance : le
|
||
timer tourne, restic initialise son dépôt, puis échoue faute de source. **Aucune donnée de
|
||
cet écosystème n'est sauvegardée.** Vraisemblablement une victime de la reconstruction
|
||
from-zero — les déclarations par nœud n'ont pas été redéclarées dans l'instance régénérée.
|
||
|
||
Consigné tel que mesuré dans `autorisation.md` §3.1, avec la sortie manuelle de l'annuaire
|
||
en attendant la correction. **Le défaut n'est pas corrigé par ce commit** : il est rendu
|
||
visible, et une unité en échec qui n'alerte personne est le second défaut à traiter.
|
||
|
||
## 2026-08-11 — L'ancre Keycloak existe (et la commande que j'avais donnée ne prouvait rien)
|
||
|
||
La vérification de signature était en place, mais elle ne prouvait que « la même clé
|
||
qu'hier ». Restait à établir que cette clé est bien celle de Keycloak.
|
||
|
||
**La première tentative était circulaire.** `gpg --recv-keys <empreinte>` demande la clé
|
||
*par son empreinte* — or une empreinte est le condensat du matériel de la clé : le serveur
|
||
ne peut rien renvoyer d'autre. Confirmer que la clé reçue porte l'empreinte demandée
|
||
n'établit donc rien. Et un serveur de clés n'est pas une autorité : n'importe qui y
|
||
téléverse n'importe quelle clé avec n'importe quel UID (GPG l'affiche : `[ unknown ]`).
|
||
|
||
La manœuvre a tout de même révélé l'identité : **`Keycloak Bot <keycloak.bot@gmail.com>`**,
|
||
ed25519 créée le 2024-02-13, expirant le 2027-02-12. Et, mesuré localement, la clé est
|
||
**auto-signée uniquement** — aucune certification tierce, aucune toile de confiance.
|
||
|
||
**L'ancre réelle** : <https://www.keycloak.org/keys> publie
|
||
`861ab50e8cc6611fb6bc01a6b8f12ea26fd6eeba`, identique à l'épinglage. Ce canal
|
||
(`keycloak.org`) est **distinct de celui qui livre l'archive** (`github.com`) — la
|
||
propriété qu'avait déjà Forgejo et qui manquait ici.
|
||
|
||
Deux réserves consignées dans `roles/serveur_keycloak/defaults/main.yml` plutôt que
|
||
passées sous silence : la page décrit la clé comme servant aux **artefacts Maven** (c'est
|
||
notre vérification qui établit que le `.asc` de l'archive est validé par elle), et
|
||
l'ancrage vaut ce que vaut le contrôle de `keycloak.org` — DNS et TLS.
|
||
|
||
## 2026-08-11 — Les épinglages éprouvés en vrai, par une reconstruction ciblée
|
||
|
||
Les rôles **installent, ils ne mettent pas à jour** (`creates:`). Relever une version ne
|
||
change donc rien tant qu'une machine neuve ne la rencontre pas. Restait à l'éprouver sans
|
||
raser les quatorze VM pour trois.
|
||
|
||
`make raser` accepte `HOTE=` et ne peut que **restreindre**. Trois VM concernées, rasées ;
|
||
leurs trois bases supprimées puis recréées **vides** par `serveur_postgresql` depuis le
|
||
registre — 7425 Ko chacune, la taille d'une base neuve.
|
||
|
||
| VM | Version obtenue | Livraison réelle, via le frontal, TLS validé |
|
||
|---|---|---|
|
||
| `idm-01` | Keycloak **26.7.1** | document OIDC complet du realm `chezlepro` |
|
||
| `forge-01` | Forgejo **16.0.2** | `{"version":"16.0.2+gitea-1.22.0"}` |
|
||
| `collab-01` | Nextcloud **34.0.2.1** | `installed:true, needsDbUpgrade:false` |
|
||
|
||
33 couches, **0 échec, 0 injoignable**, en ~15 minutes contre 54 pour une reconstruction
|
||
complète.
|
||
|
||
### Ce que la manœuvre a réellement prouvé
|
||
|
||
Les deux vérifications de signature PGP se sont exécutées **en conditions réelles**, sans
|
||
`ignore_errors` ni `failed_when: false` : un refus aurait cassé le play *avant* le dépôt de
|
||
l'archive. L'épinglage sur la clé **primaire** de Forgejo tient — la 16.0.2 est signée par
|
||
une sous-clé différente de celle de la 12.0.0, et la vérification passe sans qu'on ait eu à
|
||
baisser la garde.
|
||
|
||
Supprimer les bases n'était pas une commodité : `occ maintenance:install` refuse une base
|
||
peuplée. Garder les bases aurait fait échouer Nextcloud, et fait traverser six majeures à
|
||
Forgejo.
|
||
|
||
Verdict : **sept devis CONFORME**, `prouver.py` **35 OK / 0 échec**.
|
||
|
||
Deux points restent ouverts, et il faut le dire : l'ancre de confiance Keycloak repose
|
||
encore sur la **continuité** — rien dans la machine n'établit que l'empreinte épinglée est
|
||
la bonne, c'est la décision humaine que `verifier_signature.py` dit explicitement ne pas
|
||
pouvoir prendre. Et Collabora tourne toujours **dans Docker** sur `collab-01`.
|
||
|
||
## 2026-08-11 — Forgejo à 16.0.2, et une ancre de confiance qui existe vraiment
|
||
|
||
Six versions majeures d'un coup — mais la découverte importante est ailleurs.
|
||
|
||
### La « rotation de clé » n'en était pas une
|
||
|
||
Quatre versions, trois signataires différents :
|
||
|
||
```
|
||
10.0.0 → B3B1F60AC577F2A2 14.0.0 → C4186DF66F4B6750
|
||
12.0.0 → D0A820050E1609E5 16.0.2 → C4186DF66F4B6750
|
||
```
|
||
|
||
Ce ne sont pas des clés distinctes : ce sont des **sous-clés de signature** sous une **clé
|
||
primaire stable depuis 2022** — `EB114F5E…C5923710`, `Forgejo <contact@forgejo.org>`. La
|
||
sous-clé `0F527CF9…0E1609E5` est bien celle qui avait signé la 12.0.0.
|
||
|
||
**D'où une correction du vérificateur** : il comparait l'empreinte du *signataire*, donc une
|
||
sous-clé. Il aurait échoué à chaque rotation légitime — et on aurait appris à lever la garde
|
||
pour avancer, ce qui est la pire chose qui puisse arriver à un contrôle. Il accepte désormais
|
||
la **clé primaire** (dernier champ de `VALIDSIG`), qui survit aux rotations tout en refusant
|
||
une clé étrangère.
|
||
|
||
### Forgejo a l'ancre que Keycloak n'a pas
|
||
|
||
`forgejo.org/download` **publie l'empreinte** — et le binaire vient de `codeberg.org`. La
|
||
source de confiance est donc **indépendante du canal de livraison**, exactement ce qui
|
||
manquait pour Keycloak. Forgejo publie en outre une somme `sha256`, vérifiée conforme.
|
||
|
||
Le projet annonce lui-même la rotation : *« the GPG key is updated on a regular basis »* —
|
||
ce qui confirme qu'épingler la primaire est le bon choix.
|
||
|
||
### Vérifié, dans les deux sens
|
||
|
||
Nominal `0`. Binaire altéré d'un octet `1`. Empreinte de Keycloak appliquée à Forgejo `1`.
|
||
Signature d'un *autre* artefact `1`. Et Keycloak ne régresse pas après la modification du
|
||
comparateur.
|
||
|
||
`make versions-mesurer` : **0 en retard**. Rôle appliqué de bout en bout sur `forge-01`.
|
||
|
||
Rappel du modèle : `forge-01` tourne toujours 10.0.0 — l'épinglage décrit ce qu'on
|
||
**installe**, pas ce qui **tourne**.
|
||
|
||
|
||
## 2026-08-10 — Keycloak : vérifier QUI a produit l'archive, pas seulement qu'elle est intacte
|
||
|
||
L'exploitant : *« j'ai besoin d'une confiance réelle. Keycloak est probablement l'élément le
|
||
plus dangereux de cet écosystème. »* C'est exact — Keycloak signe les jetons de **tout**
|
||
l'écosystème. Une archive substituée là, et l'identité entière tombe.
|
||
|
||
### Une correction, d'abord
|
||
|
||
J'avais écrit que « Keycloak ne publie aucune somme de contrôle ». **Faux, et l'exploitant
|
||
l'a relevé.** Mesuré ensuite :
|
||
|
||
```
|
||
26.6.2 : .sha1 200 .md5 200 .asc 200
|
||
26.7.0 : .sha1 404 .md5 404 .asc 200
|
||
26.7.1 : .sha1 404 .md5 404 .asc 200
|
||
```
|
||
|
||
Les sommes existaient **jusqu'à 26.6.2**, puis ont disparu. Et surtout : j'avais raté le
|
||
`.asc` — une **signature PGP**, présente sur toutes les versions, et plus forte qu'une somme.
|
||
Une somme prouve qu'un fichier n'a pas été corrompu ; une signature prouve **qui l'a
|
||
produit**.
|
||
|
||
### Ce qui est établi, et ce qui ne l'est pas
|
||
|
||
La même clé `861AB50E…6FD6EEBA` a signé **26.0.7** (la version alors en production),
|
||
**26.3.0**, **26.6.2** et **26.7.1**. C'est une continuité réelle.
|
||
|
||
Mais **aucune source indépendante ne publie cette empreinte** : ni `keycloak.org/downloads`,
|
||
ni la page *getting started*, ni `SECURITY.md`, ni un fichier `KEYS`. Elle est absente de
|
||
`keys.openpgp.org` ; on la trouve sur `keyserver.ubuntu.com`, qui n'est pas une autorité. Et
|
||
la somme `.sha1` n'ajoute rien : même canal que l'archive et la signature.
|
||
|
||
**On peut donc prouver la continuité, pas l'origine.** L'ancre est une décision humaine — et
|
||
elle est maintenant *écrite*, versionnée, et vérifiée à chaque téléchargement.
|
||
|
||
### `scripts/verifier_signature.py`
|
||
|
||
Trois exigences, chacune contre un contournement précis :
|
||
|
||
- **la clé publique vit dans le dépôt** (`roles/serveur_keycloak/files/keycloak-release.asc`),
|
||
versionnée et relue — aucune interrogation de serveur de clés au déploiement ;
|
||
- **l'empreinte est épinglée** à côté de la version : une rotation de clé en amont devient un
|
||
échec bruyant qui exige une relecture, pas un remplacement silencieux ;
|
||
- **trousseau jetable** (`GNUPGHOME` temporaire) : le trousseau personnel n'est ni lu ni
|
||
modifié, et deux machines donnent la même réponse.
|
||
|
||
Il lit `VALIDSIG` et compare l'empreinte du **signataire réel** à celle épinglée — se
|
||
contenter de « bonne signature » laisserait passer une signature valide faite par une autre
|
||
clé du trousseau.
|
||
|
||
**Éprouvé sur cinq cas** : nominal `0` ; artefact altéré d'un octet `1` ; empreinte épinglée
|
||
différente `1` ; clé du dépôt corrompue `1` ; signature absente `1`.
|
||
|
||
### Ce que ça ne prouve pas
|
||
|
||
Que l'empreinte épinglée soit la bonne. Aucune machine ne peut l'établir. Le script garantit
|
||
seulement qu'on ne s'en écarte plus sans le voir.
|
||
|
||
|
||
## 2026-08-10 — `make versions-mesurer` : l'écart avec l'amont devient lisible
|
||
|
||
Question de l'exploitant : *« ne devrait-on pas prendre les versions les plus récentes ? »*
|
||
|
||
**Non**, et la réponse tient en trois points. Résoudre « la dernière » au moment du
|
||
déploiement **détruirait la reproductibilité** — celle-là même qu'on vient de prouver en
|
||
rasant et remontant deux écosystèmes. Ça transformerait chaque déploiement en **loterie** :
|
||
une heure de reconstruction ne doit pas dépendre de ce qu'un tiers a publié cette nuit. Et
|
||
six versions majeures de Forgejo ne s'avalent pas en effet de bord d'un `make` — ça se fait
|
||
délibérément, avec une sauvegarde avant et une vérification après.
|
||
|
||
**Mais le vrai problème était ailleurs, et l'exploitant avait raison de tirer le fil :
|
||
l'écart était invisible.** Il a fallu quatre requêtes à la main pour découvrir l'état réel :
|
||
|
||
| Composant | Épinglé | Publié |
|
||
|---|---|---|
|
||
| oauth2-proxy | `v7.15.3` | **à jour** |
|
||
| Nextcloud | `34.0.1` | `34.0.2` |
|
||
| Keycloak | `26.0.7` | `26.7.1` |
|
||
| Forgejo | `10.0.0` | **`v16.0.2`** |
|
||
|
||
Le devis ne juge pas, il **renseigne**. Un retard n'est pas une faute ; il sort donc en 0.
|
||
|
||
**Ce qui le distingue d'une liste écrite à la main** : les versions épinglées sont *dérivées*
|
||
du dépôt (`roles/*/defaults/*_version`). La source amont, elle, ne peut pas se dériver —
|
||
elle dépend de l'éditeur — et vit dans une table. **Toute version épinglée sans entrée dans
|
||
cette table fait sortir en erreur.** Sans cette garde, un épinglage ajouté demain vieillirait
|
||
sans que personne ne le voie, et on croirait tout surveillé alors qu'on ne verrait plus rien.
|
||
Une exemption reste possible, mais **écrite et motivée** — deux le sont déjà (la série PHP de
|
||
Debian, la version PostgreSQL vide par défaut).
|
||
|
||
Éprouvé dans les deux sens : un `serveur_redis_version` ajouté temporairement fait sortir en
|
||
**1** en le nommant ; retiré, retour à **0**.
|
||
|
||
**Et il rappelle ce qu'on n'épingle pas.** Grafana n'a aucune version dans le dépôt — le
|
||
`13.1.1` vu dans l'historique `apt` était simplement ce que le dépôt Grafana servait ce
|
||
jour-là. Debian, Grafana, smallstep et Icinga prennent tous ce qu'on leur donne au moment du
|
||
déploiement. **Deux régimes coexistent dans la même flotte**, et les taire donnerait
|
||
l'illusion que tout est maîtrisé.
|
||
|
||
## 2026-08-10 — `raser` n'annonce plus des destructions qui n'ont pas eu lieu
|
||
|
||
Le défaut noté la veille est corrigé. `raser` lisait l'**accusé de réception** de l'API et
|
||
concluait au succès : le `DELETE` rend un UPID et la main immédiatement, la destruction se
|
||
fait en tâche de fond, et elle peut échouer **après**. Le 2026-08-10, six VM ont été
|
||
rapportées « détruites » alors qu'elles étaient toujours là — la tâche sortait sur
|
||
`VM is locked (clone)`, un verrou laissé par des clonages interrompus.
|
||
|
||
Confondre « demande acceptée » et « travail fait » est le pire mensonge possible pour la
|
||
**seule commande destructive du moteur** : on croit la place libre, on relance la
|
||
construction, et rien ne se crée sans qu'on comprenne pourquoi.
|
||
|
||
`_attendre_tache()` relit l'UPID, interroge l'état jusqu'à `stopped`, et rend l'`exitstatus`
|
||
réel. Chaque VM est annoncée détruite **ou** en échec, avec la cause telle que le cluster
|
||
l'a donnée — et le compte final ne ment plus.
|
||
|
||
### Un test qui exerce le défaut, pas seulement le correctif
|
||
|
||
`test_raser_resultat.py` fabrique la situation exacte : un faux cluster qui accepte tout,
|
||
puis rend une tâche **terminée en erreur**. `raser` doit sortir en 1, nommer la cause, et
|
||
n'annoncer aucune destruction.
|
||
|
||
**Éprouvé dans les deux sens** — c'est ce qui distingue un test d'une décoration. Avec
|
||
l'ancien comportement rétabli temporairement, il échoue en désignant précisément le défaut
|
||
(« raser a rendu 0 alors que la destruction a ÉCHOUÉ ») ; avec le correctif, il passe.
|
||
Raccordé à `make test`, donc rejoué par **P02**.
|
||
|
||
C'est le même motif que le clonage corrigé une heure plus tôt, dans l'autre sens : une
|
||
opération asynchrone dont on ne vérifie pas l'issue. Les deux venaient du passage à des
|
||
appels d'API directs, où plus rien n'attend à notre place.
|
||
|
||
## 2026-08-10 — Le clonage ne s'attendait plus lui-même, et ça a saturé le stockage
|
||
|
||
**Mon optimisation de la veille au soir a mis le cluster à genoux, et la faute est entière.**
|
||
|
||
En remplaçant `proxmox_kvm` par un appel d'API direct (pour corriger la résolution par nom),
|
||
j'ai perdu quelque chose que le module faisait pour moi : **attendre la fin de la tâche**
|
||
(`timeout: 600`). `POST .../clone` rend un UPID et la main immédiatement ; Proxmox copie le
|
||
disque en tâche de fond.
|
||
|
||
En séquentiel ça ne se voyait pas — l'attente de SSH qui suit absorbait le délai. **En
|
||
parallèle, c'est tout autre chose** : `make creer-vm` rendait la main pendant la copie, la
|
||
limite de concurrence ne retenait plus que des **processus vides**, et les clones
|
||
s'empilaient. Mesuré : limite à 4, **quatorze copies intégrales du gabarit simultanées**.
|
||
|
||
Le symptôme trompait : **CPU de l'hyperviseur à 2 %**, RAM à 13/62 Gio — et tout ramait. Ce
|
||
n'était pas la machine, c'était TrueNAS (LVM sur iSCSI). Les 14 hôtes ont échoué, et
|
||
`flotte-creer` a refusé de continuer — la garde ajoutée le matin même a fait son travail.
|
||
|
||
**Le clonage attend désormais la fin réelle** : il relit l'UPID rendu par l'API et interroge
|
||
l'état de la tâche jusqu'à `stopped`, avec un message clair si la sortie n'est pas `OK`. La
|
||
limite de concurrence retrouve alors un sens — quatre clones *réels*, pas quatre coquilles.
|
||
|
||
### Au passage : `raser` annonçait des destructions qui échouaient
|
||
|
||
En nettoyant, `make raser` a rapporté « 6/6 VM détruites » alors que les six étaient toujours
|
||
là. L'API accepte le `DELETE`, rend un UPID… et la tâche échoue ensuite sur
|
||
`VM is locked (clone)`. **`raser` ne lit que la réponse immédiate, jamais le résultat.**
|
||
C'est exactement le même défaut, dans l'autre sens — noté ici, pas encore corrigé.
|
||
|
||
### Ce que les optimisations ont réellement donné
|
||
|
||
Reconstruction complète de Chezlepro, gabarit déplacé sur `CephNVMe` (proposition de
|
||
l'exploitant), concurrence à 3 : **`54 min 02 s` contre `1 h 12 min 48 s` — 26 % de moins,
|
||
zéro échec, 2 838 tâches.**
|
||
|
||
| Phase | Avant | Après |
|
||
|---|---|---|
|
||
| création des 14 VM | 22m08s | **17m57s** |
|
||
| amorçage PKI + DNS | 6m01s | 6m07s |
|
||
| six couches | 44m39s | **29m58s** |
|
||
|
||
**Le cache d'artefacts est le plus rentable, et de loin** : Nextcloud `10m55s → 4m14s`,
|
||
Forgejo `3m42s → 1m33s`. Vérifié — `skipping` sur chaque téléchargement, les fichiers du
|
||
cache portent toujours leur horodatage d'origine. J'avais annoncé un gain « limité à la part
|
||
téléchargement » : cette part était bien plus grosse que je ne le croyais, et la
|
||
décompression bz2 n'était pas le mur que je décrivais.
|
||
|
||
**`forks = 20`** : socle + durcissement + AC + enrôlement PKI des 14 hôtes, `3m09s → 1m37s`.
|
||
|
||
**Gabarit sur NVMe + 3 clones** : `1m35s → 1m17s` par VM. Le gain le plus modeste — les
|
||
disques **écrivent toujours sur TrueNAS**, donc seule la moitié du chemin a été traitée.
|
||
|
||
**L'amorçage n'a pas bougé**, et c'est cohérent : deux hôtes l'un après l'autre, aucun levier
|
||
ne s'y applique.
|
||
|
||
Sept devis **CONFORME** après coup, dont le MTU : les quatorze invités naissent à 1450 sans
|
||
le moindre geste.
|
||
|
||
## 2026-08-10 — Le contrôleur télécharge et pousse ; la cible ne tire plus d'Internet
|
||
|
||
**Inventaire mesuré** de ce qu'une reconstruction de tenant télécharge — environ **1,5 Gio** :
|
||
|
||
| Quoi | D'où | Taille |
|
||
|---|---|---|
|
||
| `nextcloud-34.0.1.tar.bz2` | download.nextcloud.com | **230 Mio** |
|
||
| `keycloak-26.0.7` | github.com/keycloak | **140 Mio** |
|
||
| `forgejo-10.0.0-linux-amd64` | codeberg.org | **101 Mio** |
|
||
| `oauth2-proxy v7.15.3` | github.com/oauth2-proxy | 18 Mio |
|
||
| image `collabora/code` | Docker Hub | **471 Mio** |
|
||
| paquets `apt` | Debian + Grafana + smallstep + Icinga | le reste, ×14 hôtes |
|
||
|
||
**Les quatre premières sont épinglées en version et vont chacune sur UN SEUL hôte.** Les
|
||
retélécharger à chaque reconstruction est un gaspillage, et une dépendance de plus sur le
|
||
chemin critique — un serveur tiers lent a déjà fait tomber un déploiement de flotte le
|
||
2026-08-09, sur le binaire Forgejo précisément.
|
||
|
||
### Pourquoi pousser plutôt que servir un cache
|
||
|
||
L'exploitant proposait son poste comme cache HTTP. L'intention est juste, mais elle butait
|
||
sur ce qu'on avait fermé le matin même : les règles sortantes visent `!SETOPS_INTERNES`,
|
||
donc les trois blocs privés. Une VM de tenant **ne peut plus atteindre le poste**. Servir un
|
||
cache aurait exigé de **rouvrir un flux vers le plan d'administration**.
|
||
|
||
L'inversion évite le problème entier : le contrôleur télécharge dans son cache
|
||
(`~/.cache/setops`, gardé par un `stat` — une fois, jamais deux), puis **pousse par le canal
|
||
SSH qui existe déjà**. Aucun port, aucun service, aucune règle, aucun couplage.
|
||
|
||
Effet recherché en prime : ces artefacts deviennent **déployables hors ligne** une fois le
|
||
cache rempli. Sur une plateforme qui se veut souveraine, ce n'est pas un détail.
|
||
|
||
### Ce que ça ne couvre pas, et qu'il faut nommer
|
||
|
||
- **`collabora/code`, 471 Mio depuis Docker Hub.** `serveur_collabora` installe `docker.io`
|
||
et tire une image — la **seule entorse** à la doctrine « plateforme native, zéro Docker »
|
||
du dépôt. C'est aussi ce qui explique les règles `docker0` du `nftables` généré. Elle
|
||
mérite sa propre décision, pas un contournement discret.
|
||
- **Les paquets `apt`**, quatorze `apt update` contre les mêmes dépôts. `apt-cacher-ng` est
|
||
la bonne réponse, mais il lui faut un hôte toujours allumé et un flux déclaré : sa place
|
||
est côté **hébergeur**, partagé par les tenants — pas sur le poste.
|
||
|
||
### Une mesure qui a contredit mon hypothèse
|
||
|
||
J'avais avancé que le `.zip` de Nextcloud décompresserait plus vite que le `.tar.bz2`.
|
||
Vérification : **271 Mio contre 230**. Il télécharge donc *plus* pour décompresser *moins
|
||
lentement*. Le gain net n'est pas établi — le changement de format reste en attente d'une
|
||
mesure, pas d'une intuition.
|
||
|
||
## 2026-08-10 — Deux optimisations, choisies sur la mesure et non sur l'intuition
|
||
|
||
Le chronométrage d'une reconstruction complète (`1 h 12 min 48 s`, phase par phase) a
|
||
désigné où part le temps. Deux leviers, pris dans l'ordre du gain mesuré.
|
||
|
||
### `forks = 20` — les plays multi-hôtes tournaient en trois vagues
|
||
|
||
`ansible.cfg` ne déclarait pas `forks` : **défaut 5**, pour **14 hôtes**. Chaque couche qui
|
||
balaie la flotte — socle, durcissement, enrôlement PKI, les cinq agents — s'exécutait donc
|
||
en trois vagues successives.
|
||
|
||
Les gros rôles n'en profitent pas : Nextcloud (10 min 55 s), Forgejo, Grafana et Keycloak
|
||
sont sur **un seul** hôte. C'est bien les couches larges qui payaient.
|
||
|
||
### La création des VM se faisait une par une
|
||
|
||
14 clones × 1 min 35 s = **22 min 08 s**, soit **30 %** d'une reconstruction — et ce temps
|
||
est surtout de l'**attente** : clone, démarrage, SSH, verrou `dpkg`, quatorze fois sans
|
||
recouvrement. `flotte-creer` en lance désormais **quatre à la fois** (`PARALLELE=n` pour
|
||
ajuster).
|
||
|
||
**La sortie de chaque hôte va dans son propre fichier**, recopiée en bloc à la fin. Quatre
|
||
clones écrivant simultanément sur la même sortie donneraient un journal illisible — un
|
||
comble après une journée passée à traquer des diagnostics masqués. Le marqueur
|
||
`=== Creation VM: <hôte> ===` reste émis en direct pour suivre l'avancement ; le détail
|
||
arrive ordonné.
|
||
|
||
**Et un échec n'est pas avalé** : le code de retour de chaque hôte est relu, et la cible sort
|
||
en erreur si l'un d'eux a échoué — sinon `_attendre-flotte` partirait sur une flotte
|
||
incomplète.
|
||
|
||
**Gains estimés, à vérifier par la prochaine mesure** : ~15 min sur les clones, ~5 min sur
|
||
les couches larges. Le troisième levier identifié — l'archive Nextcloud en `.tar.bz2`,
|
||
décompressée sur un seul cœur — est laissé de côté tant qu'il n'est pas mesuré.
|
||
|
||
## 2026-08-10 — Le jeton Keycloak vivait 60 s, et Grafana rendait son échec illisible
|
||
|
||
Technolibre remonté depuis zéro une seconde fois — `14 hôtes · 2 588 tâches ok · 0 failed`.
|
||
Deux défauts trouvés en chemin, dont un dont la cause était **restée non établie** le matin
|
||
même.
|
||
|
||
### Le jeton d'administration Keycloak était pris une fois pour toutes
|
||
|
||
Il était obtenu dans `politique-mdp.yml` — le **2ᵉ** des neuf fichiers du rôle — et réutilisé
|
||
jusqu'au **8ᵉ**. Or le jeton `admin-cli` du realm `master` vit **60 secondes**. Entre les
|
||
deux : six fichiers de travail, dont `groupes-ldap.yml` et ses reprises espacées de 15 s.
|
||
|
||
Sur une construction **neuve**, le temps écoulé dépasse la minute et l'appel suivant se prend
|
||
un 401. Sur un **rejeu**, tout est convergé, ça va vite, ça passe. D'où deux échecs le matin
|
||
même, suivis chaque fois d'un succès au rejeu — ce qui donnait l'illusion d'une course au
|
||
démarrage de Keycloak. J'avais écrit alors ne pas avoir de mesure qui le prouve ; c'était la
|
||
bonne prudence, et la cause était l'**âge du jeton**.
|
||
|
||
`jeton-admin.yml` prend désormais un jeton frais **là où on s'en sert**. C'est gratuit :
|
||
Keycloak répond en quelques millisecondes en local.
|
||
|
||
### Grafana : un échec transitoire, rendu illisible par systemd
|
||
|
||
Au premier démarrage, après **67 secondes** de migrations, `grafana-server` a échoué sur
|
||
`failed to create admin user: SQL logic error: no such column: uid` — alors que la migration
|
||
qui ajoute cette colonne était journalisée comme **réussie**. Base neuve : tout remigre
|
||
correctement, service actif, colonne présente. **L'incident ne s'est pas reproduit, et
|
||
Chezlepro ne l'a jamais eu.**
|
||
|
||
Je n'ai donc pas corrigé la cause — je ne l'ai pas reproduite. J'ai corrigé ce qui la rendait
|
||
indéchiffrable :
|
||
|
||
- `Restart=on-failure` venait du paquet **sans `RestartSec`**, donc 100 ms : systemd a relancé
|
||
**six fois en une seconde**, chaque relance rejouant les migrations sur la même base SQLite.
|
||
Un échec unique se présentait comme un désastre, et il a fallu remonter tout le journal pour
|
||
retrouver la **première** erreur — la seule qui disait quelque chose. `RestartSec=10` ;
|
||
- la rotation du compte de secours échouait **cinq fois sous `no_log`** en annonçant « the
|
||
output has been hidden », alors que la vraie cause était ailleurs et parfaitement lisible :
|
||
le serveur ne démarrait pas. Une attente explicite sur le port précède maintenant la CLI,
|
||
avec un message qui dit d'aller chercher la **première** erreur du journal.
|
||
|
||
**Troisième fois dans la journée que `no_log` masque la cause au moment où elle sert.** Le
|
||
motif est constant, et il mérite d'être retenu : *une garde qui protège un secret ne doit pas
|
||
emporter le diagnostic avec lui.*
|
||
|
||
### Sept devis sur Technolibre
|
||
|
||
MTU, identité, certificats, PostgreSQL, courriel et frontière : **CONFORME**. Les expositions
|
||
répondent depuis l'edge ; seul le plancher `/etc/hosts` du poste manquait.
|
||
|
||
**Le devis du MTU mérite une mention** : c'est la première flotte du dépôt à naître au bon
|
||
MTU sans une seule intervention — le gabarit porte `mtu=1`, les quatorze invités sont à 1450
|
||
dès leur premier démarrage.
|
||
|
||
## 2026-08-10 — Le MTU de la zone n'atteignait pas les invités
|
||
|
||
Question de l'exploitant : « je ne vois nulle part un MTU à 1450 ». Elle était fondée.
|
||
|
||
**Ce qui existait déjà** : `MTU_OVERLAY_DEFAUT = 1450` dans `scripts/underlay.py`, dont
|
||
**P23** dérive sa garde (*transport ≥ overlay + 50*), et que le devis SDN pose sur chaque
|
||
zone. Vérifié sur le cluster : zones `t11` et `t17` bien à **1450**.
|
||
|
||
**Ce qui manquait** : le MTU d'une zone ne se propage pas à la carte de l'invité. Les
|
||
quatorze VM tournaient à **1500**, et `cloner_vm_debian.yml` ne contenait aucune occurrence
|
||
de `mtu`. La VM émettait donc des trames que son propre chemin ne pouvait pas encapsuler —
|
||
la connexion s'établit, les petites requêtes passent, les grosses réponses restent
|
||
suspendues. C'est la panne que le registre des flux décrit comme « la plus coûteuse à
|
||
diagnostiquer », et pour laquelle il déclare l'ICMP « fragmentation nécessaire ».
|
||
|
||
**Pourquoi personne ne l'avait vue** : les quatorze VM vivent sur `asgard`. Deux VM du même
|
||
hyperviseur communiquent par le pont local, **sans encapsulation** — rien ne rencontre le
|
||
1450. Le défaut serait apparu au premier éclatement de la flotte sur plusieurs nœuds, c'est-
|
||
à-dire exactement quand il faudra héberger les deux tenants ensemble.
|
||
|
||
**Corrigé en deux endroits, et `mtu=1` plutôt que `1450`** — la valeur Proxmox qui signifie
|
||
« hérite du pont » : juste en SDN (1450) comme hors SDN (1500), et encore juste le jour où la
|
||
fabric passera aux trames jumbo.
|
||
|
||
- le **gabarit** le porte (`net0 … mtu=1`), ce qui couvre les clonages qui ne passent pas par
|
||
le playbook — un clone fait à la main, par exemple. Proposition de l'exploitant, et elle
|
||
est meilleure : elle attrape tous les chemins ;
|
||
- `cloner_vm_debian.yml` le repose à chaque clone, parce qu'un gabarit **se recapture** (fait
|
||
la veille) et que ce qui n'est pas versionné se perd en silence.
|
||
|
||
### `make mtu-mesurer` — septième devis
|
||
|
||
Il rattache chaque hôte à sa zone par son **pont dérivé** (`t17serv` → zone `t17`) et lit le
|
||
MTU attendu dans `devis_sdn.py`, la source qui configure les zones. Rien n'est saisi. Il a
|
||
trouvé l'écart sur 14 hôtes du premier coup, et l'a confirmé corrigé.
|
||
|
||
### Ce que j'ai cassé en corrigeant
|
||
|
||
Appliquer `mtu=1` aux cartes de VM **en marche** a coupé le réseau des **quatorze machines
|
||
d'un coup** : Proxmox détache et rebranche la carte, l'invité ne reconfigure pas son
|
||
interface. Flotte à 0/14 pendant trois minutes.
|
||
|
||
Ce qui a permis d'en sortir : les VM tournaient et l'**agent qemu répondait** — un canal
|
||
indépendant du réseau invité. Redémarrage par l'API, la configuration s'est appliquée
|
||
proprement au démarrage, 14/14 ensuite.
|
||
|
||
La faute est d'avoir appliqué à la flotte entière un changement dont je n'avais pas mesuré
|
||
l'effet à chaud. Dans le playbook, la même tâche s'exécute **avant** le démarrage du clone :
|
||
aucun risque. Sur une VM déjà en service : poser la configuration, **puis** redémarrer — et
|
||
sur une seule d'abord.
|
||
|
||
## 2026-08-10 — Technolibre est debout : six devis, et P35
|
||
|
||
**L'épreuve de portabilité est passée.** Un second écosystème souverain complet, monté depuis
|
||
zéro par le même moteur : `14 hôtes · 2 583 tâches ok · 331 changed · 0 failed`. Plan
|
||
distinct, voûte séparée, realm `technolibre`, sa propre autorité de certification — et une
|
||
topologie différente, LDAP et SSO sur des machines séparées là où Chezlepro les co-localise.
|
||
|
||
**Les six devis, sur le déployé :**
|
||
|
||
| Devis | Verdict |
|
||
|---|---|
|
||
| identité | **CONFORME** — realm, fédération, mappeurs, politique |
|
||
| certificats | **CONFORME** — aucun certificat servi en fin de vie (2 réserves latentes, identiques chez Chezlepro) |
|
||
| PostgreSQL | **CONFORME** — chiffrement imposé, aucun réseau hors du supernet dérivé |
|
||
| courriel | **CONFORME** — la chaîne tient, de la résolution LDAP à la boîte |
|
||
| frontière | **CONFORME** — 55 lignes, 0 écart, 17 services livrés comme déclarés |
|
||
| expositions | les 6 services répondent **depuis l'edge** ; le poste ne résout pas encore `technolibre.internal` (6 entrées `/etc/hosts` absentes — le « plancher ») |
|
||
|
||
### Le devis d'identité lisait l'annuaire par un socket local, depuis l'hôte SSO
|
||
|
||
Sixième défaut, et le plus instructif : le play tourne sur `serveur_keycloak` et interrogeait
|
||
LDAP en `ldapi:///` — un socket **UNIX local**. Cela ne fonctionnait que par **co-location
|
||
accidentelle**. Un tenant qui sépare l'annuaire du SSO faisait échouer le devis sur
|
||
« Failed to import the required Python library (python-ldap) » : l'hôte SSO n'a évidemment
|
||
pas de client LDAP. Les deux lectures sont désormais **déléguées à l'hôte dérivé** par
|
||
`resoudre_annuaire`. Co-localisés, la délégation est un aller-retour sans effet ; séparés,
|
||
elle est la seule façon que ça marche.
|
||
|
||
### P35 — une application qui exige une base en a une, et on le sait en deux secondes
|
||
|
||
`resoudre_base` porte déjà la garde (D-72), mais elle s'est déclenchée **à la 92ᵉ tâche de
|
||
`collab-01`, après quarante minutes**, pour un écart entièrement lisible dans le plan.
|
||
**D-75** : ce qui est statiquement lisible se prouve statiquement.
|
||
|
||
Rien n'y est codé en dur — et c'est ce qui la rend juste. Les rôles qui exigent une base sont
|
||
ceux qui **incluent `resoudre_base`** ; le groupe qu'ils réclament est lu dans le **défaut de
|
||
la variable qu'ils passent**, jamais déduit de leur nom : `serveur_icingaweb2` réclame la base
|
||
de `serveur_icinga`, et une preuve qui aurait supposé « rôle = groupe » aurait crié sur un cas
|
||
parfaitement sain. Les noms acceptables suivent la même règle que le résolveur : le groupe, ou
|
||
toute application qui déclare ce groupe.
|
||
|
||
Éprouvée dans les deux sens et sur **les deux tenants** — dont les registres n'ont pas la même
|
||
portée (noms courts chez l'un, noms de groupe chez l'autre) : base retirée → **ÉCHEC** la
|
||
nommant ; restaurée → **OK**. `P01–P35`.
|
||
|
||
## 2026-08-10 — Épreuve de portabilité : monter un SECOND tenant révèle trois défauts invisibles
|
||
|
||
Les deux reconstructions from-zero de la semaine rebâtissaient **Chezlepro** sur son propre
|
||
matériel : une preuve de reproductibilité, pas de portabilité. La vraie épreuve est un
|
||
**second tenant** — Technolibre, index 11, plan distinct (`id-ldap-01`, `id-sso-01`,
|
||
`sup-01`… là où Chezlepro a `idm-01`, `mon-01`), voûte séparée, sur le même cluster.
|
||
|
||
Elle a trouvé en une heure trois défauts qu'un seul tenant ne pouvait pas révéler.
|
||
|
||
### 1. Le clonage résolvait par NOM — et ne faisait rien
|
||
|
||
`community.general.proxmox_kvm` cherche d'abord une VM portant le `name` demandé. S'il en
|
||
trouve une, il conclut « elle existe déjà », rend **`ok`** et ne clone **rien** — aucune tâche
|
||
n'apparaît même côté cluster. Or les noms courts sont **volontairement identiques d'un tenant
|
||
à l'autre** : même fonction, même nom, c'est le pool qui restitue l'appartenance. Le premier
|
||
clone de Technolibre, `backup-01`, est donc tombé sur le `backup-01` de Chezlepro, n'a rien
|
||
fait, et l'attente a expiré sur une configuration qui n'existerait jamais.
|
||
|
||
**Mesuré, pas déduit** : `id-ldap-01` et `sup-01` — noms que Chezlepro n'a pas — se sont
|
||
créés du premier coup ; `backup-01` échouait systématiquement. Zéro VM créée, zéro tâche
|
||
`qmclone` au cluster.
|
||
|
||
Le clonage passe désormais par un appel d'API **ciblé par VMID** : recensement des VM, puis
|
||
`POST /nodes/<n>/qemu/<gabarit>/clone` seulement si le VMID cible est libre. Plus aucune
|
||
résolution par nom.
|
||
|
||
**Deux défauts de ce correctif, trouvés en le mesurant** — et tous deux du même genre que ce
|
||
qu'il corrige :
|
||
|
||
- le corps de la requête était assemblé en Jinja avec `>-`, ce qui rend une **chaîne** : le
|
||
`pool` s'est perdu en route et la VM est née hors de son pool, sans un mot. Réécrit en
|
||
mapping YAML avec `omit` ;
|
||
- l'application du gabarit de calcul expirait à 5 s de lecture — le nœud vient de terminer un
|
||
clone complet. La VM restait aux valeurs du gabarit (2 cœurs / 2 Go au lieu du plan), **en
|
||
silence**. Six tentatives espacées de 10 s.
|
||
|
||
Et `no_log: true` a masqué la cause au moment précis où elle servait : l'échec se lisait
|
||
« the output has been hidden », et il a fallu interroger le cluster à la main. Les deux
|
||
attentes disent maintenant ce qu'elles ont constaté, sans révéler l'en-tête d'autorisation.
|
||
|
||
### 2. Le GUI détruisait des intrants
|
||
|
||
Dans `ecrire_intrants`, la branche `identite` était **la seule sur quatre** à écrire par-dessus
|
||
le disque au lieu de fusionner. Un enregistrement du panneau a supprimé `dns_amorcage` et
|
||
`amorcage_acces_courriel` de Technolibre. Sans le premier, une VM naît sans résolution et
|
||
`apt` ne peut rien installer ; sans le second, le déploiement s'arrête sur la garde de
|
||
`amorcage_acces` (D-72). **Le même geste sur Chezlepro aurait mangé les mêmes clés.**
|
||
|
||
### 3. Le verrou de `raser` n'était prouvé que pour un tenant
|
||
|
||
Le faux cluster de `scripts/tests/test_raser.py` codait en dur les VMID de Chezlepro. Monté
|
||
sur un autre tenant, le test rendait `0` au lieu de `2` — « aucune VM du plan n'est présente,
|
||
rien à faire ». Le verrou de la seule commande destructive du moteur passait donc au vert
|
||
sans rien éprouver. Le faux cluster **fabrique désormais la collision sur le plan courant**,
|
||
quel qu'il soit.
|
||
|
||
### 4. Le repli nftables survivait à la bascule — et annulait tout
|
||
|
||
Le socle pose l'un de **deux** fichiers dans `/etc/nftables.conf` : le ruleset **dérivé**
|
||
(`make flux`, table `setops_flux`) s'il existe, sinon un **gabarit de repli** plat (table
|
||
`setops_filter`) qui n'ouvre que le `22`. Ce sont des **alternatives**, jamais des couches.
|
||
|
||
Mais le rechargement est `nft -f`, qui **ajoute sans purger**, et le fichier dérivé retirait
|
||
soigneusement `setops_flux`… jamais `setops_filter`. Un hôte passé du repli au dérivé se
|
||
retrouvait donc avec **deux chaînes `input` sur le même hook, toutes deux en `policy drop`**.
|
||
Le paquet traverse les deux : seule l'**intersection** de leurs `accept` passait — le `22` et
|
||
l'ICMP, rien d'autre.
|
||
|
||
Le symptôme était parfaitement trompeur : l'AC **debout**, son port 8443 **en écoute**, sa
|
||
règle `ip saddr { … } tcp dport 8443 accept` **posée et acceptante**, l'ICMP entre les deux
|
||
VM à **0,15 ms** — et `step ca bootstrap` qui expire. Il a fallu lire le ruleset entier pour
|
||
voir la seconde table.
|
||
|
||
Le fichier dérivé retire désormais **les deux** tables (le repli, lui, portait déjà
|
||
`flush ruleset`). Pas de `flush ruleset` côté dérivé : c'est un choix du dépôt pour ne pas
|
||
détruire de tables étrangères, et il est respecté.
|
||
|
||
**Ce qui l'avait rendu possible** : `make reconstruire` ne générait **jamais** les flux.
|
||
Sans eux, `flux-genere/` est vide, le repli est posé, et l'hôte passe ensuite au dérivé —
|
||
exactement la bascule qui casse. `make flux` est maintenant la première étape de
|
||
`reconstruire`. Invisible sur un écosystème déjà construit, dont les `.nft` traînent d'une
|
||
exécution précédente.
|
||
|
||
### `no_log` a masqué la cause trois fois dans la même journée
|
||
|
||
Le clonage, l'attente de configuration, et la déclaration des URI de déconnexion Keycloak :
|
||
trois échecs lus « the output has been hidden », dont un au terme d'un déploiement de 157
|
||
tâches. Le mot-clé protège de vrais secrets — un jeton d'API porté par un en-tête — et on ne
|
||
peut pas simplement l'enlever.
|
||
|
||
Les trois tâches **extraient donc désormais le verdict à part** : ce qui a échoué, avec quel
|
||
code et quel message, sans jamais toucher aux en-têtes. Une garde qui protège un secret ne
|
||
doit pas emporter le diagnostic avec lui.
|
||
|
||
### Ce qui relevait des données du tenant, pas du moteur
|
||
|
||
L'instance datait d'avant plusieurs évolutions, et les preuves statiques les ont toutes
|
||
attrapées **avant** le déploiement : `client_unbound` déclaré au plan alors qu'il est devenu
|
||
une intégration universelle ; `amorcage_acces_courriel` absent ; gabarit `99999` alors que le
|
||
recapturé porte `99998` — le premier clone aurait échoué ; `parefeu_interface: false`, qui
|
||
aurait laissé le pare-feu est-ouest inerte sans le dire ; `collab-01` à 1 cœur / 1 Go au lieu
|
||
du dimensionnement dérivé.
|
||
|
||
## 2026-08-10 — P34 : la convention « chaque document déclare son lecteur » devient une garde
|
||
|
||
La refonte de ce matin posait une convention. Une convention qu'on n'outille pas tient tant
|
||
que quelqu'un y pense — c'est exactement le raisonnement de **D-70**, et voici son
|
||
application au corpus documentaire. **D-74**, gardée par **P34**.
|
||
|
||
**L'état de départ, mesuré : 2 documents sur 34 déclaraient leur lecteur.** Les 32 autres
|
||
disaient leur *sujet*. C'est ce qui avait enfoui le runbook de reprise le plus utile du dépôt
|
||
au §6 de `autorisation.md`.
|
||
|
||
**Les 38 documents le déclarent désormais**, et le lecteur a été déterminé document par
|
||
document — pas collé au gabarit. Trois familles : l'**exploitant** (les devis, la migration
|
||
de tenant, le cycle de vie des VM, le gabarit d'or, `autorisation.md` §6…), le **mainteneur**
|
||
(les conceptions, les registres, la carte), et deux cas à part — `ecosysteme-chezlepro.md`
|
||
s'adresse au **lecteur externe**, `MISE-A-JOUR-CODEX-CLAUDE.md` à l'**agent IA** qui reprend
|
||
le dépôt.
|
||
|
||
**Deux exemptions, dérivées et non listées** — un chemin en dur aurait vieilli à la première
|
||
page ajoutée :
|
||
|
||
- un document qui **s'annonce généré** ne se lit pas, il se régénère. On le reconnaît à sa
|
||
propre en-tête (« Généré par », « ne pas éditer à la main ») : 13 documents, tous
|
||
réellement générés — vérifié un par un, aucun document écrit à la main n'est exempté par
|
||
accident ;
|
||
- un fragment sans titre `#` n'est pas un document.
|
||
|
||
**La preuve ne lit que l'en-tête**, jamais le corps : une mention de « Pour qui » perdue au
|
||
milieu d'une page ne serait pas une porte. C'est aussi ce qui empêche `frontiere-opnsense.md`
|
||
et `plan-et-generation.md` — qui parlent de génération dans leur corps — d'être exemptés à
|
||
tort.
|
||
|
||
**Éprouvée dans les deux sens, parce qu'une garantie qu'on n'a jamais vue dire *non* est une
|
||
habitude, pas une garantie.** Elle a d'abord échoué toute seule à sa première exécution, en
|
||
nommant deux documents que mon inventaire avait manqués (`protocole-operateur-independant.md`,
|
||
`reference-avant-reconstruction-2026-08-08.md` — tous deux dans `docs/audit/`, hors de mon
|
||
motif). Puis test négatif délibéré : déclaration retirée de `meta-classe.md` → **ÉCHEC** le
|
||
nommant précisément ; restaurée → **OK**.
|
||
|
||
**Ce qu'elle ne teste pas :** que le lecteur déclaré soit le *bon*. Ça se juge en revue. Elle
|
||
garantit qu'on a dû y penser — ce qui est précisément ce qui manquait.
|
||
|
||
`P01–P34`, et les comptes périmés corrigés au passage (`AGENTS.md` et `devis-services.md`
|
||
annonçaient encore 30 preuves).
|
||
|
||
## 2026-08-10 — Refonte documentaire : on n'arrive pas avec un sujet, on arrive avec une situation
|
||
|
||
La documentation était organisée **par sujet** — identité, courriel, DNS, PKI, sauvegardes.
|
||
C'est l'organisation juste pour de la *référence*. Mais personne n'arrive avec un sujet. Il y
|
||
a exactement quatre situations, trois avaient déjà une porte, et **deux de ces trois ne
|
||
s'annonçaient pas** :
|
||
|
||
| Situation | Lecteur | Porte |
|
||
|---|---|---|
|
||
| « c'est quoi ? » | qui découvre | `README.md` |
|
||
| « je viens d'hériter » | l'exploitant | **`wiki/Reprendre-l-écosystème.md`** — n'existait pas |
|
||
| « je dois modifier » | le mainteneur | `docs/carte-set-ops.md` — le dit désormais |
|
||
| « j'apprends le métier » | l'apprenant | `wiki/Home.md` — le dit désormais |
|
||
|
||
**Une seule page créée**, et elle ne contient presque rien en propre : un **ordre** et des
|
||
renvois, en cinq temps. Dans quel état tu hérites (les six devis avant tout geste) ; entrer
|
||
(la clé de voûte, l'amorçage, la racine qui mène à la mauvaise console, l'AC) ; de quoi c'est
|
||
fait (à *demander* au plan, pas à lire) ; quand ça casse ; ce qui va te mentir.
|
||
|
||
**Aucun fichier déplacé, aucune réécriture du wiki.** Les liens, l'historique git et les
|
||
renvois croisés valent plus qu'un rangement.
|
||
|
||
**La convention qui empêche la rechute : chaque document déclare son lecteur en première
|
||
ligne** — pas un sujet, un lecteur et sa situation. C'est ce qui manquait vraiment :
|
||
`autorisation.md` contient un runbook de reprise parce que le *sujet* est l'autorisation, et
|
||
personne ne va l'y chercher. Un document qui déclare son lecteur se range tout seul, et un
|
||
intrus s'y voit.
|
||
|
||
**Le wiki devient la porte unique du lecteur, le dépôt reste la source.** `wiki-publier` fait
|
||
un `delete` puis recopie : une page modifiée dans l'interface de la forge est **détruite** à
|
||
la publication suivante. La règle est maintenant écrite dans `README.md`, `Home.md` et la page
|
||
de reprise — elle ne l'était nulle part.
|
||
|
||
**Ce qui n'a finalement pas été écrit, et pourquoi.** La page « Ce qui va te mentir » était
|
||
prévue. Trois des cinq pièges qu'elle devait cataloguer ont trouvé un meilleur domicile
|
||
pendant qu'on travaillait — le `connect()` vers le vide (`frontiere-opnsense.md`, et
|
||
`frontiere-mesurer` porte désormais le contrôle qui tranche), le `make prouver` vert
|
||
(*Vérifier le déployé*), le *banner exchange*. Les deux orphelins s'adressent à qui **écrit du
|
||
code**, pas à qui reprend l'exploitation. Une page séparée aurait redit ce que trois autres
|
||
disent déjà.
|
||
|
||
Sa substance survit : la section ⑤ de la page de reprise porte la **règle** qui les relie —
|
||
*vérifier l'instrument avant d'accuser le composant, une sonde porte toujours un contrôle* —
|
||
et renvoie chaque signal faux à son domicile.
|
||
|
||
**Un trou trouvé en vérifiant mes propres renvois.** Le *banner exchange* n'était documenté
|
||
nulle part où on le cherche : un commentaire du `Makefile` et trois entrées de ce fichier.
|
||
Écrit en **runbook §3**, avec ses trois causes par fréquence et ce qui tranche dans l'ordre.
|
||
|
||
Vérifié : chaque cible `make` et chaque lien contrôlés un à un (`make ca-installer` n'existe
|
||
pas — c'est `ca-racine` + `ca-empreinte`) ; les cinq liens du README résolvent ; `prouver.py`
|
||
0 ; plan de recette inchangé.
|
||
|
||
## 2026-08-09 — La frontière est étanche : 56 lignes conformes, dans les deux sens
|
||
|
||
L'exploitant a retiré la dernière règle héritée, celle qu'il avait lui-même étiquetée
|
||
`PAS SUPPOSÉ -> ACTION REQUISE`. **`make frontiere-mesurer` : CONFORME, code 0.** Tout ce
|
||
qui est déclaré est livré, tout le reste est refusé — y compris `collab-01:9980`, le seul
|
||
qui livrait vraiment un `HTTP/1.1 200 OK` depuis le poste.
|
||
|
||
**Et mon instrument avait tort, pas la frontière.** Il comptait 38 écarts. Il concluait
|
||
depuis le client : *connexion établie ⇒ la bordure a relayé*. Faux, et vérifié **à la
|
||
destination** — pendant que le poste tenait une connexion « établie » vers `idm-01:389`,
|
||
`idm-01` n'en voyait aucune ; `collab-01` n'en voyait aucune sur 9980. La frontière répond
|
||
elle-même à la poignée TCP, pour toute destination qu'elle route, sans jamais relayer.
|
||
|
||
Le devis raisonne désormais sur la **livraison** seule : un port est conforme s'il livre
|
||
quand il doit livrer et ne livre rien quand il ne doit pas. Ce que fait la poignée TCP ne
|
||
regarde personne. Le contrôle, en conséquence, ne rend le relevé NUL que s'il **livre** des
|
||
données — qu'il ressorte AMBIGU est attendu ici, et le rapport le dit en toutes lettres à
|
||
chaque exécution. Cette relaxation rend aussi le sens sortant mesurable : il était déclaré
|
||
NUL en permanence.
|
||
|
||
**Un flux publié n'est pas forcément fait pour un poste de travail.** Nouveau mot-clé
|
||
`poste: false` dans `meta/flux.yml` : le `25` entrant de Postfix est un flux serveur à
|
||
serveur (les MX distants). La frontière l'étendait au VLAN d'administration, où le
|
||
`nftables` de l'hôte le refusait — deux couches qui ne déclarent pas la même politique, et
|
||
une politique qu'on ne peut plus lire. Deux règles retirées. Le mot-clé vit avec le rôle,
|
||
qui sait ce que son port veut dire ; le générateur ne connaît toujours aucun numéro de port.
|
||
|
||
Vérifié : `frontiere-plan` sans écart (41 règles, 12 routes), flotte 14/14,
|
||
`frontiere-mesurer` CONFORME, `prouver.py` 0.
|
||
|
||
## 2026-08-09 — Un sixième devis : « ce qui n'est pas déclaré est-il refusé ? »
|
||
|
||
`devis_expositions.py` pose la question positive — chaque exposition déclarée répond-elle.
|
||
Il manquait la négative, et ce n'est pas la même : un pare-feu peut très bien servir tout
|
||
ce qu'on lui demande **et** laisser passer tout le reste. `make frontiere-mesurer` la pose.
|
||
|
||
**Les cibles ne sont pas saisies** : ce sont les ports réellement en écoute dans la flotte,
|
||
relevés par le playbook. Sonder un port fermé ne prouverait rien du pare-feu — le refus
|
||
viendrait de la machine. **La politique attendue non plus** : elle est lue dans
|
||
`devis_opnsense.py`, la source même qui configure la frontière.
|
||
|
||
**`scripts/sonde_tcp.py` refuse de conclure.** Deux principes, tirés des trois faux
|
||
diagnostics de la semaine :
|
||
|
||
1. Un **contrôle** avant tout verdict — une adresse où personne n'écoute. Si elle répond,
|
||
le relevé est déclaré **NUL** et aucun verdict n'est rendu. Mieux vaut pas de mesure
|
||
qu'une mesure fausse.
|
||
2. **Établir n'est pas livrer.** La sonde fait parler le service : bannière, sinon requête
|
||
HTTP minimale, sinon poignée TLS. Si rien ne revient, le verdict est **AMBIGU** — pas
|
||
« ouvert ». LDAP et PostgreSQL attendent un message bien formé qu'on ne fabrique pas
|
||
ici, et un synproxy se comporte exactement pareil.
|
||
|
||
Le verdict distingue deux natures d'écart, qui n'appellent pas le même geste : *refusé
|
||
attendu mais connexion établie* = la bordure a relayé, c'est le trou ; *livré attendu mais
|
||
rien livré* = la bordure autorise et l'**hôte** refuse, rien ne fuit mais les deux couches
|
||
ne déclarent pas la même politique.
|
||
|
||
**Deux pièges rencontrés en le construisant, tous deux corrigés et commentés sur place :**
|
||
|
||
- La sonde « tenant » s'exécutait en réalité **sur le poste** : dans un play
|
||
`connection: local`, un `delegate_to` hérite de cette connexion. Elle rendait donc la
|
||
frontière joignable depuis un tenant — ce qu'elle est, depuis le VLAN d'administration.
|
||
Vérifié à la main avant de la croire : `TimeoutError` depuis `backup-01` comme depuis
|
||
`infra-dns-01`. Même famille que le `connect()` — vérifier d'où l'instrument mesure.
|
||
- Le délai de lecture de 2 s faisait ressortir le `25` d'un Postfix parfaitement sain en
|
||
AMBIGU : `postscreen` retarde sa bannière exprès. Porté à 8 s, compensé par du
|
||
parallélisme — raccourcir aurait fabriqué de faux écarts.
|
||
|
||
**Premier verdict, la frontière étant encore en l'état : 38 écarts.** Trente-sept sont
|
||
« la bordure a relayé » (la règle héritée étiquetée `PAS SUPPOSÉ -> ACTION REQUISE` laisse
|
||
passer le VLAN d'administration vers tout port en écoute), dont un livre vraiment —
|
||
`collab-01:9980`, Collabora, répond `HTTP/1.1 200 OK` depuis le poste. Le trente-huitième
|
||
est de l'autre nature : la frontière autorise `edge-mta-01:25` depuis le VLAN
|
||
d'administration, mais l'hôte le refuse. Le relevé sortant est déclaré NUL, son contrôle
|
||
ayant répondu.
|
||
|
||
## 2026-08-09 — « La frontière ne doit jamais laisser passer de trafic impertinent » — validé, et deux trous fermés
|
||
|
||
Exigence de l'exploitant, validée à l'instrument : de vraies requêtes applicatives contre
|
||
des destinations que la politique **interdit**, jamais un `connect()`.
|
||
|
||
**Le transit tenant était déjà correct.** Depuis une VM : `192.168.11.41:22` (hyperviseur),
|
||
`10.0.0.1:22` (frontière), `10.0.0.17:22` (poste), `192.168.11.41:8006` (Proxmox) — tous
|
||
muets. Seul ce qui est déclaré passe. Mieux : le `25` sortant passe depuis `edge-mta-01`
|
||
(bannière `220 mx.google.com ESMTP`) et **est refusé depuis `infra-dns-01`** — le filtrage
|
||
est bien par hôte source, pas par tenant.
|
||
|
||
**Trou n° 1 — nos flux sortants visaient `any`.** « Vers Internet » n'excluait ni le plan de
|
||
gestion, ni la frontière, ni le supernet du voisin. Mesuré : depuis une VM du tenant,
|
||
`https://10.0.0.1/` — la console d'administration du pare-feu — **répondait**. Autorisé par
|
||
notre propre règle. Les dix-huit règles sortantes visent désormais `!SETOPS_INTERNES`, une
|
||
destination **niée** valant les trois blocs privés RFC 1918. Pas la liste de nos réseaux :
|
||
elle laissait dehors `192.168.11.0/24`, le plan de gestion hérité — et une exclusion
|
||
incomplète ne protège rien. Un réseau interne ajouté demain est couvert sans rien changer.
|
||
|
||
Vérifié après application : `10.0.0.1:443` bloqué depuis les deux VM testées, sortie web,
|
||
DNS public et SMTP vers un MX public toujours passants, flotte 14/14.
|
||
|
||
**Trou n° 2 — le défaut-deny du LAN n'avait aucun effet.** Mesuré depuis le poste : le `443`
|
||
d'un nginx répondait alors que seul le `22` est déclaré. La cause est la règle **d'usine**
|
||
`Default allow LAN to any rule`, qui autorise tout depuis le VLAN d'administration. Elle
|
||
n'est pilotable par **aucune API** — vérifié : zéro règle non-Set-OPS visible côté API. Sa
|
||
désactivation appartient donc à l'exploitant, dans l'interface.
|
||
|
||
Ce que Set-OPS pouvait faire, et fait : **déclarer les flux d'administration légitimes**
|
||
pour que cette désactivation ne coupe pas l'exploitant de ses propres services. Un service
|
||
publié est joignable depuis Internet par le WAN, mais aussi depuis le VLAN d'administration
|
||
où se trouve son poste ; ce second chemin ne reposait jusqu'ici que sur la règle d'usine.
|
||
Dix règles ajoutées sur l'interface de gestion (80, 443, 993, 25 et ICMP `frag-needed` vers
|
||
les hôtes concernés, par tenant). Vérifié : `curl` vers le nginx du tenant rend `302`.
|
||
|
||
**Ce qui reste, et qui n'est pas à nous** : tant que `Default allow LAN to any rule` est
|
||
active, le VLAN d'administration atteint tout. Les règles qui la rendent superflue sont
|
||
maintenant en place — la désactiver est un geste d'interface, à faire les yeux ouverts.
|
||
|
||
## 2026-08-09 — La frontière ne déclare plus que ce qui existe, et l'applicateur possède enfin ses routes
|
||
|
||
Suite directe de l'enquête ci-dessous, menée jusqu'au bout à l'instrument plutôt qu'à
|
||
l'hypothèse. Trois corrections, dans l'ordre où la mesure les a imposées.
|
||
|
||
**1. Retrait des deux routes `/16` de la frontière.** Les douze `/24` réellement attribués
|
||
étant en place, les supernets ne servaient plus qu'à envoyer vers le nœud de sortie des
|
||
destinations qui n'existent nulle part. Retirées par l'API après vérification que les
|
||
quatorze hôtes planifiés tombent tous dans les six `/24`. Flotte : 14/14 avant, 14/14 après.
|
||
|
||
**2. Les alias de tenant valaient le supernet.** Le retrait des `/16` n'a rien changé au
|
||
symptôme, ce qui a désigné le vrai coupable : `SETOPS_TENANT_CHEZ17` valait `10.27.0.0/16`,
|
||
donc **nos propres règles** autorisaient `admin → tout le /16:22`. L'état pf portait la
|
||
description de notre règle. Les alias énumèrent désormais les sous-réseaux attribués —
|
||
même geste que pour les routes, et pour la même raison. Ils servent à la fois de
|
||
destination aux règles et de source au NAT sortant : les deux se resserrent ensemble.
|
||
|
||
**3. Une garde pour que les deux ne divergent plus.** `verifier()` exige maintenant que
|
||
l'ensemble des réseaux **routés** et l'ensemble des réseaux **autorisés** coïncident
|
||
exactement. Un alias plus large laisse le filtre approuver l'inexistant ; un alias plus
|
||
étroit fait acheminer vers ce que le filtre refuse. Les deux pannes se voient au devis,
|
||
plus à l'usage. Attachée à P24, qui ne vérifiait jusqu'ici que la traduction NAT.
|
||
|
||
**Et le symptôme, alors ?** Il subsiste, et il n'est ni dans nos règles ni dans nos routes.
|
||
L'état pf porte désormais le nom de la règle d'usine `Default allow LAN to any rule`, qui
|
||
répond au SYN à la place de la destination. Mesure qui tranche : **depuis une VM du
|
||
tenant**, `10.99.99.99` et `172.31.99.99` — des adresses qui n'appartiennent à personne —
|
||
« s'établissent » en 1 ms, et **aucune ne rend de bannière SSH**, quand la vraie VM rend
|
||
`SSH-2.0-OpenSSH_10.0p2`. Le `connect()` ne mesure rien sur ce chemin, quel que soit le
|
||
point de départ ; seule une requête applicative tranche. Les règles héritées appartiennent
|
||
à l'exploitant : Set-OPS n'y touche pas.
|
||
|
||
**Les routes sont enfin réconciliées.** Je les avais posées avec un script hors dépôt :
|
||
rien ne les comparait au devis, et leur disparition n'aurait été vue par personne — le
|
||
défaut exact que cet applicateur existe pour empêcher. `appliquer_opnsense.py` les traite
|
||
maintenant comme les règles et le NAT : identité portée par la description
|
||
(`setopsroute:<tenant>:<réseau>-><saut>`), création avant retrait, périmètre strict. Le nom
|
||
de la passerelle est résolu depuis l'adresse du prochain saut plutôt que redemandé en
|
||
intrant. Les douze routes existantes ont été réétiquetées en place — aucune coupure.
|
||
|
||
Vérifié : `make frontiere-plan` → « la frontière dit déjà ce que le devis dit », 12 routes
|
||
inchangées, flotte 14/14, `prouver.py` code de sortie 0.
|
||
|
||
## 2026-08-09 — Le `connect()` qui « ne prouvait rien » n'était pas de l'anti-usurpation
|
||
|
||
L'exploitant : « je ne trouve pas ça normal ». Il avait raison, et pendant deux jours nous
|
||
avons tous les deux attribué ce comportement à une fonction d'anti-usurpation de la
|
||
frontière — moi le premier, et je l'avais même consigné comme tel.
|
||
|
||
**Mesuré, pas raconté.** Depuis le poste, quatre connexions sur quatre s'établissaient, y
|
||
compris vers une adresse où aucune machine n'existe. Mais vers des réseaux hors du tenant,
|
||
tout était refusé — donc rien n'interceptait globalement. Et **depuis l'intérieur du
|
||
tenant**, le comportement était correct partout où un VNet existe : hôte absent → refusé,
|
||
port fermé → refusé. L'anomalie ne touchait que les portions de supernet **non couvertes
|
||
par un VNet**.
|
||
|
||
**La cause était une route manquante, pas un pare-feu.**
|
||
|
||
```
|
||
vrf_t17 : les six /24 des VNets, puis default -> 10.0.4.1
|
||
RIEN pour le reste de 10.27.0.0/16
|
||
```
|
||
|
||
Une adresse non attribuée sortait donc du VRF par le défaut, atteignait la frontière, qui
|
||
la renvoyait à l'hyperviseur — où elle arrivait dans la table **principale**, pas dans le
|
||
VRF, et repartait vers `192.168.11.254`, la passerelle du réseau d'**administration**.
|
||
`ip route get 10.27.99.99` le disait en une ligne.
|
||
|
||
**Corrigé où le dépôt a la main** : `strophe_frr` pose désormais, dans chaque VRF, un puits
|
||
sur le supernet du tenant — **moins spécifique** que les `/24` de ses VNets, donc invisible
|
||
au trafic légitime. Dérivé du seed, comme tout le reste.
|
||
|
||
```
|
||
depuis le tenant, apres : 10.27.99.99 refusee 10.27.18.99 refusee 10.27.18.21 ETABLIE
|
||
```
|
||
|
||
**Une machine du tenant ne peut plus atteindre le réseau de gestion par une faute de
|
||
frappe.** C'était le vrai risque, et il est fermé.
|
||
|
||
**Ce qui reste, et que je ne corrige pas sans arbitrage.** Depuis le VLAN d'administration,
|
||
le `connect()` réussit encore : ce trafic n'entre jamais dans le VRF. OPNsense route tout
|
||
`10.27.0.0/16` vers l'hyperviseur, dont la table principale ne connaît que les six `/24`.
|
||
Deux remèdes possibles — un puits symétrique dans la table principale, ou n'annoncer à la
|
||
frontière que les `/24` réellement attribués. Le premier touche la table qui porte
|
||
l'administration des hyperviseurs ; ce n'est pas un geste à faire de sa propre initiative.
|
||
|
||
**La leçon dépasse la route.** Nous avons expliqué pendant deux jours un symptôme par une
|
||
cause plausible et fausse, et cette explication est entrée dans la documentation. Ce qui l'a
|
||
défaite n'est pas un raisonnement plus fin : c'est d'avoir mesuré depuis **deux points de
|
||
vue différents**. Un seul point de vue donne une histoire cohérente — souvent la mauvaise.
|
||
|
||
## 2026-08-09 — Le wiki rattrape ce que la reconstruction a appris
|
||
|
||
Le wiki datait du 3 août — six jours avant tout ce qui précède. Vérifié avant d'y toucher :
|
||
**toutes les cibles `make` qu'il cite existent**, aucune commande morte. Il était
|
||
factuellement plus sain que craint.
|
||
|
||
Deux corrections, dont une qui compte.
|
||
|
||
**`La-preuve.md` annonçait « P01–P21 »** ; le harnais est à **P33**. Les trois nouvelles
|
||
sont ajoutées au tableau des classes d'erreur — et surtout, la page dit désormais **ce que
|
||
ces preuves ne font pas** : elles sont toutes statiques, elles lisent le dépôt, et c'est
|
||
dans cet angle mort qu'une AC est restée expirée huit heures sous un harnais vert.
|
||
|
||
**`Infra-as-Code-et-idempotence.md` enseignait l'idempotence comme acquise** — « redéployer
|
||
un rôle déjà en place → `changed=0` ». Vrai rôle par rôle, faux à l'échelle de la flotte, ce
|
||
que personne n'avait mesuré. La page enseigne maintenant depuis les chiffres (924 → 17 → 0)
|
||
et raconte ce que les 17 cachaient : un service mort depuis des semaines, et un secret que
|
||
le dépôt faisait tourner à chaque déploiement. Elle explique aussi pourquoi il faut
|
||
**vérifier le zéro** — 2266 tâches exécutées contre 2152, donc convergence et non silence.
|
||
|
||
**Nouvelle unité : `Vérifier-le-déployé`.** C'est la notion que la journée a mise au jour et
|
||
qu'aucune page ne portait : la différence entre *valider du code* et *vérifier un système*.
|
||
Elle enseigne les trois règles qui séparent un devis utile d'un devis décoratif — ne jamais
|
||
redéclarer ce qu'on vérifie, faire une vraie requête plutôt qu'un `connect()`, et se méfier
|
||
d'un code de retour pris pour un verdict — puis renvoie au vocabulaire commun
|
||
(*drift detection*).
|
||
|
||
Sa dernière consigne est celle que je retiens de ces deux jours : **chercher, dans son
|
||
propre outillage, une vérification qui n'a jamais échoué, et se demander si c'est parce que
|
||
tout va bien ou parce qu'elle ne regarde rien.**
|
||
|
||
## 2026-08-09 — Idempotence de la flotte : zéro, et c'est un zéro qui veut dire quelque chose
|
||
|
||
```
|
||
plays taches ok changed
|
||
rejeu depuis zero 61 2152 924
|
||
2e passage 30 2249 17
|
||
3e passage 30 2266 0
|
||
```
|
||
|
||
**Zéro tâche `changed`, zéro échec, sur les quatorze hôtes.** Le dépôt n'avait jamais fait
|
||
ce test à l'échelle de la flotte.
|
||
|
||
**Vérification du zéro**, parce qu'un zéro peut aussi signifier que les rôles ne font plus
|
||
rien : **plus de tâches se sont exécutées au passage à vide qu'au rejeu** — 2266 contre
|
||
2152. Elles ont toutes tourné et toutes trouvé le système conforme. Un zéro obtenu avec
|
||
*moins* de tâches aurait dit l'inverse.
|
||
|
||
**Ce que ce test aura coûté et rapporté.** Trois défauts trouvés, dont deux n'étaient pas
|
||
des défauts d'idempotence mais des **pannes silencieuses** : node_exporter mourait à chaque
|
||
renouvellement de certificat sur les quatorze hôtes, et chaque déploiement invalidait les
|
||
jetons OAuth2 de la forge. Aucune des deux ne se signalait autrement — c'est le compte de
|
||
`changed` qui les a fait apparaître.
|
||
|
||
Ce chiffre devient la **ligne de base**. Un déploiement futur qui rapporte `changed` sur
|
||
une flotte non modifiée signale désormais quelque chose. Tant que le fond était à 17, ce
|
||
signal était noyé.
|
||
|
||
## 2026-08-09 — Les deux dernières tâches non idempotentes, et ce qu'elles cachaient
|
||
|
||
**Prometheus — une liste non ordonnée.** `intersect` rend un *ensemble*, dont l'ordre
|
||
d'itération n'est pas stable d'un processus Python à l'autre. Le fichier de configuration
|
||
se rendait donc différemment à chaque passage — mêmes quatorze cibles, ordre différent — et
|
||
Prometheus redémarrait pour rien. Trié sur les hôtes : ordre déterministe **et** lisible.
|
||
Second passage à `changed=0`.
|
||
|
||
**Forgejo — le dépôt faisait tourner un secret du service.** `JWT_SECRET` est généré par
|
||
Forgejo au premier démarrage et ajouté par lui à la fin d'`app.ini`. Le gabarit ne le
|
||
portait pas : **chaque rendu l'effaçait**, Forgejo en générait un nouveau au redémarrage,
|
||
et le passage suivant recommençait.
|
||
|
||
Ce n'était donc pas du bruit : **chaque déploiement invalidait les jetons OAuth2 émis par
|
||
la forge.** Le rôle relit maintenant le secret avant de rendre et le repose. Vérifié : même
|
||
empreinte avant et après un déploiement, `changed=0` aux deuxième et troisième passages.
|
||
|
||
**Trois erreurs de méthode de ma part, dans cette seule enquête**, et elles méritent
|
||
d'être écrites :
|
||
|
||
1. J'ai conclu « le diff est vide, donc le contenu est identique » — alors que `no_log`
|
||
masquait le diff. Toute mon hypothèse sur le mode `0640` reposait là-dessus.
|
||
2. J'ai appliqué un `str.replace` sur le gabarit **sans vérifier qu'il avait pris**. La
|
||
section `[oauth2]` n'existait pas : le remplacement n'a rien fait, j'ai affiché un
|
||
message de succès, et j'ai interprété trois passages d'essai sur cette base.
|
||
3. J'ai gardé une expression Jinja qui fonctionnait en isolation mais échouait sur l'hôte,
|
||
sans pouvoir la diagnostiquer parce que `no_log` — indispensable, c'est un secret —
|
||
masquait l'erreur. Remplacée par une forme plus simple.
|
||
|
||
La leçon commune est celle de la journée, retournée contre moi : **vérifier l'effet, pas
|
||
l'intention.** Un `replace` qui ne trouve rien réussit silencieusement, exactement comme
|
||
`kcadm -s` sur une map.
|
||
|
||
## 2026-08-09 — Le passage d'idempotence trouve une panne, pas une imperfection
|
||
|
||
**17 tâches `changed` au second passage, contre 924 au rejeu depuis zéro.** La flotte
|
||
converge à 98 %. Mais les 17 restantes ne sont pas du bruit — l'une d'elles cachait un
|
||
service mort.
|
||
|
||
```
|
||
13x client_metrique : Activer et demarrer node_exporter
|
||
1x serveur_prometheus : Deployer la configuration Prometheus -> redemarrage
|
||
1x serveur_forgejo : Deployer app.ini -> redemarrage
|
||
```
|
||
|
||
**Treize hôtes sur quatorze redémarraient node_exporter à chaque passage.** Pas parce que
|
||
la tâche est mal écrite : parce qu'Ansible le trouvait **arrêté** et le ressuscitait. Le
|
||
dump du module le disait sans ambiguïté — `ActiveState: inactive`, `SubState: dead`,
|
||
`ExecStart ... code=killed ; status=1/HUP`.
|
||
|
||
**La cause.** Le script de synchronisation du certificat faisait
|
||
`systemctl try-reload-or-restart prometheus-node-exporter`. Cette commande *recharge* si
|
||
l'unité déclare un `ExecReload` — et Debian en déclare un : `kill -HUP $MAINPID`. Or
|
||
node_exporter ne sait pas se recharger : **il meurt sur SIGHUP**.
|
||
|
||
Le commentaire du script énonçait l'hypothèse inverse — « node_exporter relit le cert à
|
||
chaud ; un reload suffit ». C'est l'hypothèse qui était fausse, pas le code.
|
||
|
||
**Conséquence, jusqu'à aujourd'hui** : à chaque renouvellement de certificat — toutes les
|
||
24 h — la collecte de métriques s'arrêtait sur toute la flotte, et **rien ne le disait**.
|
||
Elle repartait au déploiement suivant, ce qui rendait la panne invisible à qui déploie
|
||
souvent.
|
||
|
||
**Mesuré plutôt que supposé**, sur `backup-01` :
|
||
|
||
```
|
||
systemctl reload alloy -> active
|
||
systemctl reload loki -> active
|
||
systemctl reload prometheus-node-exporter -> INACTIVE
|
||
```
|
||
|
||
Seul node_exporter est concerné ; `alloy` et `loki` honorent leur `ExecReload`. Le motif
|
||
`try-reload-or-restart` reste donc valable ailleurs — mais il fait confiance à une
|
||
promesse de l'unité que le binaire peut ne pas tenir, **en silence**.
|
||
|
||
Corrigé en `restart`. **Preuve** : synchronisation déclenchée sur les quatorze hôtes,
|
||
quatorze `active`.
|
||
|
||
## 2026-08-09 — Plus aucun `get_url` sans garde : la dépendance externe est comptée
|
||
|
||
Arbitrage rendu par l'exploitant : garder aussi les clés de signature. **Zéro `get_url`
|
||
sans garde** dans le dépôt, contre neuf ce matin.
|
||
|
||
```
|
||
avant : 6 serveurs tiers x 14 hotes recontactes a chaque deploiement
|
||
apres : 0
|
||
```
|
||
|
||
**Conséquence assumée, écrite dans chaque rôle** : une rotation de clé amont n'est plus
|
||
récupérée toute seule. Elle ne passe pas inaperçue pour autant — `apt` refuse alors le
|
||
dépôt, bruyamment, et le remède est d'une ligne : supprimer le fichier et rejouer le rôle.
|
||
C'est un défaut *sonore*, pas un défaut silencieux ; toute la journée a consisté à
|
||
transformer les seconds en premiers.
|
||
|
||
Ce que ça change vraiment : un déploiement de flotte ne dépend plus d'aucun serveur
|
||
étranger pour ce que la machine possède déjà. La question posée le matin — *combien de
|
||
serveurs tiers doivent être joignables pour redéployer ce que l'on possède ?* — a
|
||
maintenant une réponse mesurée, et c'est **zéro**.
|
||
|
||
Reste à éprouver sur un rejeu depuis zéro : sur un hôte neuf le fichier n'existe pas, donc
|
||
la garde laisse passer le téléchargement. Correct par construction, pas encore mesuré.
|
||
|
||
## 2026-08-09 — Dépendances externes sur le chemin critique du déploiement
|
||
|
||
Le passage d'idempotence a échoué sur `Telecharger le binaire Forgejo` :
|
||
« Connection failure: The read operation timed out » — pour **106 Mo déjà présents** sur la
|
||
machine. Un déploiement de flotte tombait parce qu'un serveur tiers était lent.
|
||
|
||
Recensement de tous les `get_url` du dépôt : **9 sans aucune garde** (ni `checksum`, ni
|
||
condition d'existence), 1 avec.
|
||
|
||
| Nature | Rôles | Traitement |
|
||
|---|---|---|
|
||
| artefact **épinglé à une version** | forgejo, keycloak, nextcloud, oauth2-proxy | **gardé** |
|
||
| **clé de signature** de dépôt apt | client_journal, client_pki, grafana, loki, step_ca | *à arbitrer* |
|
||
| trousseau `.deb` | icinga | *à arbitrer* |
|
||
|
||
Les quatre premiers sont immuables **par construction** : leur chemin de destination porte
|
||
la version. `forgejo-10.0.0` ne peut pas désigner un autre contenu demain. Les
|
||
retélécharger n'a aucun sens, et les recontacter encore moins.
|
||
|
||
**Ce qui reste à trancher, et ce n'est pas à moi.** Cinq rôles récupèrent une clé de
|
||
signature apt à chaque passage — cinq serveurs externes × quatorze hôtes, soit **soixante-dix
|
||
allers-retours par déploiement**. Les garder par existence supprimerait cette dépendance,
|
||
au prix de ne plus détecter une rotation de clé. L'argument contraire : une clé tournée
|
||
casse `apt` bruyamment, donc l'oubli se voit.
|
||
|
||
Sur une plateforme qui se veut souveraine, la question mérite d'être posée explicitement :
|
||
**combien de serveurs tiers doivent être joignables pour redéployer ce que l'on possède
|
||
déjà ?**
|
||
|
||
## 2026-08-09 — Reconstruction complète d'un seul trait, et une correction que je me dois
|
||
|
||
**Deuxième reconstruction from-zero : zéro échec, zéro injoignable, sur les quatorze
|
||
hôtes.** La première avait demandé six corrections et autant de reprises ; celle-ci est
|
||
allée au bout d'un seul trait, `make myDay` compris — création, amorçage du socle,
|
||
trente couches.
|
||
|
||
D-71 se lit dans les chiffres : `infra-pki-01` à `changed=3` et `infra-dns-01` à
|
||
`changed=1`, parce qu'ils étaient déjà debout, montés par `_amorcer-socle` avant que la
|
||
flotte ne démarre. Les couches sont passées à vide sur eux.
|
||
|
||
Les cinq devis : **CONFORME**.
|
||
|
||
**La correction.** J'ai écrit hier que `MaxStartups` et `MaxSessions` « ne viennent d'aucun
|
||
rôle, elles sont dans le gabarit doré ». **C'est faux.** J'avais grepé `ssh_baseline` seul.
|
||
C'est `ssh_hardening` qui les pose — depuis toujours, dans
|
||
`templates/20-setops-hardening.conf.j2`, **en dur** :
|
||
|
||
```
|
||
MaxSessions 2
|
||
MaxStartups 5:30:20
|
||
```
|
||
|
||
Le dépôt déclarait donc bien son durcissement. Ce qu'il ne faisait pas, c'est l'exposer :
|
||
des valeurs écrites dans un fichier de rendu sont invisibles à qui lit les `defaults`, et
|
||
inajustables sans toucher au template. Elles sont désormais des variables
|
||
(`ssh_hardening_max_startups`, `ssh_hardening_max_sessions`).
|
||
|
||
Et ma première correction avait **empiré** les choses : en ajoutant ces mêmes clés à
|
||
`ssh_baseline`, j'avais créé deux fichiers gérés par deux rôles déclarant la même directive
|
||
avec des valeurs différentes. Retiré.
|
||
|
||
**Mesure finale, sur les quatorze** : `logingracetime 20 maxsessions 10 maxstartups
|
||
10:30:60` — identique partout, et conforme à ce qui est déclaré.
|
||
|
||
## 2026-08-09 — « Banner exchange » : ce n'était ni le réseau, ni l'hôte, ni sshd
|
||
|
||
La course qui avait interrompu deux déploiements est **comprise**, cette fois — parce que
|
||
j'ai gardé le journal.
|
||
|
||
**Ce que la machine dit d'elle-même** : un seul démarrage, toujours en cours ; aucune
|
||
coupure réseau ; aucun redémarrage de `sshd`. Et le « trou » de 72 secondes dans son
|
||
journal n'en était pas un — l'entrée qui le referme est *ma propre commande de
|
||
diagnostic*. L'hôte n'a rien fait pendant ce temps **parce que plus personne ne lui
|
||
parlait**. Ce n'est pas lui qui a disparu, c'est Ansible qui n'entrait plus.
|
||
|
||
**La cause, mesurée** :
|
||
|
||
```
|
||
maxstartups 5:30:20 defaut Debian : 10:30:100
|
||
maxsessions 2 defaut Debian : 10
|
||
```
|
||
|
||
Au-delà de **cinq connexions non authentifiées simultanées**, `sshd` en refuse une partie
|
||
**sans envoyer de bannière**. Le client attend une bannière qui ne viendra pas et rapporte
|
||
« Connection timed out during banner exchange » — un message qui accuse le réseau pour un
|
||
refus applicatif. Les sessions arrivaient à une par seconde juste avant la coupure.
|
||
|
||
Ces valeurs ne viennent d'aucun rôle : `ssh_baseline` ne les pose pas. **Elles sont dans le
|
||
gabarit doré**, où l'exploitant les a durcies. C'est un réglage de sécurité qui ne vit que
|
||
dans une image disque — invisible du dépôt, invisible des preuves, et qui gouverne pourtant
|
||
la voie d'administration.
|
||
|
||
**Corrigé côté client, pas en affaiblissant l'hôte.** `ansible.cfg` n'avait *aucune* section
|
||
`[ssh_connection]` : ni `pipelining`, ni `ControlPersist` explicite. Ajoutés, avec un
|
||
`control_path_dir` court — un chemin trop long dépasse la limite des sockets UNIX et fait
|
||
retomber Ansible sur une connexion par tâche, ce qui ramènerait le problème sans qu'on le
|
||
voie.
|
||
|
||
**Mesure avant / après**, sur le même play de 30 tâches :
|
||
|
||
```
|
||
avant : une session SSH par seconde, des centaines par hôte
|
||
après : 0 nouvelle session pour tout le play
|
||
```
|
||
|
||
**Ce qui reste ouvert** : `MaxStartups` et `MaxSessions` devraient être déclarés par
|
||
`ssh_baseline` plutôt que dormir dans le gabarit. Un durcissement qu'aucun fichier du dépôt
|
||
ne mentionne ne peut être ni revu, ni prouvé, ni expliqué à qui reprend la machine.
|
||
|
||
## 2026-08-09 — L'ancien gabarit supprimé, le nouveau porte son nom
|
||
|
||
Le cluster ne porte plus qu'un gabarit : **`modeleChezlepro`, VMID 99998**. Le plan le
|
||
désigne par nom *et* par VMID, et les deux concordent.
|
||
|
||
**Trois vérifications avant de supprimer**, parce qu'un gabarit doré ne se remplace pas
|
||
sur une impression :
|
||
|
||
- le nouveau avait déjà **produit une VM déployée et prouvée** — `web-dorsal-01`, rasée,
|
||
recréée, déployée sans échec, cinq devis `CONFORME` ;
|
||
- `proxmox_clone_complet: true`, et **aucune VM ne dépendait du disque de 99999** (vérifié
|
||
en cherchant `base-99999` dans les disques de toutes les VM du cluster) : un clone lié
|
||
aurait rendu la suppression destructrice pour la flotte entière ;
|
||
- le nom de 99999 confirmé avant le `DELETE` — le même verrou que `raser`, pour la même
|
||
raison.
|
||
|
||
**L'ordre n'était pas indifférent : supprimer d'abord, renommer ensuite.** L'inverse aurait
|
||
laissé deux `modeleChezlepro` sur le cluster, et le clonage *par nom* serait devenu
|
||
ambigu — exactement la collision qui avait fait rapporter `ok` à `proxmox_kvm` sans rien
|
||
faire, le 2026-08-07.
|
||
|
||
Ce que le nouveau n'a plus, et que l'ancien portait depuis sa fabrication : un
|
||
`/etc/resolv.conf` pointant `192.168.12.254`, un `searchdomain` public, et une **clé privée
|
||
d'hôte SSH**.
|
||
|
||
## 2026-08-09 — `client_unbound` : survivre à la perte de connexion, sans la masquer
|
||
|
||
Le déploiement de `web-dorsal-01` s'était interrompu autour du redémarrage d'Unbound —
|
||
« Connection timed out during banner exchange ». Ansible marque alors l'hôte injoignable et
|
||
**abandonne toutes ses couches suivantes**, alors que la machine répondait de nouveau une
|
||
minute plus tard.
|
||
|
||
**Ma première explication était fausse, et je l'ai vérifiée avant de coder** : je pensais à
|
||
la résolution inverse de `sshd` au moment où le résolveur bascule. `sshd -T` répond
|
||
`usedns no` — elle n'a pas lieu. Et **la cause reste inconnue** : j'avais écrasé le journal
|
||
du déploiement raté en relançant, donc il n'est plus lisible. Faute de méthode, pas de
|
||
raisonnement.
|
||
|
||
Ce que le dépôt sait déjà de ce symptôme est consigné dans le `Makefile` (`_attendre-hote`) :
|
||
à travers la frontière, le TCP s'établit par proxy SYN, et l'échec se lit « banner
|
||
exchange » **même quand l'hôte n'est simplement pas là**. Le message accuse SSH pour un
|
||
problème d'accessibilité.
|
||
|
||
**Corrigé sans prétendre connaître la cause** : le handler de redémarrage tolère la perte
|
||
(`ignore_unreachable`), et une tâche **exige ensuite le retour** de l'hôte
|
||
(`wait_for_connection`, 180 s). On attend une condition, pas une durée.
|
||
|
||
Ce n'est **pas** masquer une panne : si l'hôte ne revient pas, la tâche suivante échoue
|
||
franchement. Ce qui change, c'est qu'une absence d'une minute n'annule plus une heure de
|
||
déploiement.
|
||
|
||
**Et une règle pour moi** : ne plus écraser le journal d'un échec avant de l'avoir lu. Le
|
||
diagnostic de cette course a été rendu impossible par un `rm -f` de confort.
|
||
|
||
## 2026-08-09 — Le plan bascule sur le gabarit recapturé, prouvé par une VM réelle
|
||
|
||
`proxmox_clone_source_nom` / `proxmox_clone_vmid_modele` pointent désormais sur
|
||
`modeleChezlepro-travail` (99998). L'ancien (99999) reste sur le cluster : il porte encore
|
||
un `/etc/resolv.conf` et une clé privée d'hôte SSH du réseau de fabrication. À supprimer
|
||
quand plusieurs VM seront nées du nouveau — pas avant.
|
||
|
||
**Preuve par une machine réelle**, et non par lecture de configuration : `web-dorsal-01` —
|
||
l'hôte le moins engagé de la flotte, 12 Ko dans `/var/www` — rasé puis recréé par le chemin
|
||
normal (`make raser HOTE=…`, `make creer-vm`, `make deployer`).
|
||
|
||
Ce qu'elle a hérité du nouveau gabarit :
|
||
|
||
```
|
||
resolv.conf → les commentaires seuls, aucune identité de fabrication
|
||
machine-id → cc9c58c1… neuf et unique
|
||
clé d'hôte → SHA256:pES0/… régénérée
|
||
cloud-init → done
|
||
```
|
||
|
||
Déploiement complet, **zéro échec**, et les cinq devis de service `CONFORME`.
|
||
|
||
**`raser` accepte maintenant `--hote`.** Raser une seule machine sert à éprouver un gabarit
|
||
ou à reprendre un hôte ; les **quatre verrous restent en vigueur** — on ne fait que
|
||
restreindre la liste dérivée du plan, jamais l'élargir.
|
||
|
||
**Un défaut relevé au passage, non corrigé.** Le premier déploiement s'est interrompu sur
|
||
`client_unbound`, à l'instant où le résolveur bascule vers `127.0.0.1` : toute résolution
|
||
en vol se fige le temps qu'Unbound réponde, y compris celle que fait `sshd` à l'ouverture
|
||
de session — d'où « Connection timed out during banner exchange ». L'hôte répondait de
|
||
nouveau une minute plus tard et la reprise est passée intégralement, presque tout en
|
||
`changed=0`. C'est une **course**, de la même famille que celles de la reconstruction, et
|
||
elle mérite le même remède : attendre une condition plutôt que de subir la bascule.
|
||
|
||
## 2026-08-09 — Gabarit recapturé sur copie de travail, sans toucher à l'original
|
||
|
||
`modeleChezlepro` (99999) cloné en `modeleChezlepro-travail` (99998), préparé, vérifié,
|
||
nettoyé, converti. **L'original n'a pas été touché** — il reste le gabarit en service tant
|
||
que le nouveau n'a pas produit une VM qui fonctionne.
|
||
|
||
**Deux défauts de mon propre outillage, trouvés en l'utilisant pour de vrai** — et c'est
|
||
tout l'intérêt de s'en servir plutôt que de le déclarer prêt :
|
||
|
||
- l'inventaire d'un seul hôte (`-i "<ip>,"`) ne porte **aucun `group_vars`**, donc aucun
|
||
utilisateur de connexion : Ansible tentait le compte local de l'opérateur. `MODELE_HOTE`
|
||
passe maintenant `ansible_user` (défaut `ansible`, le `ciuser` du gabarit) ;
|
||
- les playbooks ciblent `hosts: modeles_vm`, et un inventaire d'un seul hôte place la
|
||
machine dans `all` — **pas** dans ce groupe. La commande était juste et la cible
|
||
introuvable : « skipping: no hosts matched », qui n'est pas une erreur. J'avais vérifié
|
||
l'affichage de la commande, pas son effet. Les trois playbooks acceptent désormais
|
||
`cible_modele`.
|
||
|
||
**Le nettoyage a gagné deux choses :**
|
||
|
||
`/etc/resolv.conf` est vidé — il portait `search chezlepro.ca` et
|
||
`nameserver 192.168.12.254`, l'identité du réseau de fabrication.
|
||
|
||
Et les **clés d'hôte SSH** sont supprimées. Le gabarit transportait une clé **privée** :
|
||
quiconque détient l'image détient de quoi se faire passer pour une VM qui n'aurait pas
|
||
régénéré la sienne. La suppression n'est sûre que parce que cloud-init les recrée au
|
||
premier démarrage — vérifié avant de l'écrire : les hôtes de la flotte portent des
|
||
empreintes toutes différentes.
|
||
|
||
L'exploitant a par ailleurs retiré `mtu=9000` des deux gabarits — le réglage mort relevé
|
||
plus tôt, qui ne se propageait pas aux clones.
|
||
|
||
**Ce qui reste à faire, et qui n'est pas à moi** : faire pointer
|
||
`proxmox_clone_source_nom` / `proxmox_clone_vmid_modele` sur le nouveau, une fois qu'une VM
|
||
en sera née et aura fonctionné. Tant que ce n'est pas fait, rien n'a changé pour la flotte.
|
||
|
||
## 2026-08-09 — `modeles_vm` : trois commandes qui ne pouvaient rien faire
|
||
|
||
Le groupe était **toujours vide**, et rien ne le signalait. `instancier` l'émet comme
|
||
squelette (`"modeles_vm": {"hosts": {}}`) et les états d'un serveur ne connaissent que
|
||
`actif` et `planifie` — aucun chemin ne permettait d'y faire entrer une machine. Les trois
|
||
cibles qui le ciblent recevaient « skipping: no hosts matched », qui n'est pas une erreur.
|
||
|
||
**Le gabarit ne peut pas venir du plan, et c'est structurel.** Sa configuration Proxmox le
|
||
place sur le réseau de fabrication (`ip=192.168.12.99/24`), pas dans le supernet. Ce n'est
|
||
pas un hôte de l'écosystème : c'est la matrice dont l'écosystème est tiré. Peupler
|
||
`modeles_vm` depuis `plan/serveurs.yml` aurait été forcer un objet dans un registre qui
|
||
n'est pas le sien.
|
||
|
||
`MODELE_HOTE=<ip>` le désigne explicitement, et l'inventaire d'un seul hôte (`-i "<ip>,"`)
|
||
sert les trois playbooks. Sans lui — et sans groupe peuplé — elles **refusent en
|
||
expliquant**, au lieu de ne rien faire :
|
||
|
||
```
|
||
Refus: aucune VM de gabarit designee.
|
||
Le gabarit vit sur le reseau de fabrication, pas dans le tenant :
|
||
il ne peut pas venir du plan. Le designer explicitement —
|
||
make preparer-modele MODELE_HOTE=192.168.12.99
|
||
```
|
||
|
||
Les prérequis d'accès et de privilèges suivent la même cible : ils interrogeaient eux aussi
|
||
le groupe vide.
|
||
|
||
C'est la même famille que tout ce que la journée a produit — **une capacité déclarée dont
|
||
personne ne vérifiait qu'elle est branchée**. À la différence près que celle-ci ne se
|
||
manifestait par aucun symptôme : elle ne faisait rien, poliment.
|
||
|
||
## 2026-08-09 — Le gabarit doré : ce qu'il transporte de son réseau de naissance
|
||
|
||
Question de l'exploitant : « on peut l'optimiser ? ». Mesuré avant de répondre — et la
|
||
réponse n'est pas celle que la question suggère.
|
||
|
||
**Rien à gagner côté performance ni paquets.** La configuration Proxmox est soignée :
|
||
UEFI/q35, `virtio-scsi-single` avec `iothread`, `discard=on` + `ssd=1` pour le TRIM,
|
||
`cpu x86-64-v2-AES` — ce dernier compte quand tout le trafic est chiffré. `agent 1`,
|
||
`balloon 0`. Et tous les paquets du socle sont déjà cuits dans l'image : `common_packages`
|
||
les trouve présents, l'optimisation évidente est déjà faite.
|
||
|
||
**Ce qu'il transporte, en revanche, c'est son lieu de naissance** :
|
||
|
||
```
|
||
ipconfig0 ip=192.168.12.99/24,gw=192.168.12.254
|
||
nameserver 192.168.10.10 192.168.10.20
|
||
searchdomain chezlepro.ca
|
||
net0 bridge=vmbr1,mtu=9000,tag=12
|
||
```
|
||
|
||
Les deux premiers sont surchargés au clonage. Le troisième ne l'était **pas** :
|
||
`proxmox_clone_domaines_recherche` n'était défini nulle part, donc les 14 VM héritaient de
|
||
`chezlepro.ca` — le domaine *public* — alors qu'elles vivent dans `chezlepro.internal`.
|
||
Désormais dérivé de `domaine_interne`, par le mécanisme qui existait déjà pour le DNS
|
||
(`SETOPS_DOMAINE`, comme `SETOPS_DNS`).
|
||
|
||
**Honnêtement : ça ne réparait pas de panne.** `serveur_debian` réécrit `/etc/resolv.conf`
|
||
au déploiement avec les seuls `nameserver`, sans ligne `search` — vérifié sur la flotte.
|
||
Le domaine hérité ne vit donc qu'entre le clonage et la première couche, et la zone
|
||
publique n'a pas de joker. C'était faux, et ça ne tenait que par chance.
|
||
|
||
**`mtu 9000` est un réglage MORT** : les clones tournent en 1500, la valeur ne se propage
|
||
pas. Un réglage mort dans l'actif le plus central du dépôt est un mensonge pour qui le lira
|
||
ensuite — à retirer ou à rendre délibéré de bout en bout.
|
||
|
||
**Et le vrai enjeu n'est pas l'optimisation, c'est l'appartenance.** Ce gabarit est celui de
|
||
Chezlepro : son IP, son DNS, son domaine, son pont, son VLAN. La preuve de portabilité
|
||
suppose qu'il serve aussi Technolibre. Le rendre agnostique vaut plus que n'importe quel
|
||
réglage de performance.
|
||
|
||
**Défaut trouvé en chemin** : le groupe `modeles_vm` est **vide**. `make preparer-modele`,
|
||
`verifier-modele` et `nettoyer-modele` n'ont aucune cible — trois commandes documentées qui
|
||
ne peuvent rien faire, et rien ne le signale.
|
||
|
||
## 2026-08-09 — P33 : deux rôles co-localisés ne revendiquent pas le même port
|
||
|
||
Deuxième des trois chantiers ouverts par la reconstruction. Il retrouve son défaut nº 6 à
|
||
froid, sans machine.
|
||
|
||
Un port n'appartient à personne : le premier service démarré le prend, l'autre échoue.
|
||
Sur `infra-mail-01`, le SASL de Dovecot (12345, choix délibéré) et l'interface HTTP d'Alloy
|
||
(12345, défaut amont) se le disputaient **depuis le premier jour** — et c'est Dovecot qui
|
||
perdait, sans que rien ne le dise. Il a fallu inverser l'ordre de démarrage, ce que fait un
|
||
rejeu depuis zéro, pour que ça devienne audible.
|
||
|
||
**Le contrôle n'était possible qu'après avoir déclaré le port d'Alloy.** C'est la vraie
|
||
leçon du défaut nº 6 : un port **subi** — le défaut amont d'un logiciel qu'on n'a pas
|
||
choisi — n'existe pour aucun registre, donc aucune preuve ne peut le voir. Il faut
|
||
l'imposer pour pouvoir le vérifier.
|
||
|
||
**`partage: true`**, nouveau mot du registre des flux, distingue deux situations qu'il
|
||
confondait : un rôle qui **ouvre** une écoute, et un rôle qui **décrit** celle d'un autre —
|
||
`serveur_backup` empruntant le sshd de `serveur_debian`. Sans lui, la seule co-location
|
||
légitime de la flotte (`tcp/22` sur `backup-01`) serait signalée à tort. Une preuve qui
|
||
crie sur un cas sain finit par être ignorée : c'est pire que de ne pas l'avoir.
|
||
|
||
**Vérifié dans les deux sens.** Sur la flotte réelle : 32 revendications, aucune collision.
|
||
En remettant le port d'Alloy à 12345 comme hier : `infra-mail-01 : tcp/12345 revendiqué par
|
||
client_journal et serveur_dovecot`, code 1 — le défaut nº 6 reproduit sans toucher à une
|
||
machine.
|
||
|
||
Harnais : **33 preuves, 0 échec, 0 sautée.**
|
||
|
||
## 2026-08-09 — P32 : un `assert` de rôle est un contrat, et l'instance doit l'honorer
|
||
|
||
Le premier des trois chantiers que la reconstruction avait rendus évidents. Il aurait
|
||
trouvé son défaut nº 1 — `amorcage_acces_courriel` — **sans rien détruire**.
|
||
|
||
Un rôle qui `assert` une variable non vide déclare un contrat : sans cette valeur, le
|
||
déploiement s'arrête. Rien ne vérifiait que l'instance les honore, et le manque ne se voit
|
||
qu'au moment où la garde s'exécute pour de vrai — c'est-à-dire, pour un intrant d'amorçage,
|
||
seulement quand on repart de rien.
|
||
|
||
**Un intrant est satisfait** par un défaut non vide dans le rôle (y compris un
|
||
`{{ vault_* }}`, dont la présence réelle relève de P18), par un `set_fact` de résolveur, ou
|
||
par une déclaration de l'inventaire. Aucune voûte n'est déchiffrée : la preuve reste
|
||
statique, comme les 31 autres.
|
||
|
||
**Deux fois mon instrument a accusé le composant à sa place**, et les deux fois avant la
|
||
première exécution utile. Il criait au manque sur `serveur_postfix_mailstore_hote`, qui est
|
||
pourtant bel et bien fourni — d'abord parce que je ne lisais que `group_vars/` et
|
||
`host_vars/` en oubliant le fichier d'inventaire lui-même, ensuite parce que je n'y
|
||
cherchais que les blocs `vars:` alors que `instancier` écrit les valeurs dérivées
|
||
**directement sous le nom d'hôte**. Un vérificateur incomplet est pire qu'absent : il fait
|
||
douter de ce qui marche.
|
||
|
||
**Vérifié dans les deux sens.** Sur l'instance réelle : 30 exigences, toutes satisfaites.
|
||
Sur un double où l'on retire la déclaration ajoutée la veille : `amorcage_acces_courriel`
|
||
nommé, code de sortie 1 — le défaut nº 1 reproduit à froid.
|
||
|
||
Ce qu'il ne fait pas, et c'est écrit dans son en-tête : il ignore les `when:` qui rendent
|
||
une assertion conditionnelle, donc il peut signaler un intrant exigé seulement quand une
|
||
option est active. Signaler à tort coûte une ligne de déclaration ; ne pas signaler coûte
|
||
un déploiement.
|
||
|
||
Harnais : **32 preuves, 0 échec, 0 sautée.**
|
||
|
||
## 2026-08-09 — La reconstruction from-zero est prouvée : cinq devis sur cinq
|
||
|
||
Écosystème `chezlepro` détruit — 14 VM, disques compris — puis **rejoué depuis le plan
|
||
seul**. Aucune sauvegarde restaurée. Les cinq devis rendent le même verdict qu'avant la
|
||
destruction, et `docs/audit/reference-avant-reconstruction-2026-08-08.md` porte les deux
|
||
états côte à côte pour que « identique » soit vérifiable et non ressenti.
|
||
|
||
C'est la première fois que le dépôt peut affirmer que le **système reconstruit est celui
|
||
que le plan décrit**, au lieu d'affirmer que le dépôt est cohérent avec lui-même.
|
||
|
||
**Six défauts trouvés, tous invisibles autrement** — trois d'ordre, deux de course, un
|
||
conflit de port. Chacun a fait l'objet de son propre commit ; ce qu'ils ont en commun
|
||
mérite d'être dit : **ils dormaient tous derrière un état préexistant.** Un compte qui
|
||
existait déjà, des rôles créés par un passage antérieur, des clients déjà là, un service
|
||
qui tournait depuis toujours, un port déjà tenu. Le rejeu n'a rien cassé — il a retiré
|
||
l'état qui masquait.
|
||
|
||
Le sixième est le plus instructif. Alloy et le SASL de Dovecot revendiquent tous deux le
|
||
port 12345 sur `infra-mail-01`. Le conflit existait depuis le premier jour, mais dans
|
||
l'autre sens : Alloy tenait le port et c'est **l'écoute SASL de Dovecot qui échouait, en
|
||
silence**. L'ordre des couches d'une reconstruction a inversé les rôles et rendu le défaut
|
||
audible. Le port d'Alloy est désormais **imposé et déclaré** — le vrai défaut n'était pas
|
||
le numéro, c'était qu'un port *subi* ne se déclare nulle part, donc qu'aucun contrôle ne
|
||
pouvait voir la collision.
|
||
|
||
**Ce qu'il reste à construire, et que ce rejeu a rendu évident :**
|
||
|
||
- une preuve « tout intrant qu'un rôle **exige** est fourni par l'instance » — le défaut
|
||
nº 1 se serait vu sans détruire quoi que ce soit ;
|
||
- une preuve « deux rôles co-localisés ne revendiquent pas le même port » — maintenant que
|
||
les ports subis se déclarent, elle devient possible ;
|
||
- vider `/etc/resolv.conf` à la capture du gabarit doré : il transporte encore le
|
||
résolveur de son réseau de fabrication.
|
||
|
||
## 2026-08-08 — D-71 éprouvée : l'AC et le DNS montent seuls, sur des machines neuves
|
||
|
||
Première exécution de `_amorcer-socle` sur une flotte qui vient d'être clonée, aucun pair
|
||
debout. Les deux exceptions structurelles nommées la veille se sont exercées pour de vrai.
|
||
|
||
**L'AC s'auto-signe**, comme annoncé :
|
||
|
||
```
|
||
subject=O=Set-OPS Internal CA, CN=Set-OPS Internal CA Root CA
|
||
issuer =O=Set-OPS Internal CA, CN=Set-OPS Internal CA Root CA
|
||
```
|
||
|
||
**Les deux zones sont posées et répondent** — requêtes réelles, pas lecture de fichier :
|
||
|
||
```
|
||
zones : chezlepro.internal.zone 27.10.in-addr.arpa.zone
|
||
10.27.19.21 -> infra-pki-01.chezlepro.internal.
|
||
10.27.21.11 -> forge-01.chezlepro.internal.
|
||
10.27.18.21 -> backup-01.chezlepro.internal.
|
||
```
|
||
|
||
`forge-01` et `backup-01` **ne sont pas déployés** — ils viennent d'être clonés, et leur
|
||
PTR répond quand même. C'est la démonstration de l'arbitrage rendu la veille : la zone est
|
||
**générée depuis le plan**, pas enrôlée par la VM. Un enrôlement aurait fait dépendre le
|
||
DNS de l'état de chaque machine ; ici le nom existe parce que le plan le dit.
|
||
|
||
Deux fois de plus, ma sonde était fausse avant le système : `dig @127.0.0.1` refusait la
|
||
connexion — PowerDNS écoute sur `ansible_host`, pas sur la boucle locale. Vérifier
|
||
l'instrument avant d'accuser le composant, encore.
|
||
|
||
## 2026-08-08 — D-71 : PKI et DNS debout avant tout le reste, et la zone inverse
|
||
|
||
Contrainte posée par l'exploitant en voyant `backup-01` et `collab-01` créés avant l'AC et
|
||
le DNS : **une PKI et un DNS fonctionnels avant toute chose** ; puis par VM, socle →
|
||
enrôlement PKI → enregistrement DNS (A et PTR).
|
||
|
||
Ma première réponse était incomplète. J'avais expliqué qu'un clone est inerte et que
|
||
`site.yml` respecte bien les couches — c'est exact, mais ça esquivait deux points justes :
|
||
un échec de création à la douzième VM coûte quarante minutes sans rien déployer, et un
|
||
journal qui montre `backup-01` en tête donne l'impression que le moteur ignore ses propres
|
||
couches.
|
||
|
||
**Ce qui manquait vraiment, mesuré avant de coder :**
|
||
|
||
```
|
||
enregistrements A → DEJA derives du plan (zone generee depuis `hotes_actifs`)
|
||
zone inverse / PTR → n'existe NULLE PART — aucun role ne touche `in-addr.arpa`
|
||
ordre d'amorcage → aucun : `deployer-tout` est par couches, pas par hote
|
||
```
|
||
|
||
Le premier point a réduit le travail de moitié : je m'apprêtais à écrire un enrôlement DNS
|
||
par hôte alors que la zone directe se dérivait déjà correctement.
|
||
|
||
**La zone inverse** est dérivée du supernet, comme le reste : un `/16` `10.(10+index).0.0`
|
||
donne `(10+index).10.in-addr.arpa` — `27.10.in-addr.arpa` ici. Les PTR viennent de la
|
||
**même source** que les A (`hotes_actifs` et son `ansible_host`) : deux zones alimentées
|
||
par une seule vérité, donc pas d'endroit où elles puissent diverger. Vide si le supernet
|
||
n'est pas un `/16` — mieux vaut pas de zone inverse qu'une zone fausse.
|
||
|
||
**`_amorcer-socle`** monte l'AC puis le DNS **complètement**, hôte par hôte, avant
|
||
`deployer-tout`. Les deux se dérivent de `applications.<app>.hote` ; l'ordre entre eux
|
||
n'est pas alphabétique mais causal — le DNS a besoin d'un certificat, l'autorité n'a besoin
|
||
de personne.
|
||
|
||
**Deux exceptions structurelles, nommées plutôt que découvertes à l'exécution** : l'AC
|
||
s'auto-signe, et le DNS pose son propre enregistrement. La règle « socle → PKI → DNS » ne
|
||
peut pas s'appliquer à ceux qui la rendent possible.
|
||
|
||
Au passage, le harnais a attrapé une faute que je venais d'introduire : j'avais inventé un
|
||
handler `Recharger PowerDNS` qui n'existe pas — le rôle écoute `Validate and reload
|
||
PowerDNS`. P10 l'a nommée avant tout déploiement.
|
||
|
||
## 2026-08-08 — Une question de l'exploitant trouve un trou dans P31, une heure après
|
||
|
||
« Pourquoi pas `make myDay` ? » — la cible existe, c'est un alias strict de
|
||
`reconstruire`. Mais elle n'avait **aucun texte d'aide**, et P31 la déclarait conforme.
|
||
|
||
Le motif était `^[a-z][a-z0-9_-]*:` : **toute cible contenant une majuscule échappait au
|
||
contrôle.** `myDay` est citée dans l'aide du Makefile et dans la GUI ; elle n'apparaissait
|
||
dans aucun recensement. Corrigé en `^[A-Za-z][A-Za-z0-9_-]*:`, et l'aide posée.
|
||
|
||
Ce n'est pas un détail sur une cible. **Une preuve ne vaut que ce que vaut son motif** —
|
||
et celle-ci a été écrite avec la conviction d'être rigoureuse, testée dans les deux sens le
|
||
jour même, et elle laissait quand même passer un cas. Le trou n'a pas été trouvé par un
|
||
test mais par quelqu'un qui a demandé « et celle-là ? ».
|
||
|
||
À ranger à côté des deux critères creux de P31 (le rapport généré qui se citait lui-même,
|
||
et l'inventaire généré qui aurait satisfait le critère par construction). Trois fois sur la
|
||
même preuve, en une journée : la difficulté n'est pas d'écrire un test, c'est de délimiter
|
||
honnêtement ce qu'il regarde.
|
||
|
||
Compte après correction : **87 cibles documentées, 36 scripts, 54 rôles**.
|
||
|
||
## 2026-08-08 — `make raser` : la seule commande destructive du moteur
|
||
|
||
Ajoutée pour rendre la reconstruction from-zero **répétable** — un test qu'on ne peut jouer
|
||
qu'une fois, à la main, n'est pas une recette. Tout le reste du dépôt crée ou réconcilie ;
|
||
celle-ci détruit, et elle est écrite en conséquence.
|
||
|
||
**Quatre verrous, tous éprouvés avant usage :**
|
||
|
||
| Verrou | Ce qu'il empêche | Vérifié |
|
||
|---|---|---|
|
||
| VMID **dérivés du plan** uniquement | détruire une VM hors écosystème ; le gabarit doré est structurellement exclu, son VMID ne se dérive pas | à blanc : 14 VM listées, template absent |
|
||
| **le nom doit correspondre** | détruire la machine de quelqu'un d'autre sous un VMID du plan | test isolé, faux cluster |
|
||
| **nommer l'écosystème** (`INSTANCE=`) | raser la mauvaise instance : le symlink `instance/` peut pointer n'importe où | refus sans nom, et refus sur mauvais nom |
|
||
| `CONFIRMER=true` | tout le reste | sans lui : inventaire, rien d'autre |
|
||
|
||
Le deuxième mérite d'être détaillé, parce qu'il vient d'un fait et non d'une précaution
|
||
abstraite : le 2026-08-07, une VM héritée portait un VMID du plan sous le nom
|
||
`web-frontal-01`, et `proxmox_kvm` avait rapporté `ok` sans rien faire. Rasé sans ce
|
||
contrôle, on détruisait une machine étrangère. Le refus porte sur **l'opération entière**,
|
||
pas sur la seule VM en conflit — un cluster qui ment sur un VMID peut mentir sur d'autres.
|
||
|
||
C'est aussi le seul verrou qu'on ne peut pas éprouver sur le vrai cluster sans y fabriquer
|
||
une collision : `scripts/tests/test_raser.py` isole la logique derrière un faux cluster, et
|
||
le test est rattaché à **P02**. Le harnais passe désormais **31 preuves, 0 sautée**.
|
||
|
||
## 2026-08-08 — État de référence figé avant la reconstruction from-zero
|
||
|
||
L'exploitant recadre : les 14 VM sont un **POC**, pas de la production. Ma prudence venait
|
||
d'une hypothèse que je portais, pas de lui. Les constats sur les sauvegardes restent du
|
||
travail à faire **avant** que ça devienne de la production — pas avant le test.
|
||
|
||
**Et reconstruire Chezlepro est un meilleur test que construire Technolibre.** On dispose
|
||
d'un état de référence : les cinq devis y sont `CONFORME` à l'instant. Toute divergence
|
||
après rejeu sera un défaut réel, mesurable contre une base connue. Sur Technolibre, qui n'a
|
||
jamais tourné, un échec serait ambigu — plan faux ou moteur faux ?
|
||
|
||
`docs/audit/reference-avant-reconstruction-2026-08-08.md` fige : les cinq verdicts, les 14
|
||
hôtes avec leur adresse et leur compte de services, et surtout **ce qui sera perdu et devra
|
||
être refait à la main** (clé racine de l'AC — donc la racine installée dans le navigateur —
|
||
et le mot de passe du compte `sysadmin`). Ce document existe pour que « identique » soit
|
||
**prouvable plutôt que ressenti**.
|
||
|
||
Ce qui survit et rend le rejeu possible : les deux voûtes et `~/.config/setops-vault-pass`
|
||
vivent hors dépôt et hors cluster ; le code est sur `eregion.chezlepro.ca`
|
||
(192.168.12.201), machine **distincte** du tenant — vérifié, parce qu'un dépôt dont le
|
||
`origin` vivrait dans l'écosystème à détruire serait une dépendance circulaire fatale.
|
||
|
||
**Constat au passage : le moteur n'a aucun chemin de destruction.** `make reconstruire`
|
||
crée les VM manquantes et déploie ; il ne rase rien. C'est cohérent avec la doctrine (rien
|
||
de destructif sans garde explicite), mais ça veut dire qu'un test « depuis zéro » suppose
|
||
une suppression faite hors du moteur.
|
||
|
||
## 2026-08-08 — « Set-OPS trichait ? » — non, il se sous-estimait
|
||
|
||
Question de l'exploitant après avoir vu P31 manquer deux fois sa cible. Elle méritait un
|
||
audit, pas une assurance.
|
||
|
||
**La réponse est non, et c'est le dépôt lui-même qui la donne.** Le registre des
|
||
affirmations contient onze de ses propres promesses publiques marquées **❌ fausse**. Il
|
||
déclare son périmètre — « aucune VM / Proxmox / réseau touché ». Et D-25 en fait une
|
||
règle : *le dépôt n'affirme pas que ses devis s'appliquent, il affirme qu'ils dérivent*.
|
||
Un système qui triche n'écrit aucune de ces trois choses.
|
||
|
||
**Il y avait bien un angle mort, et c'est celui fermé aujourd'hui** : les 30 preuves sont
|
||
statiques. `CONFORME : 30 preuves` *se lit* comme « le système fonctionne » alors que ça
|
||
signifie « le dépôt est cohérent avec lui-même ». La restriction était écrite dans le
|
||
registre et invisible dans la sortie quotidienne — c'est ainsi que le certificat de l'AC a
|
||
pu expirer huit heures sous un harnais vert.
|
||
|
||
**Les onze ❌ ont été rejugées**, chacune reconfrontée au dépôt : `make verifier` passe
|
||
(30 preuves) ; QUICKSTART ne promet plus que le modèle `socle` et pointe
|
||
`inventories/production/` ; `PasswordAuthentication no` par défaut et le texte
|
||
contradictoire a disparu ; `make help` et `syntax-template` n'existent plus nulle part ;
|
||
la voûte Proxmox est unifiée ; les domaines du socle valident ; le socle génère bien dans
|
||
`production/` sans repli. **Onze sur onze : résolues.**
|
||
|
||
**Et le rejugement a trouvé mieux qu'un registre oublié.** Les résolutions étaient **déjà
|
||
documentées** dans les sections « Phase 3 » du registre. Mais son tableau de synthèse
|
||
annonçait encore « ❌ fausse : 8 ». Deux représentations du même fait, une corrigée et
|
||
l'autre non, **rien qui vérifie qu'elles se rejoignent** — le défaut exact que ce registre
|
||
existe pour traquer, appliqué à lui-même. Il penchait du bon côté, ce qui l'a rendu
|
||
invisible : personne ne se plaint d'une mauvaise nouvelle périmée.
|
||
|
||
Le tableau de juillet est conservé comme photo de départ ; un bloc « état courant » le
|
||
suit. Un `❌` qui subsiste doit désormais se lire comme un signal vivant, pas un vestige.
|
||
|
||
## 2026-08-08 — D-70 : l'exigence de documentation devient une preuve (P31)
|
||
|
||
Directive de l'exploitant : « la doc dit et explique tout ce que Set-OPS fait, et pourquoi
|
||
c'est ainsi. » Une exigence qu'on se contente d'énoncer pourrit en silence — on venait
|
||
d'en avoir trois exemples le jour même dans la carte.
|
||
|
||
**L'écart mesuré avant de le combler :**
|
||
|
||
```
|
||
cibles make sans texte d'aide : 66 sur 85 → `make help` en montrait 19
|
||
scripts jamais cités en doc : 11 sur 35 → dont 3 applicateurs et 4 devis du jour
|
||
```
|
||
|
||
Les 66 cibles ont reçu leur aide : `make aide` couvre maintenant **85 commandes** au lieu
|
||
de 19. C'est ce qui rend le moteur utilisable par quelqu'un qui ne lit pas le Makefile —
|
||
la règle « un sysadmin l'exploite sans IA » n'a pas d'autre traduction concrète.
|
||
|
||
**P31 garde l'exigence**, et le chemin pour l'écrire a été instructif : *deux fois* mon
|
||
critère s'est révélé creux.
|
||
|
||
D'abord « le nom du script apparaît dans un document » : le rapport d'audit **généré**
|
||
recopiait les noms manquants dans son message d'échec, ce qui les rendait cités au tour
|
||
suivant. Une preuve qui se nourrit de sa propre sortie passe au vert sans qu'une ligne
|
||
soit écrite.
|
||
|
||
Puis, en corrigeant, j'ai failli créer le même trou en plus grand : générer un inventaire
|
||
de l'outillage aurait satisfait le critère par construction. **Un critère qu'on peut
|
||
satisfaire en générant du texte ne prouve rien.** P31 teste donc que chaque script porte
|
||
une docstring qui l'explique et qu'il reste **atteignable** — par une cible `make`, ou par
|
||
un autre outil.
|
||
|
||
Vérifiée dans les deux sens, comme les devis : on retire l'aide d'une cible et la
|
||
docstring d'un script, les deux défauts sont nommés ; on restaure, `CONFORME`.
|
||
|
||
**Ce que P31 ne garde pas, et c'est dit dans son propre code** : que l'explication soit
|
||
*bonne*. Le « pourquoi » se juge en revue. Il vit dans ce journal — qui porte le fait
|
||
mesuré, pas seulement le changement — et dans le registre des décisions. Prétendre le
|
||
mesurer mécaniquement serait se mentir.
|
||
|
||
## 2026-08-08 — Tisser le travail du jour dans les points d'entrée
|
||
|
||
Question de l'exploitant : faut-il refondre la documentation ? **Non.** L'état mesuré ne le
|
||
justifie pas — 54 rôles, 54 README (couverture complète), 34 documents, une carte avec un
|
||
ordre de lecture, un registre de décisions. Une refonte ferait courir le vrai risque :
|
||
perdre le *pourquoi* accumulé, qui a pris des mois et ne se régénère pas.
|
||
|
||
Ce qui était réellement en retard était petit et nommable : le travail du jour était
|
||
documenté **dans son coin**. Les cinq devis n'existaient que dans deux fichiers — leur
|
||
propre doc et le registre des décisions. Absents de la carte, d'`AGENTS.md`, du runbook du
|
||
sysadmin et de la GUI. Autrement dit : découvrables uniquement par qui connaît déjà le
|
||
Makefile — ce qui contredit « un sysadmin l'exploite sans IA ».
|
||
|
||
Tissés dans les quatre points d'entrée : ligne « Conformité du déployé » dans la carte,
|
||
section « Écrire, puis relire (D-68) » dans `AGENTS.md`, **§6.0 du runbook** (le premier
|
||
réflexe avant de suivre quoi que ce soit), et la vue *Reconstruction* de la GUI — sa place
|
||
logique, puisque ce sont ces devis qui diront si un remontage a produit le système décrit.
|
||
|
||
**Et le tissage a fait tomber trois affirmations périmées**, ce qui est sa vraie utilité :
|
||
|
||
- la carte annonçait **28 décisions** ; il y en a **66 en vigueur** (D-01 → D-69, 3
|
||
renversées) ;
|
||
- elle disait des accès et habilitations « **décidé, non construit** : `ou=people` et
|
||
`ou=groups` existent et restent vides ». Mesuré : un compte, un groupe, et la chaîne
|
||
LDAP → Keycloak → groupe → service exercée de bout en bout sur Icinga Web 2 le jour même ;
|
||
- la GUI parlait des « **deux** devis » d'infrastructure ; il y en a quatre depuis l'arrivée
|
||
du SDN EVPN et du pare-feu est-ouest.
|
||
|
||
**Ce qui n'est pas fait, et pourquoi.** La formation et le wiki attendent — leur audience et
|
||
leur condition de vérité sont différentes. Un runbook que personne n'a suivi sauf son auteur
|
||
est une hypothèse ; la reconstruction from-zero est le test de cette documentation. Écrire
|
||
la formation avant, ce serait enseigner une procédure que personne n'a exécutée.
|
||
|
||
## 2026-08-08 — D-68 / D-69 : la règle n'est pas « toujours l'API »
|
||
|
||
Question de l'exploitant après deux pannes causées par `kcadm` : ne devrait-on pas toujours
|
||
utiliser une API quand il en existe une ?
|
||
|
||
Non — et la journée le montre mieux qu'un principe. Sur six familles de défauts, **deux**
|
||
seulement viennent d'un CLI ; un module Ansible (`ldap_entry`, qui crée sans jamais
|
||
modifier) a commis exactement la même faute, et trois autres viennent d'un `grep` de
|
||
fichier, de la précédence Ansible, et de mon propre comparateur. Le facteur commun n'est pas
|
||
l'interface : c'est d'avoir **écrit sans relire**.
|
||
|
||
**D-68** — on écrit, puis on relit et on compare, quelle que soit l'interface ; on choisit
|
||
celle dont le chemin de *lecture* parle le même langage que le chemin d'*écriture*. Une API
|
||
est souvent préférable pour une raison précise — elle rend la ressource entière, ce qui
|
||
permet le patron de chaque devis : *fusionner l'attendu dans le réel ; si rien ne change,
|
||
c'est conforme*. Mais la plupart de la flotte n'a pas d'API (Postfix, Dovecot, nginx, slapd,
|
||
nftables), et `postconf -h` / `postconf -e` sont parfaitement symétriques.
|
||
|
||
**D-69** — sur Keycloak en particulier : l'API pour toute map ou collection (`smtpServer`,
|
||
`attributes`, `config`), où `kcadm -s` sort en succès sans rien écrire ; `kcadm` ailleurs,
|
||
parce que c'est le vocabulaire de la documentation du produit — donc lisible par un
|
||
sysadmin sans IA.
|
||
|
||
Deux raisons de ne pas systématiser l'API méritent d'être dites : le CLI est souvent le
|
||
**contrat du fournisseur** et encode des invariants (`occ user:resetpassword` hache
|
||
correctement), et chaque appel d'API demande un jeton, donc du secret manipulé dans chaque
|
||
tâche.
|
||
|
||
## 2026-08-08 — Déconnexion OIDC : Keycloak valide une SECONDE liste d'URI
|
||
|
||
Nextcloud se connectait parfaitement et échouait à la déconnexion, sur un
|
||
« We are sorry… invalid redirect uri » qui ne dit pas de quelle liste il parle.
|
||
|
||
Keycloak valide les URI de retour **après déconnexion** séparément des URI de rappel.
|
||
Aucun des quatre clients ne déclarait l'attribut ; Nextcloud était seulement le seul à
|
||
envoyer une URI de retour, donc le seul à révéler le trou. Les trois autres l'auraient
|
||
rencontré dès qu'on leur aurait câblé une déconnexion propre.
|
||
|
||
`post.logout.redirect.uris` est désormais **dérivée de `web_origins`**, qui porte déjà
|
||
l'URL de base de chaque service : la connaissance existait, il n'y avait pas à la
|
||
réécrire. Posée par l'API et non par `kcadm -s` — `attributes` est une map, et sur une map
|
||
kcadm accepte la commande, sort en succès et n'écrit rien (mesuré le même jour sur
|
||
`smtpServer`). Relu après écriture, comme il se doit maintenant.
|
||
|
||
**Et le second passage a révélé un défaut dans le travail de l'heure précédente.** La tâche
|
||
de journalisation se déclarait `changed` à chaque déploiement : écrits champ par champ,
|
||
Jinja rendait `True` et `1209600` en **chaînes**, et la comparaison au réel (booléen,
|
||
entier) ne pouvait jamais être satisfaite. Corrigé en composant le dictionnaire en une
|
||
seule expression, qui rend des types natifs. Un `changed` permanent n'est pas cosmétique :
|
||
c'est un bruit qui finit par masquer un vrai changement. Deux passages consécutifs à
|
||
`changed=0` désormais.
|
||
|
||
## 2026-08-08 — 502 sur Icinga : le tampon de nginx, après une authentification réussie
|
||
|
||
Symptôme trompeur s'il en est : `oauth2-proxy` journalisait `AuthSuccess` — jeton, jeton
|
||
d'identité, rafraîchissement, tout obtenu — pendant que le navigateur recevait une erreur
|
||
de passerelle. L'authentification n'était pas en cause ; c'est la réponse qui ne passait
|
||
plus.
|
||
|
||
```
|
||
upstream sent too big header while reading response header from upstream
|
||
server: icinga.chezlepro.internal, request: "GET /oauth2/callback?..."
|
||
```
|
||
|
||
Le cookie de session d'`oauth2-proxy` porte le jeton d'identité, découpé en plusieurs
|
||
en-têtes `Set-Cookie`. Le tampon par défaut de nginx (4 Ko) ne peut pas les contenir.
|
||
`serveur_nginx` pose désormais `proxy_buffer_size` / `proxy_buffers` /
|
||
`proxy_busy_buffers_size` sur **toutes** les expositions : c'est une propriété du proxy,
|
||
pas de ce service-là, et le prochain service placé derrière un IdP rencontrerait le même
|
||
mur.
|
||
|
||
Trouvé uniquement parce que le journal des évènements de Keycloak venait d'être activé :
|
||
il a montré `LOGIN` puis `CODE_TO_TOKEN` réussis pour `icingaweb2`, ce qui a écarté d'un
|
||
coup l'identité et renvoyé l'enquête vers le chemin de retour.
|
||
|
||
**Deux constats laissés ouverts, faute de pouvoir conclure.**
|
||
|
||
Le journal de l'edge montre trois `upstream timed out` vers Keycloak en quatorze heures
|
||
(console de compte deux fois, autorisation Nextcloud une fois). Mesuré depuis l'edge,
|
||
Keycloak répond en **millisecondes** — ce n'est pas lui qui est lent. Cause non établie.
|
||
|
||
Et ma sonde MTU ne valait rien : `ping -M do` annonce 100 % de perte alors que HTTP répond
|
||
en 5 ms, parce que l'ICMP est bloqué **par construction** entre hôtes (seul `frag-needed`
|
||
est ouvert au registre des flux). Troisième fois aujourd'hui que l'instrument est le
|
||
problème et non le composant — après `/dev/tcp` sous `sh` et `Maildir/new/`.
|
||
|
||
## 2026-08-08 — Keycloak ne gardait aucune trace des connexions
|
||
|
||
Deux services n'aboutissaient pas pour l'exploitant (Nextcloud, Icinga Web 2). Tout a été
|
||
vérifié côté serveur et tout était correct : clients OIDC actifs, URI de rappel exactes,
|
||
secret d'`oauth2-proxy` identique à celui de Keycloak (même empreinte SHA-256), CA de
|
||
confiance depuis `mon-01` (`200` sur la découverte), `email_domains = ["*"]` donc aucune
|
||
restriction. Le premier saut de chaque parcours a été rejoué avec `curl` : Nextcloud
|
||
redirige vers sa page locale (qui propose bien le bouton SSO « Chezlepro »), Icinga part
|
||
correctement vers Keycloak, qui répond `200`.
|
||
|
||
**Et là, plus rien à examiner** : `eventsEnabled = False`. Keycloak ne gardait aucune trace
|
||
— ni qui est entré, ni pourquoi une authentification a échoué. Impossible de savoir ce que
|
||
l'utilisateur avait rencontré.
|
||
|
||
C'est un manque d'exploitation autant que de diagnostic : « un sysadmin l'exploite sans
|
||
IA » suppose qu'il puisse lire lui-même ce qui s'est passé. Le journal des évènements
|
||
(connexions et actions d'administration, rétention 14 jours) est désormais réconcilié par
|
||
`serveur_keycloak`, comme le reste — pas activé à la main dans une console.
|
||
|
||
## 2026-08-08 — La livraison interne était en panne, et le devis disait CONFORME
|
||
|
||
Suite de l'arbitrage sur l'adressage : **le courrier local est désormais routé par
|
||
identifiant**, plus par l'attribut `mail`.
|
||
|
||
L'argument décisif n'est pas théorique — Dovecot le fait déjà :
|
||
`mail_home = /var/vmail/%{user | username}`, la partie locale, jamais `mail`. Les deux
|
||
moitiés n'utilisaient donc pas le même mécanisme et ne s'accordaient que par coïncidence,
|
||
tant que `mail` valait `uid@<domaine_interne>`. Aligner Postfix ne crée pas un modèle
|
||
nouveau : ça met fin à une incohérence. Coût assumé : l'adresse interne est dérivée de
|
||
l'identifiant et ne se choisit plus ; un alias voulu passera par `virtual_alias_maps`, non
|
||
câblé aujourd'hui. En échange, `mail` redevient libre de porter la vraie adresse de la
|
||
personne — celle que Keycloak affiche et que « mot de passe oublié » utilise.
|
||
|
||
**Puis la preuve de bout en bout a révélé bien pire.** Un vrai courriel envoyé à
|
||
`sysadmin@chezlepro.internal` n'arrivait pas :
|
||
|
||
```
|
||
SSL_connect error to infra-mail-01[10.27.19.31]:24: Connection timed out
|
||
status=deferred (Cannot start TLS: handshake failure)
|
||
```
|
||
|
||
Postfix était durci (`lmtp_tls_security_level = verify`), le port LMTP de Dovecot écoutait
|
||
en clair — son `ssl = required` global ne concerne que les services de connexion. **Les
|
||
deux côtés d'un même flux avaient été traités séparément**, et toute livraison interne
|
||
était différée depuis, sans qu'aucun écran ne le montre. Corrigé des deux bouts, et
|
||
accordé : `ssl = yes` sur l'`inet_listener` (TLS implicite, ce que sait faire un listener)
|
||
et `lmtp_tls_wrappermode = yes` côté client. L'un sans l'autre ne marche pas.
|
||
|
||
Livraison prouvée : `status=sent (250 2.0.0 … Saved)`, message présent dans
|
||
`/var/vmail/sysadmin/Maildir/.INBOX/new/`.
|
||
|
||
**Le devis, lui, annonçait CONFORME.** Il vérifiait la résolution LDAP et les dialectes
|
||
SMTP/IMAP — tout était correct — et jamais si le courrier *bouge*. C'est la leçon la plus
|
||
chère de la série : **un devis qui ne regarde que les réglages ne dit pas si le service
|
||
rend son service.** Il relève désormais la file d'attente et ses raisons de blocage ; le
|
||
test négatif confirme qu'il aurait nommé cette panne.
|
||
|
||
Au passage, deux fois où ma sonde était fausse et non le système : `/dev/tcp` sous `sh`
|
||
(qui ne le connaît pas), et `Maildir/new/` alors que l'INBOX est `Maildir/.INBOX/new/`.
|
||
Vérifier l'instrument avant d'accuser le composant, encore.
|
||
|
||
## 2026-08-08 — Devis PostgreSQL et courriel : la série est complète
|
||
|
||
**PostgreSQL.** Il rend visibles deux défauts déjà vécus ici : un réseau *écrit* dans
|
||
`pg_hba.conf` au lieu d'être dérivé, et une ligne `host` en clair là où il faut `hostssl`
|
||
— un verrou qui saute sans bruit, puisque les clients en `verify-full` continuent de
|
||
marcher. État : conforme. Test négatif rejouant les deux défauts plus un certificat
|
||
snakeoil : trois écarts nommés, code 1.
|
||
|
||
Une leçon de méthode au passage : un `grep` de `postgresql.conf` annonce
|
||
`ssl_cert_file = snakeoil` alors que le serveur sert bien le certificat de l'AC — la valeur
|
||
vient d'un `conf.d/99-setops.conf` que le grep ne voyait pas. **C'était l'instrument qui
|
||
était incomplet, pas la configuration.** Le devis interroge `pg_settings`, jamais le
|
||
fichier.
|
||
|
||
Et une erreur trouvée par le test négatif, pas par la relecture : une variable morte dans
|
||
une branche que le cas nominal n'emprunte jamais. Troisième fois aujourd'hui.
|
||
|
||
**Courriel.** Chaque maillon interrogé là où il dit la vérité : `postmap -q` pour la
|
||
résolution LDAP de Postfix, `doveadm user` pour celle de Dovecot — précisément le maillon
|
||
où la livraison avait bloqué — et de vraies conversations SMTP/IMAP. Il vérifie aussi
|
||
qu'une adresse *inexistante* ne résout pas : sans ça, une boîte fourre-tout accepterait
|
||
n'importe quel nom et le devis ne mesurerait plus rien.
|
||
|
||
**Il a immédiatement trouvé une divergence réelle.** Dovecot connaît la boîte de
|
||
`sysadmin@chezlepro.internal` (`mail_path = /var/vmail/sysadmin/Maildir`), et Postfix ne
|
||
sait pas y router : son `query_filter` est `(mail=%s)`, et l'attribut `mail` de l'annuaire
|
||
porte désormais `sysadmin@chezlepro.ca`.
|
||
|
||
La cause n'est pas un réglage mais une **collision de rôles** : une identité ne porte
|
||
qu'une adresse `mail`, et on lui en demande deux — l'adresse de notification, qui doit être
|
||
joignable par la personne *hors* du système qu'on amorce, et la clé de routage local, qui
|
||
doit vivre dans un `virtual_mailbox_domains`. Les deux ne peuvent pas être la même valeur.
|
||
Ce n'est pas une régression (le compte n'avait aucun `mail` en début de journée, donc
|
||
n'était pas routable non plus) — le devis a rendu lisible un état qui l'était déjà.
|
||
Arbitrage à rendre avant correction.
|
||
|
||
## 2026-08-08 — Devis des expositions : « est-ce que mes services répondent ? »
|
||
|
||
Troisième devis de service. Il pose la seule question qui compte pour un utilisateur, et
|
||
quand la réponse est non, il dit **où** ça casse.
|
||
|
||
**Une vraie requête, jamais un `connect()`.** À travers l'OPNsense (anti-spoofing), toute
|
||
connexion TCP réussit — y compris vers une adresse où aucune machine n'existe. Et « lire des
|
||
données après connexion » ne vaut rien en TLS, où c'est le client qui parle en premier : le
|
||
port 443 d'un edge sain se comporte exactement comme un port mort. Le devis fait donc une
|
||
requête HTTPS complète, avec la racine de l'AC, et lit le code de retour.
|
||
|
||
**Deux points de vue** — depuis l'edge (edge + dorsal) et depuis le poste (DNS + frontière +
|
||
edge + dorsal). C'est leur *différence* qui diagnostique : l'un répond et pas l'autre, ce
|
||
n'est pas le service, c'est le chemin.
|
||
|
||
**Un code n'est pas un verdict.** Mon premier comparateur ne testait que la *présence* d'un
|
||
code : un 502 des deux côtés passait pour conforme alors que le dorsal est mort derrière.
|
||
Trouvé par le test négatif, pas par la relecture — troisième fois aujourd'hui que c'est
|
||
l'épreuve, et non le raisonnement, qui tranche. Un 302 ou un 401 reste en revanche un
|
||
service vivant : il redirige vers l'IdP ou exige une authentification.
|
||
|
||
**État** : les six expositions déclarées au plan répondent, des deux points de vue. Test
|
||
négatif — une frontière qui bloque et un dorsal tombé — les deux écarts sont nommés
|
||
distinctement, code de sortie 1.
|
||
|
||
## 2026-08-08 — Le devis des certificats trouve l'autorité expirée depuis huit heures
|
||
|
||
Deuxième application du patron devis/applicateur aux services, sur le défaut le plus
|
||
coûteux qu'on connaisse : un certificat renouvelé **sur disque** mais toujours servi
|
||
**périmé** depuis la mémoire du service.
|
||
|
||
**Trouvé à la première exécution.** Sur `infra-pki-01` — l'autorité elle-même — le
|
||
certificat était expiré depuis plus de huit heures, et le renouvellement échouait toutes
|
||
les quatorze minutes :
|
||
|
||
```
|
||
'step ca renew' requires the '--ca-url' flag
|
||
notAfter=Aug 8 02:51:21 2026 GMT (il était 11:14 UTC)
|
||
```
|
||
|
||
Cause : sur l'hôte de l'AC, `/etc/step` est le STEPPATH du **serveur**, pas un amorçage
|
||
client — il n'y a donc pas de `defaults.json`, et l'unité de renouvellement, identique
|
||
partout, en dépendait. **La leçon avait déjà été apprise**, et écrite noir sur blanc dans
|
||
le commentaire de la tâche d'émission (« l'autorité ne bootstrape pas »), qui passe
|
||
`--ca-url` et `--root` explicitement. Elle n'avait jamais été reportée sur l'unité de
|
||
renouvellement.
|
||
|
||
**Et le rôle ne pouvait pas se soigner.** La condition de ré-émission ne regardait que la
|
||
*forme* — cert absent, ou SAN manquant. Un certificat expiré portant les bons SAN ne
|
||
déclenchait rien. `client_pki` vérifie désormais aussi la **validité**
|
||
(`client_pki_marge_renouvellement`, une heure).
|
||
|
||
**Ce qu'il a fallu désapprendre pour écrire le devis.** Les certificats vivent 24 h et le
|
||
minuteur les renouvelle toutes les ~14 min : une empreinte servie *différente* de celle sur
|
||
disque est l'état **normal**. Comparer les empreintes aurait donné un vérificateur qui crie
|
||
en permanence — et qu'on aurait appris à ignorer. Le signal utile est l'échéance de ce qui
|
||
est **réellement servi**, plus l'absence de `client_pki_reload_services`.
|
||
|
||
**Le devis a d'abord menti, du défaut même qu'il traque.** `include_vars` au niveau du play
|
||
prime sur les `group_vars` : le premier jet rapportait `client_pki_reload_services: []` sur
|
||
les quatorze hôtes alors que quatre groupes le déclarent. Le correctif suivant a *paru*
|
||
fonctionner — `set_fact` accepte un dictionnaire entier en argument libre sans erreur et
|
||
n'en fait rien. Il faut réimposer clé par clé, en boucle. Les deux devis sont corrigés et
|
||
le piège est consigné dans `docs/devis-services.md`, avant d'écrire le prochain.
|
||
|
||
Deux latents relevés et corrigés au passage : `step-ca` (8443) et `icinga2` (5665) servaient
|
||
une copie qu'aucun rechargement ne rafraîchissait ; les deux déclarent maintenant leur
|
||
service, en `reload` (SIGHUP pour step-ca, `safe-reload` pour icinga2), sans interruption.
|
||
|
||
**Vérifié dans les deux sens** : `CONFORME` sur les quatorze hôtes après correction ; sur un
|
||
relevé où l'on rejoue une copie périmée en mémoire, deux écarts listés et code de sortie 1.
|
||
|
||
## 2026-08-08 — Un devis pour l'identité : rien ne comparait le déployé au déclaré
|
||
|
||
Constat de l'exploitant après trois séries de corrections : « ça fait beaucoup de trucs
|
||
incohérents qu'on débusque ensemble ». Exact, et il y a une raison mesurable.
|
||
|
||
**Les 30 preuves sont statiques.** `scripts/prouver.py` ne fait aucun appel réseau, aucun
|
||
SSH, aucun `ansible`. Elles établissent que le dépôt est cohérent *avec lui-même*. Aucune
|
||
ne demande au système déployé s'il ressemble à ce que le dépôt annonce — et c'est
|
||
exactement là que vivaient les quatre défauts de la journée.
|
||
|
||
**La classe statique, elle, est presque épuisée.** Recensement des motifs « crée mais ne
|
||
réconcilie jamais » : `amorcage_acces` (délibéré, D-67), `serveur_openldap` (corrigé le
|
||
matin), et un seul reste réel — `rbac-oidc.yml`, qui crée trois objets sans jamais les
|
||
mettre à jour. Une preuve statique de plus aurait rapporté une ligne. Le trou est ailleurs.
|
||
|
||
**Le dépôt avait déjà la réponse sans l'avoir appliquée aux services.** Le patron
|
||
devis/applicateur (D-23/D-24) existe pour les quatre pare-feu : `make frontiere-plan` lit
|
||
la frontière réelle et montre l'écart. Rien d'équivalent pour l'identité.
|
||
|
||
`make identite-plan` comble ça. Le playbook **relève** le déclaré et le réel et les dépose
|
||
en JSON ; `scripts/devis_identite.py` **compare**. La séparation n'est pas cosmétique : j'ai
|
||
écrit deux fois de suite une expression Jinja de comparaison illisible avant d'admettre que
|
||
le raisonnement n'a rien à faire là — et le dépôt a déjà cette forme pour les devis réseau.
|
||
|
||
Le déclaré n'est jamais recopié dans le devis : il charge les défauts du rôle et appelle
|
||
`resoudre_politique_mdp` et `resoudre_annuaire`. Un devis qui redéclare ce qu'il vérifie ne
|
||
vérifie rien.
|
||
|
||
**Vérifié dans les deux sens**, ce qui est le minimum pour un instrument : sur le système
|
||
réel, `CONFORME`. Sur une copie du relevé où les quatre défauts du jour sont rejoués, plus
|
||
deux régressions plausibles (SMTP disparu, compte sans adresse) — six divergences listées,
|
||
code de sortie 1. Un vérificateur qui ne sait dire que « conforme » ne vaut rien.
|
||
|
||
Couvre l'identité seule. Les autres services attendent le même traitement ; le patron est
|
||
là pour être repris.
|
||
|
||
## 2026-08-08 — La politique de mot de passe existait des deux côtés et ne s'appliquait d'aucun
|
||
|
||
Question de l'exploitant : « l'intégration Keycloak/LDAP est incomplète, non ? » Elle
|
||
l'était, et pas cosmétiquement. Quatre défauts mesurés, tous de la même famille — une
|
||
valeur déclarée d'un côté, consommée de l'autre, sans que rien ne vérifie qu'elles se
|
||
rejoignent.
|
||
|
||
**1. Aucune règle de mot de passe ne s'appliquait sur le chemin d'un vrai utilisateur.**
|
||
Compte sonde, même mot de passe `abcd` : l'opération étendue LDAP le refuse
|
||
(`Constraint violation (19) — Password fails quality checking policy`), Keycloak l'accepte
|
||
(`204`), et `ldapwhoami` avec `abcd` réussit ensuite. Deux causes empilées : Keycloak
|
||
écrivait `userPassword` **directement**, donc l'overlay `ppolicy` n'interceptait rien ; et
|
||
le realm n'avait aucune `passwordPolicy`. Chacun déléguait la vérification à l'autre.
|
||
|
||
**2. `ldap_entry` ne fait que créer.** L'entrée `cn=default,ou=policies` était figée à ce
|
||
qu'elle valait le jour de sa création : toute modification ultérieure de la déclaration
|
||
était ignorée en silence. Le dépôt annonçait `pwdMustChange: TRUE`, le serveur portait
|
||
`FALSE` (corrigé à la main après la boucle de changement de mot de passe du 2026-08-07).
|
||
Une reconstruction from-zero aurait donc **ressuscité** le défaut. `ldap_attrs state: exact`
|
||
réconcilie désormais la politique **et** les réglages de l'overlay — dont le DN, qui porte
|
||
un index attribué par slapd, est lu et non deviné.
|
||
|
||
**3. Un compte créé dans Keycloak n'atteignait jamais l'annuaire.** `POST users` → `201`,
|
||
rien dans `ou=people` : `syncRegistrations` était absent. Ce compte aurait eu un accès web,
|
||
aucune boîte aux lettres, et serait resté invisible du modèle de groupes — Postfix et
|
||
Dovecot lisent LDAP, pas Keycloak. C'est la divergence nettoyée le matin même sur l'adresse
|
||
du sysadmin, réintroduite par une autre porte.
|
||
|
||
**4. Le prénom était mappé sur `cn`.** Dans `inetOrgPerson`, `cn` porte le nom *complet* :
|
||
Keycloak affichait « Administrateur systeme systeme ». Le mappeur pointe désormais sur
|
||
`givenName`, que `amorcage_acces` écrit, dérivé par le même découpage que `sn`.
|
||
|
||
**Ce qui est ajouté.** `roles/resoudre_politique_mdp/` porte **la** déclaration, en termes
|
||
neutres, et la traduit dans les trois dialectes qui doivent l'appliquer : `pwdPolicy`,
|
||
`passwordPolicy` du realm, protection anti-force-brute. `serveur_openldap` et
|
||
`serveur_keycloak` la consomment ; aucun des deux ne la redéclare.
|
||
|
||
La fédération est durcie de six clés, dont deux portent la correction et se complètent :
|
||
`usePasswordModifyExtendedOp` (slapd voit passer le changement) et `validatePasswordPolicy`
|
||
(Keycloak valide avant d'écrire). La seconde est la porteuse — Keycloak se lie en rootDN, et
|
||
slapd n'applique pas ses contrôles de qualité au rootDN. S'en remettre à la première seule
|
||
aurait donné une correction qui *paraît* juste et ne tient pas ; c'est le test qui a tranché,
|
||
pas le raisonnement.
|
||
|
||
**Vérification, mêmes sondes qu'au diagnostic** — `abcd` → `400 Invalid password: minimum
|
||
length 12`, absent de LDAP ; mot de passe conforme → `204` puis `ldapwhoami` accepté (l'écriture
|
||
traversante reste intacte) ; `POST users` → `201` **et** `dn: uid=setops-sonde2,ou=people,…`.
|
||
Sondes supprimées des deux côtés. Second passage des deux playbooks : `changed=0`.
|
||
|
||
## 2026-08-08 — « Mot de passe oublié » : Keycloak sait enfin envoyer
|
||
|
||
Le realm affichait une politique d'accès complète et **aucun moyen d'écrire à qui que ce
|
||
soit** : `smtpServer` vide, `resetPasswordAllowed` à `false`. Conséquence concrète — tout
|
||
oubli de mot de passe remontait à l'exploitant, qui n'avait alors d'autre choix que de
|
||
manipuler le mot de passe de quelqu'un d'autre. C'est précisément ce que « une identité,
|
||
une personne » (§3 d'`autorisation.md`) cherche à écarter.
|
||
|
||
**Ce qui est ajouté.** `serveur_keycloak/tasks/courriel-realm.yml` réconcilie la strophe
|
||
courriel du realm et le drapeau « mot de passe oublié ». L'hôte du relais est **dérivé du
|
||
plan** (`applications.postfix.hote`) : aucun nom de machine n'est écrit. Si le plan ne
|
||
déclare pas de MTA, le rôle **refuse** — un écran qui promet un courriel que personne
|
||
n'enverrait serait pire que pas d'écran du tout.
|
||
|
||
**`kcadm.sh` ne sait pas écrire une map, et ne le dit pas.** Sur `smtpServer`, les deux
|
||
formes documentées — `-s smtpServer.host=…` et `-s 'smtpServer={"host":…}'` — sortent en
|
||
**succès, sans rien écrire**. Le champ est resté `{ }` après deux déploiements verts. La
|
||
tâche passe donc par l'API d'administration (`uri`), qui répond `204` et écrit vraiment.
|
||
Même famille que le reste de ce journal : une valeur déclarée d'un côté, jamais vérifiée
|
||
de l'autre. Ce qui l'a rattrapée, c'est d'avoir relu l'état après l'avoir posé — pas le
|
||
code de retour.
|
||
|
||
**L'adresse de l'amorçage ne se dérive pas.** J'avais d'abord posé
|
||
`{{ amorcage_acces_uid }}@{{ domaine_interne }}` comme défaut : c'est un piège. Cette
|
||
adresse désigne une **personne**, donc quelque chose d'extérieur au système qu'on amorce,
|
||
et une boîte interne n'est pas lisible tant qu'on n'a pas justement l'accès qu'on essaie de
|
||
récupérer. `amorcage_acces_courriel` redevient donc à déclarer, et le rôle refuse de créer
|
||
le compte sans elle — mais seulement à la création, pour qu'un écosystème déjà amorcé ne se
|
||
mette pas à échouer parce qu'on a durci la règle après coup.
|
||
|
||
**Un reliquat de `READ_ONLY` mis au jour.** Le compte `sysadmin` portait
|
||
`sysadmin@chezlepro.ca` dans Keycloak et **rien** dans LDAP. L'adresse avait été saisie
|
||
dans la console de compte quand la fédération était encore en lecture seule : Keycloak
|
||
l'avait gardée pour lui, l'annuaire ne l'a jamais reçue, et les deux côtés ont affiché des
|
||
valeurs différentes sans que rien ne le signale. Corrigé dans LDAP (source de vérité) puis
|
||
resynchronisé ; consigné au runbook (§6.6) parce que d'autres comptes créés avant la
|
||
bascule en `WRITABLE` peuvent porter le même écart.
|
||
|
||
**Preuve de bout en bout**, et pas un `connect()` : bannière SMTP lue depuis `idm-01`,
|
||
`RCPT TO` accepté, puis un vrai `execute-actions-email` déclenché — journal du MTA :
|
||
`starttls=1`, `to=<sysadmin@chezlepro.ca>, relay=mx.chezlepro.ca[69.70.26.53]:25,
|
||
status=sent (250 2.0.0 Ok)`. Le second déploiement rapporte `changed=0` : la tâche
|
||
réconcilie, elle ne réécrit pas.
|
||
|
||
## 2026-08-06 — le chemin nord-sud devient dérivable
|
||
|
||
### `vault_openldap_admin` — quatre consommateurs et deux pièges
|
||
|
||
Le secret le plus délicat de la liste : Keycloak, Dovecot, Postfix et Icinga Web 2 s'y lient
|
||
tous.
|
||
|
||
**Premier piège : `ldappasswd` ne peut pas le changer.** `cn=admin` n'est pas une entrée de
|
||
la base mais le **rootDN** déclaré dans `cn=config` — la commande répond « No such object ».
|
||
Le mot de passe vit dans `olcRootPW` et se modifie par un bind `EXTERNAL`. L'échec était sans
|
||
dégât : l'ancien fonctionnait toujours, vérifié avant de continuer.
|
||
|
||
**Second piège : Keycloak stocke le mot de passe de liaison dans sa base, et le masque.** La
|
||
réconciliation ajoutée hier couvrait l'URL, les DN et le mode — pas `bindCredential`. Tourner
|
||
le secret aurait coupé Keycloak de l'annuaire, et plus personne n'aurait pu se connecter.
|
||
Comme on ne peut pas comparer une valeur masquée, la réconciliation passe par une **empreinte**
|
||
— même mécanisme que les comptes de secours.
|
||
|
||
**Vérifié, consommateur par consommateur** : synchronisation LDAP de Keycloak (qui prouve la
|
||
liaison), carte LDAP de Postfix, Dovecot actif. Les trois rejouent à `changed=0`.
|
||
|
||
Six secrets tournés depuis hier ; chacun a d'abord demandé de **construire la capacité de le
|
||
faire**. C'est le motif de fond de ces deux jours : le dépôt savait créer, pas changer.
|
||
|
||
|
||
### `vault_forgejo_oidc` — le secret exposé ne vaut plus rien
|
||
|
||
Il avait fui dans une sortie de diagnostic. Le faire tourner a d'abord demandé de rendre la
|
||
rotation possible : **ni Keycloak ni Forgejo ne réconciliaient un secret OIDC existant.**
|
||
|
||
Le commentaire de `clients-oidc.yml` l'avouait — *« la réconciliation fine n'est pas faite :
|
||
create-si-absent »* — et `update-oauth`, côté Forgejo, ne passait pas `--secret`. Régénérer la
|
||
voûte aurait donc laissé les deux côtés sur l'ancienne valeur, ou pire, un seul des deux : le
|
||
SSO aurait cassé sans que rien ne l'annonce.
|
||
|
||
Les deux réconcilient désormais. Keycloak compare le secret **en place** (`get client-secret`)
|
||
à celui voulu avant d'écrire — pas de `changed` inutile.
|
||
|
||
**Vérifié par empreinte, aux trois endroits :**
|
||
|
||
```
|
||
voûte 2484770b53a4fd76f9b57bf1
|
||
Keycloak 2484770b53a4fd76f9b57bf1
|
||
Forgejo 2484770b53a4fd76f9b57bf1
|
||
```
|
||
|
||
Keycloak rejoue à `changed=0`.
|
||
|
||
**Une non-idempotence préexistante, signalée sans être corrigée** : `Deployer app.ini` change à
|
||
chaque passage sur Forgejo. Le rôle réécrit un fichier que Forgejo modifie lui-même — il y
|
||
persiste ses secrets générés. Ce n'est pas lié à la rotation, et le corriger demande de décider
|
||
quelles clés appartiennent au gabarit et lesquelles au service.
|
||
|
||
|
||
### `vault_keycloak_admin` : le secret qui est la clé de son propre changement
|
||
|
||
Rotation faite, chaîne d'identité intacte (groupe, membre, rôle), rejeu à `changed=0`.
|
||
|
||
**L'ordre n'est pas indifférent, et c'est le point à retenir.** Ce compte est le moyen de se
|
||
changer lui-même : régénérer la voûte d'abord l'aurait rendu inapplicable — plus rien n'aurait
|
||
pu s'authentifier pour poser la nouvelle valeur.
|
||
|
||
```
|
||
1. s'authentifier avec la valeur ACTUELLE
|
||
2. poser la nouvelle dans Keycloak
|
||
3. vérifier qu'elle fonctionne
|
||
4. seulement alors, écrire la voûte
|
||
```
|
||
|
||
C'est une **procédure**, pas un redéploiement — et la même contrainte vaut pour
|
||
`vault_openldap_admin`, qui reste à faire. Consigné au runbook (§6.9), avec le rappel qu'une
|
||
vérification n'est pas un message de succès.
|
||
|
||
|
||
### La rotation des comptes de secours devient possible — elle ne l'était pas
|
||
|
||
Le runbook §6.8 promettait de régénérer les comptes de secours ; **le code ne savait pas le
|
||
faire**. Les trois rôles ne posaient le mot de passe qu'à la *création* :
|
||
|
||
```
|
||
grafana GF_SECURITY_ADMIN_PASSWORD n'agit qu'à la création du compte
|
||
forgejo admin user create `creates: .admin-created` — une seule fois
|
||
nextcloud maintenance:install seulement à l'installation
|
||
```
|
||
|
||
Régénérer la voûte sans cela aurait produit exactement le mensonge silencieux corrigé toute
|
||
la journée : la voûte dit une chose, le service en a une autre.
|
||
|
||
Chaque rôle sait désormais **changer** un mot de passe existant, avec une idempotence par
|
||
**empreinte du secret appliqué** — on ne peut pas relire un hachage, donc on mémorise ce qu'on
|
||
a posé. Pour Nextcloud, le secret passe par l'environnement (`--password-from-env`) et non par
|
||
la ligne de commande, où il serait visible dans la table des processus.
|
||
|
||
### `grafana-cli` écrivait dans une base fantôme
|
||
|
||
Et disait « Admin password changed successfully ✔ » à chaque fois.
|
||
|
||
La CLI prend `paths.data` par défaut à `<homepath>/data` ; le paquet Debian range la base dans
|
||
`/var/lib/grafana`. Elle créait donc `/usr/share/grafana/data/grafana.db`, y écrivait, et
|
||
annonçait le succès — pendant que le serveur lisait l'autre fichier.
|
||
|
||
**Ce qui l'a démasqué** : le champ `updated` du compte, resté à l'heure du déploiement initial
|
||
malgré quatre réinitialisations « réussies ». Un message de succès n'est pas une preuve ;
|
||
l'état l'est. `--configOverrides=cfg:default.paths.data=…` est désormais imposé.
|
||
|
||
**Vérifié par authentification réelle** : Grafana `200`, Nextcloud `200`. Pour Forgejo,
|
||
`ENABLE_BASIC_AUTHENTICATION = false` ferme l'API par doctrine (D-41) — la seule preuve
|
||
disponible est le retour de la commande, qui confirme le changement.
|
||
|
||
Quatre secrets régénérés : `vault_grafana_admin`, `vault_forgejo_admin`,
|
||
`vault_nextcloud_admin`, `vault_sysadmin_amorcage`.
|
||
|
||
|
||
### Icinga Web 2 cesse de nommer des personnes
|
||
|
||
Le dernier `porte_par: liste-uid` du catalogue. `serveur_icingaweb2_admins` valait un `uid`
|
||
en dur ; `roles.ini` porte désormais `groups = "sysadmin"`, et `serveur_icingaweb2_admins`
|
||
devient un repli de dépannage, **vide par défaut**.
|
||
|
||
**Le mode SSO complique le montage, et il faut le dire.** Les membres d'un `groupOfNames`
|
||
sont des **DN** ; en `auth: external`, le nom d'utilisateur vient de `REMOTE_USER` — une
|
||
chaîne, pas un DN. Un backend LDAP supplémentaire est donc déclaré dans
|
||
`authentication.ini` : **jamais utilisé pour authentifier** (l'externe répond en premier),
|
||
uniquement pour que `groups.ini` résolve le nom vers son DN.
|
||
|
||
Sans ce pont, l'habilitation par groupe est impossible en SSO — et il faudrait continuer à
|
||
nommer des personnes.
|
||
|
||
**Ce que je n'ai pas pu vérifier.** La configuration est déployée et cohérente, mais la
|
||
résolution `REMOTE_USER → DN → appartenance` est interne à Icinga Web 2 : seule une connexion
|
||
réelle par le SSO la prouve. Je ne la déclare donc pas prouvée.
|
||
|
||
|
||
### Administrer le realm par appartenance — le dernier `porte_par: aucun`
|
||
|
||
`realm-admin` (rôle du client `realm-management`) est attaché au **groupe** `sysadmin`.
|
||
Administrer le realm ne passe plus par le compte local : il suffit d'appartenir au groupe dans
|
||
l'annuaire.
|
||
|
||
**Portée : ce realm seulement, jamais `master`.** Le compte `admin` reste hors d'atteinte du
|
||
groupe, et c'est délibéré — un accès de secours qui dépendrait des habilitations qu'il doit
|
||
pouvoir réparer n'en serait pas un (D-40).
|
||
|
||
La console à utiliser est celle du realm — `/admin/<realm>/console/` — pas la racine `/admin/`,
|
||
qui est celle de `master`. Le runbook nomme désormais les **trois** portes et dit ce que
|
||
chacune gouverne.
|
||
|
||
**Les rôles de client sont un espace de noms distinct** des rôles de realm ; la déclaration
|
||
gagne un champ `roles_client`. Et l'API attend l'**UUID** du client, pas son `clientId` :
|
||
interroger par le nom rendait une erreur, la vérification échouait toujours, et la tâche se
|
||
déclarait `changed` à chaque passage alors que le rôle était déjà posé. Corrigé — deux passages
|
||
consécutifs à `changed=0`.
|
||
|
||
### La boucle de changement de mot de passe
|
||
|
||
Après `editMode=WRITABLE`, le changement partait mais **rebouclait sans fin**. Cause :
|
||
`pwdMustChange: TRUE` signifie « quand un *administrateur* pose un mot de passe, l'utilisateur
|
||
doit le changer ». Or Keycloak écrit en tant qu'administrateur (`cn=admin`) — chaque changement
|
||
relayé était donc vu comme une réinitialisation, et OpenLDAP reposait `pwdReset` aussitôt.
|
||
|
||
**C'est incompatible par construction avec un IdP qui relaie le changement.** La contrainte a
|
||
été déplacée là où l'utilisateur la voit : `pwdMustChange: FALSE` côté annuaire, et Keycloak
|
||
pose l'action requise `UPDATE_PASSWORD` tant que `pwdReset` est vrai — un écran qui explique,
|
||
au lieu d'un refus muet au niveau du protocole.
|
||
|
||
Je ne l'avais pas vu parce que j'avais éprouvé `pwdReset` **au niveau LDAP**, où il fonctionne
|
||
parfaitement, sans jamais parcourir le chemin complet à travers Keycloak. L'opérateur l'a dit
|
||
avant moi : « ce n'est pas du tout explicite » — c'était le symptôme de deux mécanismes qui ne
|
||
se parlent pas.
|
||
|
||
|
||
### « Federated storage is not writable » — la fédération était en lecture seule
|
||
|
||
Le changement de mot de passe imposé échouait : `editMode=READ_ONLY` était **codé en dur**
|
||
dans `federation-ldap.yml`. Keycloak lisait l'annuaire sans jamais pouvoir y écrire — donc
|
||
`pwdReset` était un cul-de-sac : LDAP *exige* le changement, et Keycloak *ne peut pas* le
|
||
faire.
|
||
|
||
**Trois modes, un seul tient avec la doctrine :**
|
||
|
||
| Mode | Effet | Verdict |
|
||
|---|---|---|
|
||
| `READ_ONLY` | Keycloak n'écrit jamais | le changement de mot de passe est impossible |
|
||
| `UNSYNCED` | Keycloak écrit dans **sa** base | Dovecot et Postfix, qui se lient directement à LDAP (D-39), valideraient encore l'ancien — une identité, deux mots de passe |
|
||
| `WRITABLE` | Keycloak écrit **à travers** vers LDAP | l'annuaire reste la source unique ; Keycloak n'en est qu'un client |
|
||
|
||
`UNSYNCED` aurait « marché » à l'écran tout en cassant le courriel en silence. C'est le piège
|
||
qu'il fallait éviter.
|
||
|
||
Le mode devient une variable, et il est **réconcilié** — comme l'URL depuis hier. Un provider
|
||
créé en `READ_ONLY` le serait resté à vie.
|
||
|
||
**Deux fautes de ma part dans le même correctif.** Mon extraction du mode actuel s'ancrait sur
|
||
`$` alors que la ligne finit par un guillemet : elle ne correspondait jamais. Et sans `|| true`,
|
||
un `grep` sans correspondance tue le script entier sous `pipefail`. La tâche échouait — masquée
|
||
par `no_log`, pour la troisième fois aujourd'hui.
|
||
|
||
|
||
### « invalid username or password » — c'était la porte, pas le mot de passe
|
||
|
||
Première tentative de connexion du sysadmin : refusée. Ni le jeton ni le compte n'étaient en
|
||
cause — vérifié dans l'ordre : `ldapwhoami` avec le jeton retourne le DN, le compte n'est ni
|
||
verrouillé ni en échec (`pwdFailureTime` absent), et Keycloak voit `sysadmin` activé et
|
||
fédéré.
|
||
|
||
**`https://auth.<domaine>/` redirige vers `/admin/`** — la console d'administration du realm
|
||
`master`, où `sysadmin` n'existe pas. Il vit dans le realm applicatif. Keycloak répond donc
|
||
« identifiants invalides » : exact, et parfaitement trompeur.
|
||
|
||
Le runbook disait « se connecter à Keycloak » **sans donner d'URL**, et l'URL évidente est la
|
||
mauvaise. C'est un défaut du document, pas de la manipulation. Il nomme désormais les deux
|
||
consoles et dit laquelle sert à quoi :
|
||
|
||
```
|
||
/realms/<realm>/account/ ton compte, tes accès sysadmin + jeton
|
||
/admin/ administrer Keycloak admin + vault_keycloak_admin
|
||
```
|
||
|
||
**L'absence de trace d'échec côté LDAP était le vrai indice.** `pwdFailureTime` vide signifiait
|
||
qu'aucune tentative n'atteignait l'annuaire — donc que le problème était en amont de la
|
||
validation, pas dedans. Chercher d'abord *où* la requête s'arrête vaut mieux que présumer *ce
|
||
qui* est faux.
|
||
|
||
|
||
### Le sysadmin ne pouvait atteindre aucune interface web
|
||
|
||
Depuis le poste d'administration, `auth.chezlepro.internal` ne répondait pas — ni aucun autre
|
||
service. Le pare-feu de l'hyperviseur n'autorisait le 443 de l'edge que depuis `+t17-flotte`,
|
||
les hôtes du tenant. Le réseau d'administration est dans `t17-admin`, pas dans `t17-flotte`.
|
||
|
||
L'exploitant arrivait donc par un **troisième chemin que rien ne déclarait** : ni `externe`
|
||
(Internet, affaire de la frontière), ni `flotte` (le tenant). Il n'est ni l'un ni l'autre — et
|
||
le runbook de reprise, écrit la veille, supposait pourtant qu'on ouvre Keycloak dans un
|
||
navigateur.
|
||
|
||
`admin` devient un **pair déclarable**, au même titre que `flotte` et `edge` : les réseaux de
|
||
l'intrant `nftables_admin_ssh`, déjà source unique de la garde anti-lockout. `serveur_nginx`
|
||
le déclare pour son 443, et les deux générateurs le traduisent — l'IPSet `t17-admin` existait
|
||
déjà. **L'edge seul** reçoit ce droit : c'est le point d'entrée unique, et ouvrir les services
|
||
en direct élargirait la surface sans rien gagner.
|
||
|
||
Les six interfaces répondent maintenant depuis le poste.
|
||
|
||
**Une erreur de méthode que j'ai commise en donnant les instructions.** Mon premier test
|
||
utilisait `/dev/tcp` et concluait « atteignable ». C'était faux : la frontière répond au SYN à
|
||
la place de la cible. J'avais consigné ce piège le matin même et j'y suis retombé. Un
|
||
`connect()` ne prouve rien ; seule une lecture prouve.
|
||
|
||
### `make ca-racine` — la racine de l'AC, et son empreinte
|
||
|
||
Le runbook demandait de faire confiance à l'AC interne sans dire comment. Deux cibles :
|
||
|
||
```
|
||
make ca-racine # écrit ./root_ca.crt, affiche sujet, validité, empreinte
|
||
make ca-empreinte # la même empreinte, lue SUR l'AC — le témoin de comparaison
|
||
```
|
||
|
||
L'hôte de l'AC est **dérivé** du groupe `serveur_step_ca`, jamais nommé ; une instance sans
|
||
autorité interne reçoit un refus qui l'explique.
|
||
|
||
La racine est un certificat **public** : elle n'a rien à faire dans la voûte, et tout à faire
|
||
dans le magasin de confiance de qui administre. Mais la sortie insiste sur la comparaison
|
||
d'empreinte — installer une AC, c'est lui donner le droit de signer *n'importe quel nom*.
|
||
|
||
`step-ca` publie aussi sa racine sur `https://<ca>:8443/roots.pem`, joignable depuis le tenant.
|
||
Ce chemin n'est **pas** ouvert au réseau d'administration, délibérément : la commande ci-dessus
|
||
donne déjà le résultat, et la racine de confiance n'a pas besoin d'une porte de plus.
|
||
|
||
|
||
### Quatre empreintes muettes — et une VM qui en est morte
|
||
|
||
`collab-01` a cessé de répondre en SSH. Le symptôme ressemblait à un problème réseau ; la
|
||
cause était un fichier YAML mal formé depuis des semaines.
|
||
|
||
**Quatre `meta/empreinte.yml` déclaraient leurs valeurs à la racine**, sans la clé
|
||
`setops_empreinte:` : collabora, nextcloud, web_dorsal, web_frontal. `charger_empreinte_role`
|
||
les lisait comme vides et rendait `{0,0,0}` — **en silence**. Les VM concernées recevaient le
|
||
minimum du socle.
|
||
|
||
`collab-01` s'est donc retrouvée avec **1 cœur / 1 Go** pour porter Nextcloud *et* Collabora.
|
||
L'installation de PHP 8.4, nginx et Redis a épuisé la mémoire, et sshd n'a plus pu forker.
|
||
Après correction : **4 cœurs / 5 632 Mo**. `web-dorsal-01` passe de 1024 à 2048 Mo.
|
||
|
||
**Un fichier qui existe mais ne dit rien est pire qu'un fichier absent** : le repli aurait
|
||
donné des valeurs sensées. Une garde refuse désormais cette forme, en nommant le fichier — et
|
||
elle distingue « pas d'empreinte » de « empreinte illisible », qui n'appellent pas la même
|
||
réponse.
|
||
|
||
Les deux VM ont été redimensionnées à chaud (arrêt propre, `config PUT`, redémarrage). Elles
|
||
sont dans la couche `apps` — la dernière — donc rien ne dépendait d'elles.
|
||
|
||
### Deux courses de premier démarrage, corrigées à la racine
|
||
|
||
**Le clone rend la main avant que sa configuration existe.** Sur un stockage lent, `qm clone`
|
||
retourne et le `.conf` n'est pas encore écrit ; la tâche suivante échouait sur
|
||
« Configuration file … does not exist ». Une attente active interroge l'API jusqu'à 150 s.
|
||
|
||
Au passage, j'ai failli livrer pire que le défaut : pour raccourcir une ligne trop longue, je
|
||
l'avais coupée avec `>-` — qui replie les retours en **espaces**, insérant une espace au milieu
|
||
de l'URL. Le lint passait, la requête aurait échoué. L'URL est assemblée dans une variable.
|
||
|
||
**Le verrou dpkg frappait hors de `common_packages`.** J'y avais mis `lock_timeout` hier, mais
|
||
chaque autre rôle installant des paquets restait exposé — `serveur_nextcloud` en a fait les
|
||
frais. Il est désormais posé en `module_defaults` sur les **30 playbooks de groupe** : toute
|
||
tâche `apt` du play en hérite, y compris celles des rôles inclus. Une déclaration au lieu de
|
||
trente.
|
||
|
||
### Une collision de noms qui rapportait `ok`
|
||
|
||
`web-frontal-01` ne se créait pas : une VM héritée portait déjà ce nom (`911401`, arrêtée,
|
||
adressage `192.168.15.x` de l'ancien monde). **`proxmox_kvm` identifie par le nom, l'a trouvée,
|
||
et a rapporté `ok` sans rien cloner.**
|
||
|
||
C'est plus grave que l'échec : un déploiement peut *paraître* réussi alors qu'aucune VM n'a été
|
||
créée. Ça touche directement D-37 — les noms courts sont volontairement identiques d'un tenant
|
||
à l'autre, et un parc hérité qui partage un nom crée exactement cette collision silencieuse.
|
||
La VM héritée a été renommée.
|
||
|
||
|
||
### `resoudre_idp` — le nom inventé était dans **quatre** rôles
|
||
|
||
`forge-01` a échoué sur `serveur_forgejo_oidc_discovery`, qui pointait
|
||
`https://keycloak.<domaine>` — le nom que rien ne publie. J'avais corrigé exactement ça dans
|
||
`serveur_oauth2_proxy` quelques heures plus tôt, en croyant régler un cas isolé.
|
||
|
||
Il était en fait dans **quatre** rôles : forgejo, grafana, nextcloud, oauth2-proxy. Chacun
|
||
fabriquait le même nom par la même convention, et chacun aurait échoué au même endroit —
|
||
`oauth2-proxy` l'a fait le premier parce qu'il a été déployé le premier.
|
||
|
||
Une cinquième copie corrigée à la main aurait divergé comme les quatre autres. D'où
|
||
`roles/resoudre_idp` : il lit l'exposition déclarée au plan et rend `resoudre_idp_hote`,
|
||
`resoudre_idp_base`, `resoudre_idp_discovery`. Les trois rôles restants l'appellent ; leur
|
||
défaut ne garde plus qu'un **repli** nommé, jamais la valeur de travail.
|
||
|
||
Vérifié en base sur `forge-01` :
|
||
|
||
```
|
||
OpenIDConnectAutoDiscoveryURL : https://auth.chezlepro.internal/realms/chezlepro/…
|
||
GroupClaimName : 'groups' AdminGroup : 'sysadmin'
|
||
```
|
||
|
||
Le câblage par groupe et l'URL correcte, sur un service déployé pour la première fois.
|
||
|
||
**La leçon est la même que pour `resoudre_annuaire` hier**, et elle mérite d'être dite deux
|
||
fois : corriger la valeur là où elle échoue ne corrige que là. Ce sont les autres copies,
|
||
silencieuses, qui coûtent la journée suivante.
|
||
|
||
|
||
### Forgejo et Nextcloud câblés — avant d'être déployés
|
||
|
||
Les deux services n'existaient pas encore : les câbler maintenant vaut mieux que les corriger
|
||
après. `porte_par` passe de `aucun` à `claim-groupe` dans leurs `meta/acces.yml`.
|
||
|
||
**Forgejo** reçoit `--group-claim-name` + `--admin-group` sur sa source OAuth2. Et sa tâche
|
||
est passée de « créer si absent » à **`add-oauth` ou `update-oauth`** : le même défaut que la
|
||
fédération Keycloak — créé une fois, jamais corrigé — l'attendait sinon.
|
||
|
||
**Nextcloud** était déjà en upsert. Il reçoit `--mapping-groups` et `--group-provisioning`,
|
||
plus une tâche qui verse les membres du groupe d'habilitation dans le groupe interne `admin` :
|
||
être dans un groupe projeté ne donne aucun pouvoir en soi. L'absence du groupe au premier
|
||
déploiement n'est **pas** une erreur — il n'existe qu'à la première connexion d'un membre.
|
||
|
||
**Le maillon qui manquait aux deux.** Les groupes existaient dans le realm mais
|
||
n'apparaissaient dans aucun jeton : pas de mapper de protocole. Forgejo et Nextcloud auraient
|
||
lu un claim vide et n'auraient rien accordé — un câblage correct des deux côtés, et rien au
|
||
milieu. `oidc-group-membership-mapper` est désormais posé sur les trois clients, avec
|
||
`full.path=false` pour que le claim porte `sysadmin` et non `/sysadmin`.
|
||
|
||
Vérifié : `grafana`, `forgejo`, `nextcloud` portent chacun le mapper.
|
||
|
||
|
||
### La chaîne d'habilitation est complète
|
||
|
||
`group-ldap-mapper` construit, et le rôle est **attaché au groupe** — pas à une personne.
|
||
|
||
```
|
||
LDAP cn=sysadmin,ou=groups member: uid=sysadmin
|
||
Keycloak groupe `sysadmin` projeté, membre `sysadmin`
|
||
rôle de realm `grafana-admin` attaché AU GROUPE
|
||
Grafana claim roles → oidc_role_path → Admin
|
||
```
|
||
|
||
Ajouter quelqu'un à `cn=sysadmin` dans l'annuaire lui ouvre Grafana en Admin : **sans toucher
|
||
au dépôt, sans déploiement, sans nommer personne**. C'est D-66 réalisé, et c'est ce qui rend
|
||
la reprise par le sysadmin effective. Rejoué : `changed=0`.
|
||
|
||
`serveur_keycloak_role_assignments` reste vide, et son commentaire dit maintenant que c'est
|
||
définitif.
|
||
|
||
**Un défaut de fond découvert en chemin : la fédération n'était jamais réconciliée.**
|
||
`federation-ldap.yml` créait le provider s'il manquait, puis ne le corrigeait plus jamais. Le
|
||
provider pointait donc encore `ldaps://id-ldap-01.chezlepro.internal` — le nom d'hôte erroné
|
||
corrigé le matin même dans `resoudre_annuaire`. La fédération était muette, et la
|
||
synchronisation des groupes échouait sur un laconique `UnknownHost`.
|
||
|
||
C'est le revers de D-67 appliqué au mauvais endroit : **l'infrastructure se réconcilie**,
|
||
seules les appartenances ne le sont pas. L'URL, le DN des utilisateurs et le DN de liaison
|
||
sont désormais corrigés à chaque passage.
|
||
|
||
**Et j'avais masqué l'échec.** La synchronisation portait `|| true` : le mapper existait, le
|
||
realm restait vide, et rien ne disait pourquoi. Ça m'a coûté la moitié du diagnostic. Le `||
|
||
true` est retiré, l'erreur remonte avec son message.
|
||
|
||
|
||
### Les cinq `meta/acces.yml` — et ce qu'ils rendent visible
|
||
|
||
Chaque rôle web déclare désormais le **groupe** qu'il reconnaît et ce qu'il lui accorde. Un
|
||
troisième champ s'est imposé en écrivant : **`porte_par`** — le mécanisme qui transporte
|
||
réellement l'habilitation. Sans lui, les déclarations auraient décrit une chaîne inexistante,
|
||
ce qui est exactement le défaut levé sept fois hier.
|
||
|
||
L'état réel, mesuré rôle par rôle :
|
||
|
||
| Rôle | `porte_par` | Ce que ça veut dire |
|
||
|---|---|---|
|
||
| `serveur_grafana` | `role-realm` | mécanisme réel : claim `roles` → `oidc_role_path` |
|
||
| `serveur_forgejo` | **`aucun`** | rien n'est câblé ; tout authentifié a le niveau par défaut |
|
||
| `serveur_nextcloud` | **`aucun`** | idem — l'administration passe par le compte local |
|
||
| `serveur_icingaweb2` | **`liste-uid`** | **nomme des personnes** (D-66) |
|
||
| `serveur_keycloak` | **`aucun`** | la **projection** LDAP → rôle de realm n'existe pas |
|
||
|
||
Trois services sur cinq n'ont aucun mécanisme, et deux nomment des personnes — ce que D-66
|
||
interdit, écrit une heure plus tôt.
|
||
|
||
**Le maillon manquant est chez Keycloak.** `serveur_keycloak_role_assignments` assigne un rôle
|
||
à un **utilisateur**, nommément ; son propre commentaire l'admettait déjà (*« en prod, préférer
|
||
l'assignation via groupe d'annuaire ; ici, explicite pour la preuve »*). Sans mapper
|
||
`group-ldap-mapper`, les groupes LDAP n'atteignent jamais les services : la chaîne s'arrête
|
||
avant le premier. Sa méta a donc une autre forme — `acces_projection`, car Keycloak *projette*
|
||
au lieu de consommer (D-65).
|
||
|
||
**Un défaut concret corrigé.** `serveur_icingaweb2_admins` valait `"testmail"` — un compte de
|
||
test **codé en dur dans le moteur**, qu'aucune instance ne surchargeait. Le seul administrateur
|
||
déclaré de la supervision était donc un utilisateur inexistant. Il suit désormais l'`uid`
|
||
d'amorçage ; vérifié sur `mon-01` : `users = "sysadmin"`.
|
||
|
||
**Ce que la doctrine visait est maintenant lisible.** Le §1 d'`autorisation.md` disait
|
||
qu'aucun service ne lisait `ou=groups`. Les métas le disent maintenant service par service,
|
||
avec le mécanisme qui manque à chacun — c'est la condition pour qu'une preuve puisse un jour
|
||
le vérifier.
|
||
|
||
|
||
### `ppolicy` chargé — le jeton d'amorçage devient vraiment à usage unique
|
||
|
||
`serveur_openldap` charge désormais l'overlay `ppolicy` et pose une politique par défaut.
|
||
Le module était sur disque (`/usr/lib/ldap/ppolicy.so`) mais jamais chargé : seul
|
||
`back_mdb` l'était. Depuis OpenLDAP 2.5 son schéma est **intégré au module** — aucun
|
||
`.ldif` à charger, contrairement à 2.4.
|
||
|
||
**La contrainte mord**, mesuré sur un compte fraîchement amorcé :
|
||
|
||
```
|
||
$ ldapsearch -D uid=sysadmin,… -w <jeton>
|
||
Insufficient access (50)
|
||
Operations are restricted to bind/unbind/abandon/StartTLS/modify password
|
||
```
|
||
|
||
Le sysadmin peut se connecter et **rien d'autre** que changer son mot de passe. Ce que la
|
||
doctrine promettait est maintenant garanti techniquement, pas seulement demandé.
|
||
|
||
`pwdMustChange` est ce qui donne son effet à `pwdReset` : sans lui, marquer une entrée
|
||
n'oblige à rien. Les deux vont ensemble, et c'est le genre de couple qu'on découvre en le
|
||
testant. La politique apporte aussi la longueur minimale (12 — `pwdMinLength` n'est appliqué
|
||
que si `pwdCheckQuality` > 0), le verrouillage après 5 échecs et l'historique.
|
||
|
||
`olcPPolicyUseLockout` reste à `FALSE`, délibérément : répondre « ce compte est verrouillé »
|
||
renseignerait un attaquant sur l'existence du compte.
|
||
|
||
**Le DN de la base est lu, pas supposé.** `olcDatabase={1}mdb` est l'usage courant mais
|
||
l'index n'est pas garanti — le rôle le cherche.
|
||
|
||
**Et la détection du rôle d'amorçage s'est vérifiée d'elle-même** : rejoué après le
|
||
chargement, il annonce « Changement FORCÉ à la première ouverture (ppolicy actif) » là où il
|
||
disait l'inverse une heure plus tôt. C'est exactement pourquoi il détecte au lieu de
|
||
supposer.
|
||
|
||
|
||
### Le rôle d'amorçage : `amorcage_acces`
|
||
|
||
Crée **un** compte (`uid=sysadmin`) et **un** groupe (`cn=sysadmin`) dans LDAP, puis se
|
||
retire. Prouvé sur `idm-01` : le compte s'authentifie (`ldapwhoami` retourne son DN), et le
|
||
**second passage ne touche à rien** — `changed=0`, sept tâches sautées.
|
||
|
||
**Il suit l'annuaire, il ne se déclare pas au plan.** Le rôle écrit par `ldapi:///` — socket
|
||
locale — donc il doit tourner sur l'hôte de l'annuaire. Le déclarer comme groupe obligerait
|
||
chaque instance à le poser sur le bon hôte, et les instances ne nomment pas cet hôte pareil :
|
||
`idm-01` chez Chezlepro, `id-ldap-01` chez Technolibre. Je m'en suis convaincu en le posant
|
||
d'abord sur `infra-pki-01` par erreur. Il est donc appliqué par le playbook de
|
||
`serveur_openldap`.
|
||
|
||
**Trois défauts trouvés en le construisant, tous par la machine :**
|
||
|
||
`pwdReset` **n'existe pas dans ce schéma** — il vient de l'overlay `ppolicy`, non chargé
|
||
(seul `back_mdb` l'était). L'entrée entière était rejetée, et `no_log: true` masquait la
|
||
cause. J'avais supposé un mécanisme sans vérifier qu'il existait. Le rôle le **détecte**
|
||
désormais : overlay présent → changement forcé ; absent → il le dit en clair, et le
|
||
changement devient une obligation d'exploitation. La doctrine a été corrigée pour ne plus
|
||
promettre ce qui n'a pas lieu.
|
||
|
||
**Le mot de passe aurait été stocké en clair.** `ldap_entry` écrit `userPassword`
|
||
littéralement ; le jeton d'amorçage aurait été lisible par quiconque lit l'annuaire. Il est
|
||
maintenant haché par `slappasswd -h {SSHA}` sur la cible.
|
||
|
||
**Le recensement des secrets ne voyait pas ce rôle.** `voute.py` ne scannait que
|
||
`roles/<groupe>` — un rôle appliqué par un playbook sans être lui-même un groupe échappait au
|
||
recensement, ce que D-20 interdit. Il suit désormais les listes `roles:` des playbooks, ce qui
|
||
vaut pour tout rôle futur dans ce cas. Le gabarit signale correctement
|
||
`vault_sysadmin_amorcage`, généré ensuite dans les deux tenants.
|
||
|
||
|
||
### Set-OPS amorce les accès, le sysadmin gouverne (D-65 → D-67)
|
||
|
||
L'authentification était résolue et gardée (P29) ; l'**autorisation** n'existait nulle part.
|
||
Constat mesuré : `ou=groups` est créé par `serveur_openldap` depuis le début, et **aucun des
|
||
29 rôles ne le lit** — pas un `memberOf`, pas un filtre. `ou=people` est vide aussi : personne
|
||
ne peut entrer autrement que par les comptes de secours en voûte.
|
||
|
||
**La décision principale contredit délibérément la doctrine du dépôt (D-67).** Partout
|
||
ailleurs, un écart entre le déclaré et le réel est un défaut à corriger : les devis
|
||
réconcilient, les applicateurs retirent ce qui n'est plus demandé. Pour les habilitations,
|
||
**l'écart est légitime** — c'est le sysadmin qui fait son travail.
|
||
|
||
Deux régimes, et la frontière entre eux :
|
||
|
||
| | Qui décide | Régime |
|
||
|---|---|---|
|
||
| quel groupe accorde quoi dans un service | Set-OPS | réconcilié, comme le reste |
|
||
| qui appartient à quel groupe | **une personne** | **amorcé une fois, jamais réconcilié** |
|
||
|
||
Set-OPS crée **un** accès — celui du sysadmin — puis se retire. **Idempotence par existence,
|
||
pas par conformité** : compte absent, on le crée ; compte présent, aucune action *quel que soit
|
||
son état*. Il a pu être renommé, promu, déplacé. Un dépôt qui réconcilierait les appartenances
|
||
effacerait le compte créé la veille pour un nouvel employé — exactement la « correction » qu'un
|
||
agent zélé ferait sans y penser, d'où la nécessité de l'écrire.
|
||
|
||
**D-65 — les groupes LDAP portent l'autorisation, Keycloak les projette.** Argument mécanique :
|
||
Dovecot et Postfix ne savent pas lire un rôle Keycloak. L'y loger rendrait la moitié courriel
|
||
aveugle et imposerait au sysadmin **deux** modèles de permissions. Conséquence pratique : un
|
||
seul endroit à administrer.
|
||
|
||
**D-66 — un service nomme un groupe, jamais une personne.** C'est ce qui rend la reprise
|
||
possible : révoquer quelqu'un ne demande pas un déploiement.
|
||
|
||
**Le §6 est un runbook de reprise**, et c'est la partie utile : récupérer le mot de passe
|
||
d'amorçage, où administrer quoi, comment se rouvrir si on se ferme dehors, et ce qu'il faut
|
||
changer en priorité. Ce dernier point mérite d'être dit : les comptes de secours ont été
|
||
générés pendant le déploiement, et **l'auteur du déploiement y a eu accès**. Les régénérer
|
||
n'est pas une formalité — c'est ce qui transforme une livraison en transfert.
|
||
|
||
Effet secondaire du cadrage : sans registre de personnes, **aucune donnée personnelle n'entre
|
||
dans l'historique git**.
|
||
|
||
**Rien n'est construit.** Le rôle d'amorçage, les `meta/acces.yml` et la preuve restent à
|
||
écrire.
|
||
|
||
### Le courriel interne, et l'annuaire qui ne désignait personne
|
||
|
||
`infra-mail-01` (Dovecot) et `edge-mta-01` (Postfix + rspamd) déployées — **dix-neuf
|
||
playbooks d'affilée sans un échec**. Neuf VM debout.
|
||
|
||
```
|
||
Postfix → Dovecot:24 ouvert
|
||
lmtp_tls_security_level = verify zéro-confiance sur le LMTP
|
||
mynetworks = … 10.27.0.0/16 le supernet dérivé, à l'œuvre
|
||
virtual_mailbox_maps → ldaps://idm-01 liaison établie
|
||
```
|
||
|
||
C'est la seconde branche de la directive d'authentification : LDAP **direct** pour les
|
||
protocoles qui ne parlent pas OIDC, sans passer par Keycloak.
|
||
|
||
**`resoudre_annuaire` fixait `id-ldap-01` en dur** — une machine qui n'existe dans aucun plan.
|
||
Postfix ne pouvait pas se lier : `Unable to bind to ldaps://id-ldap-01.chezlepro.internal:636
|
||
(Can't contact LDAP server)`.
|
||
|
||
L'intention du rôle était pourtant juste, et son commentaire le disait : *« LE seul point où le
|
||
nom d'hôte de l'annuaire est fixé, au lieu d'être répété dans chaque rôle »*. Un seul point de
|
||
vérité — mais **écrit** au lieu d'être dérivé. Une valeur unique et fausse vaut mieux qu'une
|
||
valeur répétée et fausse ; elle reste fausse.
|
||
|
||
Le plan le déclare (`applications.openldap.hote: idm-01`) : c'est de là qu'elle vient
|
||
désormais. Septième occurrence du même motif aujourd'hui.
|
||
|
||
**Ce qui n'est pas prouvé** : aucun compte n'existe dans LDAP — l'approvisionnement des
|
||
utilisateurs n'est pas une étape d'infrastructure. Tout ce qui précède la boîte aux lettres est
|
||
vérifié ; la remise elle-même attend un compte.
|
||
|
||
|
||
### Le SSO fonctionne — quatre défauts, un seul motif
|
||
|
||
`obs-01` et `mon-01` déployées : Loki, Prometheus, Grafana d'un côté ; Icinga, Icinga Web 2 et
|
||
oauth2-proxy de l'autre. **Sept VM debout**, et la supervision est réelle — 7 cibles Prometheus
|
||
*up*, une par hôte vivant, scrutées en TLS à travers cinq zones de sécurité du VRF.
|
||
|
||
La preuve du SSO :
|
||
|
||
```
|
||
GET http://127.0.0.1:4180/ → 302
|
||
https://auth.chezlepro.internal/realms/chezlepro/protocol/openid-connect/auth
|
||
?client_id=icingaweb2&redirect_uri=https://icinga.chezlepro.internal/oauth2/callback
|
||
```
|
||
|
||
C'est le patron générique : Keycloak devant une application sans OIDC natif, fédérant LDAP.
|
||
|
||
Il a fallu quatre corrections, et l'erreur changeait à chaque fois — signe qu'on avançait.
|
||
|
||
**Le secret de cookie était en base64 standard.** oauth2-proxy décode en base64 *url-safe* ; un
|
||
secret contenant `+` ou `/` fait échouer le décodage, il retombe sur la chaîne brute et se
|
||
plaint de sa **longueur** — « is 44 bytes » — sans jamais mentionner l'encodage. Régénéré en
|
||
url-safe dans les deux tenants, avec une garde qui refuse `+` et `/` en nommant la cause.
|
||
|
||
**L'issuer était inventé.** Le rôle fabriquait `https://keycloak.<domaine>` par convention — un
|
||
nom que rien ne publie. Le plan expose Keycloak sous `auth.<domaine>`, et c'est ce nom que
|
||
PowerDNS résout et que nginx sert.
|
||
|
||
**Keycloak s'annonçait sous ce même nom inventé**, à la source : `issuer did not match the
|
||
issuer returned by provider`. Même correctif — le nom d'hôte se dérive de l'exposition.
|
||
|
||
**Le pare-feu est-ouest bloquait l'edge.** Le flux existait d'un seul côté : `oauth2-proxy`
|
||
déclarait `egress 443 → edge`, la matrice était satisfaite (nginx déclare bien 443) — mais avec
|
||
`pair: externe`, que le devis est-ouest **saute volontairement**, puisqu'il relève de la
|
||
frontière. Aucune règle d'hyperviseur n'était émise, et la connexion expirait. `serveur_nginx`
|
||
déclare désormais aussi son 443 depuis la `flotte` : les FQDN publiés vivent à l'edge, et un
|
||
service interne qui appelle un autre service passe par son nom publié.
|
||
|
||
**Trois de ces quatre sont le motif du jour** : un nom construit par convention d'un côté,
|
||
déclaré de l'autre. Le champ `expose` du plan a maintenant **cinq** consommateurs — PowerDNS,
|
||
nginx, `/etc/hosts`, oauth2-proxy et Keycloak — pour une seule source.
|
||
|
||
|
||
### Le supernet du tenant devient un intrant dérivé
|
||
|
||
Keycloak ne démarrait pas :
|
||
|
||
```
|
||
FATAL: aucune entrée dans pg_hba.conf pour l'hôte « 10.27.17.11 »,
|
||
utilisateur « keycloak », base « keycloak », chiffrement SSL
|
||
```
|
||
|
||
PostgreSQL n'autorisait que `10.11.0.0/16` — l'ancien monde — pour un tenant en
|
||
`10.27.0.0/16`. La valeur était figée dans `group_vars`, sous un commentaire
|
||
*« AJUSTER au sous-réseau réel de déploiement »* que personne n'avait suivi. Un commentaire
|
||
qui demande une action est une action qui n'aura pas lieu.
|
||
|
||
**Postfix portait la même valeur périmée**, et aurait échoué de la même façon plus tard, sur
|
||
le courriel. **Technolibre aussi** : `10.12.0.0/16` pour un tenant en `10.21.0.0/16`. Quatre
|
||
fichiers, une seule faute, répétée parce que recopiée.
|
||
|
||
`instancier` émet désormais `setops_supernet`, dérivé du seed comme le VMID et l'adresse.
|
||
Les quatre fichiers le consomment, et la valeur suit le tenant sans être saisie :
|
||
|
||
```
|
||
Chezlepro 10.27.0.0/16
|
||
Technolibre 10.21.0.0/16
|
||
```
|
||
|
||
### La chaîne d'identité est debout
|
||
|
||
```
|
||
keycloak active issuer = http://keycloak.chezlepro.internal:8080/realms/…
|
||
slapd active namingContexts: dc=chezlepro,dc=internal
|
||
```
|
||
|
||
`idm-01` : dix playbooks, aucun échec, fédération LDAP configurée. Et les quatre premiers se
|
||
sont rejoués à **zéro changement** — tout ce qui a été corrigé aujourd'hui converge.
|
||
|
||
Quatre VM sur quatorze sont entièrement déployées : l'autorité de certification, le DNS
|
||
autoritatif, l'identité (annuaire + SSO) et les bases (PostgreSQL + Redis).
|
||
|
||
|
||
### Trois défauts que seul un vrai déploiement pouvait montrer
|
||
|
||
`idm-01` — première VM d'une autre zone de sécurité (`t17iden`) — a prouvé le **routage
|
||
inter-zone** dans le VRF : `10.27.19.21` joint `10.27.17.11`, à travers deux passerelles
|
||
anycast. Puis elle a levé trois défauts, tous invisibles jusqu'à ce qu'on déploie pour de vrai.
|
||
|
||
**Le handler de `client_pki` rechargeait un service pas encore installé.** Conséquence directe
|
||
de l'ordre rétabli : `client_pki` s'exécute **avant** les services — ils ont besoin du
|
||
certificat — et son handler tentait `systemctl reload slapd`. Un consommateur absent n'est pas
|
||
une erreur : il prendra le certificat déjà posé à son installation. Le silence est resserré sur
|
||
ce cas précis ; un vrai échec de rechargement reste fatal, sinon un service servirait un
|
||
certificat périmé sans que personne ne l'apprenne.
|
||
|
||
**`resoudre_base` ne trouvait aucune base de portée `application`.** Le registre accepte deux
|
||
portées : `groupe` désigne un groupe opérationnel, `application` une application du plan.
|
||
Keycloak déclare `consommateur: keycloak` — valide, le validateur l'accepte — mais le rôle
|
||
cherchait `serveur_keycloak`. Le lien entre les deux était **déclaré** (`applications.yml`
|
||
nomme le `groupe` de chaque application) : on le suit désormais, plutôt que de retirer un
|
||
préfixe à la main. Une convention de nommage se contredit un jour ; une déclaration se corrige.
|
||
|
||
**Keycloak attend une base qui n'existe pas encore.** `data-sql-01` n'est pas déployée : le
|
||
service démarre, se connecte, échoue. Ce n'est pas un défaut — `make deployer HOTE=x` ne connaît
|
||
que l'ordre **intra-hôte**. L'ordre inter-hôtes existe (`couches-deploiement.yml` place
|
||
`serveur_postgresql` avant les applications) et c'est `make site` qui l'exploite.
|
||
|
||
### Deux attentes, sans lesquelles `make myDay` ne peut pas reconstruire
|
||
|
||
Enchaîner `creer-vm` puis `deployer` échouait presque toujours sur une machine neuve, pour deux
|
||
raisons distinctes :
|
||
|
||
- **SSH n'est pas levé** quand Proxmox rend la main. Le symptôme trompe : à travers la
|
||
frontière le TCP s'établit (SYN proxy) et l'échec se lit *« timed out during banner
|
||
exchange »*.
|
||
- **Le verrou dpkg** est tenu par les mises à jour automatiques de Debian, par vagues, pendant
|
||
plusieurs minutes après le premier démarrage.
|
||
|
||
`_attendre-hote` attend les deux, et `creer-vm` rend désormais une VM **prête** plutôt que
|
||
seulement démarrée. `ATTENTE_HOTE` (600 s) borne l'attente : dépasser reste un échec, pour
|
||
qu'une panne ne devienne pas une attente infinie.
|
||
|
||
**Deux couches, pas une.** Le premier essai vérifiait le verrou avec `fuser` — un instantané.
|
||
Il était libre au test, repris juste après. Chaque tâche `apt` de `common_packages` porte donc
|
||
`lock_timeout: 300` : le Makefile attend la fin des vagues, le rôle survit à une vague qui
|
||
repart **entre** deux tâches.
|
||
|
||
Et un défaut dans l'attente elle-même : `unattended-upgrades.service` est un **démon**
|
||
(`Type=simple`), toujours `active`. L'inclure dans la condition la rendait impossible à
|
||
satisfaire — elle échouait au délai, systématiquement. Seules les unités `apt-daily*` sont des
|
||
one-shot ; c'est le verrou qui dit si dpkg est occupé.
|
||
|
||
|
||
### `make deployer` ignorait l'ordre des couches
|
||
|
||
`client_metrique` échouait sur les deux premières VM : il exige le certificat TLS du
|
||
`node_exporter`, que seule l'AC peut émettre. Cause : `afficher_playbooks_hote()` triait les
|
||
groupes **alphabétiquement** après le socle. `client_journal`, `client_metrique`,
|
||
`client_unbound` passaient donc avant `serveur_step_ca` — sur l'hôte de l'AC lui-même.
|
||
|
||
`docs/couches-deploiement.yml` existe précisément pour définir cet ordre, et sa dernière
|
||
couche dit en toutes lettres : *« intégrations déployées en dernier, quand leurs cibles sont
|
||
debout »*. `make site` le lit ; `make deployer` ne l'avait jamais lu. Deux chemins pour la
|
||
même question, un seul registre consulté.
|
||
|
||
Le tri se fait désormais par couche — socle d'abord, alphabétique seulement **à l'intérieur**
|
||
d'une couche — depuis le même registre que l'orchestrateur.
|
||
|
||
```
|
||
infra-pki-01 socle → serveur_step_ca → client_pki → intégrations
|
||
infra-dns-01 socle → client_pki → serveur_powerdns → intégrations
|
||
```
|
||
|
||
### L'autorité n'est plus une exception, elle est un cas à part
|
||
|
||
Deux politiques universelles se contredisaient. `client_pki` exemptait l'AC — *« elle EST la
|
||
source de la confiance »* — et `client_metrique` refuse toute exemption — *« un collecteur
|
||
muet sur son propre état est un angle mort »*. Les deux avaient raison séparément, et l'AC
|
||
restait la seule machine impossible à mesurer.
|
||
|
||
L'exemption confondait **ne pas s'enrôler** et **ne pas avoir de certificat**. L'AC n'a pas à
|
||
aller chercher sa racine par le réseau, chez elle, en vérifiant une empreinte qu'elle vient de
|
||
produire — mais ses services ont besoin de certificats comme tous les autres. Elle est
|
||
précisément la machine qui peut se les signer, localement, sans réseau.
|
||
|
||
`client_pki` distingue donc les deux chemins : bootstrap pour les autres, **émission locale**
|
||
sur l'AC. L'exemption disparaît, et la doctrine reste intacte.
|
||
|
||
**Un effet de bord instructif.** `step ca bootstrap` écrit aussi le `defaults.json` qui porte
|
||
l'URL de l'AC : en sautant le bootstrap, on perdait l'information sans le voir —
|
||
`flag '--ca-url' is required`. `--ca-url` et `--root` sont désormais explicites pour **tous**
|
||
les hôtes. Dépendre d'un fichier écrit par une étape qu'on saute volontairement, c'était
|
||
reconstruire le même piège.
|
||
|
||
### Deux services souverains debout
|
||
|
||
```
|
||
step-ca active, :8443 `step ca health` → ok
|
||
powerdns active, 10.27.19.11:53 (plus 0.0.0.0 — la restriction d'écoute a pris)
|
||
dig @10.27.19.11 infra-pki-01.chezlepro.internal → 10.27.19.21
|
||
```
|
||
|
||
`infra-dns-01` est la première VM tenant **entièrement** déployée : huit playbooks, aucun
|
||
échec — socle, durcissement, PKI cliente, PowerDNS, sauvegarde, journaux, métriques, courriel.
|
||
La zone souveraine résout, et l'AC a émis son premier certificat à un tiers.
|
||
|
||
|
||
### L'ICMP n'a pas de port — et la seconde barrière n'avait jamais démarré
|
||
|
||
`nftables.service` refusait de démarrer sur la première VM déployée :
|
||
|
||
```
|
||
/etc/nftables.conf:23 icmp dport frag-needed accept
|
||
^^^^^ syntax error, unexpected string
|
||
```
|
||
|
||
Le générateur émettait `{protocole} dport {port}` pour **tout** flux. L'ICMP n'a pas de
|
||
port : il a un type et un code. Les deux flux PMTUD de D-30 produisaient donc un jeu que
|
||
`nft` rejette — et un jeu rejeté ne se charge pas du tout, si bien que l'hôte **perd** sa
|
||
barrière au lieu d'en gagner une.
|
||
|
||
Le défaut touchait les quatorze hôtes. La seconde barrière de D-31 — les nftables d'hôte,
|
||
qui doublent le filtrage de l'hyperviseur — n'avait jamais pu démarrer nulle part. Personne
|
||
ne s'en était aperçu parce qu'aucune VM tenant n'avait encore été déployée.
|
||
|
||
`_selecteur_nft()` traduit désormais : `tcp dport 22` reste tel quel,
|
||
`icmp frag-needed` devient `icmp type destination-unreachable icmp code frag-needed`. Un
|
||
code ICMP inconnu est **refusé à la génération**, avec le nom du rôle fautif.
|
||
|
||
**La garde tourne aussi à la vérification**, pas seulement à la génération. Un rôle peut
|
||
déclarer un flux que personne ne porte encore : le devis passerait, et la panne arriverait
|
||
le jour où un hôte prend ce rôle. `nft -c` en local demanderait des privilèges netlink ;
|
||
construire le sélecteur ne coûte rien et attrape exactement la même faute.
|
||
|
||
Mesuré après correction : `nftables=active` sur les deux VM, 16 et 17 règles chargées, dont
|
||
la garde anti-lockout `ip saddr 10.0.0.0/24 tcp dport 22 accept`.
|
||
|
||
|
||
### `client_unbound` devient universel — et l'amorçage DNS trouve sa place
|
||
|
||
Une VM ne peut pas s'installer sans résoudre des noms : `apt` en dépend. Or le DNS
|
||
autoritatif du tenant (PowerDNS) répond **uniquement** pour la zone souveraine et refuse
|
||
le reste — il ne récurse pour personne. Il manquait donc un résolveur récursif, et
|
||
`client_unbound` est exactement l'outil écrit pour ça : zone interne déléguée à
|
||
l'autoritatif, récursion depuis la racine, aucune dépendance au résolveur d'un fournisseur.
|
||
|
||
Il rejoint `client_journal` et `client_metrique` parmi les intégrations universelles :
|
||
13 hôtes sur 14, déclarés **une fois** dans `meta/integration.yml`, et 8 déclarations
|
||
redondantes retirées du plan.
|
||
|
||
**L'exemption, et son revers.** `infra-dns-01` est exempté : PowerDNS occupe déjà son port
|
||
53, y ajouter Unbound produirait un conflit de liaison. Au passage,
|
||
`serveur_powerdns_listen_addresses` passe de `0.0.0.0` à l'adresse de l'hôte — lier toutes
|
||
les interfaces occupait aussi `127.0.0.1:53`, là où un résolveur local voudrait s'installer.
|
||
|
||
Mais l'appartenance au groupe est **aussi** ce qui ouvre le port 53 à la frontière. En
|
||
exemptant la machine, je lui retirais le droit de résoudre : le serveur qui devait servir de
|
||
DNS au tenant était le seul à ne pas pouvoir s'installer. `serveur_powerdns` déclare donc son
|
||
propre flux sortant — il ne récurse pour personne, mais il doit résoudre **pour lui-même**.
|
||
|
||
### Le résolveur d'amorçage, et pourquoi cloud-init ne suffisait pas
|
||
|
||
Nouvel intrant `dns_amorcage`, dérivé jusqu'à `make creer-vm`. Cloud-init l'écrit bien —
|
||
mesuré : `dns-nameservers 9.9.9.9 149.112.112.112` dans `50-cloud-init`. Et il reste **sans
|
||
effet** : `dns-nameservers` d'ifupdown exige `resolvconf`, absent du gabarit doré, qui
|
||
transporte en outre un `/etc/resolv.conf` figé de l'ancien monde. Installer `resolvconf`
|
||
demanderait `apt`, qui demande la résolution : la boucle se referme.
|
||
|
||
`serveur_debian` pose donc le résolveur en `pre_tasks`, **avant** `common_packages` — donc
|
||
avant le premier `apt`. Une garde tient le passage de relais : si `127.0.0.1` est déjà là,
|
||
`client_unbound` a basculé et le fichier n'est pas touché. Sans elle, chaque déploiement
|
||
aurait défait la bascule, et deux rôles se seraient disputé le même fichier sans fin.
|
||
|
||
**Un défaut créé puis corrigé en chemin.** La première version de `_intrants_communs()` lisait
|
||
`instance/` en dur : un test sur inventaire synthétique serait allé chercher les intrants de
|
||
la production. Le chemin dérive maintenant de l'inventaire reçu, et un test le prouve dans les
|
||
deux sens — avec inventaire, et sans.
|
||
|
||
|
||
### La première VM tenant, et les quatre défauts qu'elle a révélés
|
||
|
||
`infra-pki-01` recréée pour éprouver la chaîne complète. Le CA est le bon premier service :
|
||
il est le seul rôle **exempté** de `client_pki` — il ne s'enrôle pas auprès de lui-même — donc
|
||
sans dépendance amont.
|
||
|
||
Tout ce qui dérive du seed est exact :
|
||
|
||
```
|
||
VMID 117402101 pool Chezlepro-17
|
||
carte bridge=t17serv, firewall=1, aucune étiquette VLAN
|
||
adresse 10.27.19.21/24, gw 10.27.19.1
|
||
calcul 1 cœur / 1024 Mo
|
||
depuis le VRF : ping 0.08 ms, port 22 ouvert
|
||
```
|
||
|
||
Mais y arriver a demandé quatre corrections, et chacune serait passée inaperçue.
|
||
|
||
**Le pare-feu est-ouest aurait enfermé Ansible.** `t<idx>-srv-debian` n'autorisait SSH que
|
||
depuis `+t<idx>-flotte` — les hôtes du tenant. `admin_de(nom)` était collecté dans le devis
|
||
puis **jamais utilisé**. La première VM passée en `policy_in=DROP` se serait fermée derrière
|
||
l'outil qui venait de la configurer. Un IPSet `t<idx>-admin` dédié porte désormais l'intrant
|
||
`nftables_admin_ssh`, et une règle s'y source. Pas d'ajout à `flotte` : ce mot-clé sert aussi
|
||
LDAP, SQL et les métriques, qu'il aurait ouverts au réseau d'administration.
|
||
|
||
**L'applicateur ne convergeait pas.** Proxmox range `192.168.255.2/32` sous la forme
|
||
`192.168.255.2`. Comparés littéralement, l'écart ne se referme jamais — chaque passage croit
|
||
devoir corriger. `_norm()` ramène les deux à la même forme.
|
||
|
||
**`cloner-vm` refusait toute VM de tenant.** Son garde exigeait `VLAN=`, alors qu'en SDN
|
||
l'étiquette est portée par le VNet et le VLAN est volontairement vide. Il accepte désormais un
|
||
VLAN vide **si** un pont est fourni — sans quoi la VM ne serait branchée nulle part.
|
||
|
||
**Le clonage ignore `cores` et `memory`.** L'API Proxmox ne les accepte pas au moment du
|
||
clone : la VM héritait du gabarit — 2 cœurs / 2048 Mo contre 1 / 1024 au plan. Le plan était
|
||
contredit sans un mot. Une tâche les repose après le clone, et la mesure le confirme.
|
||
|
||
**Troisième verrou posé** : `proxmox_clone_parefeu_interface: true`. Sans `firewall=1` sur la
|
||
carte, les groupes de sécurité affectés à la VM ne s'appliquent jamais — le filtrage est-ouest
|
||
serait posé et sans effet (D-64).
|
||
|
||
|
||
### Le DROP se pose par VM, pas au datacenter (D-64)
|
||
|
||
Le devis enseignait un geste dangereux : « pare-feu activé, politique d'entrée DROP » **au
|
||
niveau du datacenter**. Or `policy_in` y est la politique par défaut de **toute** VM dont le
|
||
pare-feu s'active. Sur ce cluster, cela vise 37 machines héritées qui n'ont aucune règle.
|
||
|
||
`policy_in` existe aussi **par VM**. Le devis et l'applicateur le posent désormais là :
|
||
|
||
```
|
||
datacenter enable=1, policy_in laissé au défaut ACCEPT
|
||
VM tenant enable=1 + policy_in=DROP + carte firewall=1
|
||
VM héritée rien — politique inchangée, pare-feu éteint
|
||
```
|
||
|
||
Même isolation est-ouest, sans le moment où tout bascule. Et l'applicateur n'a plus besoin de
|
||
refuser une partie de son devis : la partie dangereuse a disparu.
|
||
|
||
**Trois verrous, pas un.** Une VM n'est filtrée que si le datacenter est actif, que **son
|
||
propre** `enable` vaut 1 — défaut **0**, c'est le verrou du milieu — et que sa carte porte
|
||
`firewall=1`. C'est ce verrou du milieu que j'avais manqué en annonçant que huit VM en
|
||
production tomberaient.
|
||
|
||
### `enable=1` au datacenter : mesuré, pas supposé
|
||
|
||
Basculé avec vérification immédiate. Après :
|
||
|
||
```
|
||
pve-firewall enabled/running
|
||
chaînes iptables 12, toutes des chaînes-cadres PVEFW-*
|
||
chaînes par VM aucune
|
||
règles visant roxanne 0
|
||
15 VM en marche 15
|
||
hyperviseurs, frontière joignables
|
||
sortie tenant 2/2, 13 ms
|
||
```
|
||
|
||
`enable=1` pose **le cadre** et rien d'autre. Ce que le schéma laissait prévoir est maintenant
|
||
constaté sur la machine — la distinction compte, et c'est la seule raison d'avoir tenté le
|
||
geste plutôt que de l'écrire.
|
||
|
||
|
||
### Un applicateur pour le pare-feu est-ouest — et un refus assumé
|
||
|
||
`scripts/appliquer_proxmox_fw.py` réconcilie les trois couches du devis : 26 IPSets,
|
||
36 groupes de sécurité, et les affectations aux VM. Créé, mis à jour, retiré — même contrat
|
||
que les deux autres.
|
||
|
||
**Ce qu'il ne fera jamais : activer le pare-feu du datacenter.** Ce réglage
|
||
(`enable=1` + `policy_in=DROP`) vaut pour **toutes** les VM du cluster, y compris les 37
|
||
machines héritées qui n'ont aucune règle. Le basculer couperait le parc d'un coup. L'écart est
|
||
signalé à chaque exécution, en toutes lettres ; la décision reste humaine.
|
||
|
||
C'est la première fois qu'un applicateur de ce dépôt **refuse par conception** de faire une
|
||
partie de son devis. Le refus vaut mieux qu'une option qu'on finirait par cocher sans y penser.
|
||
|
||
**Les 28 VM du devis n'existent pas encore.** Elles sont listées comme différées, pas comme
|
||
erreurs : leur affectation se posera au prochain passage, une fois clonées.
|
||
|
||
**Les objets posés sont inertes**, précisément parce que le prérequis n'est pas rempli. C'est
|
||
ce qui rend l'application sûre aujourd'hui : la politique est en place et vérifiable, sans rien
|
||
filtrer tant qu'on ne l'a pas décidé.
|
||
|
||
**`scripts/proxmox_api.py`** extrait ce que les deux applicateurs Proxmox partagent — surtout
|
||
la recomposition du jeton `utilisateur@royaume!nom`, dont la voûte ne porte que le nom. La
|
||
dupliquer, c'était préparer un 401 muet le jour où l'un des deux morceaux changerait.
|
||
|
||
Une fausse alerte en passant : les noms tronqués à 18 caractères semblaient collisionner entre
|
||
IPSets et groupes. Ce sont deux espaces de noms distincts chez Proxmox, et les règles
|
||
référencent un IPSet par un `+`. Aucune collision — et le devis portait déjà sa garde.
|
||
|
||
|
||
### Un applicateur pour le SDN, et pour la sortie des VRF
|
||
|
||
`scripts/appliquer_sdn.py` — deuxième applicateur du dépôt, même contrat que celui de la
|
||
frontière : ce qui manque est créé, **ce que le devis ne demande plus est retiré**.
|
||
|
||
**Deux cibles, parce qu'elles n'ont pas la même prise.** Les objets de cluster (zone, VNets,
|
||
sous-réseaux) passent par l'API Proxmox ; la sortie du VRF est un *fichier* sur chaque nœud de
|
||
sortie, qu'aucune API n'expose — donc SSH. `make sdn-plan` lit, `make sdn-appliquer
|
||
CONFIRMER=true` agit.
|
||
|
||
**Périmètre strict.** Seules les zones nommées par le devis et celles de la liste explicite des
|
||
anciens nommages sont touchées. Une zone inconnue est signalée et **laissée intacte** : ce
|
||
dépôt n'est pas seul au monde sur ce cluster.
|
||
|
||
**Deux garde-fous sur le retrait.** Un VNet encore branché à une VM est refusé, avec le nom des
|
||
machines — on ne débranche personne par inadvertance. Et l'ordre suit les dépendances :
|
||
sous-réseaux, puis VNets, puis zones à la suppression ; l'inverse à la création. Proxmox refuse
|
||
tout autre ordre, et l'avait déjà appris à ses dépens.
|
||
|
||
**Une source unique pour la strophe.** `devis_sdn.strophe_frr()` sert à la fois au devis qui
|
||
l'affiche et à l'applicateur qui la compare au fichier distant. Deux rendus séparés auraient
|
||
fini par diverger — c'est le mode de panne que ce dépôt passe ses journées à fermer.
|
||
|
||
**Un défaut trouvé par la première exécution.** L'applicateur lisait `frr.conf.local` sans
|
||
`sudo` ; la lecture échouait, un `|| true` masquait l'échec, et un fichier **présent** était
|
||
déclaré absent — donc réécrit sans raison. Il distingue désormais « absent », « illisible » et
|
||
« différent », trois états qui appellent trois réponses.
|
||
|
||
Le rejeu ne trouve plus rien à faire, les six VRF portent leur défaut, et la sortie tenant
|
||
répond toujours.
|
||
|
||
|
||
### Trois nœuds de sortie, et le primaire enfin émis
|
||
|
||
`t17` passe à `asgard,gandalf,vishnu`, primaire `asgard` — `t11` l'était déjà. La strophe FRR
|
||
est posée sur les trois nœuds, et les six VRF (deux zones × trois nœuds) portent leur défaut.
|
||
|
||
**Un défaut du devis découvert au passage.** `proxmox_sdn.sortie_primaire` était déclaré et
|
||
**utilisé** par `devis_opnsense` pour dériver le prochain saut des routes de la frontière,
|
||
mais `devis_sdn` ne l'émettait jamais. Une zone créée depuis ce devis n'aurait pas eu de
|
||
primaire, et les deux devis se seraient contredits : la frontière aurait pointé un nœud que le
|
||
SDN n'avait pas désigné. Le devis l'émet désormais, et refuse un primaire absent de la liste
|
||
des nœuds de sortie.
|
||
|
||
**Une mesure qui accusait à tort.** Depuis `gandalf` et `vishnu`, la sortie semblait morte :
|
||
100 % de perte là où `asgard` passait. La capture a tranché — pendant que `gandalf` pingait,
|
||
**`asgard` recevait les quatre réponses** :
|
||
|
||
```
|
||
IP 10.0.4.1 > 10.27.19.1: ICMP echo reply, seq 1..4 (vu sur asgard)
|
||
```
|
||
|
||
La source du test, `10.27.19.1`, est la passerelle **anycast** : elle existe à l'identique sur
|
||
les trois nœuds. La frontière route `10.27.0.0/16` par sa route statique unique vers
|
||
`10.0.4.41`, et `asgard` consomme les réponses. Une VM tenant a une adresse unique, et le
|
||
relais EVPN la lui rend — mécanisme déjà observé (`10.27.19.21 via 10.0.5.41`).
|
||
|
||
Ce retour par le primaire n'est pas un défaut : c'est le sens de « primaire », et la
|
||
conséquence d'une route statique unique côté frontière. À retenir pour les mesures futures :
|
||
**une adresse anycast ne peut pas servir de source de test** — elle ne désigne pas le nœud
|
||
d'où l'on part.
|
||
|
||
|
||
### Les tenants sortent — et le chemin est entièrement dérivé
|
||
|
||
Bout en bout, mesuré depuis `vrf_t17` puis `vrf_t11` sur `asgard` :
|
||
|
||
```
|
||
tenant -> 10.0.4.1 (frontière) 3/3 0.14 ms
|
||
tenant -> 69.70.26.49 (passerelle FAI) 3/3 0.31 ms
|
||
tenant -> 9.9.9.9 (Internet) 2/2 11 ms (Chezlepro)
|
||
2/2 17 ms (Technolibre)
|
||
```
|
||
|
||
Il a fallu lever **deux** obstacles, et aucun des deux n'était celui qu'on croyait.
|
||
|
||
**Proxmox n'installe aucun défaut dans le VRF du tenant (D-62).** Déclarer des nœuds de
|
||
sortie ne suffit pas : `default-originate` *annonce* une route aux autres nœuds, il n'en pose
|
||
pas chez lui. `show ip route vrf vrf_tNN 0.0.0.0/0` était vide sur les **deux** zones — et
|
||
`t11` était configuré depuis plus longtemps, donc ce n'était pas un oubli récent.
|
||
|
||
La sortie vient d'une strophe dans `/etc/frr/frr.conf.local`, que Proxmox **fusionne** à
|
||
chaque régénération (`EvpnPlugin.pm`, `read_local_frr_config`). Vérifié : la ligne se retrouve
|
||
dans le `frr.conf` généré, survit à `pvesh set /cluster/sdn` **et** à un
|
||
`systemctl restart frr`.
|
||
|
||
Le choix de construction est le cœur de l'affaire. `nexthop-vrf default` emprunte **une
|
||
adresse** — celle de la frontière, connectée sur `vlan40` — au lieu d'importer la table
|
||
principale. Conséquence voulue et vérifiée : **la route par défaut des hyperviseurs ne
|
||
gouverne pas la sortie des tenants**, et peut rester où elle est. `import vrf default`, le
|
||
geste « simple », aurait fait sortir les tenants par `192.168.11.254` en contournant la
|
||
frontière, tout en leur donnant `10.0.5.0/24` (transport VXLAN), la gestion et les VLAN
|
||
hérités.
|
||
|
||
**Le NAT sortant ne couvrait pas les supernets tenants (D-63).** Le mode « automatique »
|
||
d'OPNsense ne traduit que les réseaux *directement attachés* ; un supernet joint par route
|
||
statique en sort silencieusement. Le diagnostic est venu d'un ping vers la passerelle du FAI
|
||
— un seul saut, donc aucune ambiguïté sur l'origine de la panne — puis de la table d'états :
|
||
|
||
```
|
||
état 10.27.19.1 -> 69.70.26.49 icmp 0:0 nat_addr : absent
|
||
```
|
||
|
||
Le filtre **laissait passer** (un état n'existe que si une règle a autorisé) ; c'est la
|
||
traduction qui manquait. `devis_opnsense` émet désormais une règle de NAT par tenant
|
||
(section 2bis), le réconciliateur les applique et les retire par
|
||
`/api/firewall/source_nat/*`, et **P24 refuse tout supernet routé mais non traduit** — la
|
||
garde qui aurait nommé la panne du premier coup.
|
||
|
||
`devis_sdn` §4 émet la strophe FRR : le nom du VRF vient de l'`index`, l'adresse de la
|
||
frontière vient de `passerelle_sortie` dans l'underlay. Rien n'est saisi.
|
||
|
||
|
||
### Le réconciliateur sait enfin retirer
|
||
|
||
`scripts/appliquer_opnsense.py` remplace les scripts jetables qui appliquaient la frontière
|
||
depuis un bac à sable. Il travaille dans **les deux sens** : ce que le devis demande et qui
|
||
manque est créé ; ce que le devis ne demande plus est **retiré**.
|
||
|
||
Sans ce second sens, un devis qui change laisse derrière lui des règles mortes. Elles
|
||
n'ouvrent rien — mais elles décrivent une politique qui n'est plus la nôtre, et une bordure
|
||
dont la lecture ment est pire qu'une bordure vide. C'est exactement ce que D-61 venait de
|
||
produire : deux règles SSH sur `wan` que plus aucun devis ne réclame.
|
||
|
||
**Périmètre strict.** Seuls les objets marqués `setops:` (règles) ou préfixés `SETOPS_`
|
||
(alias) sont touchés. Ce qu'un humain a posé à la main dans l'interface n'existe pas pour ce
|
||
script — il ne peut donc pas le supprimer.
|
||
|
||
**Ordre imposé par les dépendances**, et il porte une propriété : les alias d'abord (une
|
||
règle référençant un alias absent est refusée), puis les créations, **puis seulement** les
|
||
retraits. À aucun instant la politique n'est plus permissive qu'avant, et si un retrait
|
||
échoue on reste en surcouverture — jamais avec un trou.
|
||
|
||
**Garde de la règle 4.** Sans `CONFIRMER=true`, aucune écriture : le script affiche le plan
|
||
et s'arrête. `make frontiere-plan` pour lire, `make frontiere-appliquer CONFIRMER=true` pour
|
||
agir. Le devis doit en outre passer sa propre garde P24 avant qu'une seule requête ne parte.
|
||
|
||
Un alias encore référencé par une règle survivante n'est jamais retiré — la suppression
|
||
échouerait et laisserait le boîtier à moitié réconcilié.
|
||
|
||
|
||
### La frontière filtre pour de vrai
|
||
|
||
La règle `any → any` d'`opt1` est retirée. Les 26 règles posées la veille n'étaient jusque-là
|
||
qu'une intention derrière un laissez-passer ; elles sont maintenant la politique.
|
||
|
||
Prouvé dans les deux sens, pas seulement constaté :
|
||
|
||
```
|
||
frontière → asgard sonde de passerelle Online, 0 %, 0.2 ms
|
||
asgard → 10.0.4.1 ping depuis vlan40 2 transmis, 0 reçu, 100 % perte
|
||
```
|
||
|
||
Le lien L2 est sain — c'est le pare-feu qui jette. Et la sonde tient : le trafic **émis par le
|
||
pare-feu lui-même** passe par `let out anything from firewall host itself`, sa réponse revient
|
||
par l'état, et les règles `pass in on opt1` ne le concernent pas. J'avais annoncé un risque
|
||
là-dessus ; il n'existait pas.
|
||
|
||
**Conséquence ferme, désormais mesurée.** Les 14 règles d'`opt1` n'autorisent que les supernets
|
||
tenants en source. Rien n'autorise `10.0.4.41/.43/.47`. Poser la route par défaut des
|
||
hyperviseurs sur `vlan40` les couperait donc immédiatement : cette tâche dépend maintenant de
|
||
l'inventaire d'exploitation de l'hébergeur (D-46/48), qui n'avait pas d'échéance.
|
||
|
||
### L'interface d'une règle se dérive de l'attachement, pas du sens du flux (D-61)
|
||
|
||
Le passage de l'administration au VLAN 10 a révélé une hypothèse devenue fausse. Le devis
|
||
rangeait tout flux entrant sur le WAN, en supposant que l'administration revenait par
|
||
l'adresse publique. Vrai pour `192.168.255.0/24` ; faux pour `10.0.0.0/24`, qui est
|
||
directement attaché sur `lan` (igb0, « GESTION »).
|
||
|
||
Les deux règles SSH étaient donc **mortes deux fois** : mauvaise interface, et
|
||
*Block private networks* les aurait filtrées de toute façon. Elles ne fonctionnaient que grâce
|
||
au `Default allow LAN to any` hérité. Pire, le devis en tirait un conseil nuisible —
|
||
« décocher *Block private networks* sur WAN » — qui aurait affaibli l'interface publique pour
|
||
admettre un réseau qui n'y arrive jamais.
|
||
|
||
`reseaux_locaux_frontiere()` dérive de l'underlay les sous-réseaux où la frontière porte
|
||
elle-même une adresse, hors lien de transit. Chaque CIDR d'administration est rangé de ce
|
||
côté-là ou du WAN, avec **un alias par interface** : Technolibre a les deux, Chezlepro n'a que
|
||
la gestion. L'avertissement RFC1918 ne compte plus que les sources réellement côté WAN.
|
||
|
||
Sans underlay, tout retombe sur le WAN — le comportement d'avant, inchangé.
|
||
|
||
**La garde tient la décision.** P24 confronte chaque règle d'administration à l'attachement de
|
||
sa source : une règle sur la gestion dont la source n'est attachée nulle part, ou une règle sur
|
||
le WAN dont la source est locale, font échouer le devis. Vérifié en rejouant l'ancien
|
||
comportement : la garde le refuse.
|
||
|
||
Nouvel intrant `opnsense_if_gestion` (`lan` ici), au catalogue du GUI. 27 règles au lieu de 26
|
||
— Technolibre en gagne une, ayant des sources des deux côtés.
|
||
|
||
|
||
### Le réseau d'administration est le VLAN 10, partout
|
||
|
||
`nftables_admin_ssh` déclarait encore `192.168.255.0/24` chez Chezlepro — l'ancien monde.
|
||
Cet intrant a **trois** consommateurs : les alias `SETOPS_ADMIN_*` de la frontière, le
|
||
devis du commutateur, et le jeu nftables de chaque VM. Il est donc devenu la source unique
|
||
du « d'où administre-t-on ».
|
||
|
||
- **Chezlepro** : `10.0.0.0/24`. Le VLAN 10 *est* son réseau d'administration (D-54).
|
||
- **Technolibre** : ses deux réseaux **plus** `10.0.0.0/24`. Un tenant doit accepter son
|
||
propre admin **et** celui de qui l'héberge — c'est de la VLAN 10 de l'hébergeur
|
||
qu'Ansible se connecte. Sans le second, aucun déploiement ne joindrait ses VM.
|
||
|
||
Propagé : alias mis à jour sur la frontière, 14 aperçus nftables régénérés.
|
||
|
||
**Ce que ça évitait.** `flux-genere/infra-pki-01.nft` était figé avec l'ancienne valeur, et
|
||
il **prime** sur le gabarit plat quand il existe. Un `make deployer` aurait posé un jeu
|
||
n'autorisant SSH que depuis `192.168.255.0/24`, sur un hôte joint depuis `10.0.0.x` — la
|
||
machine se serait fermée derrière l'outil qui venait de la configurer. Les treize autres
|
||
n'avaient pas d'aperçu et seraient tombées sur le gabarit plat : SSH ouvert à tous, pas de
|
||
blocage mais aucune restriction non plus. Les quatorze portent maintenant la bonne source.
|
||
|
||
### `make flux` était cassé par un port non numérique
|
||
|
||
Le tri du registre comparait les ports directement, donc `int` contre `str` dès que deux
|
||
types se croisaient dans un même sens pour un même rôle. Le défaut était **latent** :
|
||
`serveur_nginx` a `derive` depuis longtemps, mais son voisin est dans l'autre sens et le
|
||
tuple tranche sur le rang avant d'atteindre le port. Les deux flux ICMP `frag-needed` de
|
||
`serveur_debian`, eux, sont dans les deux sens — ils ont rendu la comparaison inévitable.
|
||
|
||
`_cle_port()` rend la clé homogène : les numériques d'abord, les symboliques ensuite par
|
||
ordre alphabétique. Le registre se régénère.
|
||
|
||
|
||
### L'EVPN tourne
|
||
|
||
Les commutateurs configurés, le trunk vérifié : `10.0.5.41` joint `.43` et `.47`. Le SDN
|
||
appliqué a fait basculer les tunnels — ils sortaient de **`192.168.11.41`**, la carte de
|
||
gestion, et sont maintenant sur `vlan11`. C'est ce que l'objection du 4 août visait, et ce
|
||
n'est vrai que depuis cette application.
|
||
|
||
`bgpd` et `bfdd` étaient à `no` sur les trois nœuds : FRR tournait avec `zebra` seul, donc
|
||
aucune session EVPN. Activés un nœud à la fois — `vishnu`, `gandalf`, `asgard` — avec
|
||
vérification du quorum et de la route par défaut après chacun. **Maillage complet : six
|
||
sessions établies**, 16 préfixes échangés.
|
||
|
||
Sauvegarde `/etc/frr/daemons.avant-bgpd` posée sur chaque nœud avant modification.
|
||
|
||
### Une alarme retirée
|
||
|
||
J'avais annoncé les VRF « inversés » — `vrf_t11` portant les sous-réseaux de Chezlepro. Le
|
||
noyau tranche : `10.21.16.0/24 is directly connected, t11fron` dans `vrf_t11`. Chaque VRF
|
||
porte bien ses propres sous-réseaux, par les passerelles anycast de ses VNets. Les
|
||
`ip route … null0` sont ceux des *autres* zones — et avec deux tenants, « les autres » et
|
||
« inversés » se ressemblaient exactement.
|
||
|
||
### Une brèche réelle, à filtrer avant la première VM
|
||
|
||
Une fois les nœuds de sortie actifs, une VM tenant atteint **tous les réseaux directement
|
||
connectés sur son propre hyperviseur** — gestion Proxmox, stockage iSCSI, Ceph, et le
|
||
transport VXLAN lui-même. Mesuré avant la suppression de la VM d'essai.
|
||
|
||
Ce n'est pas un défaut de configuration : c'est le prix du routage inter-VRF. Une route
|
||
connectée l'emporte sur la route par défaut, donc **déplacer celle-ci (D-57) n'y suffira
|
||
pas**. Il faut une règle « supernet tenant → réseaux de l'hyperviseur : DROP », que
|
||
`make devis-proxmox-fw` sait déjà dériver.
|
||
|
||
**Déclencheur : avant la première VM tenant.** À ce stade — plateforme en construction,
|
||
zéro locataire — c'est un chantier, pas un incident.
|
||
|
||
### La frontière : identifiant vérifié, nœud de sortie dérivé
|
||
|
||
La clé d'API fonctionne (OPNsense 26.1.2_5). L'API des règles donne elle-même la liste des
|
||
identifiants qu'elle accepte :
|
||
|
||
```
|
||
lan → « GESTION » opt1 → « TENANTS » wan → « WAN »
|
||
```
|
||
|
||
Ni le périphérique (`vlan040`), ni le libellé. **L'intrant `opnsense_if_transit: opt1`
|
||
était juste** — la question ouverte depuis deux jours est tranchée par la mesure.
|
||
|
||
Le prochain saut des routes tenants ne s'écrit plus `<NOEUD-DE-SORTIE-EVPN>` : il dérive.
|
||
Deux déclarations doivent concorder, et c'est voulu — l'hébergeur **nomme** le nœud
|
||
(`proxmox_sdn.sortie_primaire`, propriété du cluster), l'underlay dit **son adresse sur le
|
||
lien de frontière**. Nommer un nœud absent du lien rend le devis muet plutôt que faux.
|
||
|
||
```
|
||
route add 10.21.0.0/16 via 10.0.4.41
|
||
route add 10.27.0.0/16 via 10.0.4.41
|
||
```
|
||
|
||
Le devis explique pourquoi cette adresse-là, et pourquoi **un seul** saut : une route
|
||
statique n'en porte qu'un, et deux nœuds actifs en sortie avec une seule route en entrée
|
||
donneraient un chemin asymétrique — la réponse reviendrait par une interface où l'état
|
||
n'a pas été créé.
|
||
|
||
30 preuves OK.
|
||
|
||
## 2026-08-04 (suite 4) — le numéro est l'adresse
|
||
|
||
Les deux commutateurs deviennent **`bifrost-3`** et **`bifrost-4`**, avec leurs adresses
|
||
`10.0.0.3` et `10.0.0.4`. Tout l'ensemble de bordure porte désormais un seul nom, et
|
||
**N est son dernier octet** :
|
||
|
||
```
|
||
bifrost-1 frontiere 10.0.0.1 · 10.0.4.1 ← passerelle
|
||
bifrost-2 frontiere 10.0.0.2 · 10.0.4.2
|
||
bifrost-3 switch 10.0.0.3 ← racine du spanning-tree
|
||
bifrost-4 switch 10.0.0.4
|
||
```
|
||
|
||
Plus de table de correspondance : `bifrost-4`, c'est `.4`.
|
||
|
||
**D-12 est renversée.** Elle disait « `bifrost` aux frontières, `sleipnir` à la fabric » —
|
||
le nom portait le type de la machine. Or c'est le champ **`role`** qui le fait, et lui
|
||
seul pilote le devis : aucune logique ne dépendait du nom, seulement des données et un
|
||
commentaire. Ce qui survit de D-12, c'est l'idée qu'un nom doit se retenir — elle passe
|
||
maintenant par le numéro.
|
||
|
||
Le devis a suivi seul, jusqu'aux marqueurs de ports (`<PORT-VERS-BIFROST-4>`).
|
||
|
||
Inconvénient assumé : `bifrost-3` ne dit plus « commutateur ». Il faut lire `role`. En
|
||
pratique le devis s'en charge — sa partie B s'intitule « SWITCHES D'ACCÈS (L2 pur) :
|
||
bifrost-4 ».
|
||
|
||
30 preuves OK.
|
||
|
||
## 2026-08-04 (suite 3) — le VNet d'une VM se dérive, et un hyperviseur a plusieurs pattes
|
||
|
||
### Le pont n'était pas seulement non portable : il était faux
|
||
|
||
`proxmox_clone_pont` faisait naître les VM sur `vmbr1` **avec une étiquette VLAN** —
|
||
l'ancien monde. En SDN, une VM appartient à son **VNet**. C'est ce qu'il a fallu corriger
|
||
à la main sur `infra-pki-01`, et **les treize suivantes auraient suivi**.
|
||
|
||
Le VNet est dérivable : `index` + zone de sécurité → `t17serv`, comme le VMID et l'adresse
|
||
le sont déjà. `deriver_nomenclature()` expose désormais la zone, `instancier` émet
|
||
`proxmox_pont` **et une étiquette vide** — le VNet porte déjà le tag, en poser un second
|
||
donnerait un double étiquetage.
|
||
|
||
```
|
||
SETOPS_PONT='t11appl'
|
||
SETOPS_VLAN=''
|
||
```
|
||
|
||
Trois pièges en chemin. Un **doublon dans le Makefile** passait `PONT_PROXMOX` deux fois
|
||
dans la même cible : la seconde, vide, aurait écrasé la valeur dérivée. Un **repli naïf**
|
||
sur `proxmox_vlan` aurait fait revenir l'étiquette en SDN — le repli ne s'applique que si
|
||
la clé est **absente**, jamais si elle est présente et vide : c'est la différence entre
|
||
« on ne sait pas » et « on a décidé qu'il n'y en a pas ». Et le **test unitaire est tombé**,
|
||
à raison ; il couvre maintenant cette distinction.
|
||
|
||
### La vocation du dépôt réseau (D-55)
|
||
|
||
Il porte le **contrat entre l'Alliance et ses hébergeurs** : si chacun présente la même
|
||
interface, un tenant se déplace sans rien changer chez lui. Il abstrait le matériel en
|
||
encapsulant chaque tenant dans sa zone EVPN — le VRF borne ce qu'il a le droit de
|
||
connaître.
|
||
|
||
Mesuré : **un tenant est à deux valeurs de la portabilité complète** (`noeud`,
|
||
`stockage`). Aucun ne nomme un commutateur, un VLAN, une adresse d'underlay ni une zone.
|
||
|
||
### Un hyperviseur a plusieurs pattes (D-57)
|
||
|
||
Les hyperviseurs reçoivent `10.0.0.41/.43/.47` sur `vmbr0` — interface **sysadmin**,
|
||
**sans route par défaut**. On ne l'atteint que depuis le même domaine de diffusion : un
|
||
accès distant doit être ouvert explicitement à la frontière, il ne peut pas exister par
|
||
accident.
|
||
|
||
Leur **route par défaut passe sur `vlan40`**, vers l'OPNsense. Ça tranche la question
|
||
laissée ouverte depuis ce matin — option A, mais sur une interface dédiée, ce qui lève
|
||
l'objection qui la bloquait : le trafic tenant ne touche plus la carte d'administration.
|
||
|
||
Le modèle ne savait pas exprimer deux interfaces sur un même hôte : ajouter le VLAN 10 l'a
|
||
mis sur le trunk de `bond3`, remettant la gestion dans le domaine qu'on venait d'en
|
||
sortir. D'où le champ **`via`**, dont le devis dérive **un port par interface** et **son
|
||
type** :
|
||
|
||
```
|
||
<PORT-VERS-PROXMOX-BOND3> trunk 11,40
|
||
<PORT-VERS-PROXMOX-VMBR0> access 10
|
||
```
|
||
|
||
Un seul VLAN sur une interface = port d'accès ; plusieurs = trunk. Dérivé, pas déclaré.
|
||
|
||
Régression créée puis corrigée au passage : le modèle public, qui ne déclare aucun
|
||
hyperviseur, n'émettait **plus rien** pour ce port. Un devis muet ferait croire qu'il n'y
|
||
a rien à configurer. Il émet désormais tout l'underlay, en disant que c'est un repli.
|
||
|
||
### Côté cluster
|
||
|
||
`vmbr3` retiré des trois nœuds, `vlan11` et `vlan40` créées sur `bond3` aux bonnes
|
||
adresses. Les pairs du contrôleur EVPN pointaient encore sur `10.0.0.x` — **des adresses
|
||
qui n'existaient plus**. Corrigés vers `10.0.5.x`, **dérivés d'`underlay.yml`** plutôt que
|
||
retapés : une liste saisie à la main diverge au premier changement, ce qui venait
|
||
précisément d'arriver. Confronté à l'API : les trois pairs correspondent à une `vlan11`
|
||
réelle.
|
||
|
||
Pas encore câblé, donc pas encore appliqué. `infra-pki-01` n'a rien senti.
|
||
|
||
30 preuves OK.
|
||
|
||
## 2026-08-04 (suite 2) — l'invariant du dernier octet retrouve sa portée
|
||
|
||
Il valait partout ; il ne vaut que là où il a un sens : l'**adressage dérivé des
|
||
tenants**, où `passerelle_de(index, zone)` produit le même `.1` dans les treize
|
||
sous-réseaux. C'est une propriété de la dérivation, pas une loi universelle.
|
||
|
||
Dans l'underlay, il produisait deux effets pervers :
|
||
|
||
- **un seuil arbitraire** — les sous-réseaux plus étroits qu'un `/24` étaient exemptés,
|
||
donc élargir un `/29` changeait la validité du fichier sans que rien d'autre bouge ;
|
||
- **une couture entre propriétaires** — l'octet attendu venait de la nomenclature d'un
|
||
*tenant*, appliquée à la fabric de l'*hébergeur*. La validité de l'underlay aurait
|
||
dépendu du tenant actif si l'un d'eux réservait autre chose.
|
||
|
||
Ce qui reste est plus fort, et suffit : **une passerelle doit être l'adresse d'un hôte
|
||
déclaré sur ce réseau** (D-52). Elle attrape les passerelles fantômes, ce que le comptage
|
||
d'octets ne faisait pas.
|
||
|
||
> **À noter, parce que l'ordre était mauvais.** Le ré-adressage de l'OPNsense en `.1` a
|
||
> été demandé au nom de cette règle, deux messages avant qu'elle soit recadrée. Il n'est
|
||
> pas perdu — `.1` est la position conventionnelle d'une passerelle, et la frontière la
|
||
> porte désormais partout — mais la portée de la règle aurait dû être questionnée avant
|
||
> de faire changer une adresse sur un boîtier en service.
|
||
|
||
### Deux décisions consignées, non construites
|
||
|
||
**D-53** — le **réseau et l'underlay** de l'hébergeur méritent leur propre dépôt.
|
||
`underlay.yml` décrit une **infrastructure** ; le dépôt de tenant décrit une
|
||
**organisation**. Un tenant peut déménager, une fabric non. Les mêler oblige, à chaque
|
||
commit, à trancher ce qu'on touche. Le symlink désignant l'hébergeur pointerait vers ce
|
||
dépôt-là — la distinction deviendrait visible dans les chemins.
|
||
|
||
**D-54** — `10.0.0.0/24` est réservé à l'**IPAM, la gestion des équipements et l'OOB** ;
|
||
accès sysadmin. Aucun hyperviseur, aucune VM, aucun trafic tenant — ni encapsulé, ni
|
||
décapsulé. C'est la raison d'être des VLAN 11 et 40. Écrit dans `underlay.yml` à côté du
|
||
réseau lui-même, pas seulement dans la documentation.
|
||
|
||
30 preuves OK.
|
||
|
||
## 2026-08-04 (suite) — plus aucun commutateur ne route
|
||
|
||
Question posée à froid : « je ne vois plus de valeur ajoutée au point de routage
|
||
`sleipnir-01` ». Vérification faite, aucun de ses trois SVI n'avait de consommateur.
|
||
|
||
| SVI | Membres du VLAN | Qui a besoin de routage |
|
||
|---|---|---|
|
||
| `Vlan11` | les trois VTEP, tous en `10.0.5.0/24` | personne — même sous-réseau |
|
||
| `Vlan40` | frontières et nœuds de sortie, tous en `10.0.4.0/24` | personne — adjacents |
|
||
| `Vlan10` | les commutateurs | eux-mêmes, pour leur sortie |
|
||
|
||
Et l'OPNsense a déjà une patte sur le VLAN 10 : les commutateurs peuvent l'utiliser
|
||
directement.
|
||
|
||
**Deux décisions séparées avaient vidé ce rôle sans qu'on regarde leur effet cumulé.**
|
||
Le passage à l'EVPN a retiré les VLAN tenants du fil ; la fusion du lien de sortie dans
|
||
le VLAN 40 a rendu les nœuds de sortie adjacents à la frontière. Chacune était justifiée
|
||
seule.
|
||
|
||
### `sleipnir-01` disparaît
|
||
|
||
Pas seulement son rôle L3 : la machine. En étoile, le centre est sur **tous** les
|
||
chemins — un point de panne unique pour le plan de données entier. Ça vidait aussi de son
|
||
sens l'ajout d'une seconde carte à `bond3` : deux liens qui aboutissent au même
|
||
commutateur protègent d'un câble, pas d'un équipement.
|
||
|
||
Deux commutateurs L2 reliés entre eux, avec les `bond3` répartis, donnent la redondance
|
||
qu'une étoile ne peut pas donner. **D-51**, et **D-05 est renversée**.
|
||
|
||
### Ce que le devis perd
|
||
|
||
Trois SVI, quatre routes statiques, et surtout **la section 5** — celle qui portait
|
||
*« à appliquer en dernier ; ces routes coupent l'accès d'administration au switch
|
||
lui-même »*. La manœuvre la plus risquée du devis n'existe plus. Le devis frontière
|
||
annonce désormais « prérequis réciproques : AUCUN ».
|
||
|
||
### `passerelle` change de sens (D-50)
|
||
|
||
Elle signifiait « l'adresse du SVI du commutateur » — une hypothèse déguisée en donnée.
|
||
Elle signifie maintenant **« la passerelle de ce sous-réseau, où qu'elle vive »**, et le
|
||
devis **dérive** s'il doit émettre une interface routée : uniquement si le porteur
|
||
déclaré a le rôle `switch`.
|
||
|
||
Le même moteur sert donc les deux postures. Le modèle public démontre celle où le
|
||
commutateur route ; Chezlepro celle où la frontière route.
|
||
|
||
### Deux gardes remplacées, pas affaiblies
|
||
|
||
`passerelle_sortie exige aussi passerelle` et `routeur.ip == passerelle` encodaient
|
||
l'ancienne hypothèse et refusaient la seule configuration correcte. À leur place, une
|
||
règle **plus forte** : une passerelle doit être **l'adresse d'un hôte déclaré sur ce
|
||
réseau**. Elle attrape en plus les passerelles fantômes — une adresse inventée, un octet
|
||
de trop. Éprouvée par trois sabotages, les trois attrapés.
|
||
|
||
Elle a immédiatement trouvé une sous-déclaration dans le **modèle public** : il annonçait
|
||
un SVI de transit à `10.0.4.6` sans dire qui le porte.
|
||
|
||
### Trois trous trouvés en chemin
|
||
|
||
Tous de la même famille — **une liste figée finit toujours par mentir** :
|
||
|
||
- le port vers la frontière était figé sur le transit, muet dès que les `bifrost` ont eu
|
||
une seconde patte ;
|
||
- le trunk vers Proxmox **excluait** le transit « parce qu'aucun hyperviseur n'y est » ;
|
||
- les switches d'accès **sautaient** le VLAN de transit à la déclaration.
|
||
|
||
Et un quatrième, créé par la suppression du SVI : **le commutateur de tête n'avait plus
|
||
d'adresse de gestion**. Tant qu'il portait le SVI, son adresse *était* la passerelle ;
|
||
sans SVI, elle n'était plus émise nulle part — visible seulement en commentaire.
|
||
|
||
### Adressage résultant
|
||
|
||
```
|
||
VLAN 10 bifrost-1 .1 (passerelle) bifrost-2 .2 sleipnir-02 .3 sleipnir-03 .4
|
||
VLAN 11 asgard .41 gandalf .43 vishnu .47 aucune passerelle
|
||
VLAN 40 bifrost-1 .1 (sortie) bifrost-2 .2 asgard .41 gandalf .43
|
||
```
|
||
|
||
La frontière porte `.1` partout : l'invariant du dernier octet (D-04) est enfin vrai pour
|
||
le seul routeur. `opnsense_api_url` passe de `.254` à `.1` — **le boîtier doit suivre**,
|
||
mais rien dans Set-OPS n'appelle son API aujourd'hui, donc aucune automatisation ne casse
|
||
en attendant.
|
||
|
||
**D-03 est renversée** : le `/29` élargi en `/24` fait tomber l'exemption d'invariant, et
|
||
le `.1` revient à la passerelle. Le plan survit, décalé d'un cran.
|
||
|
||
### Question ouverte
|
||
|
||
L'octet attendu vient de `plan/nomenclature.yml` d'un **tenant**, appliqué à l'underlay de
|
||
l'**hébergeur**. Deux propriétaires, une seule valeur : la validité de la fabric
|
||
dépendrait du tenant actif si l'un d'eux réservait autre chose. Non corrigé.
|
||
|
||
30 preuves OK.
|
||
|
||
## 2026-08-04 — séparation des plans, et où vont les services de l'hébergeur
|
||
|
||
### Deux VLAN pour séparer ce qui était mêlé
|
||
|
||
Le VTEP vivait sur le VLAN 10, donc le transport tenant partageait son domaine de
|
||
diffusion avec l'administration des **équipements** (SVI du commutateur, OPNsense).
|
||
Plus grave : un nœud de sortie décapsule le trafic tenant et le remet dans sa table
|
||
principale — dont la route par défaut sort par `vmbr0`, **l'interface de gestion des
|
||
nœuds**. Le trafic des VM aurait emprunté le lien physique de l'interface web, de SSH
|
||
et du cluster.
|
||
|
||
Ça défaisait ce que l'EVPN devait obtenir. `underlay.yml` déclare donc :
|
||
|
||
```
|
||
vlan 11 underlay-vxlan 10.0.5.0/24 SVI 10.0.5.1 transport VXLAN
|
||
vlan 41 sortie-tenant 10.0.6.0/24 aucun SVI trafic décapsulé
|
||
```
|
||
|
||
`sortie-tenant` n'a **volontairement pas de passerelle** : le commutateur le transporte
|
||
sans le router, donc le trafic tenant en clair ne traverse aucun SVI de gestion. Le
|
||
devis n'émet d'ailleurs pas d'`interface Vlan41` — le modèle a exprimé l'intention sans
|
||
qu'on ait à la commenter.
|
||
|
||
Support prévu : `bond3` une fois doublé — `enp7s0` est libre sur les trois nœuds, et le
|
||
bond est déjà en `active-backup`, le mode qui convient sans MLAG. Aujourd'hui `bond3`
|
||
n'a **qu'une carte** : tout le trafic tenant, intra-zone compris, repose sur `enp8s0`.
|
||
|
||
### Un défaut que ce changement a créé, et corrigé
|
||
|
||
Le port du commutateur vers la frontière était figé sur le **seul** VLAN de transit. Les
|
||
`bifrost` ayant désormais une patte sur le 41, ce port serait resté muet : le devis
|
||
aurait eu l'air juste et le trafic ne serait jamais arrivé. Il **dérive maintenant des
|
||
rattachements déclarés** des hôtes `role: frontiere` → `allowed vlan 40,41`.
|
||
|
||
### Vérification de la construction parallèle
|
||
|
||
Le cluster actuel ne correspond pas à ce qui est planifié, et c'est voulu : on construit
|
||
à côté. Encore faut-il qu'il n'y ait pas de collision. Mesuré sur les 38 VM héritées :
|
||
étiquettes `7, 8, 9, 10, 12, 13, 14, 15, 1001, 1003` — aucune ne croise les VNI projetés
|
||
(`1111‑1116`, `1171‑1176`) ni les VLAN d'underlay.
|
||
|
||
Deux points relevés : le VLAN 10 porte 4 VM héritées (il n'est donc pas purement de la
|
||
gestion), et `infra-pki-01` est encore branchée **à l'ancienne** — `vmbr3` + étiquette
|
||
`1174` — pas sur le VNet `t17serv`. À rebrancher à la bascule.
|
||
|
||
### Où vont les services de l'hébergeur (D-46 à D-48)
|
||
|
||
Constat : **aucun équipement de l'hébergeur n'est dans un inventaire Ansible**, et rien
|
||
ne sauvegarde leurs configurations. Ni les hyperviseurs, ni les commutateurs, ni la
|
||
frontière.
|
||
|
||
Un hébergeur porte **trois** catégories : son **tenant** (son courriel, sa forge — un
|
||
client comme les autres), ses **opérations** (supervision de la fabric, journaux,
|
||
sauvegarde des configs, DNS d'underlay), et le **plan de contrôle** (déjà dehors).
|
||
|
||
Les opérations vivent dans **le dépôt de l'hébergeur**, et leurs VM se rattachent à un
|
||
**pont VLAN, jamais un VNet** : un service qui observe la fabric ne peut pas dépendre
|
||
d'elle. Et la doctrine « hors flotte » ne vaut que pour les commutateurs et la
|
||
frontière — les hyperviseurs sont des Debian joignables en SSH.
|
||
|
||
**Décision consignée, rien n'est construit.** `docs/hebergeur-exploitation.md`.
|
||
|
||
### Question laissée ouverte
|
||
|
||
L'index `0` réservé au tenant propre de chaque hébergeur — local par construction, donc
|
||
jamais à coordonner. Deux obstacles mesurés : il produit les VNI `1001`–`1006`, que le
|
||
parc hérité utilise déjà (`1001` TechnoLibre historique, `1003` KBR) ; et P21
|
||
déclencherait une fausse collision entre deux dépôts d'hébergeurs. En attente.
|
||
|
||
## 2026-08-03 (suite 17) — le plan de données existe (`make devis-sdn`)
|
||
|
||
Ajouter un tenant n'ajoute pas qu'un plan : cela implique **1 zone EVPN + 6 VNets +
|
||
6 sous-réseaux** sur le cluster. Aucun générateur ne les produisait — c'était la
|
||
dernière lacune dans un dépôt où tout dérive du seed.
|
||
|
||
`make devis-sdn` les émet, tenant par tenant. **26 objets** pour les deux tenants,
|
||
tous dérivés : le VNI est `1000 + index×10 + zone`, le sous-réseau et la passerelle
|
||
viennent des mêmes fonctions que l'inventaire.
|
||
|
||
### Le nommage, en deux temps
|
||
|
||
Première version : `VRF0017` / `v1174`, alignés sur ce que le cluster portait déjà.
|
||
C'était le réflexe inverse du bon — cette convention venait d'une création à la main,
|
||
ne disait pas de quel tenant il s'agissait, et `chez174` demandait d'ouvrir la table
|
||
des catégories pour être lu.
|
||
|
||
Forme retenue : **`t<index>`** pour la zone, **`t<index><zone abrégée>`** pour le
|
||
VNet — `t17`, `t17serv`, `t17obse`. C'est le préfixe que le **pare-feu Proxmox
|
||
utilisait déjà** (`t17-cli-metrique`, `t17-flotte`) : un seul schéma se lit dans tout
|
||
le dépôt. L'abréviation vient des 4 premières lettres du libellé, accents retirés,
|
||
donc dérivée.
|
||
|
||
Pas de tiret entre l'index et la zone, contrairement aux IPSets : `t245-serv` ferait
|
||
9 caractères. Sans séparateur, `t245serv` en fait 8 — la forme reste **uniforme
|
||
jusqu'au dernier index de la fédération**.
|
||
|
||
### La contrainte qui a tout cadré
|
||
|
||
Zones et VNets sont **limités à 8 caractères** par Proxmox — l'identifiant sert de
|
||
base aux noms de bridge, veth et tap. Le tableau de `sdn-evpn.md` annonçait
|
||
`chez17-services-infra`, soit 21 : il aurait été **refusé à l'application**. **P30**
|
||
refuse désormais tout dépassement, sur les deux objets, et toute collision de nom, de
|
||
VNI ou de sous-réseau entre tenants.
|
||
|
||
Éprouvé aux bornes (`t1serv` 6, `t17serv` 7, `t245serv` 8) et par sabotage : deux
|
||
libellés partageant leurs 4 premières lettres produisent le même VNet, et la garde
|
||
l'attrape.
|
||
|
||
### Vérification la plus forte disponible
|
||
|
||
Avant renommage, la dérivation **reproduisait à l'identique** les deux zones déjà
|
||
créées à la main — nom, VNI de VRF, MTU, contrôleur. La dérivation retombait sur ce
|
||
qu'un humain avait posé.
|
||
|
||
### `voute.py saisir` : le pendant de la génération
|
||
|
||
On **génère** un secret dont le dépôt est la source ; on **saisit** celui dont un tiers
|
||
est la source. Inventer une clé d'API OPNsense donnerait une valeur syntaxiquement
|
||
correcte, refusée à la première requête — et P18 au vert sur une voûte inutilisable.
|
||
|
||
Saisie sans écho, double confirmation, rien sur la ligne de commande. Éprouvée sur une
|
||
voûte jetable : écrit, préserve l'existant, refuse d'écraser sans `--remplacer`.
|
||
|
||
### Ce qui reste ouvert
|
||
|
||
Aucune zone ne déclare de **nœud de sortie**, et le devis émet un marqueur plutôt
|
||
qu'une valeur plausible. Deux points à trancher avant :
|
||
|
||
- le nœud de sortie route selon **sa propre table** ; la passerelle par défaut des
|
||
trois hyperviseurs est `192.168.11.254`, pas la frontière ;
|
||
- l'**entrée n'est pas redondante** : elle dépend d'une route statique d'OPNsense vers
|
||
**un** nœud. Deux nœuds de sortie ne donnent aucune redondance entrante.
|
||
|
||
**D-43**, **D-44**, **D-45**, **AFF-112**. 30 preuves OK.
|
||
|
||
## 2026-08-03 (suite 16) — la directive d'authentification est gardée (P29)
|
||
|
||
Une règle qu'aucune garde ne vérifie finit par ne plus être vraie : c'est exactement ce
|
||
qui était arrivé aux 28 lignes d'intégration recopiées. Chaque rôle `serveur_*` porte
|
||
désormais un `meta/authentification.yml`, et **P29** le confronte à son code.
|
||
|
||
```
|
||
web-sso 5 grafana, forgejo, nextcloud, icingaweb2, oauth2-proxy
|
||
socle-identite 2 keycloak, openldap — ils SONT la chaîne d'identité
|
||
ldap-direct 2 dovecot, postfix
|
||
interne-sans-auth 2 prometheus, loki — lacunes nommées
|
||
sans-auth-humaine 12
|
||
```
|
||
|
||
### Elle refuse l'oubli **et** le mensonge
|
||
|
||
Éprouvée par sabotage, sept cas : déclaration supprimée, portée inventée, secours retiré,
|
||
posture de formulaire retirée, raison retirée, `ldap-direct` mensonger, réglage retiré des
|
||
`defaults`. Les sept sont attrapés.
|
||
|
||
Les deux derniers **passaient** dans ma première version.
|
||
|
||
**Le mensonge passait à cause d'un de mes propres commentaires.** Je cherchais le mot
|
||
« ldap » dans le rôle — or `serveur_grafana/defaults/main.yml` contient la phrase
|
||
« désactiver quelqu'un dans LDAP ». De la prose suffisait à valider une déclaration fausse.
|
||
La preuve exige maintenant un indice **nommé** : une variable du namespace du rôle
|
||
(`<rôle>_oidc`, `<rôle>_ldap`) ou une URI `ldap://`.
|
||
|
||
**Le réglage retiré passait** parce que le gabarit citait encore la variable alors que plus
|
||
rien ne lui donnait de valeur. La preuve lit désormais `defaults/main.yml` **en YAML** et
|
||
exige que la clé y soit *définie*, pas mentionnée.
|
||
|
||
### Une valeur que la preuve a forcé à inventer
|
||
|
||
Elle a d'abord échoué sur `oauth2-proxy` : je l'avais déclaré « formulaire local fermé »
|
||
alors qu'il n'a **aucun compte local** — c'est une passerelle. D'où `formulaire_local:
|
||
aucun`, qui distingue « il n'y en a jamais eu » de « il y en a un, il est fermé ». Écrire
|
||
« fermé » aurait laissé croire qu'une porte avait été close.
|
||
|
||
### Correction : Prometheus et Loki ne sont pas exposés
|
||
|
||
Contrairement à ce que la note de la veille affirmait, ils n'ont **aucun `expose` au plan**.
|
||
Seuls six groupes sont exposés : collabora, forgejo, grafana, keycloak, nextcloud,
|
||
oauth2-proxy. Le risque est intra-tenant, pas frontalier — plus petit qu'annoncé, réel
|
||
quand même.
|
||
|
||
Ces deux lacunes sont **comptées, pas masquées** : `interne-sans-auth` ne fait pas échouer
|
||
la preuve, mais figure dans chaque rapport. La refuser bloquerait le harnais sur une
|
||
décision déjà prise ; la taire la ferait oublier.
|
||
|
||
**AFF-111**, décision **D-42**. 29 preuves OK, 0 échec, 0 sautée.
|
||
|
||
## 2026-08-03 (suite 15) — la porte de secours cesse d'être annoncée
|
||
|
||
Client OIDC **Nextcloud** ajouté aux deux tenants — quatre clients chacun désormais, sur
|
||
le chemin que l'app `user_oidc` impose (`…/apps/user_oidc/code`). Le secret existait des
|
||
deux côtés depuis hier ; le client qui devait le porter manquait.
|
||
|
||
### La directive
|
||
|
||
Trois règles, indissociables parce que chacune crée le problème que la suivante résout :
|
||
toute authentification **web** passe par Keycloak ; **LDAP** est la source unique des
|
||
comptes ; chaque service garde un accès de secours par **`sudo`** sur l'hôte.
|
||
|
||
La chaîne `service → Keycloak → LDAP` est en série. Le secours n'est donc pas une entorse
|
||
au SSO : c'est ce qui rend les deux premières règles tenables. Portée : le web seulement —
|
||
IMAP et SMTP se lient à LDAP directement, SSH est en clé seule.
|
||
|
||
### La posture : `<rôle>_connexion_locale: false`
|
||
|
||
Le compte local **existe** — il ne peut pas dépendre de Keycloak, sinon il tomberait avec
|
||
lui — mais son formulaire n'est plus proposé au repos. Un formulaire ouvert en permanence
|
||
contourne la politique de mot de passe, le MFA, et surtout la **révocation centrale** :
|
||
désactiver quelqu'un dans LDAP laisserait son compte local valide, sans que rien ne le
|
||
signale.
|
||
|
||
Fermer ne coûte rien depuis qu'on a tranché que `sudo` suffit : `sudo` *est* le mécanisme
|
||
de réouverture.
|
||
|
||
### Ce que chaque service sait vraiment faire
|
||
|
||
Vérifié auprès de l'amont, puis par **rendu réel des gabarits dans les deux postures**.
|
||
|
||
| Service | Réglage | Effet réel |
|
||
|---|---|---|
|
||
| Grafana | `GF_AUTH_DISABLE_LOGIN_FORM` | ferme le formulaire |
|
||
| Forgejo ≥ 10 | `ENABLE_INTERNAL_SIGNIN` + `ENABLE_BASIC_AUTHENTICATION` | ferme la connexion interne **et** l'API en Basic |
|
||
| Nextcloud | `hide_login_form` | **masque seulement** |
|
||
|
||
**Nextcloud est une exception, écrite comme telle.** `…/login?direct=1` atteint encore le
|
||
formulaire, et l'amont le documente comme voulu — c'est ainsi qu'un administrateur entre.
|
||
La protection est de ne plus l'annoncer, pas d'interdire. Le présenter comme équivalent
|
||
donnerait un faux confort.
|
||
|
||
**Forgejo tombe juste.** `ENABLE_INTERNAL_SIGNIN` n'existe que depuis la v10 (ticket amont
|
||
7476, clos : « This option was added to Forgejo v10 ») et le rôle épingle `10.0.0`. Sur une
|
||
version antérieure il serait ignoré **sans erreur** : un `assert` refuse la fermeture sous
|
||
10.0.0 plutôt que de laisser le rôle croire qu'il a fermé la porte.
|
||
|
||
### Le défaut que le rendu a attrapé
|
||
|
||
Ma première version testait `{% if not serveur_forgejo_connexion_locale %}` sans `| bool`.
|
||
En rendu réel, **aucune des deux postures n'émettait quoi que ce soit** : une valeur
|
||
transmise en chaîne (`-e`, ou un `group_vars` non typé) est vraie au sens Jinja, donc le
|
||
bloc ne sortait jamais — la connexion locale serait restée ouverte en silence. C'est
|
||
exactement le genre de panne que lire le gabarit ne révèle pas.
|
||
|
||
Décisions **D-38** à **D-41**, `docs/authentification.md`.
|
||
|
||
### Ce qui reste
|
||
|
||
Prometheus et Loki exposent une interface sans SSO ; `oauth2-proxy` est déjà éprouvé devant
|
||
Icinga Web 2. Et **aucune preuve ne garde cette directive** — même risque que les
|
||
intégrations universelles avant leur inversion.
|
||
|
||
## 2026-08-03 (suite 14) — les deux secrets Nextcloud, et un qui ne se génère pas
|
||
|
||
`vault_nextcloud_admin` et `vault_nextcloud_oidc` étaient exigés par le plan et absents
|
||
de la voûte réelle de Technolibre. Générés (32 octets `urlsafe`), en mémoire, avec
|
||
relecture et aller-retour de chiffrement vérifiés avant écriture ; jamais affichés.
|
||
L'opération est **idempotente** : une clé déjà renseignée n'est pas touchée, et une clé
|
||
présente mais vide compte comme absente — c'est le cas du gabarit recopié.
|
||
|
||
Générer était légitime ici parce qu'**Ansible configure les deux côtés depuis la même
|
||
variable** : le client Keycloak déclare `secret: "{{ vault_nextcloud_oidc }}"` et le rôle
|
||
Nextcloud lit la même clé. La valeur n'a pas à préexister ailleurs.
|
||
|
||
Harnais complet, voûte lisible : **28 preuves OK, 0 échec, 0 sautée**.
|
||
|
||
### Le contre-exemple, trouvé chez Chezlepro
|
||
|
||
La même vérification y signale `vault_opnsense_api_key` et `vault_opnsense_api_secret`
|
||
absents. **Il ne faut surtout pas les générer.** OPNsense est hors flotte : ses
|
||
identifiants d'API sont émis par le boîtier (System > Access > Users > API keys), et le
|
||
secret n'est affiché qu'à la création. Une valeur inventée serait syntaxiquement
|
||
correcte et refusée à la première requête.
|
||
|
||
La distinction vaut d'être retenue : on génère un secret dont **le dépôt est la source**,
|
||
jamais un secret dont **un tiers est la source**. Le boîtier n'étant pas encore installé,
|
||
la question ne se pose pas avant sa mise en service.
|
||
|
||
### Une lacune connexe, non corrigée
|
||
|
||
Aucun des deux tenants ne déclare de **client OIDC Nextcloud** dans
|
||
`group_vars/serveur_keycloak.yml` — seulement grafana, forgejo et icingaweb2. Le secret
|
||
existe donc désormais des deux côtés, mais le client qui doit le porter n'est pas
|
||
déclaré. À ajouter avant tout déploiement de `collab-01`, sur le patron des trois autres.
|
||
|
||
## 2026-08-03 (suite 13) — le cluster répond, et il contredit trois hypothèses
|
||
|
||
Reconnaissance **en lecture seule** de l'API Proxmox, avec le jeton de la voûte. Trois
|
||
valeurs que j'avais devinées étaient fausses, et deux défauts bloquants sont apparus.
|
||
|
||
### Ce que le cluster a corrigé
|
||
|
||
**Stockages** : `truenas-dbsql` manquait à ma liste. Et le catalogue ne doit offrir que
|
||
ceux qui portent `images` — `PBS`, `cephFS`, `local` et `truenas` (iSCSI brut,
|
||
`content=none`) n'accueillent pas de disque de VM.
|
||
|
||
**Ponts** : `vmbr0` à `vmbr3`, vérifiés présents sur les **trois** nœuds. J'avais omis
|
||
`vmbr0` et je n'avais pas contrôlé l'uniformité — un pont partiel est un piège, la VM ne
|
||
démarre que sur certains nœuds.
|
||
|
||
**Un troisième homonyme** : `web-frontal-01` (vmid 911401) existe déjà hors Set-OPS, sans
|
||
pool. Les deux tenants en planifient un chacun.
|
||
|
||
### Les pools sont créés
|
||
|
||
`Chezlepro-17` et `Technolibre-11`, dérivés comme le reste. Les pools `Env.Tenant`
|
||
antérieurs (`Prod.Chezlepro`, `Lab.KBR`…) sont **l'ancien monde : on n'y touche pas**, et
|
||
on n'y verse pas la flotte générée — les mélanger effacerait la frontière que Set-OPS
|
||
établit.
|
||
|
||
Diff constaté sur le cluster : 2 pools ajoutés, 0 retiré, **1 VM sur 38** déplacée —
|
||
`infra-pki-01`, qui n'appartenait à aucun pool.
|
||
|
||
### Défaut bloquant : `make creer-vm` aurait échoué en 401
|
||
|
||
`proxmoxer` recompose `utilisateur!nom` à partir d'`api_user` et d'`api_token_id`. La
|
||
voûte stocke la forme complète, que les playbooks passaient telle quelle — d'où
|
||
`ansible@pve!ansible@pve!nom` et un **401 muet**, alors que le même jeton fonctionne en
|
||
`curl`. Le diagnostic aurait coûté cher.
|
||
|
||
Mesuré des deux côtés avec un module en lecture seule : forme complète = 401, forme
|
||
courte = OK, 3 nœuds. Les playbooks normalisent désormais (`split('!') | last`), ce qui
|
||
accepte les deux écritures.
|
||
|
||
### Le reliquat `proxmox.vault.yml` est supprimé
|
||
|
||
Toléré « en compatibilité », il restait le **seul** porteur du jeton chez Technolibre. Et
|
||
comme `*.vault.yml` est gitignoré, ce jeton ne voyageait avec aucun dépôt : une voûte
|
||
unique (D-19) qui ne l'était pas.
|
||
|
||
Migration faite **en mémoire** — aucune valeur en clair sur disque ni affichée — avec
|
||
relecture et aller-retour de chiffrement vérifiés avant écriture. Puis suppression du
|
||
fichier et retrait des listes de chargement des deux playbooks. Validé par un appel API
|
||
réel ne chargeant que `all/vault.yml`.
|
||
|
||
### Et la garde qui manquait
|
||
|
||
`voute.py verifier` ne comparait que le **gabarit**. C'est pourquoi il annonçait
|
||
« complet » pendant qu'un secret vivait ailleurs : le gabarit dit ce qu'il *faudrait*, pas
|
||
ce qui *est*.
|
||
|
||
Il contrôle maintenant aussi la voûte **réelle**, quand `ANSIBLE_VAULT_PASSWORD_FILE` la
|
||
rend déchiffrable — noms de clés seulement, jamais de valeur. Sans mot de passe, la
|
||
vérification se **saute** : le harnais reste utilisable sans accès aux secrets.
|
||
|
||
Dès son premier passage, elle a trouvé un second trou : la voûte réelle de Technolibre
|
||
n'a ni `vault_nextcloud_admin` ni `vault_nextcloud_oidc`, que le plan exige depuis
|
||
l'arrivée de Nextcloud. Le déploiement aurait cassé sur une variable indéfinie. **Non
|
||
corrigé** : générer ces deux secrets est une décision, et le secret OIDC doit
|
||
correspondre à ce que Keycloak connaîtra.
|
||
|
||
## 2026-08-03 (suite 12) — un pool Proxmox par tenant
|
||
|
||
Onze des quatorze serveurs portent le **même nom court** chez Chezlepro et chez
|
||
Technolibre : `infra-pki-01`, `backup-01`, `obs-01`…
|
||
|
||
Vérifié un par un, ce n'est **pas** un problème technique. Tout le reste dérive du seed
|
||
et diverge : `10.27.19.21` contre `10.21.19.21`, VMID `117402101` contre `111402101`,
|
||
VLAN 1174 contre 1114, et deux domaines internes distincts. Et rien n'est indexé sur le
|
||
nom court — toutes les opérations Proxmox portent un `vmid` (le `name:` n'est qu'une
|
||
étiquette), les certificats un FQDN, et `client_backup_repo` vise
|
||
`backup-01.{{ domaine_interne }}`, donc le serveur du tenant.
|
||
|
||
Le coût est **humain**, et il est réel : la console Proxmox affiche le nom. Deux
|
||
`infra-pki-01` y sont indiscernables à l'œil, et c'est ainsi qu'on éteint la mauvaise
|
||
machine. Le VMID porte pourtant le tenant — encore faut-il connaître le codage.
|
||
|
||
### Ce qui a été fait
|
||
|
||
Un pool par tenant, **dérivé** : dossier d'instance + `index` → `Chezlepro-17`,
|
||
`Technolibre-11`. L'`index` étant déjà unique par P21, le nom l'est aussi — aucun
|
||
registre de plus.
|
||
|
||
`make devis-proxmox-pools` produit le rattrapage de la flotte existante (création du
|
||
pool, puis affectation des VM actives). Non destructif, à relire avant d'appliquer.
|
||
|
||
Les VM créées **ensuite** entrent d'elles-mêmes : `make creer-vm` dérive le pool par la
|
||
même fonction et le passe à la création. Le playbook crée le pool au préalable — deux
|
||
raisons : `proxmox_kvm` échoue sur un pool inconnu, et l'API **ne sait pas changer** le
|
||
pool d'une VM existante. C'est aussi pourquoi le rattrapage passe par les membres.
|
||
|
||
**P28** garde deux collisions : deux tenants ne peuvent pas revendiquer le même nom de
|
||
pool, ni le même VMID — une machine appartenant à deux tenants serait pire qu'une
|
||
homonymie. Décision **D-37**, affirmation **AFF-110**.
|
||
|
||
### Ce que je n'ai pas fait
|
||
|
||
**Renommer les VM par tenant.** Ça casserait ce que ces homonymes prouvent : même
|
||
fonction, même nom, partout — c'est ce qui rend un modèle réutilisable.
|
||
|
||
Le devis **ne lit pas le cluster** : il dit l'état cible, pas l'écart. Les commandes
|
||
sont idempotentes, donc rejouables sans risque, mais il ne saura pas dire ce qui est
|
||
déjà en place.
|
||
|
||
## 2026-08-03 (suite 11) — le cluster appartient à l'hébergeur
|
||
|
||
En ouvrant le panneau « Intrants de base », on trouvait côte à côte et sans distinction
|
||
des valeurs du **tenant** (son domaine, son realm, son modèle) et des valeurs de
|
||
l'**hébergeur** (son cluster, sa frontière, sa fabric). Deux propriétaires, deux dépôts,
|
||
deux cycles de vie — et rien à l'écran ne le disait.
|
||
|
||
Trois sections sur sept étaient déjà chez l'hébergeur (Frontière, Fabric). **La section
|
||
Proxmox, elle, ne l'était pas** — alors qu'un cluster est du matériel possédé par
|
||
l'hébergeur au même titre que ses commutateurs.
|
||
|
||
### La recopie avait déjà divergé
|
||
|
||
Même cluster, deux inventaires contradictoires :
|
||
|
||
```
|
||
Chezlepro stockages [TrueNAS, CephHDD, CephNVMe] ponts [vmbr3]
|
||
Technolibre stockages [local-lvm, TrueNAS] ponts [vmbr1, vmbr2]
|
||
```
|
||
|
||
Rien ne « cassait » : ces listes ne peuplent que des menus déroulants. Mais un opérateur
|
||
plaçant une VM Technolibre ne se voyait jamais proposer `CephNVMe`, et ça n'était la
|
||
décision de personne. Le fichier de l'hébergeur prend l'**union** des deux — aucune n'était
|
||
complète, et choisir l'une aurait été arbitraire. **À confirmer contre le cluster réel.**
|
||
|
||
### Le partage retenu
|
||
|
||
**Hébergeur** (`<dépôt hébergeur>/proxmox-hebergeur.yml`, à côté d'`underlay.yml`) :
|
||
`proxmox_api_host`, `_user`, `_port`, `_validate_certs`, `proxmox_noeuds`, `_stockages`,
|
||
`_ponts`.
|
||
|
||
**Tenant** (`group_vars/proxmox.yml`) : son **golden template** — chaque tenant a le sien —
|
||
et ses **défauts de placement** (nœud, stockage, pont). Ce sont des choix faits *à
|
||
l'intérieur* de ce que l'hébergeur offre.
|
||
|
||
Le chemin se **dérive** du symlink `underlay.yml`, qui désigne déjà l'hébergeur : rien de
|
||
nouveau n'est déclaré (D-17 tenue). Sans underlay monté, tout retombe dans le fichier du
|
||
tenant — un dépôt autonome fonctionne exactement comme avant.
|
||
|
||
### Ce que ça a demandé de moins que prévu
|
||
|
||
Les playbooks chargent ces fichiers **par chemin explicite** (`include_vars`), pas par
|
||
appariement de groupe Ansible — il n'existe d'ailleurs aucun groupe `proxmox` dans
|
||
l'inventaire. Une tâche `stat` + `include_vars` de plus a suffi ; aucun symlink dans
|
||
`group_vars`, aucune génération.
|
||
|
||
Vérifié en exécution réelle : depuis Technolibre (tenant actif), la dérivation résout vers
|
||
`OPS-Chezlepro/proxmox-hebergeur.yml` et charge `asgard` + les quatre stockages.
|
||
|
||
### Le panneau nomme désormais le propriétaire
|
||
|
||
Chaque section porte une pastille **tenant** (bleu) ou **hébergeur** (ambre), avec en
|
||
infobulle ce que ça implique : éditer une section « hébergeur » vaut pour **tous** ses
|
||
tenants. Le schéma d'intrants porte un champ `proprietaire` — c'est la donnée qui manquait,
|
||
pas l'affichage.
|
||
|
||
**P27** garde la séparation : aucune clé de l'hébergeur ne peut réapparaître dans un
|
||
`group_vars` de tenant. Sans elle, le premier `make config` lancé d'un autre poste
|
||
recommençait la recopie. Décisions **D-35** et **D-36**, affirmation **AFF-109**.
|
||
|
||
### Une chose à trancher
|
||
|
||
`modeleChezlepro` reste déclaré comme golden template de **Technolibre**. Ta décision — un
|
||
modèle par tenant — le rend incorrect, mais le corriger suppose qu'un `modeleTechnolibre`
|
||
existe réellement sur le cluster. Laissé tel quel, signalé ici.
|
||
|
||
## 2026-08-03 (suite 10) — les intégrations universelles cessent d'être recopiées
|
||
|
||
Le plan portait **57 lignes d'intégration écrites à la main**. Le décompte est sans appel :
|
||
`client_metrique` 14/14, `client_journal` 14/14, `client_pki` 13/14 — mais `client_backup`
|
||
7/14, `client_smtp` 8/14, `client_unbound` 1/14.
|
||
|
||
**28 de ces 57 lignes disaient oui à quelque chose de vrai pour tout le monde.** Elles
|
||
n'existaient donc que pour être oubliées une vingt-neuvième fois — et elles l'avaient été :
|
||
dans Chezlepro, `backup-01` et `infra-pki-01` n'étaient **ni supervisés, ni journalisés, ni
|
||
certifiés**. Rien ne l'aurait signalé, puisqu'une machine non supervisée ne proteste pas.
|
||
|
||
### Ce qui change
|
||
|
||
Le **rôle** déclare sa politique, une fois, dans `roles/<role>/meta/integration.yml` :
|
||
|
||
```yaml
|
||
integration:
|
||
universelle: true
|
||
raison: "Tout hôte est mesuré. Une machine hors supervision tombe sans que personne ne l'apprenne."
|
||
sauf_role: serveur_step_ca # l'AC ne s'enrôle pas auprès d'elle-même
|
||
```
|
||
|
||
Le plan ne porte plus que les intégrations qui sont **un vrai choix** — sauvegarde, courriel,
|
||
résolveur. Et il **refuse** désormais une recopie : deux sources finiraient par diverger, et
|
||
surtout, l'absence de `client_metrique` en face d'un serveur se lirait « non supervisé » alors
|
||
qu'il l'est.
|
||
|
||
### L'exemption suit le service, pas le nom d'hôte
|
||
|
||
`sauf_role: serveur_step_ca` retire `client_pki` à l'hôte qui *rend* le service. Déplacez
|
||
step-ca sur une autre machine et l'exemption suit toute seule. Un nom d'hôte en dur, lui,
|
||
aurait laissé la nouvelle AC s'enrôler auprès d'elle-même et l'ancienne sans certificat.
|
||
|
||
### Une seule fonction de résolution
|
||
|
||
`inventory_rules.integrations_de()` est lue par les **trois** consommateurs — inventaire,
|
||
voûte et panneau. La voûte en particulier : sans elle, les secrets des intégrations
|
||
universelles auraient cessé d'être exigés et **P18 serait passé au vert sur une voûte
|
||
incomplète**.
|
||
|
||
### Vérification
|
||
|
||
Sur Technolibre, `make instancier` donne **diff vide** : la politique reproduit exactement ce
|
||
que les 41 lignes retirées produisaient. Sur Chezlepro, elle produit précisément les trois
|
||
groupes manquants sur `backup-01` et les deux sur `infra-pki-01` — et **pas** `client_pki` sur
|
||
ce dernier, l'exemption ayant joué. Le trou se referme, rien d'autre ne bouge.
|
||
|
||
**P26** garde les deux côtés : aucun hôte sans intégration universelle, aucune recopie dans le
|
||
plan. Décisions **D-33** et **D-34**.
|
||
|
||
### Ce que le panneau montre
|
||
|
||
Dans la fiche d'un serveur : les universelles en ✓ non décochables, les exemptions barrées,
|
||
chacune avec sa raison en infobulle. Sans cet affichage, un plan devenu silencieux se serait lu
|
||
comme une flotte non supervisée — l'inverse exact de la vérité.
|
||
|
||
### Et une vue **Intégrations** : la matrice
|
||
|
||
L'autre axe manquait, et c'est celui qui aurait servi. La fiche montre les intégrations **d'un
|
||
serveur** ; savoir qui n'a pas de sauvegarde demandait d'ouvrir les quatorze. Le trou de
|
||
Chezlepro n'a d'ailleurs **pas** été trouvé par le panneau — il est sorti du devis de pare-feu
|
||
Proxmox, qui énumère les rôles par hôte. L'information était à l'écran, répartie sur quatorze
|
||
clics, donc invisible.
|
||
|
||
La matrice serveurs × intégrations : colonnes ✓ vertes pour la politique, `—` barré pour les
|
||
exemptions, cases à cocher pour les facultatives — **éditables sur place**, en-têtes et colonne
|
||
de noms figées, clic sur un nom pour ouvrir sa fiche.
|
||
|
||
La ligne **couverture** affiche `n/N` sans juger : `7/14` sur `client_backup` peut être
|
||
exactement juste. Elle rend le motif visible ; décider s'il s'agit de choix ou d'oublis reste
|
||
au lecteur.
|
||
|
||
Sur Technolibre, elle affiche **41 ✓ et une exemption** — soit très exactement les 41 lignes
|
||
retirées du plan et le `client_pki` de l'AC.
|
||
|
||
## 2026-08-03 (suite 9) — le SSH inter-nœud était perdu
|
||
|
||
En éclatant les règles par rôle source, un défaut de ma première version est apparu : je
|
||
**sautais le flux entier** dès qu'un de ses pairs valait `externe`.
|
||
|
||
Or le SSH du socle est déclaré `pair: [flotte, externe]`. La moitié `externe` relève bien de
|
||
la frontière — mais la moitié **`flotte`**, le SSH entre hôtes, celui d'Ansible, était perdue.
|
||
Sous une politique `DROP`, **plus aucun hôte n'aurait été joignable en SSH depuis l'intérieur**.
|
||
Même piège pour le SMTP interne de Postfix, déclaré `[externe, client_smtp]`.
|
||
|
||
`externe` est désormais sauté **pair par pair**, jamais le flux entier. 36 groupes, 56 règles.
|
||
|
||
### Ajouté — ce qui n'a aucune règle entrante, et pourquoi
|
||
Onze rôles portés n'ont aucune règle entrante : sous `DROP`, ils sont injoignables. C'est
|
||
voulu dans les onze cas — la boucle locale pour Prometheus, Redis, rspamd, Icinga et Unbound,
|
||
la frontière seule pour nginx, aucun service pour `serveur_durci` et les clients.
|
||
|
||
Le devis les **nomme avec leur motif** au lieu de laisser un lecteur le vérifier lui-même. Et
|
||
un douzième motif existe, marqué `/!\` : « flux entrants déclarés mais aucune source résolue
|
||
ici » — celui-là serait un vrai trou.
|
||
|
||
## 2026-08-03 (suite 8) — le pare-feu Proxmox, troisième lecture du même registre
|
||
|
||
`make devis-proxmox-fw` (**preuve P25**). Le filtrage est-ouest intra-tenant est désormais
|
||
dérivé pour l'hyperviseur : **34 groupes de sécurité, 40 règles, 2 tenants** — depuis les
|
||
57 flux intra-tenant que le registre connaissait déjà.
|
||
|
||
Décidé : la défense est **en profondeur**, pas en remplacement. L'hyperviseur filtre, puis
|
||
l'hôte destinataire filtre à nouveau. Une VM compromise doit franchir les deux. Le coût de
|
||
maintenance est nul : les deux barrières lisent le registre **par les mêmes fonctions**
|
||
(`_resoudre_sources`, `_pairs`, `_hotes_du_groupe`) — la duplication est dans l'application,
|
||
jamais dans la décision.
|
||
|
||
Un IPSet par rôle porte les membres, les groupes de sécurité y renvoient : ajouter un hôte à
|
||
un rôle met à jour toutes les règles qui l'autorisent, en un seul endroit.
|
||
|
||
### La garde qui manquait à ma première version
|
||
Proxmox limite un nom de groupe à **18 caractères**. Ma première version tronquait sans
|
||
vérifier : deux rôles tronqués au même nom auraient **fusionné leurs règles**, donnant à une VM
|
||
les autorisations d'un rôle qu'elle ne porte pas — silencieusement.
|
||
|
||
Le préfixe porte maintenant l'**index** (`t17-`) plutôt que l'étiquette (`chez17-`), ce qui
|
||
rend la troncature bien plus rare, et une garde **échoue** sur toute collision plutôt que
|
||
d'émettre un devis pareil. Exercée.
|
||
|
||
### Unifié — un seul schéma de nommage dans le devis
|
||
Les IPSets portaient l'étiquette longue (`chez17_serveur_postgresql`), les groupes l'index
|
||
court (`t17-srv-postgresql`) : deux conventions à lire dans un même document. Tout porte
|
||
désormais le préfixe `t<index>-` et la même forme abrégée de rôle.
|
||
|
||
La troncature reste **propre à chaque objet** : Proxmox est large sur les IPSets, étroit
|
||
(18 caractères) sur les groupes. Un nom peut donc être entier d'un côté et abrégé de l'autre —
|
||
chacun respecte sa contrainte, et le préfixe reste commun.
|
||
|
||
Vérifié : aucune collision d'IPSet, et **tout renvoi `+X` d'une règle pointe vers un IPSet qui
|
||
existe** — 58 IPSets, 34 groupes, aucun orphelin.
|
||
|
||
### Corrigé — des listes d'adresses en dur, et 48 IPSets inutilisés
|
||
Six règles par tenant portaient **quatorze adresses en dur** : les mots-clés `flotte` et
|
||
`edge` n'avaient pas droit à un IPSet, seuls les rôles en avaient. `flotte` en reçoit un
|
||
désormais, et `edge` renvoie à celui de nginx. **36 des 40 règles** se lisent maintenant
|
||
`-source +t17-…`.
|
||
|
||
Et le devis listait **28 à 30 IPSets par tenant dont la moitié n'était référencée nulle
|
||
part** : un opérateur en aurait créé 58 pour n'en utiliser qu'une douzaine. Seuls les IPSets
|
||
réellement référencés sont émis — **6 par tenant**. Un devis crée ce qu'il liste.
|
||
|
||
### Puis : une règle par rôle source
|
||
Les quatre règles restées en liste explicite sont éclatées — un flux dont le pair nomme quatre
|
||
rôles donne quatre règles, chacune renvoyant à l'IPSet de son rôle. **Plus une seule adresse
|
||
en dur : 52 règles, toutes par IPSet.**
|
||
|
||
Le gain n'est pas cosmétique : une règle porte désormais **qui** elle autorise. `-source
|
||
+t17-srv-keycloak` se lit ; `-source 10.27.16.21,10.27.17.11,10.27.19.31,10.27.20.21` demande
|
||
de retrouver à qui appartient chaque adresse.
|
||
|
||
La raison, elle, appartient au **flux** et non à chacune de ses règles : elle est écrite une
|
||
fois au-dessus du paquet qu'elle explique, au lieu d'être répétée quatre fois.
|
||
|
||
### Corrigé — l'affectation variait selon l'état du tenant
|
||
Elle partait de `hotes_actifs`, avec un repli sur « tous » quand il n'y en avait aucun. Deux
|
||
tenants donnaient donc deux comportements : Technolibre listait ses 14 VM (zéro actif → repli),
|
||
Chezlepro **une seule** (un actif). Un opérateur aurait lu qu'une seule VM avait besoin de
|
||
règles.
|
||
|
||
Les IPSets et les groupes incluaient déjà les hôtes **planifiés**, délibérément — un pare-feu
|
||
se prépare avant que la VM existe. L'affectation suit désormais la même règle : 14 de chaque
|
||
côté, toutes avec leur VMID.
|
||
|
||
### Conséquence à retenir
|
||
Puisque **tout** ce qui entre dans un tenant passe par la frontière, le contrôleur Ansible
|
||
aussi. **L'OPNsense devient un prérequis de déploiement**, pas une étape parmi d'autres :
|
||
sans lui, plus rien ne se déploie.
|
||
|
||
## 2026-08-03 (suite 7) — le modèle déclarait un MTU que le matériel n'a pas
|
||
|
||
En préparant le déplacement des adresses de VTEP, la lecture des interfaces a montré deux
|
||
choses que le modèle ignorait.
|
||
|
||
### `underlay.yml` annonçait 9000, `vmbr3` est à 1500
|
||
La garde P23 validait donc **une déclaration fausse** : elle exigeait ≥ 1550 et passait parce
|
||
que le fichier disait 9000. **Une garde qui valide une déclaration plutôt qu'une réalité donne
|
||
un faux confort** — c'est pire qu'une garde absente, qui au moins n'endort personne.
|
||
|
||
Le seuil ne peut pas non plus être fixe : 1550 aurait rejeté à tort un transport à 1500
|
||
portant un overlay à 1450, qui tient exactement. Il **dérive** désormais d'un
|
||
`mtu_overlay` déclaré (1450 par défaut) : transport ≥ overlay + 50.
|
||
|
||
Le fichier dit maintenant la vérité — 1500 — et la garde passe pour la bonne raison. Exercé :
|
||
un overlay porté à 1500 sur ce transport est refusé.
|
||
|
||
### `vmbr3` n'est pas *VLAN-aware*
|
||
Pas de `bridge_vlan_aware`, contrairement à `vmbr2`. L'adresse du VTEP y est **non
|
||
étiquetée** : elle vit dans le VLAN natif du port de commutateur. Déplacer le VTEP vers
|
||
l'underlay n'est donc pas un changement d'adresse — il faut soit un VLAN natif 10, soit une
|
||
interface étiquetée dédiée (`bond3.10`).
|
||
|
||
C'est la raison pour laquelle le déplacement n'a **pas** été effectué : le geste demandé
|
||
suppose une décision de câblage qui n'est pas prise.
|
||
|
||
## 2026-08-03 (suite 6) — les hyperviseurs entrent au modèle, à leur adresse cible
|
||
|
||
Redresser les pairs EVPN vers l'underlay suppose d'abord que les hyperviseurs **existent dans
|
||
le modèle**. Ils n'y étaient pas.
|
||
|
||
### La reconnaissance a montré la cause
|
||
`vmbr3` — la nouvelle interface 2,5G — porte `10.27.19.{41,43,47}` sur les trois nœuds :
|
||
l'adresse des VTEP est prise **dans le supernet de Chezlepro**. Le transport du cluster dérive
|
||
donc de l'index d'un tenant, et une VM de sa zone *Services-infra* partage son sous-réseau avec
|
||
les trois VTEP.
|
||
|
||
Le modèle **refuse d'exprimer cet état** : déclarer `10.27.19.0/24` en underlay ferait échouer
|
||
**P23**. La garde écrite deux jours plus tôt détecte la faute avant qu'on ne la documente.
|
||
|
||
`underlay.yml` déclare donc `asgard`, `gandalf` et `vishnu` à leur adresse **cible**
|
||
`10.0.0.{41,43,47}` — dernier octet conservé, comme sur `vmbr0`.
|
||
|
||
### Ajouté — un `role` sur les hôtes de l'underlay
|
||
`switch` (défaut), `hyperviseur`, `frontiere`. Le réseau ne suffit pas à le déduire : un
|
||
hyperviseur partage le réseau de management avec les commutateurs, et **recevait une
|
||
configuration de commutateur** en partie B du devis dès qu'on le déclarait.
|
||
|
||
### Corrigé — une « source unique » qui n'en était pas une
|
||
`switches_acces()` avait été introduite comme *la* décision du « qui est un switch d'accès »,
|
||
utilisée pour les rayons de l'étoile. Mais `partie_acces()` avait **gardé sa copie locale** du
|
||
filtre et ne l'appelait jamais. Les deux ont divergé au premier hôte non-commutateur déclaré.
|
||
|
||
Écrire « source unique » dans un commentaire ne la crée pas.
|
||
|
||
### Deuxième blocage signalé, non corrigé
|
||
`vishnu` : son `vmbr3` n'a **aucun port physique**. Le pont existe, porte une adresse, ne mène
|
||
nulle part. Un pair VXLAN pointé sur lui ne fonctionnera jamais — c'est du câblage, pas de la
|
||
configuration.
|
||
|
||
## 2026-08-03 (suite 5) — l'ICMP entre au registre, parce que l'overlay descend à 1450
|
||
|
||
Décision : l'**overlay EVPN plafonne à 1450**. Elle a une conséquence qui ne se voit pas —
|
||
sous 1500, tout ce qui traverse la frontière dépend de la **découverte de MTU de chemin**, donc
|
||
de l'ICMP « fragmentation nécessaire ».
|
||
|
||
Or le registre des flux ne connaissait que **TCP et UDP**. Ce message ne pouvait pas être
|
||
déclaré, et la bordure en `block in log all` l'aurait jeté. Symptôme : la connexion s'établit,
|
||
les petites requêtes passent, **les grosses réponses restent suspendues** — la panne la plus
|
||
coûteuse à diagnostiquer, et celle qu'on impute d'abord à l'application.
|
||
|
||
`protocole: icmp` est admis ; pour lui, le champ `port` porte le **type** (`frag-needed`). Le
|
||
socle déclare les **deux sens** : entrant pour qu'un distant puisse nous demander de réduire
|
||
nos paquets, sortant pour que nos hôtes signalent l'overlay aux correspondants.
|
||
|
||
Vérifié : les nftables d'hôte sont **inchangés** — le pair `externe` reste sauté, ces flux
|
||
relèvent de la bordure. Le devis frontière passe à 26 règles.
|
||
|
||
### Reconnaissance de l'existant (lecture seule)
|
||
L'EVPN est déjà **à moitié construit** sur le cluster : Proxmox 8.4.19, contrôleur `EVPN0017`
|
||
(ASN 65000), zones `VRF0011` et `VRF0017` — un VRF par tenant, VNI égal à l'index, conforme à
|
||
D-08. Mais **aucun VNet** et **aucun nœud de sortie** : le plan de contrôle existe, le plan de
|
||
données non.
|
||
|
||
Signalé, non corrigé : les **pairs BGP sont `10.27.19.41/.43/.47`**, dans le sous-réseau
|
||
*Services-infra de Chezlepro*. Le transport du cluster dérive donc de l'index d'un tenant — et
|
||
une VM de cette zone partage son sous-réseau avec les trois VTEP, ce qui perce l'isolation à
|
||
l'endroit même que l'EVPN devait fermer.
|
||
|
||
## 2026-08-03 (suite 4) — un index des décisions d'architecture
|
||
|
||
`docs/decisions-architecture.md`. Les décisions étaient écrites là où elles s'appliquent, et
|
||
leur histoire dans ce journal — mais « pourquoi le `/29` et pas le `/30` ? » demandait de
|
||
relire vingt entrées. Le registre ne répète rien : il dit **quelles décisions existent,
|
||
pourquoi, où lire le détail, et ce qui les garde**.
|
||
|
||
**28 décisions** en quatre familles — le réseau, qui possède quoi, les secrets, la méthode.
|
||
Chaque ligne porte sa raison en une phrase et sa preuve quand il y en a une. Une décision peut
|
||
n'être gardée par aucune preuve : elle reste une décision, et le registre le montre plutôt que
|
||
de laisser croire à une couverture complète.
|
||
|
||
### La section qu'on omet d'habitude : les décisions renversées
|
||
Trois y figurent — l'isolation par ACL de commutateur, le routage inter-zone sur les
|
||
commutateurs, l'underlay gitignoré à la racine du moteur. Les garder évite de refaire le
|
||
chemin, et **explique pourquoi le code porte encore des branches qui semblent inutiles** :
|
||
`acl_inter_tenant: true` et `routage_tenants: switch` restent les défauts, parce qu'une autre
|
||
fabric peut en être capable.
|
||
|
||
> Aucun de ces renversements ne vient d'un changement d'avis : les trois viennent d'un fait
|
||
> découvert **après** la décision — une commande absente de l'aide du matériel, une capacité
|
||
> manquante, une dizaine de modifications irrécupérables. C'est l'argument le plus fort pour
|
||
> éprouver avant de figer.
|
||
|
||
Les 30 renvois internes du registre ont été vérifiés : aucun document ni aucune section citée
|
||
n'est introuvable.
|
||
|
||
### Corrigé le jour même — des dates déduites plutôt que vérifiées
|
||
Les trois dates de la section « décisions renversées » avaient été **estimées**. L'historique
|
||
git les corrige : les ACL et le routage sur commutateur remontent au `2026-07-07`
|
||
(`make devis-reseau`), pas au 29 juillet ; l'underlay gitignoré au `2026-07-24`, pas au 31.
|
||
|
||
Un registre qui invente une date perd la confiance qu'on lui accorde sur le reste. Chaque
|
||
renversement cite désormais **le commit qui l'a opéré**, donc vérifiable en une commande.
|
||
|
||
Ajouté aussi : **qui décide**. Toutes ces décisions sont celles de l'opérateur du dépôt,
|
||
plusieurs prises sur recommandation — l'assistance propose et argumente, elle ne tranche pas.
|
||
La distinction compte : une décision se renverse par celui qui l'a prise.
|
||
|
||
## 2026-08-03 (suite 3) — les preuves réseau sont rattachées à de vraies affirmations
|
||
|
||
Trois preuves — **P21**, **P23**, **P24** — renvoyaient à `AFF-001`, qui affirme que *« Set-OPS
|
||
est un moteur Ansible générique … à partir d'un plan »*. Aucun rapport avec la fédération,
|
||
l'underlay ni la frontière. Trois autres — **P17**, **P19**, **P20** — n'avaient aucune
|
||
référence.
|
||
|
||
**Une preuve accrochée à la mauvaise affirmation ne prouve rien.** Elle passe au vert et
|
||
n'atteste de rien de ce qu'on croit.
|
||
|
||
### Ajouté — §10 du registre : architecture réseau et fédération
|
||
Six affirmations (`AFF-101` à `AFF-106`) : dérivation intégrale depuis le seed, absence de
|
||
collision d'index, underlay disjoint de la plage tenant, garde anti-lockout de la frontière,
|
||
validité des modèles underlay compris, couverture du plan par le panneau — cette dernière en
|
||
🟡, avec ses exceptions nommées plutôt que tues.
|
||
|
||
Et une affirmation **volontairement absente** : la *justesse* des devis. Leur syntaxe dépend
|
||
d'un matériel que le dépôt ne possède pas ; six familles ont été confrontées à un commutateur
|
||
réel, deux étaient fausses, mais c'est une vérification datée et non une preuve rejouable.
|
||
**Le dépôt n'affirme pas que ses devis s'appliquent ; il affirme qu'ils dérivent.**
|
||
|
||
### Corrigé — quatre preuves sans référence, et une erreur de la table
|
||
`P03`, `P06`, `P12`, `P13` portent désormais les références que la table de couverture leur
|
||
attribuait déjà : la correspondance existait **en double**, dans le document et dans le code,
|
||
et seul le document la tenait.
|
||
|
||
La table attribuait par ailleurs `AFF-030` (« inventaire complet ») à **P15**, qui valide le
|
||
modèle socle. C'est **P16** qui exécute `ansible-inventory --list`.
|
||
|
||
Vérifié : 35 affirmations référencées, **aucune référence orpheline**, une seule preuve sans
|
||
référence — `P16`, dont la référence existe mais sous une autre forme syntaxique.
|
||
|
||
## 2026-08-03 (suite 2) — le panneau présente les deux devis
|
||
|
||
La vue *Réseau* n'affichait que le devis des commutateurs : **le devis frontière était
|
||
totalement absent de l'interface**, alors qu'il porte les règles de la bordure et ses
|
||
avertissements — dont celui sur « Block private networks », invisible dans les règles
|
||
elles-mêmes.
|
||
|
||
Ajouté : `/api/devis-opnsense` et son bloc d'affichage, avec bouton de copie. Vérifié **par
|
||
HTTP** que les deux points d'API servent exactement ce que le CLI produit — 181 et 125 lignes,
|
||
identiques au caractère près.
|
||
|
||
L'import est paresseux et gardé : ce module lit l'underlay et les inventaires de tous les
|
||
tenants, et une erreur y aurait sinon empêché l'affichage du reste de la vue.
|
||
|
||
### Corrigé — le texte d'aide était périmé sur trois points
|
||
Il annonçait les VLAN tenants « uniques **sur le trunk** » — faux en SDN, où aucun ne circule ;
|
||
renvoyait le dialecte à `SETOPS_DIALECTE` alors que c'est un **intrant** de la section
|
||
*Fabric* ; et disait que « la route par défaut vers OPNsense reste à adapter » alors que la
|
||
section 5 l'émet depuis hier.
|
||
|
||
Il dit maintenant ce qui reste réellement à nommer à la main : les **ports physiques** et, en
|
||
SDN, le **nœud de sortie EVPN**. Rien d'autre — adresses, VLAN et routes se dérivent.
|
||
|
||
## 2026-08-03 (suite) — le devis frontière rattrape la bascule SDN
|
||
|
||
Trois affirmations du devis frontière étaient devenues fausses, dont une qui cassait le
|
||
routage.
|
||
|
||
### Corrigé — le prochain saut des routes tenants
|
||
Elles pointaient le SVI du commutateur (`10.0.4.6`). En EVPN, il ne route plus les tenants :
|
||
une route pointée là arriverait sur un équipement **sans chemin vers le tenant**. Configuration
|
||
qui s'applique sans erreur et ne fonctionne pas — la signature qu'on traque depuis deux jours.
|
||
|
||
Le devis émet désormais `<NOEUD-DE-SORTIE-EVPN>` et dit pourquoi, à figer après le spike.
|
||
|
||
`underlay.passerelle_sortie` **garde** son sens : c'est l'adresse du pare-feu sur le lien de
|
||
transit, donc la sortie de l'**underlay**. Deux choses distinctes qu'il ne faut pas confondre.
|
||
|
||
### Corrigé — la description du lien affichait le marqueur du prochain saut
|
||
Régression de la correction ci-dessus : le champ décrivant le **câblage** du lien de transit
|
||
réutilisait la variable du **prochain saut**. Les deux étaient identiques jusqu'à la bascule
|
||
SDN ; elles ont divergé, et la section 1 annonçait `switch <NOEUD-DE-SORTIE-EVPN>`. Sur ce
|
||
lien, le commutateur est bien à `10.0.4.6` — le nœud de sortie n'y figure pas. Le champ décrit
|
||
désormais le câblage, indépendamment du routage.
|
||
|
||
### Corrigé — une contradiction entre les deux devis, antérieure au SDN
|
||
La section 0 du devis frontière demandait de router les réseaux d'administration **vers
|
||
`10.0.4.6`** — le SVI du commutateur lui-même. `make devis-reseau` émet `10.0.4.1`, l'adresse
|
||
du pare-feu. Les deux devis se contredisaient, alors que l'un affirmait que l'autre « émet déjà
|
||
ces routes ». Elles coïncident maintenant, vérifié ligne à ligne.
|
||
|
||
### Corrigé — deux commentaires qui affirmaient l'inverse de la décision
|
||
L'en-tête (« les passerelles de zone restent sur les switches L3 ») et la description des
|
||
routes (« routage inter-zone sur les switches L3 ») se dérivent maintenant du mode de routage.
|
||
|
||
## 2026-08-03 — le SDN prend le routage tenant, le devis switch se vide
|
||
|
||
Trois décisions, et le devis les applique déjà : **une zone EVPN par tenant** ; le routage
|
||
**et le filtrage** entre les zones d'un même tenant à ce niveau ; **l'inter-tenant
|
||
obligatoirement par l'OPNsense**.
|
||
|
||
### `underlay.routage_tenants` — `switch` (défaut) ou `sdn`
|
||
En `sdn`, le devis switch cesse d'émettre VLAN tenants, SVI de zone et ACL, et les retire des
|
||
trunks. Chez Chezlepro, les trunks ne portent plus que **deux VLAN d'underlay** au lieu de
|
||
quinze : aucun VLAN de tenant ne circule sur le fil, seulement du VXLAN que le commutateur
|
||
transporte sans le lire.
|
||
|
||
Les sections 1 à 3 sont remplacées par la raison, dont celle-ci : les passerelles `.1` n'ont
|
||
pas changé d'adresse, elles ont changé de **porteur** — passerelle anycast du VNet, présente
|
||
sur chaque hyperviseur.
|
||
|
||
### Le MTU devient une garde, pas un conseil
|
||
VXLAN ajoute 50 octets. `make underlay` **refuse** un réseau de transport sous 1550 dès que
|
||
`routage_tenants: sdn`, en disant pourquoi : sous ce seuil, le ping passe et les transferts
|
||
échouent — la panne la plus coûteuse à diagnostiquer. Les deux réseaux de la fabric principale
|
||
passent à 9000.
|
||
|
||
### Corrigé — la partie B déclarait encore les VLAN tenants
|
||
Le filtre `routage_tenants` n'avait été posé que sur la partie A et les trunks : la partie B a
|
||
sa propre boucle de déclaration, et sortait toujours les douze VLAN tenants. Aucun trunk ne
|
||
les portait, aucun SVI ne les utilisait — mais leur présence suggérait que les switches
|
||
d'accès devaient les connaître, ce qui contredit la décision.
|
||
|
||
Vérifié dans les deux sens : zéro VLAN tenant en mode `sdn`, les vingt-quatre déclarations
|
||
(douze par partie) de retour en mode `switch`.
|
||
|
||
### Le partage des responsabilités, écrit
|
||
Une table dans `docs/sdn-evpn.md` dit qui route et qui filtre pour chaque nature de trafic.
|
||
Deux conséquences y sont nommées : **l'inter-tenant ne peut plus être oublié** — il doit sortir
|
||
du VRF, donc traverser une bordure en `block` par défaut ; et **le commutateur ne voit plus
|
||
rien du trafic tenant**, donc y chercher la trace d'un problème applicatif est une perte de
|
||
temps.
|
||
|
||
Point ouvert ajouté : le registre des flux n'a **aucun mot-clé pour un flux inter-tenant**.
|
||
Défaut sûr aujourd'hui, mais il rend impossible de *déclarer* une exception légitime.
|
||
|
||
## 2026-08-02 (suite 20) — décision : le routage passe aux hyperviseurs (SDN EVPN)
|
||
|
||
`docs/sdn-evpn.md`. Les commutateurs ne savent pas lier une ACL à une interface de routage ;
|
||
plutôt que d'assumer indéfiniment la perte d'isolation réseau que cela entraîne, le routage
|
||
inter-zone passe à **Proxmox SDN, zones EVPN**.
|
||
|
||
**Une zone EVPN est un VRF** — c'est celui qu'on regrettait de ne pas avoir dans le matériel,
|
||
obtenu en logiciel. Et il referme le trou signalé quelques heures plus tôt : un tenant n'a plus
|
||
de route vers l'underlay, celui-ci n'étant pas dans sa table de routage. Le plan de gestion
|
||
redevient protégé **par construction**, pas par une règle qu'on pourrait oublier.
|
||
|
||
**La projection du modèle ne demande aucun changement de dérivation** — vérifiée sur les deux
|
||
tenants fédérés :
|
||
|
||
| Objet SDN | Vient de |
|
||
|---|---|
|
||
| zone (VRF) | le tenant |
|
||
| VNet | la zone de sécurité |
|
||
| tag (VNI) | `vlan_de(index, zone)` |
|
||
| subnet + gateway | `sous_reseau_de(...)` + `passerelle_de(...)` |
|
||
|
||
Le `.1` ne change pas d'adresse, il change de porteur : du SVI d'un commutateur vers la
|
||
passerelle **anycast** du VNet, présente sur chaque hyperviseur. L'invariant du dernier octet
|
||
survit tel quel.
|
||
|
||
Le devis switch maigrira d'autant : plus de VLAN tenants, plus de SVI de zone, plus d'ACL — en
|
||
EVPN aucun VLAN de tenant ne circule sur le fil. La fabric redevient un transport IP.
|
||
|
||
**Rien n'est éprouvé et rien n'est généré.** Le document fixe la cible, la projection et une
|
||
séquence de spike en cinq points — dont la vérification du MTU, premier mur de VXLAN, et
|
||
surtout la **tentative d'accès à l'underlay qui doit échouer**, puisque c'est le gain
|
||
principal. Le principe du dépôt s'applique : éprouver l'outil avant d'écrire le rôle.
|
||
|
||
## 2026-08-02 (suite 19) — pas d'ACL sur cette fabric : on route, et c'est tout
|
||
|
||
Les interfaces VLAN du Binardat n'offrent aucun `access-group` : impossible de lier une ACL à
|
||
un SVI. Plutôt que d'émettre des règles qui ne seraient jamais liées — elles auraient l'air
|
||
d'isoler sans jamais filtrer —, la capacité devient **déclarée** :
|
||
`underlay.acl_inter_tenant: false`.
|
||
|
||
Ce n'est pas lié au dialecte de CLI mais au **matériel** : un autre commutateur parlant la
|
||
même CLI pourrait savoir lier des ACL. Par défaut la valeur reste `true`, donc rien ne change
|
||
pour une fabric qui en est capable.
|
||
|
||
À `false`, la section 3 du devis ne contient plus de règles mais **la raison** — et surtout ce
|
||
qu'on perd :
|
||
|
||
> Une VM émettant vers l'underlay voit son paquet **routé localement** par le commutateur —
|
||
> mgmt des switches, mgmt Proxmox, OOB/IPMI. Les nftables des VM n'y peuvent rien (politique
|
||
> `output` permissive), et l'IPMI n'est pas un hôte géré.
|
||
|
||
L'isolation inter-tenant repose désormais entièrement sur les nftables d'hôte, en `policy
|
||
drop`. C'est défendable — c'est déjà là que vit le zéro-confiance est-ouest — mais le plan de
|
||
gestion de la fabric perd sa seule protection réseau.
|
||
|
||
Des **VRF** auraient donné cette isolation sans ACL, par séparation des tables de routage. Ce
|
||
matériel n'en a pas : c'est le critère à retenir au prochain renouvellement. Parade
|
||
structurelle disponible d'ici là : sortir le management de la fabric routée des tenants,
|
||
comme l'est déjà le stockage.
|
||
|
||
## 2026-08-02 (suite 18) — la liaison des ACL n'existe pas sur une interface VLAN
|
||
|
||
`ip ?` sur une interface VLAN du Binardat n'offre **aucun `access-group`**, et la liste
|
||
complète des commandes de ce mode n'en contient pas davantage. La ligne
|
||
`ip access-group <NOM> in` que le devis pose sur les douze SVI n'existe donc pas sur cette
|
||
plateforme.
|
||
|
||
**C'est la ligne qui rend l'isolation effective.** Sans elle, les ACL de la section 3 sont
|
||
parfaitement définies et jamais liées : `show access-lists` afficherait « used 0 time(s) », et
|
||
rien d'autre ne signalerait que l'isolation inter-tenant ne filtre rien. Même signature que le
|
||
défaut du trunk — une configuration qui a l'air juste et n'agit pas.
|
||
|
||
Le `firewall disable` aperçu dans un `show running-config` prend rétrospectivement du sens :
|
||
le filtrage semble conditionné globalement.
|
||
|
||
**Aucune forme de remplacement n'est devinée.** Le devis porte un avertissement à cet endroit,
|
||
en dialecte `binardat` uniquement — après trois syntaxes supposées dont deux fausses, marquer
|
||
l'incertitude vaut mieux qu'un quatrième pari. Reste à trancher sur une interface **physique**
|
||
(`ip ?`, `access-group ?`) et sur le rôle de `firewall enable`.
|
||
|
||
## 2026-08-02 (suite 17) — spanning-tree vérifié : les six familles sont closes
|
||
|
||
`spanning-tree ?` en mode configuration tranche la dernière inconnue, **en faveur de la forme
|
||
émise** : `mode` et `priority` s'acceptent au niveau **global**. La priorité n'a pas besoin
|
||
d'être portée par une instance, même en MSTP — la réserve inverse, notée la veille, était
|
||
infondée et le devis ne la porte plus. Une mise en garde fausse est aussi nuisible qu'une
|
||
syntaxe fausse.
|
||
|
||
Ajouté : `spanning-tree` seul, qui **garantit l'état actif**. Sans lui, `mode` et `priority`
|
||
sur un boîtier où le protocole aurait été désactivé configureraient un arbre qui ne tourne
|
||
pas. Sans effet s'il est déjà actif — même logique déclarative que pour les trunks.
|
||
|
||
Vérifié contre le matériel : VLAN, SVI, trunks, routes, définition des ACL, spanning-tree
|
||
**global**. Deux ont révélé un défaut réel plutôt que de confirmer l'existant — les routes
|
||
(notation CIDR) et surtout les trunks, dont la forme `add` ne retranchait rien.
|
||
|
||
> **Rectification (même jour).** L'entrée ci-dessus a d'abord annoncé « les six familles sont
|
||
> closes ». C'était faux : l'aide consultée était celle du mode configuration **globale**, et
|
||
> trois lignes du devis vivent ailleurs — `spanning-tree portfast trunk` et `ip access-group
|
||
> … in` au niveau **interface**, `ip default-gateway` en global mais absent de cette aide.
|
||
> Elles restent non vérifiées, et la plus douteuse est `portfast trunk` : `trunk` est un
|
||
> mot-clé Cisco.
|
||
|
||
## 2026-08-02 (suite 16) — la syntaxe des ACL vérifiée sur le matériel
|
||
|
||
`show access-lists` confirme la dernière famille de syntaxe restée ouverte : une ACL générée
|
||
s'applique **telle quelle** sur le Binardat, ses règles dans l'ordre émis —
|
||
`ip access-list extended <NOM>`, masques normaux, `any`.
|
||
|
||
Détail de lecture consigné : le boîtier **affiche** `any-destination` là où l'on saisit `any`.
|
||
Comparer un `show access-lists` au devis ferait apparaître une différence qui n'en est pas une
|
||
— c'est le genre de faux positif qui fait perdre une heure.
|
||
|
||
**Bilan des dialectes.** Cinq familles sur six sont désormais vérifiées contre le matériel :
|
||
VLAN, SVI, trunks, routes, ACL. Ne reste que la **forme d'entrée** des commandes de
|
||
spanning-tree, que `show` ne révèle pas.
|
||
|
||
## 2026-08-02 (suite 15) — `underlay.stp.mode` aligné sur le matériel
|
||
|
||
`mode: mstp`, parce que c'est le mode d'usine du commutateur (`show spanning-tree` :
|
||
IEEE 802.1s, *Force Version 3*). Sur une étoile sans lien redondant, l'instance 0 de MSTP se
|
||
comporte comme un RSTP : changer de mode aurait donné le même résultat, au risque près de
|
||
toucher à un protocole qui fonctionne déjà.
|
||
|
||
Le devis énonce désormais sa propre incertitude là où elle est : **en MSTP la priorité se
|
||
règle souvent par instance**, alors que la forme émise est globale. Le générateur le dit
|
||
plutôt que de laisser croire à une syntaxe vérifiée — `show spanning-tree` donne l'état du
|
||
protocole, jamais la forme d'entrée des commandes.
|
||
|
||
Corrigé au passage : le commentaire de la section 6 disait « RSTP » en dur alors que le mode
|
||
est déclaré. Il le dérive.
|
||
|
||
## 2026-08-02 (suite 14) — le spanning-tree du matériel : MSTP, actif, priorité par défaut
|
||
|
||
`show spanning-tree` sur le commutateur Binardat corrige une déduction fausse et en apporte
|
||
deux faits.
|
||
|
||
**Correction.** J'avais déduit de son absence du `show running-config` que le spanning-tree
|
||
était probablement désactivé. Il est **actif** : il n'y figurait pas parce qu'il est aux
|
||
valeurs d'usine. Une absence dans une configuration ne veut pas dire une absence de fonction.
|
||
|
||
**La plateforme est en MSTP** (IEEE 802.1s, *Force Version 3*), alors que `underlay.stp.mode`
|
||
déclare `rstp`. Sur une étoile sans lien redondant les deux se comportent identiquement — la
|
||
question est de savoir si l'on aligne la déclaration sur le matériel ou le matériel sur la
|
||
déclaration.
|
||
|
||
**La priorité de pont est déjà 32768**, donc la ligne émise pour les switches d'accès est un
|
||
non-opérant : elle écrit ce qui est déjà vrai.
|
||
|
||
Reste non vérifiée la **forme d'entrée** des commandes de spanning-tree : `show` en donne
|
||
l'état, pas la syntaxe. En MSTP la priorité se règle en général **par instance**, ce que la
|
||
forme actuellement émise ne fait pas.
|
||
|
||
## 2026-08-02 (suite 13) — le trunk ne restreignait rien
|
||
|
||
`switchport trunk allowed vlan ?` sur le matériel réel confirme la syntaxe **et** révèle un
|
||
défaut : `add` *ajoute* à la liste courante, la forme sans mot-clé la *définit*.
|
||
|
||
Le devis émettait `add`. Or un port trunk neuf autorise **tous** les VLAN — dans une
|
||
configuration réelle, les ports n'avaient aucune ligne `allowed vlan`, ce qui signifie
|
||
exactement cela. Y ajouter la liste des VLAN voulus n'en retranchait aucun : le trunk
|
||
continuait de tout transporter, et le devis donnait **l'illusion de restreindre**.
|
||
|
||
C'est le pire genre de défaut — une configuration qui a l'air juste, s'applique sans erreur,
|
||
et ne fait pas ce qu'elle annonce.
|
||
|
||
La forme sans mot-clé est retenue, et elle est aussi **atomique** : `none` puis `add` aurait
|
||
coupé le trunk entre les deux commandes, ce qui suffit à perdre la session si on l'applique
|
||
sur le port de gestion.
|
||
|
||
## 2026-08-02 (suite 12) — la syntaxe des routes, vérifiée sur le matériel
|
||
|
||
Un `show running-config` du commutateur Binardat tranche la question restée ouverte : la
|
||
plateforme écrit ses routes en **notation CIDR** — `ip route 0.0.0.0/0 192.168.10.254` — et
|
||
non en masque séparé comme Cisco. Le générateur produisait du Cisco quel que soit le dialecte.
|
||
|
||
`route_statique()` suit désormais le dialecte, au même titre que les masques d'ACL. Vérifié
|
||
dans les deux formes.
|
||
|
||
Restent non vérifiés faute d'apparaître dans la configuration réelle : la syntaxe des ACL,
|
||
celle de `switchport trunk allowed vlan add`, et le **spanning-tree** — totalement absent du
|
||
`show running-config`, ce qui suggère qu'il est désactivé par défaut sur cette plateforme.
|
||
|
||
## 2026-08-02 (suite 11) — le responsable prend sa section, et la symétrie est dite
|
||
|
||
### Corrigé — un renvoi ambigu
|
||
« Il ne dégèle qu'en cas de retour arrière (§6) » figurait **dans l'étape 6**. Deux « 6 » ne
|
||
désignant pas la même chose dans une seule phrase, alors que tout le document distingue
|
||
soigneusement sections et étapes. La section est désormais nommée plutôt que numérotée.
|
||
|
||
### Déplacé — le responsable désigné devient le §3
|
||
Il vivait dans « Le modèle : le transfert de nom de domaine », alors que ce n'est **pas un
|
||
emprunt aux registraires** : c'est une décision de modèle, valable migration ou pas. Le §2 ne
|
||
traite plus que de ce qui est emprunté et de là où l'analogie casse.
|
||
|
||
### Ajouté — ce qui suit le tenant, en regard de ce qui reste
|
||
Le §8 énumérait ce que la migration ne déplace **pas** — fabric, frontière, index — sans dire
|
||
ce qu'elle déplace. Or le responsable désigné, lui, **suit le tenant** : c'est exactement
|
||
l'inverse, et le dire renforce la ligne de partage.
|
||
|
||
> Si quelque chose appartenant à l'organisation ne peut pas partir, elle n'est pas vraiment
|
||
> souveraine ; si quelque chose appartenant à l'hébergeur devait partir, c'est que la
|
||
> frontière entre les deux est mal tracée.
|
||
|
||
Cette symétrie est le test le plus simple d'une migration bien conçue.
|
||
|
||
Sections renumérotées en conséquence (9 au lieu de 8) ; les six renvois internes vérifiés un
|
||
par un.
|
||
|
||
## 2026-08-02 (suite 10) — le responsable désigné, et la réversibilité nuancée
|
||
|
||
### Décidé — chaque tenant a un responsable désigné
|
||
Un domaine a un titulaire ; un tenant a un **responsable désigné** — la personne qui engage
|
||
l'organisation, et dont la signature seule vaut mandat de migration.
|
||
|
||
Ce n'est pas une formalité. Sans responsable nommé **d'avance**, la question « qui peut
|
||
décider de déménager cette organisation ? » se pose au pire moment : quand les deux hébergeurs
|
||
ont un intérêt dans la réponse. Un employé de bonne foi ne peut pas mandater le déménagement
|
||
de son employeur.
|
||
|
||
### Corrigé — la table des états laissait croire que revenir est facile jusqu'au bout
|
||
Elle annonçait une réversibilité « gratuite » entre `préparé` et `libéré`, alors que le §6
|
||
établit qu'elle change de nature à la bascule. Les deux ne se contredisent pas — on peut
|
||
effectivement revenir jusqu'à `libéré` — mais un lecteur pressé s'arrêtant au tableau en
|
||
retirait une fausse impression.
|
||
|
||
Ce qui reste gratuit est l'adressage, pas le retour : tout dérive d'un seul chiffre, dans les
|
||
deux sens.
|
||
|
||
### Ajouté aux points à trancher — trois questions de gouvernance
|
||
**Où le responsable désigné est déclaré**, et surtout **comment on en change** : c'est un acte
|
||
au moins aussi sensible que la migration, puisqu'il décide qui pourra la mandater ensuite.
|
||
|
||
**Le recouvrement de la clé du responsable** — elle se perd, se compromet, ou la personne
|
||
quitte l'organisation. Sans procédure, un tenant devient **inmigrable** : captif non par
|
||
contrat mais par accident, exactement ce que la recette existe pour empêcher.
|
||
|
||
Deux écueils symétriques y sont consignés. Trop lourde, la procédure n'aboutit jamais et le
|
||
tenant reste bloqué. Trop légère, elle devient le **chemin de moindre résistance** pour
|
||
contourner la signature — inutile de forger un mandat si l'on peut se faire attribuer la clé.
|
||
Le recouvrement doit être au moins aussi difficile que ce qu'il protège.
|
||
|
||
Piste retenue, la plus transposable des registraires : un **contact de secours nommé en même
|
||
temps que le responsable**, tant que personne n'est en situation d'urgence.
|
||
|
||
**La durée de rétention** : convenue avec qui, consignée où, attestée par qui. Sur une
|
||
séparation d'hébergeur, un flou ici finit en litige.
|
||
|
||
## 2026-08-02 (suite 9) — le retour arrière de la migration
|
||
|
||
La recette affirmait la réversibilité sans jamais décrire le retour. Or elle **change de
|
||
nature à la bascule**, et le geste évident — repointer le DNS — devient faux à cet instant.
|
||
|
||
Trois régimes, désormais écrits :
|
||
|
||
- **avant le gel** : sans conséquence, le sortant n'a jamais cessé de servir. C'est ce que la
|
||
règle d'ordre achète — tout ce qui peut échouer sans coût échoue là ;
|
||
- **pendant le gel** : dégeler, l'interruption se limite à la durée du gel ;
|
||
- **après la bascule** : ce n'est plus un retour mais une **migration inverse**. Les
|
||
utilisateurs ont écrit chez l'entrant — courriels, fichiers, commits — et ces données
|
||
n'existent nulle part ailleurs. Repointer le DNS les perdrait, et silencieusement.
|
||
|
||
Point rendu explicite : **le sortant reste gelé après la bascule**, jusqu'à confirmation. Le
|
||
dégeler « au cas où » créerait deux copies vivantes du même tenant et plus aucune vérité. En
|
||
contrepartie il n'a pas divergé, donc le delta d'un retour reste à sens unique.
|
||
|
||
Deux points de non-retour à ne pas confondre : la **bascule** fait perdre le retour *gratuit*
|
||
(il reste la migration inverse) ; la **purge** fait tout perdre.
|
||
|
||
D'où une exigence ajoutée aux points à trancher : les **critères de confirmation** de l'étape
|
||
7 se fixent par écrit **avant** la première bascule. Décider après coup ce qui compte comme
|
||
« ça marche » revient à se donner raison.
|
||
|
||
Corrigés au passage : deux renvois d'étape faux (le rattrapage est à l'étape 5, non 4 ; le
|
||
chemin de vérification hors DNS public est un prérequis de l'étape 3 elle-même).
|
||
|
||
## 2026-08-02 (suite 8) — recette de migration d'un tenant entre hébergeurs
|
||
|
||
`docs/migration-tenant.md` : la séquence, les états et les gardes. Écrite avant tout code,
|
||
délibérément — figer un enchaînement qu'on n'a jamais joué serait prématuré.
|
||
|
||
**Le modèle est le transfert de nom de domaine**, qui résout depuis trente ans les mêmes
|
||
problèmes : le mandat appartient au **client** (ni l'hébergeur sortant ni l'entrant ne peut
|
||
déplacer un tenant seul), verrou par défaut, deux actes délibérés et traçables.
|
||
|
||
Là où l'analogie casse, elle est remplacée plutôt qu'étirée : il n'y a **pas de registre**
|
||
central pour arbitrer, donc le mandat est **signé** par le tenant et vérifié contre une clé
|
||
publique de son plan — un secret partagé ne prouverait rien à l'entrant, il pourrait venir du
|
||
sortant.
|
||
|
||
**L'ordre est commandé par une règle unique** : *le receveur doit être prouvé prêt avant que
|
||
quoi que ce soit ne gèle.* L'entrant se construit et se prouve pendant que le sortant sert
|
||
normalement ; l'interruption se réduit au rattrapage du delta plus la propagation DNS. Ce
|
||
n'est pas un conseil mais une **garde de transition** — l'état `gelé` est inaccessible tant
|
||
que `préparé` n'est pas prouvé.
|
||
|
||
Deux pièges consignés parce qu'ils ne vont pas de soi : le **TTL** s'abaisse à l'étape 1, pas
|
||
à la bascule, sinon tout le bénéfice de l'ordre est perdu ; et l'entrant doit être
|
||
**vérifiable sans être public**, sinon le tenant sert des deux côtés et l'identité se
|
||
dédouble.
|
||
|
||
Enfin, la libération est une **révocation, pas une transmission** : re-clétage de la voûte et
|
||
rotation des secrets chez l'entrant. Transmettre le mot de passe laisserait à l'ancien
|
||
hébergeur un accès à vie aux secrets d'un client parti — rien ne rattrape ça après coup.
|
||
|
||
## 2026-08-02 (suite 7) — la frontière appartient à l'hébergeur, pas au tenant actif
|
||
|
||
Un hébergeur sert **plusieurs tenants** et n'a qu'**une** frontière. Or ses intrants
|
||
(`group_vars/opnsense.yml`) étaient lus chez le **tenant actif** : basculer sur un invité —
|
||
Technolibre, qui n'a pas de frontière à lui — faisait perdre au devis l'URL de gestion,
|
||
l'adresse publique et les deux interfaces. Il repartait en marqueurs, comme si le boîtier
|
||
n'existait pas. Vérifié en simulant la bascule : `AUCUN` intrant lu.
|
||
|
||
Même famille que le défaut de l'underlay corrigé plus tôt, et c'est la distinction
|
||
hébergeur/tenant qui le fait apparaître.
|
||
|
||
Le devis **et** le panneau lisent désormais la frontière chez l'hébergeur. Celui-ci n'est pas
|
||
déclaré pour autant : le symlink `underlay.yml` le **désigne déjà**, et une seconde
|
||
déclaration ouvrirait la porte à deux valeurs contradictoires. Repli sur l'instance active
|
||
quand aucun underlay n'est monté — un site sans fabric déclarée continue de fonctionner.
|
||
|
||
Vérifié : devis identique avec l'hébergeur actif, et intrants **conservés** avec un invité
|
||
actif. `docs/frontiere-opnsense.md` gagne un §2 qui pose qui possède quoi.
|
||
|
||
## 2026-08-02 (suite 6) — l'underlay devient modélisable
|
||
|
||
Un modèle décrivait jusqu'ici un **tenant** : ses services, ses zones, ses bases. Or tous les
|
||
hébergeurs n'ont pas le même matériel, et l'infrastructure physique mérite le même traitement.
|
||
|
||
### Ajouté — `exemples/modeles/socle/underlay.yml`
|
||
Le modèle public gagne un underlay **volontairement minimal** : un seul commutateur, pas de
|
||
fabric de stockage séparée. C'est le point de départ honnête d'un petit hébergeur ; les
|
||
montages plus riches (étoile à trois commutateurs, paire en MLAG, stockage jumbo dédié) sont
|
||
d'autres modèles, conformément à la doctrine — un générique public, les étoffés en privé.
|
||
|
||
Le modèle contient désormais **deux moitiés qui ne vont pas au même endroit** : `plan/` et
|
||
`inventories/` chez le tenant, `underlay.yml` chez l'hébergeur. Chez un hébergeur qui est son
|
||
propre tenant, les deux atterrissent au même dépôt — c'est le cas particulier, pas la règle.
|
||
|
||
### Étendu — `modeles.py verifier` valide l'underlay (preuve P17)
|
||
La validation est **facultative** (un modèle sans underlay reste valide) et porte sur la
|
||
cohérence **interne** seulement : VLAN sous la plage tenant, sous-réseaux disjoints,
|
||
passerelle dans son réseau, routeur déclaré, ports non dupliqués, dernier octet partagé.
|
||
|
||
Elle n'est **pas** confrontée aux tenants fédérés réels : un modèle est un gabarit, pas un
|
||
site déployé. Il a fallu pour cela rendre paramétrables deux hypothèses du validateur, qui
|
||
lisait la nomenclature de l'instance *active* et globait les dépôts frères — sur un modèle,
|
||
les deux auraient été faux. `charger_depuis()` et `plan_nomenclature=` ; comportement par
|
||
défaut inchangé.
|
||
|
||
Cinq cas de rejet exercés sur un modèle fautif : VLAN empiétant sur la plage tenant,
|
||
passerelle au mauvais dernier octet (lue dans la nomenclature **du modèle**), routeur inconnu,
|
||
sortie hors du lien, port déclaré deux fois.
|
||
|
||
## 2026-08-02 (suite 5) — l'underlay rejoint le dépôt de l'hébergeur
|
||
|
||
`underlay.yml` vivait **gitignoré** à la racine du moteur : consommé par deux générateurs,
|
||
validé par la preuve P23, et versionné nulle part. La dizaine de modifications de la journée
|
||
— transit, renumérotage du `/29`, séparation des fabrics, spanning-tree, dialecte, ports —
|
||
n'était récupérable d'aucune façon, et un clone frais repartait du gabarit.
|
||
|
||
Il appartient à l'**hébergeur** : ce sont ses switches, ses câbles, ses VLAN. Pas au moteur,
|
||
qui est générique, ni à un tenant, qui n'en possède pas. Chezlepro est ici hébergeur *et*
|
||
tenant, d'où la confusion : un tenant qui s'hébergerait sur son propre matériel aurait son
|
||
propre underlay, dans son dépôt.
|
||
|
||
Le moteur le monte par symlink, comme il monte le plan par `instance/` :
|
||
|
||
```
|
||
Set-OPS-public/underlay.yml -> ../OPS-Chezlepro/underlay.yml
|
||
```
|
||
|
||
**Ce lien ne suit pas `make instance-utiliser`.** Basculer l'instance active sur un autre
|
||
tenant ne change pas la fabric : elle reste celle de l'hébergeur. Deux symlinks, deux durées
|
||
de vie — c'est la conséquence directe de la distinction hébergeur/tenant.
|
||
|
||
Vérifié : les deux devis sortent **identiques octet pour octet** avant et après, P23 reste
|
||
verte, 24 preuves. Et un symlink **brisé** — le cas d'un clone du moteur sans le dépôt de
|
||
l'hébergeur — dégrade proprement : `exists()` suit le lien, l'underlay est vu comme absent,
|
||
et les devis omettent leurs sections au lieu d'échouer. Cas exercé.
|
||
|
||
## 2026-08-02 (suite 4) — deux devis qui se contredisaient, et une case à cocher
|
||
|
||
### Corrigé — le devis frontière certifiait des routes inexistantes
|
||
Sa section 0 annonçait trois routes de retour « DÉJÀ ÉMISES par `make devis-reseau` ». Le
|
||
devis switch n'en émettait **qu'une** : `devis_reseau` lisait `nftables_admin_ssh` de la
|
||
seule instance active, alors que la frontière était passée multi-tenant la veille. Les deux
|
||
réseaux d'administration de Technolibre n'étaient routés nulle part.
|
||
|
||
Pire qu'un silence : une affirmation fausse désamorce la vérification.
|
||
|
||
`admin_tous_tenants()` vit désormais dans `devis_reseau` et **`devis_opnsense` l'importe**
|
||
au lieu d'en refaire une copie. Les routes de retour et les règles lisent les mêmes tenants,
|
||
par construction. Vérifié : les deux listes sont identiques.
|
||
|
||
### Ajouté — l'avertissement « Block private networks »
|
||
Le SSH d'administration a une source RFC1918 arrivant sur une interface **WAN**. OPNsense
|
||
active par défaut ce filtre d'interface, qui s'applique **avant** les règles : coché, il jette
|
||
le paquet sans qu'aucune règle ne soit consultée. La config paraît juste, le SSH ne passe pas.
|
||
|
||
Le devis le signale dès qu'une source RFC1918 entre par le WAN — un réglage d'interface est
|
||
invisible dans les règles, il fallait donc l'écrire à part.
|
||
|
||
Le prédicat est **exactement** RFC1918, périmètre de cette case ; `ipaddress.is_private`
|
||
aurait été trop large (plages de documentation, CGNAT), et l'avertissement se serait déclenché
|
||
à tort. Les trois cas exercés : RFC1918 → averti ; `8.8.8.8/32` → muet ; `203.0.113.7/32`
|
||
(documentation) → muet.
|
||
|
||
## 2026-08-02 (suite 3) — chaque règle porte son interface, et l'octet est gardé
|
||
|
||
### Ajouté — l'interface d'arrivée, dérivée du sens du flux
|
||
Dans OPNsense une règle est **toujours `in` sur l'interface d'arrivée** : posée ailleurs, elle
|
||
ne s'applique jamais et le trafic est bloqué sans que la configuration paraisse anormale. Les
|
||
règles n'en portaient aucune, alors que le champ est obligatoire dans l'API.
|
||
|
||
L'attribution se dérive : un flux `ingress`/`externe` arrive par le **WAN**, un flux
|
||
`egress`/`externe` par le **lien de transit**. Le SSH d'administration suit la première ligne
|
||
— le VPN est hébergé sur le pfSense voisin et revient par l'adresse publique de la frontière.
|
||
C'était la dernière inconnue, et le montage parallèle décrit le 2026-08-02 la lève.
|
||
|
||
Le rendu abandonne `pass out` pour `pass in on <interface>`, qui est l'idiome réel d'OPNsense
|
||
et ce que le futur client d'API devra envoyer.
|
||
|
||
### Ajouté — `opnsense_wan_ip`, la face publique
|
||
L'adresse publique de la frontière (`69.70.26.62`, reprise du pfSense) est un intrant de la
|
||
section *Frontière* et s'affiche en section 1 du devis.
|
||
|
||
### Ajouté — invariant du dernier octet (preuve P23)
|
||
Convention d'exploitation : un point de routage porte **le même dernier octet sur tous les
|
||
sous-réseaux où il participe** — on retient une adresse, pas treize. `sleipnir-01` est `.1`
|
||
partout. Le chiffre n'est pas codé en dur : il vient de `reservations.passerelle` dans la
|
||
nomenclature, et `make underlay` refuse une passerelle qui s'en écarte.
|
||
|
||
Exemption assumée : les liens plus étroits qu'un `/24`. Sur le `/29` de transit, l'adressage
|
||
est dicté par les participants — les deux frontières prennent `.1` et `.2`, le switch `.6`.
|
||
Vérifié que l'invariant était **déjà respecté** sur les 13 sous-réseaux routés avant d'écrire
|
||
la garde.
|
||
|
||
## 2026-08-02 (suite 2) — la frontière porte les règles de TOUS les tenants
|
||
|
||
Le devis était multi-tenant pour ses routes et mono-tenant pour ses règles : il routait
|
||
`10.21.0.0/16` et `10.27.0.0/16`, mais ne filtrait que l'instance active. Technolibre aurait
|
||
été routé jusqu'à la bordure puis bloqué dans les deux sens, SSH d'administration compris,
|
||
sans qu'aucune ligne ne dise pourquoi. Chemin présent, politique absente — le mode de panne
|
||
du 2026-07-29, transposé.
|
||
|
||
La résolution est désormais paramétrée par tenant : `inventaire_de()` lit le `hosts.yml` de
|
||
chaque instance fédérée, `cibles_par_role()` prend l'inventaire en argument, et les alias
|
||
d'hôtes sont préfixés (`SETOPS_CHEZ17_SERVEUR_NGINX`). 11 règles par tenant, 22 au total.
|
||
|
||
### Cloisonnement du plan de gestion
|
||
Première version de ce correctif : `SETOPS_ADMIN` devenait l'**union** des réseaux
|
||
d'administration. Le plan de gestion de Technolibre aurait alors pu entrer en SSH chez
|
||
Chezlepro — la bordure rouvrait ce que les ACL de switch ferment. Corrigé avant livraison :
|
||
**un alias par tenant**, `SETOPS_ADMIN_<TENANT>`, n'ouvrant que son propre supernet.
|
||
|
||
L'union est conservée pour les routes de **retour** côté switch et la garde P24 : router
|
||
n'est pas autoriser, et le switch doit savoir revenir vers tous les plans de gestion.
|
||
|
||
### Deux omissions annoncées au lieu d'être tues
|
||
Un tenant sans inventaire généré : aucune règle, et le devis le dit. Un tenant dont
|
||
`nftables_admin_ssh` est vide : la règle SSH est **omise** plutôt qu'ouverte à `any`, ce qui
|
||
exposerait le SSH à Internet. Cas dégradé exercé.
|
||
|
||
## 2026-08-02 (suite) — la sortie générale est déclarée, pas subie
|
||
|
||
Le devis frontière se terminait par `block out log all` avec **une seule** règle sortante
|
||
(le relais SMTP). Appliqué tel quel, il coupait la flotte d'Internet : plus de `apt`, plus
|
||
de NTP, plus de récursion DNS. Rien ne le signalait — c'était la ligne la plus lourde de
|
||
conséquences du devis, posée à la suite des autres.
|
||
|
||
La sortie est désormais **déclarée dans le registre**, donc dérivée comme tout le reste :
|
||
|
||
- `serveur_debian` (le socle, porté par les 14 hôtes) — `443/tcp` et `80/tcp` pour les
|
||
dépôts apt, `123/udp` pour l'horloge. Une dérive d'horloge fait échouer la validation des
|
||
certificats step-ca et le SSO, des semaines après la cause.
|
||
- `client_unbound` — `53/udp` et `53/tcp` : `client_unbound_transitaires` est vide, donc
|
||
Unbound interroge lui-même la racine. C'est le choix souverain ; il a un coût réseau qu'il
|
||
faut déclarer. Le TCP n'est pas optionnel — c'est le repli obligatoire dès qu'une réponse
|
||
DNSSEC dépasse la taille UDP.
|
||
|
||
Le devis passe de 6 à 11 règles, et sa section 5 **énonce** le default-deny sortant, le
|
||
nombre de règles qui l'accompagnent, et où déclarer un besoin oublié — jamais à la main dans
|
||
le pare-feu, la règle serait perdue à la génération suivante.
|
||
|
||
Vérifié : les nftables d'hôte sont **inchangés**, octet pour octet. Le pair `externe` reste
|
||
sauté par `resoudre_flux.py` — ces flux relèvent de la bordure, et d'elle seule.
|
||
|
||
Non déclaré volontairement : le rôle `chrony` existe mais n'est référencé par aucun groupe,
|
||
aucun playbook ni le graphe de dépendances. Lui écrire un flux aurait créé une règle morte.
|
||
|
||
## 2026-08-02 — les interfaces se nomment par leur identifiant
|
||
|
||
`opt1`, `igb1` et `TENANTS` désignent le même port dans OPNsense : l'identifiant interne, le
|
||
périphérique FreeBSD et le libellé affiché. L'API REST ne parle que du **premier**, et c'est
|
||
lui que veulent `opnsense_if_wan` et `opnsense_if_transit`. Le libellé de ces deux intrants ne
|
||
le disait pas — la question s'est posée en pratique.
|
||
|
||
Les libellés le disent maintenant explicitement, et `docs/frontiere-opnsense.md` §6 explique
|
||
les trois couches ainsi que le motif du choix : `opt1` est le plus stable des trois, il
|
||
survit à un changement de carte réseau comme à un renommage.
|
||
|
||
La note « `opnsense_prochain_saut` dérive de l'underlay » est repliée dans l'en-tête que le
|
||
panneau régénère : une sauvegarde l'effaçait, puisque le fichier est réécrit depuis le YAML
|
||
analysé. Vérifié qu'une sauvegarde préserve valeurs **et** références de voûte.
|
||
|
||
### Corrigé — la doc portait encore l'ancien plan du `/29`
|
||
Après le renumérotage (`bifrost-1/-2` en `.1`/`.2`, SVI en `.6`), deux passages de
|
||
`docs/frontiere-opnsense.md` annonçaient toujours `10.0.4.2` comme prochain saut. La doc
|
||
contredisait le devis généré ; les `ip route` des deux coïncident désormais.
|
||
|
||
## 2026-08-01 (suite 14) — l'empreinte du root CA n'est pas un secret de voûte
|
||
|
||
`client_pki` **dérive l'empreinte à chaud** depuis l'autorité (`step certificate
|
||
fingerprint` en `delegate_to` sur `serveur_step_ca`) — précisément parce qu'un `from-zero`
|
||
régénère l'AC avec une empreinte neuve. Une empreinte figée en voûte serait périmée dès la
|
||
première reconstruction, et une empreinte périmée fait échouer le `bootstrap` de chaque hôte.
|
||
|
||
Or `defaults/main.yml` portait encore `client_pki_ca_fingerprint: "{{
|
||
vault_step_ca_fingerprint | default('') }}"`. Ce défaut était **mort** : la tâche suivante
|
||
écrase le fait sans condition. La valeur de la voûte n'avait aucun effet, quel qu'en soit le
|
||
contenu.
|
||
|
||
Le recensement de `voute.py` s'y laissait prendre — il cherche la chaîne `vault_*` dans les
|
||
fichiers, sans pouvoir savoir qu'un défaut n'est jamais lu. La « source unique » avait donc
|
||
hérité de l'erreur, et le panneau réclamait un secret impossible à fournir avant que l'AC
|
||
n'existe.
|
||
|
||
Le défaut mort est retiré, et la clé disparaît d'elle-même du recensement (25 → 24 exigés),
|
||
du gabarit et du panneau. `client_pki_ca_fingerprint_override` reste le moyen documenté
|
||
d'épingler une empreinte (AC externe, migration).
|
||
|
||
### Corrigé — une troisième copie manuelle de la liste des secrets
|
||
`docs/intrants-communs.md` §H énumérait les secrets à la main, avec les deux mêmes erreurs.
|
||
Elle renvoie désormais à `scripts/voute.py lister` et à la preuve P18 plutôt que d'entretenir
|
||
une copie de plus.
|
||
|
||
## 2026-08-01 (suite 13) — le rappel des secrets dérive de `voute.py`
|
||
|
||
`SECRETS_ATTENDUS` était une liste écrite à la main dans `inventory_gui.py`, en parallèle du
|
||
recensement que `scripts/voute.py` fait déjà depuis le plan, les rôles des groupes actifs et
|
||
les `group_vars`. Deux sources pour la même vérité, et la manuelle avait divergé :
|
||
|
||
| Clé | Panneau | Gabarit | Référencée |
|
||
|---|---|---|---|
|
||
| `vault_ldap_sssd` | annoncée | absente | **nulle part** — aucun rôle `sssd` n'existe |
|
||
| `vault_step_ca_fingerprint` | **omise** | présente | `roles/client_pki/defaults/main.yml:24` |
|
||
|
||
Un opérateur qui suivait le panneau créait donc un secret que rien ne consomme, et oubliait
|
||
celui dont `client_pki` a besoin pour vérifier l'empreinte de l'AC racine.
|
||
|
||
Le rappel dérive désormais de `voute.secrets_exiges()` — la **même source que la preuve
|
||
P18** — augmentée de `SECRETS_HORS_MOTIF` pour les jetons Proxmox, qui ne portent pas le
|
||
préfixe `vault_`. Vérifié : **27 noms, écart nul avec le gabarit**. Sur un dépôt public nu,
|
||
la liste est vide plutôt qu'en erreur.
|
||
|
||
## 2026-08-01 (suite 12) — la fabric se règle depuis la console
|
||
|
||
Tout le modèle d'underlay bâti aujourd'hui — routeur, spanning-tree, fabrics, transit —
|
||
s'éditait à la main dans un YAML, pendant que la doctrine dit qu'un sysadmin doit exploiter
|
||
l'outil **sans IA**. La frontière avait eu sa section dans le panneau ; l'underlay, non.
|
||
|
||
Une section **Fabric** couvre désormais les valeurs plates : le switch routeur, le dialecte
|
||
de CLI, le mode et la topologie de spanning-tree. Les listes de tables (`reseaux`, `hotes`,
|
||
et donc les ports) restent hors de portée du panneau — elles demandent une vue dédiée, comme
|
||
celle des serveurs.
|
||
|
||
### Le dialecte devient un intrant déclaré
|
||
Il ne vivait que dans `SETOPS_DIALECTE`, une variable d'environnement. C'est une propriété du
|
||
**matériel**, donc de la fabric : elle se déclare dans `underlay.yml`. Précédence désormais
|
||
explicite : drapeau `--dialecte` > variable d'environnement > intrant déclaré > `cisco`.
|
||
`make underlay` refuse un dialecte inconnu.
|
||
|
||
### Écriture chirurgicale, pas de `safe_dump`
|
||
`underlay.yml` porte 23 lignes de commentaires qui expliquent des décisions d'architecture —
|
||
pourquoi un seul routeur, pourquoi le transit vit dans l'underlay, pourquoi les rayons ne
|
||
sont pas des ports de bord. Un `safe_dump` les aurait toutes effacées, comme c'est arrivé aux
|
||
commentaires de `plan/applications.yml`. Le panneau remplace donc la ligne existante en
|
||
respectant son indentation. Vérifié : trois valeurs modifiées, **73 lignes et 23 commentaires
|
||
avant comme après**.
|
||
|
||
Une clef absente du fichier n'est pas créée : le panneau refuse explicitement plutôt que de
|
||
l'inventer à un endroit arbitraire.
|
||
|
||
## 2026-08-01 (suite 11) — les ports physiques entrent dans le modèle
|
||
|
||
Les noms de ports n'étaient modélisés **nulle part** : `<PORT-VERS-PROXMOX>` et consorts
|
||
étaient des marqueurs littéraux dans le générateur. L'opérateur les remplaçait à la main dans
|
||
la sortie, et recommençait **à chaque régénération**. C'était le seul endroit du devis où le
|
||
travail était perdu à répétition.
|
||
|
||
Ils se déclarent désormais par équipement dans `underlay.yml`, sous quatre clefs qui
|
||
correspondent aux quatre natures de lien :
|
||
|
||
```yaml
|
||
ports:
|
||
hyperviseurs: [Te1/0/1, Te1/0/2, Te1/0/3] # ports terminaux (portfast)
|
||
frontiere: [Gi1/0/23] # vers le pare-feu (portfast)
|
||
rayons: { sleipnir-02: Te1/0/47, … } # côté ROUTEUR
|
||
montante: Te1/0/48 # côté SWITCH D'ACCÈS
|
||
```
|
||
|
||
Le devis émet alors les vrais ports, y compris **plusieurs** vers les hyperviseurs — il n'en
|
||
supposait qu'un seul jusqu'ici, alors que le cluster en compte trois. Non déclarés, les
|
||
marqueurs reviennent : la dégradation est par équipement, un switch renseigné et un autre non
|
||
cohabitent sans problème.
|
||
|
||
La partie B devient **par switch** : adresse de gestion, montante et ports terminaux
|
||
différant d'une machine à l'autre, un bloc commun n'avait plus de sens.
|
||
|
||
`make underlay` refuse un port déclaré deux fois sur un même équipement, un rayon vers un
|
||
switch inconnu, des `rayons` sur autre chose que le routeur, une `montante` sur le routeur
|
||
lui-même. Les quatre cas exercés.
|
||
|
||
## 2026-08-01 (suite 10) — une interface, un bloc
|
||
|
||
La partie A déclarait ses ports de bord deux fois : l'interface en section 4, son `portfast`
|
||
en section 6. La partie B, elle, posait tout dans le même bloc. Deux conventions pour la même
|
||
chose dans un seul document — un opérateur qui applique la partie A section par section
|
||
configurait la même interface à deux endroits.
|
||
|
||
Le `portfast` est désormais posé **avec son interface** (sections 4 et 4b). La section 6 se
|
||
réduit à ce qui est global — mode, priorité du pont racine — plus les deux avertissements :
|
||
que les rayons de la section 4c n'en sont volontairement pas, et pourquoi BPDU guard n'est
|
||
pas émis.
|
||
|
||
Vérifié : aucune interface n'est déclarée deux fois dans une même partie, trois `portfast`
|
||
avec `stp`, zéro sans lui, zéro sur un rayon.
|
||
|
||
## 2026-08-01 (suite 9) — rayons de l'étoile séparés des ports terminaux
|
||
|
||
L'ajout du spanning-tree venait de rendre dangereuse une imprécision qui, jusque-là, n'était
|
||
qu'un titre approximatif. La section 4 s'appelait « Trunk vers Proxmox **+ inter-switch** »
|
||
et n'émettait qu'un port, que la section 6 déclarait en bord de réseau. Réutiliser ce
|
||
placeholder pour les rayons vers les switches d'accès revenait à mettre `portfast` sur les
|
||
liens qui portent les BPDU — c'est-à-dire à désactiver la protection anti-boucle exactement
|
||
là où elle sert.
|
||
|
||
Les rayons sont désormais **dérivés** et émis à part (section 4c côté routeur, B3a côté
|
||
accès), un par switch d'accès, avec l'avertissement qu'ils ne sont pas des ports de bord.
|
||
La section 4 ne désigne plus que les hyperviseurs, et la partie B distingue sa montante
|
||
(`B3a`) de son trunk terminal (`B3b`).
|
||
|
||
`underlay.switches_acces()` devient la source unique du « qui est un switch d'accès » —
|
||
utilisée pour leur devis **et** pour les rayons côté routeur : les deux ne peuvent pas
|
||
diverger.
|
||
|
||
Vérifié : trois commandes `portfast` émises avec `stp` déclaré, **aucune** sans lui, et
|
||
**aucune** sur un rayon.
|
||
|
||
## 2026-08-01 (suite 8) — spanning-tree dérivé de la topologie déclarée
|
||
|
||
### Ajouté — `underlay.stp` et les sections 6 / B4
|
||
Le devis ne disait rien du spanning-tree. Sur une fabric convergée à trois switches, une
|
||
boucle par brassage accidentel n'est pas discrète : c'est une tempête de diffusion.
|
||
|
||
La topologie se déclare (`mode: rstp`, `topologie: etoile`) et le devis en tire la
|
||
configuration. Le **routeur est désigné pont racine** — il est le centre de l'étoile, tous
|
||
les chemins passent déjà par lui, donc l'arbre logique suit le câblage physique au lieu de
|
||
sortir d'une élection arbitraire. Les switches d'accès reçoivent une priorité volontairement
|
||
haute : ils ne doivent jamais devenir racine.
|
||
|
||
Les ports terminaux (hyperviseurs, frontière) sont déclarés en bord de réseau. **BPDU guard
|
||
n'est délibérément pas émis** : un pont Linux dont le STP serait activé enverrait des BPDU et
|
||
ferait tomber le port côté hyperviseur. Le devis dit pourquoi, et à quelle condition
|
||
l'ajouter.
|
||
|
||
En étoile, aucun lien n'est redondant : RSTP est un filet, pas une nécessité — le devis le
|
||
dit plutôt que de laisser croire à une protection indispensable.
|
||
|
||
`make underlay` valide `mode` et `topologie`, et refuse un `stp` déclaré sans `routeur` :
|
||
sans lui, aucun pont racine ne peut être désigné. Sans `stp`, la section signale l'absence
|
||
de protection au lieu de disparaître.
|
||
|
||
Réserve consignée : la forme `binardat` des lignes de spanning-tree n'a pas été confrontée au
|
||
matériel, comme les `ip route`.
|
||
|
||
## 2026-08-01 (suite 7) — l'underlay connaît ses fabrics physiques
|
||
|
||
Le stockage jumbo (iSCSI, Ceph) est porté par un **réseau indépendant de deux switches
|
||
10G**, sans câble commun avec la fabric convergée des `sleipnir`. Le modèle l'ignorait :
|
||
le devis déclarait les VLAN 20/30/31 sur les switches convergés et les mettait dans leurs
|
||
trunks. C'était faux.
|
||
|
||
Chaque réseau de l'underlay porte désormais une `fabric` (`principal` par défaut). Le devis
|
||
ne configure que celle du routeur, et **énonce ce qu'il ne couvre pas** plutôt que de le
|
||
taire :
|
||
|
||
```
|
||
! HORS PERIMETRE — fabric 'stockage' : stockage-iscsi (VLAN 20), ceph-public (VLAN 30), …
|
||
! Portee par des switches distincts, sans cable commun avec celle-ci :
|
||
! ni VLAN a declarer ici, ni trunk, ni spanning-tree partage.
|
||
```
|
||
|
||
Conséquence directe : la question du spanning-tree ne se pose que sur la fabric principale —
|
||
trois switches — et pas sur le stockage, dont les deux switches forment un domaine séparé.
|
||
|
||
Le roster d'hôtes de la section 0 est filtré de la même façon : un équipement d'une autre
|
||
fabric n'apparaît pas, même en commentaire. Un devis est une configuration qu'on applique,
|
||
pas un inventaire. Vérifié en déclarant un hôte de la fabric stockage — il reste absent, et
|
||
la sortie du jour est inchangée.
|
||
|
||
Les `deny` de l'ACL couvrent en revanche **toutes** les fabrics, y compris celles hors
|
||
périmètre : la règle porte sur l'adresse de destination, pas sur le câblage. Si un chemin
|
||
s'ouvre un jour vers le stockage, il est déjà fermé.
|
||
|
||
## 2026-08-01 (suite 6) — nommage : `bifrost` aux frontières, `sleipnir` à la fabric
|
||
|
||
`bifrost-1` et `bifrost-2` sont réservés aux deux frontières OPNsense — Bifröst est le pont
|
||
vers l'extérieur. Les switches internes deviennent `sleipnir-01…03` : le cheval qui traverse
|
||
les mondes, pas le pont qui en sort. La division du nom suit celle de l'architecture.
|
||
|
||
Les deux boîtiers sont déclarés comme hôtes du lien de transit (`10.0.4.1`, `10.0.4.2`) :
|
||
hors flotte Ansible, la déclaration documente le lien et réserve les noms. Le `/29` choisi
|
||
plus tôt les loge tous les deux, comme prévu.
|
||
|
||
Le plan du `/29` est réorganisé en conséquence : frontières en bas (`.1`, `.2`), SVI du
|
||
switch en haut (`.6`), et `.3` laissée libre pour une future IP virtuelle CARP si les deux
|
||
OPNsense passent en haute disponibilité. Ce jour-là, `passerelle_sortie` pointera sur la VIP
|
||
plutôt que sur un boîtier nommé — un seul endroit à changer.
|
||
|
||
Conséquence à traiter : la partie B du devis switch aurait listé les deux pare-feux parmi les
|
||
« switches d'accès ». Elle ne retient désormais que les hôtes du **réseau de management** —
|
||
un équipement déclaré ailleurs ne reçoit aucune ligne de configuration de switch.
|
||
|
||
Le devis frontière nomme le boîtier quand il est déclaré : `frontiere 10.0.4.1 (bifrost-1)`.
|
||
|
||
## 2026-08-01 (suite 5) — deux incohérences du devis switch
|
||
|
||
### Corrigé — le routeur avait deux adresses de gestion contradictoires
|
||
`bifrost-01` portait le SVI `Vlan10 → 10.0.0.1` **et** était déclaré dans `underlay.hotes`
|
||
à `10.0.0.2`. Une interface VLAN n'a qu'une adresse primaire : les deux ne pouvaient pas
|
||
être vraies. L'entrée datait d'avant la désignation du routeur, quand `10.0.0.1` était une
|
||
passerelle abstraite.
|
||
|
||
Le routeur est désormais déclaré à l'adresse du SVI qu'il porte, et `make underlay` **refuse**
|
||
la divergence : `ip 10.0.0.2 != passerelle 10.0.0.1 — le routeur porte ce SVI, les deux
|
||
doivent coïncider`. Le roster des trois switches reste complet.
|
||
|
||
### Corrigé — VLAN de transit déclaré sur les switches d'accès
|
||
La partie B créait `vlan 40` alors que le trunk B3 ne le transporte pas — le transit ne relie
|
||
que le routeur à la frontière. Un VLAN qui n'aurait jamais vu de trame. Il est exclu de la
|
||
partie B, comme il l'est déjà des trunks généraux.
|
||
|
||
## 2026-08-01 (suite 4) — un seul switch route, les autres en L2 pur
|
||
|
||
### Décidé — `underlay.routeur`
|
||
Sans MLAG, le routage est porté par un **unique** switch (`bifrost-01` chez Chezlepro) ; les
|
||
autres restent en L2 pur. Le devis émettait jusqu'ici un jeu unique de SVI sans dire à quel
|
||
switch il s'adressait : appliqué sur les trois, il aurait créé autant de conflits d'adresses
|
||
qu'il y a de zones.
|
||
|
||
### Ajouté — le devis se scinde en deux parties
|
||
- **Partie A — switch routeur** : VLANs, SVI, ACL d'isolation, trunks, routes. Va sur lui
|
||
seul, et l'en-tête le nomme.
|
||
- **Partie B — switches d'accès (L2 pur)** : mêmes VLANs pour commuter les trames étiquetées,
|
||
une adresse de gestion par switch (tirée de `underlay.hotes`) avec `ip default-gateway`
|
||
vers le routeur, et les trunks. Aucun SVI de zone, aucune ACL, aucune route.
|
||
|
||
Deux gardes : `make underlay` refuse un `routeur` qui ne nomme aucun hôte déclaré ; et la
|
||
partie B avertit qu'un seul de ses blocs de gestion va sur chaque machine — les coller tous
|
||
écraserait l'adresse. Sans `routeur` désigné, l'en-tête signale explicitement le risque de
|
||
duplication au lieu de laisser croire que le devis est applicable partout.
|
||
|
||
Reste ouvert et consigné : la syntaxe des `ip route` n'est pas dialecte-consciente,
|
||
contrairement aux ACL.
|
||
|
||
## 2026-08-01 (suite 3) — les tenants n'atteignent plus l'underlay
|
||
|
||
### Corrigé — ACL de switch : `deny` vers la fabric physique
|
||
L'ACL d'isolation bloquait l'autre tenant, puis se terminait par `permit ip <tenant> any`.
|
||
Ce `any` autorisait `10.27.x` → `10.0.0.0/24` : le management des switches, celui de Proxmox
|
||
et l'**OOB/IPMI**, plus les réseaux iSCSI et Ceph. Une VM compromise atteignait la console
|
||
physique des hyperviseurs.
|
||
|
||
Le commentaire du générateur disait « Reste → passerelle OPNsense », mais c'est faux pour
|
||
l'underlay : ce trafic est routé **localement** par le switch et ne passe jamais par la
|
||
frontière, donc il n'est jamais filtré par elle.
|
||
|
||
`devis_reseau.py` émet désormais un `deny` par sous-réseau underlay avant le `permit` final,
|
||
dérivé de `underlay.yml` — dialecte respecté (masque normal ou wildcard). Aucun flux du
|
||
registre ne vise l'underlay : le blocage ne casse rien de déclaré.
|
||
|
||
Deux limites consignées dans `docs/frontiere-opnsense.md` §6 : le registre des flux n'a pas
|
||
de mot-clé `underlay`, donc un besoin légitime (superviser l'hyperviseur) ne pourrait pas
|
||
être déclaré ; et `devis_reseau.py` émet un jeu unique de SVI pour trois switches sans MLAG,
|
||
ce qui reste une décision d'architecture ouverte.
|
||
|
||
## 2026-08-01 (suite 2) — le port vers la frontière, et l'ordre d'application
|
||
|
||
### Ajouté — section 4b : le port du switch vers la frontière
|
||
Le devis étiquetait le VLAN de transit sur le trunk `<PORT-VERS-PROXMOX>` et n'émettait
|
||
aucune interface vers le pare-feu. Deux erreurs en une : les hyperviseurs n'ont pas
|
||
d'interface sur le transit, et le pare-feu n'a rien à faire des VLAN tenants — il route vers
|
||
eux, il ne les étiquette pas. Le VLAN de transit sort donc du trunk Proxmox et prend son
|
||
propre port, dérivé du transit déclaré.
|
||
|
||
### Ajouté — avertissement d'ordre en tête de la section 5
|
||
Les routes de la section 5 déplacent la sortie du switch, **y compris celle de ses propres
|
||
réponses**. Tant que l'adresse de la frontière ne répond pas, elles coupent l'accès
|
||
d'administration au switch lui-même — le mécanisme qui a rendu une VM muette le 2026-07-29,
|
||
appliqué cette fois à l'équipement depuis lequel on travaille.
|
||
|
||
Le devis crachait ces lignes à la suite des autres comme si elles étaient équivalentes.
|
||
Il énonce désormais les préalables (boîtier câblé, adressé, joignable depuis le switch,
|
||
session console ouverte) — inacceptable autrement pour un outil censé être exploitable sans
|
||
IA.
|
||
|
||
## 2026-08-01 (suite) — le prochain saut dérive du transit
|
||
|
||
`opnsense_prochain_saut` n'est plus un intrant du panneau : il **dérive** du réseau de
|
||
transit de l'underlay (la `passerelle` du réseau portant `passerelle_sortie`).
|
||
|
||
Un seul bloc de six lignes alimente désormais les deux devis : `10.0.4.1` devient le SVI
|
||
côté switch **et** le prochain saut des routes tenants côté frontière ; `10.0.4.2` devient
|
||
la route par défaut du switch. Le saisir en doublon dans le panneau rouvrait la possibilité
|
||
de deux valeurs contradictoires pour un seul lien — précisément le mode de panne qu'on venait
|
||
de fermer pour les réseaux d'administration.
|
||
|
||
Le devis frontière gagne au passage un bloc `transit` (JSON compris) et cesse de réclamer en
|
||
section 0 des routes que `make devis-reseau` émet maintenant : il dit lesquelles sont **déjà
|
||
émises**, ou signale l'absence de transit déclaré. Sans underlay, le marqueur
|
||
`<PROCHAIN-SAUT-SWITCH>` revient — le repli reste explicite.
|
||
|
||
Reste un seul intrant à figer au câblage : `opnsense_if_transit`, le nom de l'interface qui
|
||
porte le VLAN 40 sur le boîtier — la seule valeur que rien ne peut deviner.
|
||
|
||
## 2026-08-01 — le lien de transit et les deux routes (la boucle est fermée)
|
||
|
||
### Ajouté — réseau de transit dans l'underlay (clé `passerelle_sortie`)
|
||
Le lien entre le routeur est-ouest (switches L3) et la frontière nord/sud manquait dans
|
||
**tous** les fichiers : le devis switch ne contenait pas une seule `ip route`.
|
||
|
||
Il vit dans l'**underlay** et non dans un tenant, pour une raison qui tranche : la frontière
|
||
route vers tous les supernets tenants par le **même** prochain saut. Le lien est donc partagé
|
||
et ne peut dériver d'aucun `index`. Un réseau underlay portant `passerelle_sortie` (l'adresse
|
||
du pare-feu sur le lien) le déclare — chez Chezlepro : VLAN 40, `10.0.4.0/29`, SVI `10.0.4.1`,
|
||
frontière `10.0.4.2`. Un `/29` et non un `/30` parce que deux pare-feux cohabitent pendant la
|
||
transition.
|
||
|
||
`underlay.py` valide la nouvelle clé (sortie sur le lien, distincte du SVI, SVI obligatoire,
|
||
un seul transit) — **preuve P23**, cas de rejet exercés un par un.
|
||
|
||
### Ajouté — `devis-reseau` émet les routes (section 5)
|
||
Deux routes dérivées, et il en faut impérativement deux :
|
||
- **l'aller** : `ip route 0.0.0.0 0.0.0.0 <sortie>` — sans elle, aucun hôte n'a de sortie ;
|
||
- **le retour** : une route par réseau d'administration — sans elle, la réponse d'une VM
|
||
revient au pare-feu par une autre interface que celle où l'état a été créé, et se fait
|
||
jeter en silence. C'est le piège qui a coûté la passe de déploiement du 2026-07-29.
|
||
|
||
Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` : **même source
|
||
unique** que la garde anti-lockout des nftables et l'alias `SETOPS_ADMIN` de la frontière —
|
||
les trois pare-feux et les routes ne peuvent pas diverger. Sans transit déclaré, la section
|
||
s'affiche en clair comme manquante plutôt que de disparaître silencieusement.
|
||
|
||
### Corrigé — le panneau refusait d'enregistrer les intrants de la frontière
|
||
`group_vars/opnsense.yml` porte à la fois des paramètres anodins et deux **références** de
|
||
voûte (`{{ vault_opnsense_api_key }}`). La fusion « préserve les clés non gérées » les
|
||
relisait, et le garde-fou, qui ne regardait que les **noms**, les prenait pour des secrets
|
||
soumis. Il regarde désormais la **valeur** : une référence de voûte est un pointeur et passe ;
|
||
toute valeur réelle sur ces clés fait toujours échouer l'écriture. Ajout d'un refus explicite
|
||
à l'entrée pour une requête forgée, au lieu d'un abandon silencieux.
|
||
|
||
### Retiré — reliquat `proxmox.vault.yml`
|
||
La voûte est **unique** (`group_vars/all/vault.yml`) ; l'ancien fichier séparé n'était plus
|
||
chargé automatiquement (aucun groupe `proxmox` dans l'inventaire) et entretenait la confusion.
|
||
Supprimé de l'instance, avec son gabarit.
|
||
|
||
Au passage : `supprimer_vm_debian.yml` ne chargeait **que** ce reliquat pour ses secrets. Le
|
||
supprimer tel quel aurait cassé `make detruire`, l'outil même du rebuild from-zero. Sa liste
|
||
est alignée sur celle du playbook de clonage (`all/vault.yml` en dernier, il l'emporte), et la
|
||
résolution du jeton depuis la voûte unique est vérifiée en exécution réelle.
|
||
|
||
## 2026-07-29 (suite) — la frontière OPNsense, dérivée du registre des flux
|
||
|
||
### Ajouté — `make devis-opnsense` (+ preuve P24)
|
||
La bordure nord/sud devient un **artefact dérivé**, comme le devis switch. Rien de saisi à la
|
||
main : ni port, ni adresse, ni nom d'hôte n'apparaît dans le générateur.
|
||
|
||
Le constat qui rend la chose évidente : `resoudre_flux.py` **saute volontairement** les flux
|
||
`pair: externe` (`scripts/resoudre_flux.py:184`) parce qu'ils ne concernent pas le pare-feu
|
||
d'hôte. Plusieurs `raison` du registre disent déjà « filtré à l'OPNsense ». **La politique de
|
||
la frontière était donc déjà écrite** — il ne restait qu'à la dériver.
|
||
|
||
- **`scripts/devis_opnsense.py`** — agrège les flux `externe`, résout les destinations depuis
|
||
l'inventaire (hôtes actifs **et** planifiés : la frontière se prépare avant les VM), les
|
||
supernets depuis les nomenclatures fédérées, et l'accès d'administration depuis l'intrant
|
||
`nftables_admin_ssh`. Sort un devis relisible ou `--json` (destiné à l'API OPNsense).
|
||
- **Garde anti-lockout (P24)** — `--verifier` **refuse** un devis dont `nftables_admin_ssh`
|
||
est vide : la règle SSH entrante n'aurait aucune source et le `block in` final fermerait
|
||
l'accès d'administration. Même intrant que les nftables d'hôte : source unique, donc pas de
|
||
divergence possible entre la bordure et les hôtes.
|
||
- **Section 0 du devis : les routes de retour à poser sur les switches.** C'est le piège qui a
|
||
coûté la passe de déploiement du jour — la passerelle de zone répond au ping, mais aucun
|
||
hôte derrière elle n'est joignable, parce que la réponse revient au pare-feu par une autre
|
||
interface que celle où l'état a été créé.
|
||
- **`docs/frontiere-opnsense.md`** — les décisions d'architecture (frontière nord/sud, les SVI
|
||
restent sur les switches L3), le partage des rôles entre les trois pare-feux, le chemin
|
||
d'application par l'API, et ce qui reste ouvert (câblage, second VPN, sortie générale).
|
||
|
||
### Corrigé — intrant `nftables_admin_ssh` vide sur l'instance Chezlepro
|
||
Il valait `[]` alors que `group_vars/hotes_actifs.yml` active `nftables_baseline_enabled`.
|
||
Autrement dit : la flotte se serait mise en `policy drop` sans **aucune** règle autorisant le
|
||
contrôleur, qui arrive par le VPN hors du sous-réseau de la flotte. Renseigné à
|
||
`192.168.255.0/24`. Le sous-réseau du second VPN (OPNsense) devra y être ajouté.
|
||
|
||
### Ajouté — la frontière devient réglable depuis la console (section *Frontière*)
|
||
Les valeurs non sensibles du pare-feu de bordure sont de **vrais intrants**, pas un fichier
|
||
YAML tenu à la main : un opérateur règle la frontière depuis le panneau « Intrants de base »,
|
||
sans IA et sans éditeur.
|
||
|
||
- **`INTRANTS_SCHEMA`** — nouvelle section *Frontière* : `opnsense_api_url`,
|
||
`opnsense_api_verifier_certs`, `opnsense_if_wan`, `opnsense_if_transit`,
|
||
`opnsense_prochain_saut`. Cible d'écriture `group_vars/opnsense.yml`, en **fusion** (comme
|
||
Proxmox) pour préserver les références de voûte du fichier. Le panneau rend ses sections
|
||
génériquement : aucune modification d'interface n'a été nécessaire.
|
||
- **Garde-fou** — `opnsense_api_key` / `opnsense_api_secret` ajoutés à
|
||
`INTRANTS_CLES_INTERDITES` : le GUI **refuse** de les écrire, donc impossible de coller un
|
||
secret d'API dans le panneau par mégarde. Ils n'apparaissent qu'en lecture seule, par leur
|
||
nom, dans `SECRETS_ATTENDUS`.
|
||
- **`devis_opnsense.py`** lit désormais ces intrants et n'affiche ses marqueurs
|
||
(`<IF-TRANSIT>`, `<PROCHAIN-SAUT-SWITCH>`) qu'en repli : le devis se complète de lui-même
|
||
dès que la console est renseignée.
|
||
|
||
### Ajouté — les identifiants d'API de la frontière, dans la voûte
|
||
Même partage que Proxmox : l'anodin en clair, le secret dans la voûte unique de l'instance.
|
||
|
||
- **`group_vars/opnsense.yml`** (instance, en clair) — URL de gestion, interfaces et prochain
|
||
saut à figer au câblage, plus les **références par nom** `{{ vault_opnsense_api_key }}` et
|
||
`{{ vault_opnsense_api_secret }}`. Aucune valeur de secret n'y figure.
|
||
- **Gabarit de voûte** — les deux clés ajoutées à `vault.yml.example`. `scripts/voute.py` les
|
||
a recensées **tout seul** depuis les `group_vars` (il ne lit jamais la voûte, il ne compare
|
||
que des noms) : le gabarit passe de 23 à 25 secrets, et la **preuve P18** reste verte.
|
||
|
||
Validation : `make verifier` vert — **24 preuves CONFORME**, 0 échec, 0 sauté.
|
||
|
||
## 2026-07-29 — dette documentaire soldée (un README par rôle) + carte revue
|
||
|
||
### Ajouté — les 12 README de rôles manquants
|
||
**Tous les rôles ont désormais un README.** Les 12 restants sont écrits, au format maison
|
||
(intention → rôle → variables → notes/limites → prérequis), et documentent surtout ce qui
|
||
ne se lit pas dans les tâches :
|
||
|
||
- **Socle et résolution** — `serveur_debian` (rôle-catégorie sans tâches : il ne porte que le
|
||
flux SSH du plan de gestion, *sans quoi les nftables couperaient l'accès Ansible*),
|
||
`hosts_statiques` (le plancher `/etc/hosts` qui rend l'ordre de reconstruction possible).
|
||
- **Rôles utilitaires** — `resoudre_base` et `resoudre_annuaire` : entrées/sorties (facts),
|
||
et *pourquoi* le FQDN plutôt que le nom court (fédération + `verify-full`).
|
||
- **Courriel** — `serveur_dovecot` (les trois réglages Dovecot 2.4 qui conditionnent la
|
||
remise ; le local-part seul comme chemin commun LMTP/IMAP), `serveur_postfix` (liens
|
||
`mailstore`/`milter`, recopie de `/etc/hosts` dans le chroot), `serveur_rspamd` (clé DKIM
|
||
idempotente ; le domaine signé doit être **public** en prod).
|
||
- **Sauvegardes** — `client_backup` (jobs déclaratifs, chiffrement côté client, *le dépôt
|
||
neuf est vide tant qu'une première sauvegarde n'a pas tourné*) et `serveur_backup`
|
||
(hors-nœud ≠ hors-site).
|
||
- **SSO et supervision** — `serveur_oauth2_proxy` (le patron réutilisable Keycloak-devant-
|
||
n'importe-quoi ; pourquoi `allow_unverified_email` est nécessaire avec un annuaire LDAP)
|
||
et `serveur_icingaweb2` (modes `ldap` vs `external`, et l'écoute à restreindre en SSO).
|
||
- **`client_unbound`** — le garde-fou de bascule du résolveur (`apply` **et** `confirm`,
|
||
validation avant de toucher `/etc/resolv.conf`).
|
||
|
||
### Corrigé — `docs/carte-set-ops.md` ne décrivait plus l'état du code
|
||
- **`expose` est consommé au déploiement** (la carte l'annonçait encore comme « Phase 3 à
|
||
venir ») : vhosts nginx dérivés via `expositions_des_applications` +
|
||
`expositions.conf.j2`, alias `/etc/hosts`, SANs d'edge dérivés par `instancier.py`.
|
||
- **Trois mécanismes transverses ajoutés au catalogue** : résolution d'annuaire
|
||
(`resoudre_annuaire`), plancher de résolution (`hosts_statiques`), et `resoudre_base`
|
||
nommé dans la ligne des bindings app→base.
|
||
- **Cinq entrées d'index ajoutées** : réseau/pare-feu, ordre de déploiement, preuve/recette,
|
||
exploitation courante, wiki — des pans entiers du corpus n'étaient pas indexés.
|
||
- Reste ouvert, explicitement : `meta/liens.yml` sur le seul `serveur_postfix`, et `requiert`
|
||
non consommé au déploiement (indice applicatif ; l'ordre, lui, vient des couches+graphe).
|
||
|
||
Validation : `make verifier` vert (ansible-lint 514 fichiers, tests, syntax-check,
|
||
**23 preuves CONFORME**).
|
||
|
||
## 2026-07-28 — figures annotées dans le wiki (console d'exploitation)
|
||
|
||
### Ajouté — les 8 vues de la console illustrées, dans le wiki
|
||
La série des figures annotées (une par vue du GUI, en **SVG auto-contenu** : capture + repères
|
||
intégrés en base64) est désormais **intégrée** à l'unité wiki
|
||
[`Le GUI (console d'exploitation)`](wiki/Le-GUI-console-d-exploitation.md), en fin de section ②.
|
||
Vues éditables (Serveurs, Applications, Bases × 2, Domaines) puis dérivées (Flux, Couches, Réseau).
|
||
|
||
- **Symlink `wiki/img → ../docs/img`** — foyer unique des figures dans `docs/img/` ; les pages wiki
|
||
y réfèrent en relatif (`img/*.svg`) sans duplication dans l'arbre source.
|
||
- **`make wiki-publier`** embarque désormais les `docs/img/*-annote.svg` (déréférencés) dans le
|
||
wiki Forgejo publié — c'étaient jusqu'ici les seules `.md` qui voyageaient, donc aucune image.
|
||
|
||
## 2026-07-24 — underlay (fabric physique, cluster-global)
|
||
|
||
### Ajouté — l'underlay comme concept de premier plan
|
||
Le modèle dérive l'adressage **par tenant** (VLAN `1000+index×10+zone`), mais la **fabric
|
||
physique** qui porte la flotte — mgmt des switches, mgmt Proxmox/OOB, iSCSI, Ceph — n'appartient
|
||
à aucun tenant et ne dérive d'aucun `index`. Elle vit dans le *sous-sol* du modèle. Jusqu'ici
|
||
elle n'était pas codifiée. Elle l'est.
|
||
|
||
- **`scripts/underlay.py`** + **`make underlay`** — charge/affiche/**valide** `underlay.yml` :
|
||
réseaux (nom, VLAN, sous-réseau, passerelle optionnelle → SVI, MTU/jumbo) et hôtes fixes
|
||
documentés (les switches). La validation refuse toute **collision avec la plage tenant** :
|
||
VLAN < 1000, sous-réseaux hors des supernets `10.(10+index).0.0/16`.
|
||
- **`underlay.yml`** (racine du moteur, **gitignore** comme le vault ; gabarit public
|
||
`underlay.yml.example`), surchargeable par `SETOPS_UNDERLAY`. Absent → tout reste inchangé.
|
||
- **`make devis-reseau`** émet désormais une **section 0. Underlay** (VLANs, SVI, hints jumbo,
|
||
IP des switches en commentaire) et ajoute les VLAN underlay au **trunk** Proxmox, avant les
|
||
tenants. Respecte le dialecte (`cisco`/`binardat`).
|
||
- **Preuve P23** — `underlay.py --verifier` : la fabric n'empiète pas sur la plage tenant.
|
||
**Sautée** (⚪) si `underlay.yml` est absent (dépôt public), comme P16 sans vault.
|
||
|
||
Le plafond tenant (245) est inchangé : l'underlay occupe `10.0.0.0/16 .. 10.10.0.0/16`, laissé
|
||
libre par la dérivation (index ≥ 1 → `10.11+`).
|
||
|
||
## 2026-07-23 (suite 7)
|
||
|
||
### Ajouté — dialecte de CLI du commutateur (`devis-reseau`)
|
||
Constat de l'opérateur : son switch est un **Binardat**, dont la CLI diffère de Cisco sur
|
||
deux points que le devis généré ignorait — et deux pièges qui font passer un VLAN mais fuir
|
||
un tenant :
|
||
|
||
- **Masque d'ACL** — Cisco veut un masque **inversé** (wildcard `0.0.255.255`), Binardat un
|
||
masque **normal** (`255.255.0.0`). Le SVI (`ip address … 255.255.255.0`) était déjà normal,
|
||
donc valide sur les deux.
|
||
- **`remark`** — Binardat n'a pas la commande de commentaire d'ACL de Cisco. Ces lignes
|
||
faisaient rejeter le bloc.
|
||
|
||
**`scripts/devis_reseau.py`** gagne un **dialecte** (`cisco` par défaut, `binardat`) :
|
||
`masque_acl()` choisit wildcard ou masque normal ; `remarque()` omet les `remark` en Binardat.
|
||
Réglable par `--dialecte`, par `SETOPS_DIALECTE`, ou `make devis-reseau DIALECTE=binardat`. Le
|
||
GUI (lecture seule) suit l'env. Le code public reste **générique** (défaut `cisco`).
|
||
|
||
## 2026-07-23 (suite 6)
|
||
|
||
### Ajouté — plan de recette (le pendant manuel de `make prouver`)
|
||
Constat de l'opérateur : les 78 exercices « ④ À toi de jouer » du wiki forment, ensemble, un
|
||
**plan de tests d'acceptation**. Formalisé, sans dupliquer :
|
||
|
||
- **`scripts/plan_recette.py`** + **`make plan-recette`** — **génère** `docs/audit/plan-de-recette.md`
|
||
depuis les exercices du wiki : une grille auto-contenue (le geste inline) par unité, colonnes
|
||
*Ce qu'on éprouve · Le geste · Type (observe/casse-répare) · **Preuve auto***. La dernière colonne
|
||
est extraite du texte (le `Pxx` que l'exercice mentionne) : elle montre quels gestes manuels sont
|
||
**aussi** gardés par la machine. Étant générée, la grille **ne peut pas dériver** du wiki.
|
||
- **Preuve P22** — `plan_recette.py --verifier` échoue si le fichier committé n'est plus à jour
|
||
(le wiki a changé sans régénérer). Le plan de recette devient un artefact **auto-gardé**.
|
||
- **Honnêteté de couverture** assumée dans le document : un « — » = **manuel seul** (aucune preuve
|
||
machine ne le double) ; le plan ne prétend pas à l'exhaustivité au-delà des exercices du wiki.
|
||
|
||
C'est le **pendant humain** de `make prouver` : le harnais prouve le *moteur* (P01–P21), la recette
|
||
valide l'*exploitation* — et sert de checklist au `protocole-operateur-independant.md` (« exploitable
|
||
sans IA »).
|
||
|
||
Validé : 78 gestes sur 19 unités, 5 doublés d'une preuve `Pxx` ; P22 testée (détecte une dérive) ;
|
||
`make verifier` → **CONFORME 22/22** (contre une instance cohérente).
|
||
|
||
## 2026-07-23 (suite 5)
|
||
|
||
### Ajouté — wiki : l'axe « méthode » (KB enrichie)
|
||
Le wiki enseignait les fondamentaux *services* (identité, PKI, courriel…) mais pas la
|
||
*méthode* de Set-OPS. Cinq pages ajoutées, au moule à 4 temps (concept → Set-OPS →
|
||
transférable → à toi de jouer), avec exercices concrets :
|
||
|
||
- **Le plan & l'adressage dérivé** — un seed (`index`), tout en découle (DRY, source unique).
|
||
- **Multi-instance & fédération** — un moteur, N écosystèmes ; découverte par convention.
|
||
- **La preuve** — « ne jamais affirmer plus que ce qu'on prouve » ; le registre, `make prouver`,
|
||
P01–P21.
|
||
- **Le GUI (console d'exploitation)** — éditer la source, dry-run avant apply, l'invalide
|
||
impossible à saisir (le `<select>` sans hôte fantôme) ; la flotte et la bascule.
|
||
- **Glossaire** — 24 concepts en une phrase (était « à venir »).
|
||
|
||
Raccordées dans `Home.md` (deux unités-pilotes : services **et** méthode) et `_Sidebar.md`
|
||
(section « Flotte & preuve »). Fidèle à la doctrine : le wiki pointe vers `docs/`, ne recopie
|
||
pas. Publié via `make wiki-publier`. Wiki : 855 → 1573 lignes, 22 pages, aucun lien mort.
|
||
|
||
## 2026-07-23 (suite 4)
|
||
|
||
### Ajouté — créer un MODÈLE (`make model-creer`, dépôt privé)
|
||
Symétrique de `instance-creer`, mais produit un modèle réutilisable dans le dépôt PRIVÉ
|
||
(`Set-OPS-Modeles/`, jamais `exemples/modeles/`). `scripts/model_creer.py`, deux modes :
|
||
|
||
- **`MODE=base BASE=<modele> NOM=<x>`** — copie un modèle déjà générique (copier + éditer).
|
||
- **`MODE=instance SOURCE=OPS-<x> NOM=<y>`** — **promeut une instance éprouvée en modèle** :
|
||
généralise l'identité (`domaine → exemple.internal`, organisation → `Exemple`, realm →
|
||
`exemple`), fixe `index → 1`, `setops_production → false`, `nftables_admin_ssh → []`, vide
|
||
la clé publique de sauvegarde, générique `proxmox.yml` (host/nœud/stockage/VMID vidés,
|
||
golden template → `modele-debian13`).
|
||
|
||
Sûreté : **aucun secret** ne sort (`vault.yml`/`proxmox.vault.yml` jamais copiés ; refus si
|
||
l'un subsiste ; le `.example` est conservé). Copie **ciblée** (plan/ + inventories/ seulement,
|
||
symlinks résolus) — robuste aux dépôts imbriqués et boucles de symlinks. Le modèle produit
|
||
**doit valider** (`modeles.py verifier`), sinon il est annulé. `make model-creer`.
|
||
|
||
Validé : les deux modes testés en isolement (destination tmp, dépôt privé jamais touché) —
|
||
promotion du labo (cohérent) réussie + validée, aucun `chezlepro` résiduel, aucun secret,
|
||
`proxmox.yml` génériqué ; copie base (socle) OK ; refus d'une instance INVALIDE (le garde-fou
|
||
a détecté un hôte fantôme dans une instance en cours d'édition). `node --check`, `make verifier`
|
||
rc=0 CONFORME 21/21.
|
||
|
||
## 2026-07-23 (suite 3)
|
||
|
||
### Ajouté — créer une instance depuis un modèle (CLI + GUI)
|
||
Le moteur est indépendant des instances (le symlink `instance/` est gitignoré, aucun
|
||
artefact d'instance n'est committé). Créer une instance existait seulement à la main
|
||
(`cp -r` + `ln -s`, QUICKSTART). C'est désormais une capacité de premier ordre.
|
||
|
||
- **`scripts/instance_creer.py`** — copie un modèle (`exemples/modeles/*` + `SETOPS_MODELES`)
|
||
vers un dépôt frère `../<nom>`, y fixe l'`index` (le seed), et **refuse** : un nom déjà
|
||
existant (rien n'est écrasé), un modèle inconnu, un **index en collision** avec une
|
||
instance fédérée (le garde-fou vérifie AVANT toute copie). Le `.git` et
|
||
`hosts.genere.yml` du modèle ne sont pas copiés.
|
||
- **`make instance-creer NOM=OPS-X MODELE=socle [INDEX=N]`** et **`make instance-modeles`**
|
||
(liste les modèles + les index déjà pris).
|
||
- **GUI, vue Réseau** — formulaire « Créer une instance depuis un modèle » sous la flotte :
|
||
modèle (liste), nom, index. `POST /api/instance-creer` ; `/api/instances` renvoie aussi
|
||
`modeles` et `index_pris`. Ne bascule pas l'active (geste explicite).
|
||
|
||
### Corrigé
|
||
- **Gabarit de voûte du labo complété.** P18 (voûte) a échoué en passant l'active sur le
|
||
labo : son `vault.yml.example` était resté à l'ancienne version (17 clés, sans
|
||
`vault_restic_password`, `vault_oauth2_cookie`, etc.). Aligné sur le gabarit complet
|
||
(23 secrets). P18 fait exactement son travail — attraper un gabarit incomplet, quelle que
|
||
soit l'instance active.
|
||
|
||
Validé : garde-fous de création testés en isolement (collision d'index refusée avant copie,
|
||
écrasement refusé, modèle inconnu refusé, aucune pollution des dépôts frères) ; `node --check`
|
||
du GUI ; `make verifier` rc=0 **CONFORME 21/21** (active = labo).
|
||
|
||
## 2026-07-23 (suite 2)
|
||
|
||
### Ajouté — bascule d'instance depuis le GUI (vraiment multi-instance)
|
||
Demande explicite et répétée de l'opérateur : gérer les instances **depuis le GUI**, pas
|
||
seulement en CLI. Le plan de contrôle reste maison, mais cette capacité y entre.
|
||
|
||
- **Inventaire résolu dynamiquement.** Le serveur GUI figeait l'inventaire au démarrage
|
||
(`args.inventaire.resolve()`). Il est désormais relu **à chaque requête** depuis le
|
||
symlink `instance/` (propriété `Gestionnaire.inventaire`) — la bascule prend effet sans
|
||
redémarrer. Les chemins de plan (`FICHIER_*`) étaient déjà relatifs au symlink et suivent
|
||
de même. `SETOPS_INVENTAIRE` force encore un inventaire fixe (CI).
|
||
- **`POST /api/instance-utiliser`** + `basculer_instance(nom)` — repointe le symlink avec
|
||
les garde-fous de `make instance-utiliser`, plus une **validation stricte** : `nom` doit
|
||
être une instance **découverte** (dossier frère), ce qui interdit toute traversée de
|
||
chemin (testé : `../etc` refusé).
|
||
- **GUI, vue Réseau** — la table de flotte gagne un bouton **« Activer »** par instance
|
||
(l'active affiche « active »). Il confirme (avec avertissement renforcé si l'instance est
|
||
en **production**), bascule, puis recharge la page : toutes les vues et les déploiements
|
||
visent la nouvelle instance.
|
||
|
||
L'édition du drapeau `federe` reste au CLI (rarement changé). La bascule, elle, est
|
||
maintenant CLI **et** GUI.
|
||
|
||
Validé : `node --check` du GUI ; bascule testée de bout en bout (repoint → l'inventaire
|
||
dynamique suit → traversée refusée → restauration) ; `make verifier` rc=0 CONFORME 21/21.
|
||
|
||
## 2026-07-23 (suite)
|
||
|
||
### Ajouté — gestion multi-instances : vue d'ensemble + garde-fou de collision
|
||
Le mécanisme de bascule existait déjà (`make instance-utiliser`, `make instance-courante` :
|
||
repointer le symlink `instance`). Ce qui manquait : le regard d'ensemble et le filet.
|
||
|
||
- **`make instances`** (`scripts/instances.py`) — liste toutes les instances de la
|
||
fédération (dépôts frères avec `plan/nomenclature.yml`), marque l'active (`*`), et
|
||
montre pour chacune index, plage VLAN dérivée, statut fédéré/local (`federe`) et
|
||
production (`setops_production`). Lecture seule.
|
||
- **Détection de collision d'index** — signale toute paire d'instances **fédérées**
|
||
partageant un `index` (donc mêmes VLAN/VMID sur le trunk convergé). C'est exactement le
|
||
piège vécu (Chezlepro-prod et le labo tous deux à l'index 1) : désormais crié, pas
|
||
découvert par hasard. Le mode `--verifier` sort en erreur (rc=2) sur collision.
|
||
- **Preuve P21** (`make prouver`/`make verifier`) — câble ce garde-fou dans le harnais :
|
||
la cohérence de la fédération est vérifiée à chaque passage. No-op quand moins de deux
|
||
instances fédérées sont présentes (comme P17 sans `SETOPS_MODELES`).
|
||
|
||
Validé : `make instances` liste les 3 instances (labo LOCAL, Technolibre et Chezlepro
|
||
fédérées, index 2 et 13) ; détection testée en synthétique (deux fédérées au même index →
|
||
collision levée ; labo `federe: false` → cohérent) ; `make verifier` rc=0 **CONFORME 21/21**.
|
||
|
||
## 2026-07-23
|
||
|
||
### Changé — l'adressage se dérive du seul seed `index` (RUPTURE, mode compact retiré)
|
||
Principe posé par l'utilisateur : les valeurs de configuration doivent se dériver des
|
||
intrants, pas se réécrire à la main. La nomenclature dupliquait ce que `index` détermine
|
||
déjà (supernet, sous-réseaux, passerelles, VLAN). C'est corrigé, en rupture nette.
|
||
|
||
- **`inventory_rules`** : nouvelles fonctions de dérivation, **source unique** —
|
||
`supernet_de`, `base3_de`, `sous_reseau_de`, `passerelle_de`, `vlan_de`. Le modèle à
|
||
6 zones est encodé une fois : 2ᵉ octet = `10+index`, 3ᵉ octet de zone = `15+catégorie`,
|
||
VLAN trunk = `1000+index×10+zone`. `deriver_nomenclature` ne lit plus **aucun** adressage
|
||
stocké ; le mode `compact` est supprimé (tout est ip-miroir dérivé).
|
||
- **`devis_reseau`** importe ces helpers (plus de duplication) ; découverte des tenants sur
|
||
`index` présent (le filtre `vmid_schema` disparaît).
|
||
- **Les 10 nomenclatures** (3 instances + 7 modèles) passent au **format maigre** : `index`,
|
||
`cidr_hote`, `reservations`, libellés de zones et `fonctions` seulement. Supprimés :
|
||
`supernet`, `vmid_schema`, et par zone `sous_reseau`/`passerelle`/`vlan`. `presence-web`,
|
||
encore en `compact`, gagne `index: 1`.
|
||
- **GUI** : `index` devient un **intrant** (section « Réseau » du panneau Intrants). Il vit
|
||
dans la nomenclature (le plan réseau, uniforme partout — contrairement aux intrants des
|
||
modèles, hétérogènes) et le GUI l'écrit **chirurgicalement** (une ligne, sans reformater
|
||
le fichier). Le miroir JS dérive le VLAN du seed (fin de la lecture de `c.vlan` stocké).
|
||
|
||
### Ajouté
|
||
- **Preuve P20** (`preuve_nomenclature_derivee`) : aucune nomenclature ne stocke d'adressage
|
||
— garde-fou permanent contre une rechute vers l'écriture manuelle. Testée en négatif
|
||
(une rechute simulée est bien rejetée).
|
||
|
||
Validé : **DIFF VIDE sur les 3 instances** (la dérivation reproduit exactement l'adressage
|
||
qui était stocké), les **7 modèles valident**, `devis_reseau` génère les mêmes VLAN
|
||
(1011-1016 dérivés), `make verifier` rc=0 **CONFORME 20/20**, `node --check` du GUI OK,
|
||
aller-retour d'écriture de `index` : une seule ligne touchée.
|
||
|
||
## 2026-07-22 (suite)
|
||
|
||
### Ajouté — trois preuves qui ferment les angles morts du harnais
|
||
Le constat : `make prouver` ne vérifiait qu'*une* instance et le seul modèle `socle`. Tout
|
||
ce qui vit à côté du moteur dérivait en silence — d'où l'hôte fantôme d'`integral`, les
|
||
neuf secrets absents du gabarit Chezlepro, les champs de plan que le GUI ne sait pas écrire.
|
||
|
||
- **P17 — tous les modèles valident** (`scripts/modeles.py`). Rejoue les 4 validateurs de
|
||
registres sur chaque modèle découvert (les `exemples/modeles/*` du dépôt, plus les chemins
|
||
de `SETOPS_MODELES`, ex. le dépôt privé). **A immédiatement trouvé 6 modèles invalides
|
||
sur 7** : cinq portaient `autorite: interne` (valeur périmée jamais propagée depuis la
|
||
correction de `socle` en phase 5), `presence-web` manquait son domaine interne. Corrigés.
|
||
- **P18 — gabarit de voûte complet** (`scripts/voute.py`). Confronte `vault.yml.example`
|
||
aux secrets réellement exigés par le plan (champ `secret` des bases + références `vault_*`
|
||
des rôles actifs et des group_vars). Ne déchiffre jamais la vraie voûte : compare des noms.
|
||
`--strict` signale aussi les clés devenues inutiles.
|
||
- **P19 — le GUI couvre le plan** (`scripts/couverture_gui.py`). Croise les champs présents
|
||
dans les plans réels (instance + modèles) avec `CHAMPS_ECRITS_PAR_GUI`, nouveau tableau de
|
||
`inventory_gui.py` déclarant ce que les fonctions de sauvegarde écrivent. Signale tout
|
||
champ non éditable par le GUI (**a trouvé `applications.websocket`**, comblé — case à
|
||
cocher ajoutée), et toute dérive entre le tableau et le source du GUI. La nomenclature
|
||
reste un trou connu (lecture seule), tolérable via `--tolerer nomenclature`.
|
||
|
||
### Corrigé
|
||
- **Garde-fou contre l'hôte fantôme** : `valider_applications` accepte désormais le registre
|
||
des serveurs et **refuse une application posée sur un hôte non déclaré** — la faute exacte
|
||
qu'`integral` portait. Câblé dans `scripts/applications.py` (les 4 appels), au POST du GUI,
|
||
et vérifié : re-teste le bug d'origine → rejeté avec un message actionnable.
|
||
- **6 modèles de `Set-OPS-Modeles`** : `autorite: interne` → `auto-heberge` (collaboration,
|
||
forge, identite, integral, observabilite) ; `presence-web` gagne son domaine interne pour
|
||
ses expositions `site.*`/`app.*`.
|
||
- **Champ `websocket` dans le GUI** (inspecteur d'application) — Collabora en a besoin.
|
||
|
||
Validé : `make verifier` → **rc=0, CONFORME 19/16→19** (P01–P19), `ansible-lint` 0 échec,
|
||
les 7 modèles valident (`SETOPS_MODELES` posé), gabarit de voûte complet (23/23), couverture
|
||
GUI 27/27 champs (nomenclature tolérée), DIFF VIDE conservé, JS du GUI valide.
|
||
|
||
## 2026-07-22
|
||
|
||
### Ajouté
|
||
- **Champ « Liens (bindings) » dans le GUI** (`scripts/inventory_gui.py`). L'inspecteur
|
||
d'application porte une section dédiée : une ligne par lien, `rôle → cible`, les deux en
|
||
listes déroulantes, avec bouton d'ajout et de retrait. Les **rôles proposés sont
|
||
exactement ceux que le rôle porteur déclare accepter** (`roles/<groupe>/meta/liens.yml`),
|
||
et les cibles sont les autres applications du plan. Chaque ligne affiche les **variables
|
||
que le lien injectera**. Changer le groupe d'une application vide les rôles de lien
|
||
devenus inacceptés. Comble un manque relevé à l'audit du GUI : les bindings ne pouvaient
|
||
se déclarer qu'en éditant `plan/applications.yml` à la main, ce qui rendait la
|
||
configuration du courriel (Postfix → Dovecot, Postfix → rspamd) inaccessible sans éditeur.
|
||
- **`liens_acceptes(groupe)` et `catalogue_liens()`** dans `scripts/inventory_rules.py` —
|
||
source **unique** de « quels liens un rôle accepte », partagée par le validateur, le GUI
|
||
et `instancier.py`. Nouvelle clé `liens_acceptes` dans la charge utile de `/api/inventaire`.
|
||
|
||
### Modifié
|
||
- **`valider_applications` valide désormais les `liens`** : liste de tables `{vers, role}`,
|
||
champs non vides, cible connue, pas de lien vers soi-même, et **rôle accepté par le rôle
|
||
porteur** (avec la liste des rôles acceptés dans le message d'erreur). Un rôle sans
|
||
`meta/liens.yml` reste toléré au validateur — `instancier.py` tranche avec le même message.
|
||
Bénéficie aussi à `make inventaire-verifier` et à `scripts/applications.py verifier`.
|
||
- **`scripts/instancier.py`** — sa copie locale `_liens_acceptes()` est retirée au profit de
|
||
la fonction partagée. Un seul endroit lit `meta/liens.yml`.
|
||
- **`docs/bindings-conception.md`** — la Phase 4 passe à 🟡 : l'éditeur est fait, le
|
||
**graphe des liens reste à faire**.
|
||
|
||
Validé : `make verifier` → **rc=0**, `ansible-lint` 0 échec sur 485 fichiers,
|
||
`prouver.py --verifier` → CONFORME 16/16, `verifier_gui.py` (node --check) OK,
|
||
**DIFF VIDE** conservé et les deux variables de binding du courriel toujours générées
|
||
(`serveur_postfix_mailstore_hote`, `serveur_postfix_rspamd_milter`). Aller-retour
|
||
d'écriture testé : les liens survivent au cycle chargement → sauvegarde → relecture.
|
||
Sept garde-fous du validateur éprouvés (rôle inconnu, cible inexistante, lien vers
|
||
soi-même, champ manquant, types invalides).
|
||
|
||
## 2026-07-21 (suite)
|
||
|
||
### Modifié
|
||
- **Palette aurore enrichie sur les quatre pages `promo/`** — ajout de deux teintes,
|
||
`--rose:#ff6fc4` (frange magenta) et `--vert:#5cff9d` (vert fluo), présentes uniquement
|
||
dans les **dégradés foncés** : fond fixe du corps, nappe `.aurora` dérivante, et filets
|
||
de séparation `.rule`. Opacités de 5,5 % à 8,5 % — une teinte, pas un motif. Les
|
||
dégradés de texte et de boutons (`--aurora`) sont **inchangés** : l'identité de marque
|
||
ne bouge pas. Appliqué identiquement aux quatre fichiers (0 conflit CSS après coup).
|
||
- **Thème Forgejo étendu au wiki** (`roles/serveur_forgejo/files/custom/public/assets/css/alliance.css`,
|
||
29 → 118 lignes). La feuille étant injectée par `templates/custom/header.tmpl` sur toutes
|
||
les pages, le wiki est couvert **sans feuille distincte**. Réorganisée en deux sections
|
||
de risque explicite : **§1 Variables** (surcharge des variables de couleur officielles,
|
||
sûr, résiste aux mises à jour) et **§2 Décor** (fond aurore, filet sous les titres,
|
||
citations, tableaux et code en ligne de `.markup` — sélecteurs internes, à revérifier à
|
||
chaque montée de version majeure). Supprimer §2 ramène au thème sobre d'origine. Le ciel
|
||
nocturne ne s'applique qu'aux thèmes sombres, pour ne pas casser le thème clair.
|
||
`roles/serveur_forgejo/README.md` documente le tout.
|
||
|
||
### Ajouté
|
||
- **`docs/theme-forgejo-hors-flotte.md`** — pose **manuelle** du thème sur une instance
|
||
Forgejo **non gérée par Set-OPS** (la forge historique qui héberge ce dépôt et son wiki).
|
||
Forgejo n'ayant aucun réglage web pour le CSS, il faut déposer la feuille et référencer
|
||
`header.tmpl` sur le serveur. La procédure **ne duplique aucun fichier** : elle pointe
|
||
vers ceux de `roles/serveur_forgejo/files/custom/`. Comprend la détection du répertoire
|
||
`custom`, un garde-fou pour ne pas écraser un `header.tmpl` existant (ajout de ligne, pas
|
||
remplacement), la vérification par `curl`, et la marche arrière.
|
||
|
||
### Corrigé
|
||
- **État de forge-01 élucidé — ce n'était pas une contradiction.** L'hôte a été **créé,
|
||
puis supprimé**. Le plan dit donc `etat: planifie` (état **courant**) et le CHANGELOG du
|
||
2026-07-03 dit le branding « prouvé sur forge-01 » (état **passé**) : les deux disent
|
||
vrai. `roles/serveur_forgejo/README.md` l'énonce désormais ainsi ; la preuve reste
|
||
valable, simplement non rejouable tant que l'hôte n'est pas recréé. (La note précédente,
|
||
qui présentait l'écart comme une contradiction à trancher, était erronée.)
|
||
- **Teintes rose et verte trop concentrées dans les coins** — nappes élargies d'environ
|
||
×2 (780×540 → 1500×1020 px pour le rose, 840×580 → 1620×1120 px pour le vert), ramenées
|
||
vers l'intérieur (97 % → 76 %, 3 % → 20 % ; nappe dérivante 95 % → 79 % et 6 % → 22 %),
|
||
extinction repoussée de 62 % à 82 % avec un **arrêt intermédiaire** pour une rampe douce,
|
||
et flou de `.aurora` porté de 64 px à 86 px. La couleur est répartie au lieu d'être tassée
|
||
aux bords.
|
||
- **Opacités des teintes réduites d'environ 40 %** dans la foulée — l'étalement les rendait
|
||
trop présentes. Rose `.075 → .044`, vert `.058 → .034`, violet `.062 → .036` (arrêts
|
||
intermédiaires abaissés dans la même proportion), et opacité de la nappe `.aurora`
|
||
`.44 → .32`. Géométrie inchangée : seule l'intensité baisse.
|
||
- **Statut du thème précisé, section par section** : §1 (variables) est **éprouvée** sur
|
||
forge-01 ; §2 (décor), ajoutée aujourd'hui, n'a **jamais été rendue** par un Forgejo réel.
|
||
L'affirmation précédente (« non rendu par un Forgejo réel », tous les cas confondus) était
|
||
fausse pour §1.
|
||
|
||
Validé : équilibre des accolades et parenthèses du CSS, 0 conflit CSS entre les quatre
|
||
pages promo, `ansible-lint` 0 échec, `prouver.py --verifier` → CONFORME 16/16.
|
||
|
||
## 2026-07-21
|
||
|
||
### Ajouté
|
||
- **Protocole d'épreuve de l'opérateur indépendant** (`docs/audit/protocole-operateur-independant.md`).
|
||
Met **AFF-002** (« Set-OPS s'exploite entièrement à la main, sans aucune IA ») à l'épreuve
|
||
d'un sysadmin qui n'est pas l'auteur, sur sa propre grappe Proxmox, à froid depuis le modèle
|
||
public `socle`. Définit : la **règle du silence** (l'observateur journalise, n'aide pas ; trois
|
||
niveaux N0/N1/N2), les **interdits** (aucune IA, aucun accès aux dépôts privés, aucune lecture
|
||
de `docs/audit/` qui divulguerait les pièges connus), les **critères de réussite R1→R6 fixés
|
||
d'avance**, le périmètre matériel et la sécurité, et le **gabarit de rapport**
|
||
(`operateur-independant-AAAA-MM-JJ.md`). Le registre peut **perdre** : le protocole prévoit
|
||
explicitement la redescente d'AFF-002 en 🟡 ou ❌ selon le verdict.
|
||
|
||
### Modifié
|
||
- **`docs/audit/affirmations.md`** — nouvelle limite consignée : **AFF-002 est ✅ *par
|
||
inspection*, pas par démonstration**. Ses écarts bloquants sont soldés, mais aucun opérateur
|
||
indépendant ne l'a exécutée ; ✅ y signifie « plus aucun défaut connu ». Renvoi vers le
|
||
protocole.
|
||
- **`docs/audit/README.md`** — « Les trois pièces » → « Les pièces » : ajout du protocole comme
|
||
quatrième pièce du dispositif (l'épreuve humaine, hors harnais automatisable).
|
||
|
||
Validé : `python3 scripts/prouver.py --verifier` → **CONFORME 16/16** (voûte exportée) ;
|
||
équilibre des blocs de code du nouveau document vérifié. Aucun code touché.
|
||
|
||
## 2026-07-20
|
||
|
||
### Modifié
|
||
- **`make verifier` inclut désormais les preuves (`make prouver`).** `make verifier` se
|
||
termine par `python3 scripts/prouver.py --verifier` : nouveau mode qui exécute toutes les
|
||
preuves du registre (verdict + code de sortie) **sans écrire de rapport**, pour ne pas
|
||
écraser la pièce justificative committée `docs/audit/preuve-<date>.md`. `make verifier`
|
||
échoue donc si une preuve échoue. `make prouver` seul (sans `--verifier`) continue d'écrire
|
||
le rapport horodaté. Vérifié : `make verifier` → CONFORME 16/16 (voûte exportée), aucun
|
||
churn du rapport committé ; `make prouver` écrit toujours. Doc mise à jour
|
||
(`docs/audit/README.md` § « Rapport avec `make verifier` »).
|
||
- **Conformité Phase 2 — 3 incohérences internes corrigées (documentation d'autorité).**
|
||
Aucun code Ansible touché ; le code SSH était déjà conforme à `AGENTS.md`.
|
||
- **`CLAUDE.md` réduit à un pointeur mince** : autorité unique d'`AGENTS.md` + les cinq
|
||
règles absolues (agent unique ; `hosts.yml` généré jamais édité ; aucun secret ;
|
||
confirmation des actions destructives ; rien n'est prêt sans validation). Toute la
|
||
doctrine dupliquée (template, SSH, pare-feu, cloud-init, handlers, Makefile…) retirée.
|
||
- **Contradiction SSH levée** : `CLAUDE.md` prescrivait `PasswordAuthentication yes`
|
||
pendant la construction ; le code applique en réalité `PasswordAuthentication no` +
|
||
`AuthenticationMethods publickey` dès le départ (prouvé, cf. `docs/audit/affirmations.md`
|
||
AFF-037/050). La doctrine SSH périmée de `CLAUDE.md` est supprimée — la duplication en
|
||
était la cause racine.
|
||
- **`AGENTS.md` : « Comportement attendu de Codex » → « …des agents IA »** (la section
|
||
vaut pour tout agent IA, Claude inclus).
|
||
- Suivi dans `docs/audit/affirmations.md` (§ Journal des traitements) : AFF-040, AFF-050,
|
||
AFF-052 résolus.
|
||
|
||
### Corrigé
|
||
- **Conformité Phase 3 — lot A « doc de démarrage » (le parcours QUICKSTART marche seul).**
|
||
- **Modèle absent (AFF-020/021).** `QUICKSTART.md` proposait `cp -r
|
||
exemples/modeles/presence-web …` — modèle **inexistant** dans le dépôt public (seul
|
||
`socle` l'est). Étape 2 réécrite sur `socle` ; les modèles assemblés renvoyés au dépôt
|
||
privé `Set-OPS-modeles`, cohérent avec `exemples/modeles/README.md`.
|
||
- **Chemin de voûte faux + piège de shadowing (AFF-023).** Le chemin `lab/` (QUICKSTART,
|
||
`exemples/vault.exemple.yml`, `docs/config-proxmox.md`) est corrigé en `production/`
|
||
(le modèle `socle` n'a qu'un inventaire `production/` ; `make config` résout
|
||
`lab > principal > production`). **Piège corrigé** : le socle livrait ses intrants en
|
||
`production/group_vars/all.yml` (forme fichier) ; y ajouter `all/vault.yml` (forme
|
||
dossier) fait **ignorer silencieusement** `all.yml` par Ansible (le dossier masque le
|
||
fichier — vérifié empiriquement). Le modèle est converti en forme dossier
|
||
(`group_vars/all/10-intrants.yml`), alignée sur l'instance prouvée. Validé :
|
||
`ansible-inventory --host` charge `domaine_interne` depuis la nouvelle disposition.
|
||
- **Commandes `make` périmées (AFF-080/081/082).** `make help` → `make` ;
|
||
`make syntax-template` → `make syntaxe-modele` (dans `docs/config-proxmox.md`,
|
||
`docs/modeles_vm/debian13-proxmox.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md`).
|
||
- **Découvert et consigné (AFF-097, non traité ici) :** `lab/` codé en dur restant dans
|
||
les docs template/clone (`vm-lifecycle`, `procedure-template…`, `proxmox/README`,
|
||
message de `cloner_vm_debian.yml`) — lot séparé à prévoir.
|
||
- **Conformité Phase 3 — lot B « doc (prose) ».**
|
||
- **Prérequis Vault rappelé (AFF-026).** `QUICKSTART.md` : note que `make
|
||
inventaire-verifier` / `make verifier` chargent l'inventaire complet et exigent
|
||
`ANSIBLE_VAULT_PASSWORD_FILE`, sinon « no vault secrets found ».
|
||
- **Sous-dossiers `playbooks/` (AFF-039).** `AGENTS.md` : précisé que seuls `groupes/`,
|
||
`maintenance/`, `modeles_vm/`, `proxmox/` sont peuplés ; les autres sont prospectifs.
|
||
- **`SOLUTION.md` remis au présent (AFF-060/061).** Bannière « document historique »
|
||
(supplanté par README/QUICKSTART/`make`) ; arborescence complétée ; commande de
|
||
construction `--ask-pass` (mot de passe) → `make preparer-modele` (accès par clé).
|
||
- **Découvert et consigné (AFF-098, traitement B, non traité ici) :** contradiction
|
||
fonctionnelle — `make config` écrit le token Proxmox dans `all/vault.yml`, mais
|
||
`cloner_vm_debian.yml` ne le lit que depuis `proxmox.vault.yml`/env. Couplé à AFF-097
|
||
dans un futur lot « voûte Proxmox ».
|
||
|
||
### Corrigé
|
||
- **Conformité Phase 3 — lot « voûte Proxmox » (AFF-098 bug fonctionnel + AFF-097).**
|
||
- **Le clonage lit la voûte unifiée (AFF-098, option a).** `make config` écrit le token
|
||
Proxmox dans `instance/inventories/<env>/group_vars/all/vault.yml`, mais
|
||
`playbooks/proxmox/cloner_vm_debian.yml` (lancé `-i localhost,`) ne le chargeait que
|
||
depuis `proxmox.vault.yml` → `make creer-vm` échouait l'assert `proxmox_api_token_secret`
|
||
pour qui suivait la voûte unifiée. Corrigé : `all/vault.yml` ajouté aux sources de
|
||
secrets du playbook (autoritaire) ; la détection de voûte chiffrée du `Makefile`
|
||
(`cloner-vm`) cherche d'abord `all/vault.yml` puis `proxmox.vault.yml`. `proxmox.vault.yml`
|
||
reste accepté en compatibilité. **Validé** : `--syntax-check` OK, `ansible-lint` 0 échec,
|
||
test fonctionnel (token chargé depuis `all/vault.yml`, assert vert).
|
||
- **Docs Proxmox/template alignées (AFF-097).** `lab/` codé en dur → `production/` + voûte
|
||
unifiée dans `playbooks/proxmox/README.md`, `docs/procedure-template-debian13-proxmox.md`,
|
||
`docs/vm-lifecycle.md`, `docs/modeles_vm/debian13-proxmox.md`. Plus aucune référence
|
||
`inventories/lab/group_vars` dans les fichiers suivis.
|
||
- **Conformité Phase 3 — lot C « `make verifier` vert » (AFF-006).** `ansible-lint` passe de
|
||
**33 échecs à 0** (profil `min` → `production`), donc `make lint` **rc=0**. Trois causes :
|
||
- **`site.yml` généré lint-propre** : `scripts/orchestrer.py` émet un `name:` avant chaque
|
||
`import_playbook` (30× `name[play]`) ; `site.yml` régénéré. Orchestration inchangée.
|
||
- **`risky-shell-pipe`** : `set -o pipefail` + `executable: /bin/bash` sur les deux tâches
|
||
shell à pipe de `playbooks/valider.yml`.
|
||
- **`name[template]`** : Jinja déplacé en fin de `name` dans `supprimer_vm_debian.yml`.
|
||
**Validé** : `make lint` rc=0 ; toutes les étapes de `make verifier` vertes (lint, test,
|
||
site-verifier, flux-verifier, syntaxe) — seule `inventaire-verifier` requiert la voûte de
|
||
l'opérateur (prérequis documenté, AFF-026). Débloque AFF-002 (« exploitable sans IA » → ✅).
|
||
|
||
### Corrigé
|
||
- **Conformité Phase 5 — boucle documentaire (le parcours QUICKSTART marche seul, prouvé).**
|
||
Relecture de cohérence bout-en-bout (README/QUICKSTART/docs/wiki ↔ code final). Deux bogues
|
||
du modèle public/outillage débusqués et corrigés, en plus des alignements de prose :
|
||
- **Modèle `socle` invalide (AFF-099).** `exemples/modeles/socle/plan/domaines.yml` :
|
||
`autorite: interne` (périmé, rejeté par le validateur) → `auto-heberge`. Le modèle valide.
|
||
- **Split-brain d'inventaire (AFF-100).** `scripts/instancier.py` et `scripts/inventory_gui.py`
|
||
retombaient sur `principal/` quand aucun `hosts.yml` n'existe encore ; or le socle est en
|
||
`production/` → la 1ʳᵉ génération écrivait dans `principal/`, à côté des `group_vars` restés
|
||
en `production/`. Corrigé : le repli vise le **répertoire d'inventaire déjà présent** (comme
|
||
`config_proxmox.py`). Instances existantes (avec `hosts.yml`) **inchangées** (non-régression
|
||
vérifiée sur `principal`).
|
||
- **Alignements de prose.** `docs/intrants-communs.md` (`group_vars/all.yml` →
|
||
`all/10-intrants.yml`, forme dossier que le GUI écrit déjà) ; `QUICKSTART.md` étape 8
|
||
(`serveur_postgresql` → `serveur_powerdns`, groupe présent dans le socle).
|
||
- **Preuve** : parcours QUICKSTART rejoué **hors-ligne** sur une copie du socle
|
||
(`instancier generer`→`comparer`→`appliquer`) — écrit `production/hosts.yml`, diff **vide**
|
||
ensuite, 4 validateurs verts. Nouvelle preuve récurrente **P15** dans `make prouver`
|
||
(« modèle public socle valide ») : `make prouver` = **15 OK, 0 échec, 1 sautée**.
|
||
|
||
### Ajouté
|
||
- **Harnais de preuve `make prouver` (Phase 4).** Nouveau `scripts/prouver.py` — un
|
||
**orchestrateur mince** qui rejoue les preuves automatisables du registre en appelant
|
||
l'outillage **existant** (les mêmes scripts que `make verifier` : lint, tests, diff-vide,
|
||
validateurs de registres, cohérence groupes/playbooks, handlers, orchestration, flux,
|
||
syntaxe, existence des runbooks, invariants structurels) — **aucune validation
|
||
réimplémentée**. Produit `docs/audit/preuve-AAAA-MM-JJ.md` : rapport **horodaté,
|
||
rejouable**, reliant chaque preuve aux affirmations couvertes, listant à part les
|
||
déclarations d'intention (⚪). Sort en erreur si une preuve échoue ; la preuve `P15`
|
||
(inventaire Ansible complet) est **sautée** proprement sans mot de passe Vault (prérequis
|
||
AFF-026). Documenté dans `README.md` (une phrase) et `docs/audit/README.md` (mode
|
||
d'emploi complet + comment ajouter une preuve). `affirmations.md` : section « Couverture
|
||
par `make prouver` » reliant chaque ✅ à sa preuve. **1re exécution : 14 preuves OK,
|
||
0 échec, 1 sautée → CONFORME.**
|
||
- **Registre des affirmations (audit de conformité, Phase 1).** Nouveau
|
||
`docs/audit/affirmations.md` : chaque affirmation publique vérifiable du dépôt
|
||
(`README`, `AGENTS`, `CLAUDE`, `QUICKSTART`, `SOLUTION`, `docs/`, `wiki/`, aide du
|
||
`Makefile`, GUI) est tracée vers une **commande de preuve reproductible** et un statut
|
||
(✅ prouvée / 🟡 partielle / ❌ fausse / ⚪ invérifiable localement). **54 affirmations
|
||
enregistrées : 30 ✅, 13 🟡, 8 ❌, 3 ⚪.** Audit **sans aucun correctif** (les
|
||
traitements relèvent des phases suivantes). Preuves exécutées localement, hors
|
||
production : diff-vide du plan, recoupement notify↔handlers, validateurs de registres
|
||
(serveurs/applications/bases/domaines/GUI/orchestrateur/flux), `--syntax-check` de tous
|
||
les playbooks, test unitaire. Dix écarts majeurs classés par risque pour un opérateur
|
||
suivant la doc à la lettre — dont : `QUICKSTART` renvoie à un modèle absent
|
||
(`presence-web`), `make verifier` échoue (ansible-lint : 33 failures), contradiction
|
||
SSH `CLAUDE.md` ↔ code/`AGENTS.md`, chemin de voûte faux, commandes `make` périmées
|
||
dans `docs/`.
|
||
|
||
## 2026-07-07 (soir)
|
||
|
||
### Modifié
|
||
- **Nomenclature `ip-miroir` : longueurs UNIFORMES.** Le VLAN dérivé passe à
|
||
`1000 + index×10 + zone` → **toujours 4 chiffres** (1011..4094), donc le VMID
|
||
(`VLAN·octet·seq`) fait **toujours 9 chiffres**. Longueurs uniformes et mnémotechniques
|
||
(retirer 1000 redonne `index×10+zone`). Les deux tenants sont **convergés** sur le réseau
|
||
fédéré 10/8 : Chezlepro (idx 1) → VLANs 1011-1016 / 10.11.x ; Technolibre (idx 2) →
|
||
VLANs 1021-1026 / 10.12.x. Chezlepro quitte le bac-à-sable plat 192.168.15/VLAN 15.
|
||
|
||
### Ajouté
|
||
- **`make devis-reseau` : config switch dérivée du plan.** Nouveau `scripts/devis_reseau.py`
|
||
qui découvre les instances fédérées (`../*/plan/nomenclature.yml`, schéma `ip-miroir`) et
|
||
émet la config Cisco-like du réseau convergé — **VLANs + SVIs (passerelles) + ACLs d'isolation
|
||
inter-tenant** — dérivée des nomenclatures, jamais saisie à la main (toujours synchrone avec
|
||
le plan). One-shot précédemment ; maintenant reproductible.
|
||
- **GUI : vue « Réseau » (devis switch) + bouton Copier.** Nouvel onglet lecture seule qui
|
||
affiche le devis `make devis-reseau` (VLANs + SVIs + ACLs), via `GET /api/devis-reseau`, avec
|
||
un bouton **Copier** (prêt à coller sur le switch). Un opérateur génère et transmet la config
|
||
sans CLI.
|
||
- **GUI : erreurs de déploiement en LANGAGE CLAIR.** Quand une action (créer/vérifier/déployer)
|
||
échoue, le GUI n'oblige plus à lire le dump Ansible : un encadré résume les tâches en erreur —
|
||
**étape · hôte · type · message clair** — avec un bouton **« Copier »** (pour transmettre le
|
||
résumé à un humain / au mainteneur). Le backend (`_extraire_echec`) parse les `fatal:`/`UNREACHABLE!`,
|
||
privilégie la vraie cause (`stderr` plutôt que le générique « non-zero return code »), gère
|
||
`no_log` (« sortie masquée — secret ») et les injoignables. Éprouvé sur les 4 échecs réels du
|
||
from-zero du jour. `node --check` OK.
|
||
- **GUI : éditeur « Domaines » (6ᵉ onglet éditable).** Le registre des domaines publics — le seul
|
||
sans édition dans le GUI — a désormais son onglet : ajouter/retirer une zone DNS, **autorité**
|
||
(sélecteur `primaire-cache`/`auto-heberge`/`delegue`), **edge**, **DNSSEC**, **mail**, secondaires,
|
||
et affichage des **expositions (FQDN) sous la zone**. Backend : route `/api/domaines`,
|
||
`ecrire_domaines`, validation `valider_domaines` (rejette une autorité inconnue). Éprouvé :
|
||
round-trip backend OK, `node --check` OK, smoke serveur OK. (Le `autorite: interne` périmé des
|
||
instances Chezlepro/Technolibre a été corrigé en `auto-heberge` au passage.)
|
||
- **GUI : persistance des journaux d'exécution.** Chaque action lancée depuis l'interface
|
||
(créer-vm / vérifier / déployer) écrit désormais, **en plus du streaming live**, un journal
|
||
horodaté `<instance>/logs/<hôte>-<action>-<date>.log` (gitignoré). Bonus : si le navigateur se
|
||
déconnecte, l'exécution **continue** et le journal est capturé jusqu'au bout (au lieu d'être
|
||
interrompue) — utile pour un déploiement qu'on ne veut pas voir avorter à la fermeture d'un onglet.
|
||
- **GUI : vues « Flux » et « Couches » (lecture seule).** Deux nouveaux onglets rendent visibles
|
||
dans l'interface deux registres jusque-là en ligne de commande seulement : **Flux** = la matrice
|
||
d'audit réseau (rôle · sens · port · pair · chiffrement coloré · raison, depuis les `meta/flux.yml`),
|
||
et **Couches** = l'ordre de reconstruction (socle → pki → services → apps → agents, avec les groupes
|
||
triés topologiquement par couche). Alimentées par `resoudre_flux`/`orchestrer` via l'API. Éprouvé :
|
||
`node --check` OK, smoke test serveur (63 flux, 6 couches servis).
|
||
|
||
### Modifié
|
||
- **GUI : intégration des intrants de session.** Le panneau « Intrants de base » expose désormais
|
||
**`nftables_admin_ssh`** (type liste, section « Sécurité ») — la garde anti-lockout du pare-feu
|
||
était éditable en fichier mais absente du GUI. Retrait de `vault_step_ca_fingerprint` des secrets
|
||
attendus (empreinte du root CA désormais dérivée dynamiquement, plus un secret). `node --check` OK.
|
||
|
||
## 2026-07-07 — Reconstruction from-zero PROUVÉE (preuve de portabilité « sans réserve »)
|
||
|
||
Les 14 VM du lab (+ sauvegardes + AC) supprimées, puis `make myDay` a reconstruit l'écosystème
|
||
POC **de rien** : 13 hôtes déployés (0 échec), et `make valider` **entièrement vert** (Prometheus
|
||
6/6 UP, 6 vhosts HTTPS, courriel bout-en-bout remis, 6 dépôts restic restaurés) — le tout sous
|
||
pare-feu actif. 4 bugs de portabilité débusqués et corrigés dans le moteur :
|
||
|
||
### Corrigé
|
||
- **client_pki : empreinte du root CA dérivée dynamiquement** (au lieu d'une valeur figée en Vault).
|
||
Une AC régénérée (from-zero) a une empreinte neuve ; le rôle la lit désormais de l'autorité
|
||
elle-même (`step certificate fingerprint`, délégué au nœud step-ca), source de vérité.
|
||
`client_pki_ca_fingerprint_override` permet un épinglage explicite.
|
||
- **serveur_keycloak : assignation de rôle tolérante aux utilisateurs absents.** Sur un annuaire
|
||
vide (from-zero), assigner un rôle à un user inexistant échouait ; on vérifie désormais son
|
||
existence (Keycloak fédère LDAP à la demande) et on saute proprement sinon.
|
||
- **serveur_prometheus : ne scrute que les hôtes ACTIFS** (`client_metrique ∩ hotes_actifs`). Un
|
||
hôte planifié (non déployé) n'est plus une cible morte.
|
||
- **Pare-feu nftables compatible Docker.** Le ruleset résolu (a) remplace **uniquement la table
|
||
`setops_flux`** au lieu de `flush ruleset` (préserve les tables Docker : DNAT/forward des
|
||
conteneurs) et (b) autorise `docker0` + `ct established,related` dans la chaîne `forward`. Sans
|
||
ça, `forward policy drop` coupait Collabora (conteneur). Diagnostic prouvé au niveau paquet.
|
||
|
||
### Ajouté
|
||
- **`playbooks/proxmox/supprimer_vm_debian.yml`** — suppression de VM par VMID (from-zero), avec
|
||
garde-fous : n'agit que sur les VMID présents dans le cluster, refuse si le modèle est ciblé,
|
||
secrets `no_log`.
|
||
|
||
## 2026-07-07
|
||
|
||
### Ajouté
|
||
- **`make valider` — recette d'acceptation fonctionnelle (phase 4, v1).** Vérifie que les services
|
||
*fonctionnent*, pas juste qu'ils sont déployés (complète les `*-verifier` statiques). `playbooks/valider.yml`,
|
||
lecture seule : **cibles Prometheus toutes UP** (API `/targets`) + **vhosts HTTPS exposés répondent**
|
||
(dérivés des `server_name` réels de l'edge, filtrés sur `domaine_interne` — générique) + **courriel
|
||
bout-en-bout** (envoi via le MTA → LDAP → LMTP → Maildir, remise vérifiée par `doveadm` sur le
|
||
compte `testmail`, message de test nettoyé) + **restauration de sauvegarde** (pour chaque nœud
|
||
`client_backup` : restic restaure le dernier snapshot dans un dossier temporaire — lecture seule
|
||
sur le dépôt — et vérifie que des fichiers en sortent). Éprouvé sur la flotte vivante sous pare-feu
|
||
actif : 7 cibles UP, 8 vhosts OK, courriel remis, 6 dépôts restaurables. **La recette a trouvé un
|
||
vrai trou** (collab-01/Nextcloud sans `client_backup_jobs` → service de sauvegarde en échec, fichiers
|
||
non protégés), corrigé côté instance.
|
||
- **Pare-feu nftables activé sur TOUTE la flotte (14 nœuds, activation prudente).** Les 14 hôtes
|
||
actifs tournent sous `policy drop` avec leur ruleset **résolu moindre-privilège** (flux est-ouest
|
||
déclarés autorisés par source, reste refusé). Vérifié en conditions réelles : flux déclarés OPEN
|
||
(keycloak→pg, postfix→dovecot LMTP, prometheus→node_exporter…), flux non déclaré DROP
|
||
(forge→redis), 14/14 `active`+`enabled`+`policy drop`, contrôleur toujours joignable.
|
||
- **Intrant `nftables_admin_ssh`** (garde anti-lockout) — CIDR d'administration TOUJOURS
|
||
autorisés en SSH, indépendamment des flux. Le résolveur (`resoudre_flux.py`) l'injecte en tête
|
||
de chaque ruleset. **Bug de conception rattrapé avant activation** : le contrôleur Ansible arrive
|
||
par VPN (`192.168.255.2`, hors sous-réseau flotte) — sans cette règle, activer = lockout immédiat.
|
||
- **Rollout prudent** : d'abord `infra-pki-01` seul (test dead-man switch `systemd-run`, SSH
|
||
re-vérifié sous drop, puis permanent), puis les 13 autres par lot (dead-man 5 min + sonde des
|
||
flux est-ouest avant de persister). Activation pilotée par `nftables_baseline_enabled: true`
|
||
(group_vars `hotes_actifs` ; le golden template n'y est pas → reste sans pare-feu, voulu).
|
||
- Le déploiement dépose `instance/flux-genere/<hôte>.nft` dans `/etc/nftables.conf` + service
|
||
`enabled` (survit reboot ET futurs `make myDay`).
|
||
|
||
### Corrigé
|
||
- **Détection du coffre Vault : `production/` codé en dur → inventaire réel.** `deployer`,
|
||
`deployer-tout` et `verifier-deploiement` cherchaient le coffre chiffré dans
|
||
`inventories/production/group_vars`, alors qu'une instance en `principal/` (cas courant) n'a pas
|
||
ce chemin → l'invite du mot de passe Vault ne se déclenchait jamais et le déploiement échouait au
|
||
déchiffrement. Corrigé : la garde vise désormais le `group_vars` de l'inventaire **résolu**
|
||
(`$(dir $(INVENTAIRE_PRODUCTION))group_vars`). Vérifié : le coffre de `principal/` est bien détecté.
|
||
|
||
### Modifié
|
||
- **`nftables_baseline` branché sur les flux résolus (reconstruction, phase 0).** Le rôle déploie
|
||
désormais le ruleset **résolu** généré par `make flux` (`instance/flux-genere/<hôte>.nft` — règles
|
||
par source, `ip saddr` = moindre privilège) quand il est présent ; sinon repli sur le gabarit plat.
|
||
**Toujours `nftables_baseline_enabled: false` par défaut → aucune activation** (l'activation reste
|
||
un geste dédié, testé par nœud). Nouveau var `nftables_baseline_ruleset_genere`. Syntax-check OK.
|
||
### Ajouté
|
||
- **Reconstruction from-zero en une commande (reconstruction, phase 3 — outillage).** La création
|
||
de VM était unitaire (`creer-vm HOTE=…`) ; on comble le trou entre *créer* (2a) et *configurer*
|
||
(2b) :
|
||
- **`make flotte-creer CONFIRMER=true`** — boucle `creer-vm` sur tous les hôtes actifs du plan
|
||
(clone Proxmox). Nouvelle sous-commande `inventory_host.py lister-actifs`.
|
||
- **`make reconstruire CONFIRMER=true`** — enchaîne **flotte-creer → attente SSH de la flotte
|
||
(`_attendre-flotte`, `ATTENTE_MAX` réglable) → `deployer-tout`**. La reconstruction complète en
|
||
une commande, **idempotente de bout en bout** : le clone (`proxmox_kvm`) saute une VM déjà
|
||
présente (par nom), le réseau/disque sont `present`/`resized` (grow-only), le déploiement Ansible
|
||
converge. Re-lançable sans risque, qu'il reste des VM ou non.
|
||
- **`make myDay`** repointé sur `reconstruire` (le vrai « bouton rouge » ; n'était qu'un alias de
|
||
`deployer-tout`). Distinction assumée : `deployer-tout` = **converger** la config d'une flotte
|
||
existante (2b, avec `MODE_CHECK=1`) ; `reconstruire`/`myDay` = **créer les VM manquantes puis
|
||
déployer** (2a+2b).
|
||
Gardes `CONFIRMER=true` sur les trois. Non testé contre Proxmox/lab (validé : énumération des 14
|
||
hôtes actifs, refus sans `CONFIRMER`, enchaînement `make -n`).
|
||
|
||
## 2026-07-06
|
||
|
||
### Ajouté
|
||
- **`make wiki-publier` — fin du dernier geste manuel (reconstruction, phase 0).** Le wiki
|
||
pédagogique (`wiki/`, source versionnée) se publie désormais dans le wiki Forgejo par
|
||
`make wiki-publier WIKI_REMOTE=…<dépôt>.wiki.git` : clone superficiel du wiki, synchronisation des
|
||
pages (`wiki/*.md` sauf `README.md` ; suppressions propagées), commit + push seulement s'il y a du
|
||
changement. Refuse sans `WIKI_REMOTE`. Éprouvé de bout en bout contre un dépôt bare local (17
|
||
pages publiées = 17 source, diff vide, README exclu, `_Sidebar` inclus, idempotent au 2e passage).
|
||
|
||
- **Registre des flux réseau complété (reconstruction, phase 0).** Transcription du travail
|
||
zéro-confiance est-ouest dans `meta/flux.yml` : **16 rôles remplis** (step_ca, openldap, powerdns,
|
||
prometheus, loki, redis, rspamd, backup, dovecot, postfix, keycloak, forgejo, grafana, icinga,
|
||
icingaweb2, nextcloud, oauth2_proxy + le socle `serveur_debian` pour le plan de gestion SSH, +
|
||
les 5 clients pki/journal/smtp/backup/unbound). Le registre couvre désormais **29 rôles, 63 flux**
|
||
(qui-parle-à-qui : port, sens, pair, chiffrement, raison) — la base de génération nftables/OPNsense
|
||
et la matrice d'audit. Schéma enrichi (`docs/flux-conception.md`) : valeurs `ssh` (transport SSH,
|
||
restic/backup + SSH de gestion) et `tls-cible` (TLS visé, feuille de route edge→backends).
|
||
**Point critique traité** : SSH (22) déclaré au socle, sinon les nftables générés couperaient
|
||
l'accès Ansible. Validé : schéma conforme (0 erreur) et **matrice cohérente** (tout egress vers un
|
||
service a l'ingress correspondant en face). Reste phase 0 : la cible `make wiki-publier`.
|
||
|
||
- **Résolveur de flux (reconstruction, phase 0 — §Séquence 2).** `scripts/resoudre_flux.py` agrège
|
||
les `meta/flux.yml`, résout les `pair`, et produit **deux artefacts, hors-ligne, sans activation** :
|
||
- **`docs/registre-flux.md`** (généré) — la matrice d'audit source→destination (rôle, sens, port,
|
||
chiffrement, raison) + synthèse chiffrement. Artefact du label de certification.
|
||
- **aperçus nftables par hôte** (`instance/flux-genere/<hôte>.nft`, gitignorés) — règles résolues
|
||
avec IP réelles, `ip saddr` = **moindre privilège** (ex. LMTP 24 sur le mail n'accepte que l'IP
|
||
du nœud Postfix), `policy drop`. **Aperçus inspectables, NON activés** (l'activation reste un
|
||
geste dédié testé par nœud, cf. flux-conception §Activation prudente).
|
||
- Cibles **`make flux`** (registre + aperçus) et **`make flux-verifier`** (schéma + matrice,
|
||
branché dans `make verifier`). Validé : 29 rôles / 63 flux cohérents, 14 aperçus générés.
|
||
Reste : brancher `nftables_baseline` (modèle plat aujourd'hui) sur ces aperçus, et le test lab.
|
||
|
||
- **Orchestrateur ordonné (reconstruction, phase 2).** `playbooks/site.yml` n'est plus un stub :
|
||
c'est désormais un **point d'entrée ordonné généré**, qui déploie l'écosystème **couche par
|
||
couche, dans l'ordre de reconstruction**, sans intervention manuelle. Nouveautés :
|
||
- **`docs/couches-deploiement.yml`** — registre central des couches ordonnées
|
||
(`socle → pki_racine → pki_client → services → apps → agents`), les 30 groupes déployables classés.
|
||
- **`scripts/orchestrer.py`** — trie les groupes par couche (clé primaire) puis **topologiquement
|
||
intra-couche** via `dependances-groupes.yml` (ex. dovecot avant postfix, icingaweb2 après icinga,
|
||
nextcloud en dernier). Génère `site.yml` comme une séquence d'`import_playbook`. Artefact du
|
||
moteur (déterministe, sans donnée d'instance ; Ansible saute les groupes sans hôte actif).
|
||
**Deux gardes anti-dérive** (refus si violé) : bijection univers↔couches (un nouveau rôle non
|
||
classé casse la génération) et aucune arête « en arrière » (un prérequis dans une couche plus
|
||
tardive = classification fausse). Éprouvées par test négatif.
|
||
- **`make site`** (régénère + syntax-check), **`make site-verifier`** (cohérence, branché dans
|
||
`make verifier`), **`make deployer-tout CONFIRMER=true`** (déploiement orchestré de la flotte,
|
||
limité à `hotes_actifs` ; garde `CONFIRMER` car action impactante ; `MODE_CHECK=1` pour l'essai
|
||
idempotent à blanc). Validé : `verifier` OK (30 groupes, aucun cycle/arête arrière),
|
||
`--syntax-check` du `site.yml` généré OK, refus `deployer-tout` sans `CONFIRMER` (rc=2).
|
||
|
||
### Modifié
|
||
- **Audit exhaustif du codé-en-dur (reconstruction, phase 1b).** Balayage complet
|
||
`tasks + templates + defaults` de tous les rôles (noms de tenant, IP, domaines, emails, orgs).
|
||
Résultat : le moteur ne porte plus **aucun** nom de tenant en dur. Corrigé — les labels/slug
|
||
OIDC dérivent désormais de l'intrant `organisation` : `serveur_forgejo_oidc_nom` (slug de
|
||
callback, `organisation | lower | replace(' ','-')`), `serveur_grafana_oidc_nom`,
|
||
`serveur_nextcloud_oidc_nom`, `serveur_nextcloud_theme_nom` (labels d'affichage). Commentaires
|
||
« Se connecter avec Chezlepro » → génériques. Non-régression SSO : `organisation: Chezlepro` →
|
||
slug `chezlepro`, **identique** à l'URI de redirection Keycloak de l'instance (pas de casse).
|
||
Conservés intentionnellement : realm `default('chezlepro')` (décision `identite_realm` actée) et
|
||
le thème visuel **Alliance Boréale** (identité par défaut assumée du réseau, pas un tenant).
|
||
Validé : re-balayage vide, rendu Jinja du slug testé (Chezlepro/Alliance Boréale/Ma Coop),
|
||
`--syntax-check` OK (playbook forgejo via inventaire `principal`).
|
||
- **Audit du graphe de dépendances (reconstruction, phase 1a).** `docs/dependances-groupes.yml`
|
||
gagne les prérequis inter-groupes confirmés dans le code, en vue de l'orchestrateur trié en
|
||
topologie. Ajouts : `serveur_keycloak` → **`serveur_openldap`** (fédération LDAP via
|
||
`resoudre_annuaire_uri`, en plus de PostgreSQL) ; **`serveur_dovecot`** → `serveur_openldap`
|
||
(userdb/passdb LDAP) ; **`serveur_postfix`** → `serveur_dovecot` (remise LMTP au mailstore) ;
|
||
**`serveur_icingaweb2`** → `serveur_icinga` + `serveur_postgresql` + `serveur_openldap`
|
||
(IcingaDB + auth LDAP) ; **`serveur_nextcloud`** → `serveur_postgresql` + `serveur_keycloak`
|
||
(OIDC) + `serveur_collabora` (validation WOPI). Réconciliation `meta/liens.yml` : le seul lien
|
||
structurel (`serveur_postfix` mailstore → Dovecot) coïncide avec le graphe. **Conclusion d'archi :**
|
||
la règle « TLS vérifié ⇒ `client_pki` aux deux bouts » ne devient PAS des arêtes par-groupe
|
||
(client_pki est quasi universel) — c'est une **couche** de l'ordre de reconstruction
|
||
(`socle → step_ca → client_pki → services → apps → agents`) ; `dependances-groupes.yml` ne
|
||
capture que le fin ordonnancement intra-couche. Validé : YAML conforme, **aucun cycle**, tri-topo
|
||
réussi (19 nœuds), chargeur `charger_dependances` accepte (12 groupes, `est_groupe_operationnel`
|
||
OK), tous les groupes ont un rôle.
|
||
|
||
## 2026-07-05
|
||
|
||
### Modifié
|
||
- **VLAN dérivé du tenant (réseau convergé).** `deriver_nomenclature` (schéma `ip-miroir`) dérive
|
||
désormais le VLAN = `index × 10 + zone` — **unique globalement** sur un trunk convergé (chaque
|
||
tenant son bloc de 10 ; 1-9 réservés à l'infra partagée). Le VLAN contenant déjà le tenant (1er
|
||
chiffre = index), le VMID **mène avec le VLAN** (`VLAN·octet·seq`, ≤ 9 chiffres Proxmox). Ex.
|
||
Technolibre (index 2) → VLANs 21-26, VMID 2101101. Le schéma `compact` (lab, sandbox) reste
|
||
inchangé. Champs `vlan:` codés en dur retirés des catégories ip-miroir (désormais dérivés).
|
||
|
||
### Ajouté
|
||
- **Zéro-confiance est-ouest — flux Métriques, Logs et Courriel chiffrés.** Suite du chantier
|
||
(après PostgreSQL) : **métriques** (node_exporter sert en HTTPS via cert step-ca +
|
||
`--web.config.file` + cert-sync owned prometheus ; Prometheus scrape `scheme: https` +
|
||
`tls_config`), **logs** (Loki `http_tls_config` + cert-sync owned loki ; Alloy push `https` +
|
||
`tls_config`), **courriel** (LMTP `edge-mta→infra-mail:24` en `lmtp_tls_security_level=verify` +
|
||
`lmtp_tls_CAfile` ; `client_smtp` en STARTTLS vérifié). Chacun prouvé de bout en bout (200 HTTPS,
|
||
cibles UP, livraison `status=sent`, HTTP rejeté). Motif cert-sync `.path` industrialisé. Reste :
|
||
edge→backends + DNS (DoT).
|
||
- **VMID 9 chiffres mnémotechnique (schéma `ip-miroir`, opt-in).** `vmid_schema: ip-miroir` dans la
|
||
nomenclature → VMID `I·VVV·HHH·NN` (index·VLAN·octet-hôte·séquence) : le VMID *contient* l'IP
|
||
(`10.(10+index).VLAN.hôte`) + le tenant, lisible d'un coup d'œil. Défaut `compact` rétro-compatible
|
||
(instances déployées inchangées).
|
||
- **Instance partenaire Technolibre.** Écosystème complet (12 VM, `etat: planifie`) dans
|
||
`10.12.16.0/20`, **6 zones de sécurité** (Frontière/Identité/Données/Services-infra/Observabilité/
|
||
Applications, un /24 + VLAN chacune), index de fédération 2, VMID ip-miroir. Preuve de portabilité
|
||
d'un tenant.
|
||
|
||
### Modifié
|
||
- **Références par FQDN partout (fin des IP codées en dur).** Décision d'archi : FQDN pour toute
|
||
référence inter-services (non ambigu en fédération, canonique pour TLS ; nom court = hostname OS).
|
||
`resoudre_base` renvoie le FQDN (→ keycloak/forgejo/icinga) ; `client_journal_loki_url` dérivé du
|
||
groupe `serveur_loki` ; defaults db_host IP morts nettoyés. **Aucune IP littérale dans les defaults.**
|
||
- **Découplage du tenant d'origine.** Realm SSO centralisé sur l'intrant **`identite_realm`** (défaut
|
||
`chezlepro` ; les 4 rôles keycloak/forgejo/grafana/oauth2_proxy en dérivent ; exposé dans la GUI).
|
||
Vars brandées renommées génériques : `chezlepro_timezone→fuseau_horaire`,
|
||
`chezlepro_organisation→organisation`. Le moteur ne porte plus le nom d'un tenant.
|
||
- **Modèles d'instance rafraîchis.** `integral` régénéré depuis le cas prouvé (6 zones, fonctions
|
||
éprouvées `data-sql`/`id-ldap`/`id-sso`/`sup`, ip-miroir, nouveaux intrants) ; `socle`/`identite`/
|
||
`observabilite`/`forge` réalignés sur le même moule (prouvés : dérivation + `valider_serveurs`).
|
||
`presence-web` marqué **aspirationnel** (rôles web-frontal/dorsal absents) plutôt que faussement prêt.
|
||
|
||
## 2026-07-04
|
||
|
||
### Ajouté
|
||
- **Zéro-confiance : flux PostgreSQL entièrement chiffré et vérifié.** PG sert désormais son **cert
|
||
step-ca** (vérifiable contre root_ca) au lieu du snakeoil, et **refuse** toute connexion non-TLS
|
||
du réseau (`hostssl` dans pg_hba). Les 3 clients passent en **verify-full** : keycloak
|
||
(`db-url-properties sslmode=verify-full`), forgejo (`SSL_MODE=verify-full` + `PGSSLROOTCERT`),
|
||
IcingaDB (`tls: true` + `ca`). Prérequis posés : `client_pki` sur data-sql-01 (cert), et
|
||
**`root_ca.crt` en 0644** (cert public, requis par les clients TLS non-root). cert-sync PG
|
||
(motif `.path`, owned postgres) + reload de l'instance `postgresql@NN-main`. Vars :
|
||
`serveur_postgresql_tls_actif`/`_tls_force`, `serveur_*_db_sslmode`/`_ca`. Prouvé de bout en bout
|
||
(cert Set-OPS CA servi, apps 200, non-TLS rejeté « aucun chiffrement », TLS accepté).
|
||
|
||
### Corrigé
|
||
- **PG : détection de version robuste (collision avec un répertoire non-numérique).** Placer le
|
||
`tls_dir` sous `/etc/postgresql/` faisait choisir `tls` comme « version » de cluster
|
||
(`find | sort | last`) → configs déployées au mauvais endroit (verrou hostssl inopérant).
|
||
Corrigé : détection filtrée aux dossiers **numériques** (`^[0-9]+$`) + `tls_dir` déplacé sous
|
||
`/var/lib/postgresql/tls`.
|
||
- **Renouvellement de cert : recharger le VRAI consommateur (bug latent de flotte).** Le
|
||
`cert-renewer@.service` (client_pki) renouvelait le cert sur disque mais son `ExecStartPost`
|
||
rechargeait un service nommé *d'après le cert* (`%i` = FQDN), inexistant → **nginx (et postfix,
|
||
dovecot, slapd) n'étaient jamais rechargés** et servaient l'**ancien cert jusqu'à expiration**.
|
||
Symptôme vécu : cert edge expiré en mémoire (renouvelé sur disque), échec TLS de l'échange
|
||
code→jeton OIDC → **login Grafana/SSO cassé** (tous les services derrière l'edge). Correctif :
|
||
`client_pki_reload_services` (liste des vrais consommateurs), câblée par groupe (edge→nginx,
|
||
mail→postfix/dovecot, annuaire→slapd). Appliqué + vérifié sur les 4 hôtes. Fix immédiat de
|
||
l'incident : `systemctl reload nginx` sur l'edge.
|
||
|
||
### Ajouté
|
||
- **Doc à jour : unité wiki « Autorisation & RBAC », leçon renouvellement, runbooks.** Fermeture
|
||
des dettes de doc : nouvelle **unité wiki authZ/RBAC** (pendant d'*Identité & SSO*, avec l'exemple
|
||
Grafana), **section « le renouvellement est un système »** versée dans l'unité *PKI* (comparer cert
|
||
servi vs fichier ; recharger le consommateur), et **`docs/runbooks-exploitation.md`** (cert expiré,
|
||
RBAC Grafana, branding Forgejo). 15 unités wiki désormais.
|
||
- **UI des logs Loki : dashboard Grafana provisionné.** Loki n'a pas d'UI ; son UI est Grafana.
|
||
Ajout d'un **dashboard « Journaux de la flotte »** (dossier Set-OPS) : sélecteur d'hôte multi +
|
||
filtre regex insensible à la casse + panneau logs + débit par hôte. Référence Loki par une
|
||
**variable de datasource** (`ds_loki`), pas un UID codé en dur (leçon : ajouter un uid explicite à
|
||
une datasource déjà provisionnée casse le démarrage de Grafana). **Visible par les Viewers** (dont
|
||
testmail) sans accès Explore. Prouvé sur obs-01 (dashboard chargé, Grafana actif).
|
||
- **RBAC via SSO : rôle de realm → niveau Grafana.** Machinerie *additive et idempotente* dans
|
||
`serveur_keycloak` (`rbac-oidc.yml`) : rôles de realm (`serveur_keycloak_realm_roles`), **mapper
|
||
`roles`** sur les clients choisis (`_role_mapper_clients`, rôles de realm → claim `roles` dans
|
||
ID token + userinfo), **assignations** rôle→utilisateur (`_role_assignments`). Côté Grafana,
|
||
`role_attribute_path` (`grafana-admin→Admin`, `grafana-editor→Editor`, sinon Viewer). kcadm **à
|
||
chaud, zéro coupure SSO**. Prouvé (idempotence `changed=0`) sur id-sso-01 : rôles créés, mapper
|
||
présent, `testmail` = `grafana-editor` (→ Explore). Illustre l'**authZ** (vs authN du SSO).
|
||
|
||
## 2026-07-03
|
||
|
||
### Ajouté
|
||
- **Identité visuelle Alliance Boréale sur Forgejo (léger, officiel).** Branding via le dossier
|
||
**`custom/`** de Forgejo (mécanisme *officiel* — pas de fork, résistant aux MAJ) : accent **aurore
|
||
par variables CSS** (`--color-primary`…, aucune classe interne touchée), **logo/favicon étoile**
|
||
(réutilisés du thème Keycloak), **page d'accueil brandée** (`home.tmpl` : hero aurore + accroche),
|
||
**thème sombre** par défaut, **nom + méta**. Codifié dans `serveur_forgejo`
|
||
(`serveur_forgejo_branding`, `_app_name`, `_theme`), déployé dans `{{ data }}/custom/`. **Prouvé**
|
||
sur forge-01 : accueil rend (200, « Forge Chezlepro »), `alliance.css` servi (cyan aurore), lint OK.
|
||
- **Wiki pédagogique Forgejo — 14 unités d'apprentissage.** Set-OPS comme *compagnon pédagogique* :
|
||
chaque service = une lentille sur un fondamental TIC, méthodes génériques (on apprend OIDC, pas
|
||
Keycloak). Source versionnée dans `wiki/`, publiée dans le wiki Forgejo (`eregion`). Moule à 4
|
||
temps (concept → Set-OPS → transférable → à toi de jouer, avec *casse-répare*).
|
||
- **GUI : boutons cohérents.** « Pousser » (surchargé : clonait *et* déployait) → « 🖥 Créer la VM »
|
||
pour le clone, « Déployer » partout pour le déploiement, dry-run obligatoire partout. GUI 100 %
|
||
française.
|
||
- **Agents d'observabilité/ops éprouvés — observabilité *flotte-complète*.** Les 3 intégrations
|
||
« agent » (liaisons nœud × optionnelles) déployées sur 4 nœuds (obs-01, data-sql-01, id-ldap-01,
|
||
forge-01) et **prouvées** : `client_metrique` (node_exporter → **Prometheus scrape les 4 cibles,
|
||
toutes UP**) ; `client_journal` (journald → **Loki reçoit les logs des 4 nœuds**) ; `client_smtp`
|
||
(msmtp → **courriel système d'un nœud relayé par l'edge-MTA et livré**). Comble le trou : les
|
||
*serveurs* d'observabilité (Grafana/Prometheus/Loki) étaient prouvés, mais pas la collecte
|
||
fleet-wide — désormais Grafana voit toute la flotte. Cibles corrigées (bac à sable) : Loki→obs-01,
|
||
relais→edge-mta-01 (pas infra-mail-01, qui est le store Dovecot sans SMTP :25).
|
||
- **Thème de connexion Keycloak à l'identité Alliance Boréale.** Thème de login `alliance-boreale`
|
||
(`roles/serveur_keycloak/files/themes/`, `parent=keycloak` + overlay CSS) reprenant l'identité du
|
||
site de l'Alliance (extraite de `site-alliance-boreale`) : **ciel nocturne aurore** (`#05060f`/
|
||
`#0a0d24` + dégradés), **carte glassmorphism**, **logo étoile aurore** (le `favicon.svg` du site),
|
||
**bouton dégradé aurore** (teal→cyan, pilule), liens cyan, **police système** (souveraineté, zéro
|
||
dépendance externe). Déployé dans `{{ keycloak_home }}/themes/`, appliqué au realm via
|
||
`kcadm ... -s loginTheme` (var `serveur_keycloak_login_theme`, idempotent), Keycloak rechargé
|
||
(`flush_handlers` avant la config realm). **Prouvé** : la page de login charge `alliance.css`
|
||
(HTTP 200) + le `logo.svg` (200), `loginTheme=alliance-boreale` actif sur `chezlepro`.
|
||
**Constellation animée en fond** (`scripts=js/constellation.js`) : le JS crée son propre ciel
|
||
(canvas + aurore, le template n'en ayant pas) — étoiles scintillantes qui dérivent, liens de
|
||
constellation cyan, blob d'aurore ondulant ; respecte `prefers-reduced-motion`. Prouvé :
|
||
`constellation.js` référencé + servi (200).
|
||
**Console de compte thémée aussi** (thème `account`, `parent=keycloak.v3`) : overlay CSS
|
||
surchargeant les variables PatternFly 5 (fond aurore, cartes en verre, accent aurore) + la même
|
||
constellation animée. Var `serveur_keycloak_account_theme` via `kcadm -s accountTheme`. Prouvé :
|
||
console charge (HTTP 200, `keycloak.v3` intact), `account.css` servi (200).
|
||
- **Soumission courriel `:587` interne (authentifiée) — la boucle souveraine est bouclée.**
|
||
Postfix (`edge-mta`) sert la **soumission `:587`** (bloc `master.cf` : STARTTLS requis, `SMTP AUTH`,
|
||
seuls les authentifiés relaient) ; l'auth SASL est **déléguée à Dovecot** (`infra-mail`, passdb LDAP
|
||
prouvé) via un **auth-listener réseau** (`service auth { inet_listener sasl }`, port 12345). Aucune
|
||
sortie internet : interne→interne uniquement (l'externe = déliverabilité, Étape B). **Prouvé** (swaks) :
|
||
`testmail` s'authentifie (`235 Authentication successful`), Postfix accepte (`250 queued`), et le
|
||
courriel est **livré dans la boîte** (LMTP→Dovecot). Le courriel souverain fait maintenant **recevoir
|
||
ET envoyer**. Vars : `serveur_dovecot_sasl_reseau`, `serveur_postfix_submission_actif`. Pièges :
|
||
Dovecot 2.4 exige un **nom** de section `inet_listener` ; ajouter un service `master.cf` (nouveau
|
||
listener) → handler **restart** (pas reload) ; et une config cassée peut bloquer un redéploiement si
|
||
la synchro cert/restart précède le template (corriger la config à la main pour débloquer).
|
||
- **Sauvegardes applicatives (logiques) — `serveur_backup` + `client_backup` (restic), Tier 0 prouvé.**
|
||
Choix : sauvegarder la **donnée d'état** (non régénérable) plutôt que les VM (reconstructibles par
|
||
le code + le template). Outil **restic** (chiffrement côté client, déduplication, rétention).
|
||
`serveur_backup` (nœud `backup-01`) = cible SFTP/SSH (utilisateur `restic`, clé autorisée, dépôts
|
||
sous `/srv/restic/<nœud>`). `client_backup` (intégration par nœud) = restic + **jobs déclaratifs**
|
||
(`client_backup_jobs` : `{nom, commande?, chemins}`), clé SSH + mot de passe restic en voûte,
|
||
script + **timer systemd** (quotidien) + rétention `forget --prune`. **Prouvé de bout en bout**
|
||
sur le **Tier 0** (`infra-pki-01` → `/etc/step-ca`, l'ancre de confiance) : sauvegarde **hors-nœud**
|
||
vers `backup-01`, puis **restauration byte-identique** des clés CA (`root_ca_key`,
|
||
`intermediate_ca_key`, `ca.json`). Piège corrigé : le plancher `/etc/hosts` d'un nœud existant
|
||
ignore un nœud nouvellement ajouté → rafraîchir le socle.
|
||
- **Sauvegardes Tier 1 généralisées — 5 nœuds, restauration prouvée.** `client_backup` étendu (jobs
|
||
déclaratifs en host_vars) à : `data-sql-01` (`pg_dumpall` — keycloak/forgejo/icingadb),
|
||
`id-ldap-01` (`slapcat` LDIF — les identités), `infra-mail-01` (`/var/vmail` — les boîtes),
|
||
`forge-01` (`/var/lib/forgejo` + `/etc/forgejo` — dépôts Git ; la BD est déjà couverte par PG).
|
||
**Prouvé par restauration** : dump PostgreSQL restauré contient bien les 3 bases (`CREATE DATABASE
|
||
forgejo/icingadb/keycloak`) ; LDIF restauré contient `testmail`. Les 5 dépôts restic (pki, sql,
|
||
ldap, mail, forge) sont hors-nœud sur `backup-01`, chiffrés. Ajouter un service à sauvegarder =
|
||
déclarer un job. Reste : cible **offsite** (3-2-1, Étape B — le dépôt n'est qu'une URL swappable).
|
||
- **Consolidation — binding `annuaire` (`resoudre_annuaire`) + retrait de la cruft.**
|
||
*Cruft* : supprimés les 8 dossiers-catégories inertes (`roles/{applications,backup,database,
|
||
identity,monitoring,proxmox,storage,web}/`, README seuls) et les 5 playbooks-échafaudages `debug`
|
||
sans rôle (nextcloud, collabora, client_supervision, web_frontal, web_dorsal) ; l'intention reste
|
||
documentée dans `docs/catalogue-services.md`. *Binding annuaire* : nouveau rôle utilitaire partagé
|
||
`resoudre_annuaire` (comme `resoudre_base`) qui **dérive** la connexion OpenLDAP du `domaine_interne`
|
||
+ un hôte d'annuaire surchargeable — LE seul endroit où le nom d'hôte de l'annuaire est fixé, au lieu
|
||
d'être répété. Facts `resoudre_annuaire_{uri,port,base_dn,users_dn,bind_dn,bind_password}` (secret
|
||
déréférencé, `no_log`). **Migrés + prouvés (config neutre, `changed=0`)** : `serveur_keycloak`
|
||
(fédération LDAP — testmail token HTTP 200), `serveur_dovecot` + `serveur_postfix` (flux courriel
|
||
Postfix→LDAP→LMTP→Dovecot **livré de bout en bout**). Piège appris : les *defaults* d'un rôle inclus
|
||
ne persistent pas hors de son exécution — publier via `set_fact`.
|
||
- **Binding annuaire complété — `icingaweb2` + `client_ldap` migrés vers `resoudre_annuaire`.** Fin des
|
||
2 loose ends : `serveur_icingaweb2` (connexion LDAP dormante en mode SSO) résout via
|
||
`resoudre_annuaire` (redéploiement `changed=0`, SSO intact) ; `client_ldap` (SSSD, dormant) ne pointe
|
||
plus sur un `idm-01` périmé. **Plus AUCUN rôle ne code en dur l'hôte d'annuaire** — un seul point de
|
||
vérité (`resoudre_annuaire`).
|
||
- **Rôle `serveur_oauth2_proxy` — passerelle SSO OIDC générique (Keycloak) + Icinga Web 2 au SSO.**
|
||
oauth2-proxy (v7.15.3, binaire GitHub) place Keycloak **devant** n'importe quelle app sans OIDC
|
||
natif : elle reçoit l'utilisateur authentifié via en-tête, en auth `external`. Rôle paramétrable
|
||
(client, secret voûte, redirect, upstream, cookie voûte) — **réutilisable** pour toute app
|
||
OIDC-less. Éprouvé sur `sup-01` **devant Icinga Web 2** : client Keycloak `icingaweb2`,
|
||
oauth2-proxy `:4180` (exposé par l'edge) → upstream nginx local `:8080` → icingaweb2
|
||
`backend = external` (REMOTE_USER depuis `X-Forwarded-Preferred-Username`). **Prouvé** (flux
|
||
authorization code headless) : `testmail` → oauth2-proxy → Keycloak → **icingaweb2 `/dashboard`,
|
||
connecté** (« Se connecter avec Chezlepro », comme Grafana/Forgejo). Ferme le gap LDAP-direct
|
||
d'icingaweb2. **Réglages appris** : `insecure_oidc_allow_unverified_email` (les users LDAP n'ont
|
||
pas `email_verified` ; IdP interne de confiance) ; en reverse-proxy oauth2-proxy passe
|
||
`X-Forwarded-*` (pas `X-Auth-Request-*`) ; **handler nginx en `restart` (pas `reload`)** car un
|
||
changement d'adresse d'écoute n'est pas pris par un reload gracieux.
|
||
- **Rôle `serveur_icingaweb2` — Icinga Web 2 (UI native) + module IcingaDB : éprouvé.** App PHP
|
||
(php8.4-fpm) servie par un **nginx local**, exposée par l'edge (`icinga.lab.chezlepro.internal`,
|
||
auto-dérivé : vhost + cert SAN + A PowerDNS + alias plancher). Config **par fichiers `.ini`**
|
||
(config/resources/authentication/roles + module `icingadb`), pas d'assistant de setup. Base
|
||
IcingaDB via `resoudre_base` (registre). **Auth LDAP direct** vers OpenLDAP (LDAPS, `client_pki`
|
||
sur `sup-01`) — icingaweb2 n'a pas d'OIDC natif ; SSO-par-proxy = raffinement futur. **Prouvé** :
|
||
`testmail` (LDAP) se connecte (`/dashboard`), et le **module IcingaDB affiche la supervision**
|
||
(hôte `icinga`). Déploiement `failed=0` (le rôle est bon ; les frictions étaient dans le
|
||
simulateur de login curl : contrôle de cookie `_checkCookie`, champs `uid`/`submit_login`,
|
||
valeur CSRF avant `name`).
|
||
- **Module BPM (Business Process) éprouvé + codifié — pile Icinga complète.** `serveur_icingaweb2`
|
||
installe + active `icingaweb2-module-businessprocess` (`serveur_icingaweb2_modules`), crée le
|
||
répertoire des processus (éditable via l'UI, groupe `icingaweb2`, setgid) et **sème des processus
|
||
métier en IaC** (`serveur_icingaweb2_bpm_processes`, nom → contenu `.conf`). Format des feuilles
|
||
`host;service` (éprouvé via les fixtures du module). **Prouvé** : un processus « Supervision
|
||
Chezlepro » (agrège load/procs/swap/ping4/ssh du host `icinga` en logique ET) **rend un état**
|
||
dans l'UI (`testmail` connecté), **avec le backend IcingaDB** (pas d'IDO). Rôle re-prouvé (reset
|
||
→ recrée le processus, idempotent). BPM n'est **pas remplaçable par Grafana** (roll-up d'impact
|
||
métier). Pile Icinga = **moteur + Web 2 + BPM**, complète.
|
||
- **Cœur Icinga éprouvé (supervision active).** `serveur_icinga` (cœur : `icinga2` + `icingadb` +
|
||
`icingadb-redis`) déployé sur `sup-01` (🔧→⭐), base `icingadb` PostgreSQL via le registre.
|
||
**Prouvé** : 3 services actifs, et le moteur **supervise** — IcingaDB peuplée (1 hôte, 12
|
||
services, résultats de checks persistés en base). Zéro bug de déploiement (rôle bien bâti).
|
||
**Icinga Web 2 + module BPM restent différés** (phases dédiées : UI native + vues d'impact
|
||
métier ; le BPM n'est pas remplaçable par Grafana). Confirme aussi le **DRY `resoudre_base`**
|
||
sur icinga (les 3 rôles consommateurs validés).
|
||
- **Forgejo branché au SSO OIDC (« Se connecter avec Chezlepro ») — 2e app SSO, prouvée.**
|
||
`serveur_forgejo` (10.0.0) éprouvé sur `forge-01` (🔧→⭐), adossé à PostgreSQL (base `forgejo`
|
||
auto-provisionnée depuis le registre), exposé par l'edge (vhost + cert SAN + A PowerDNS + alias
|
||
plancher, tout auto-dérivé de `expose`). **Source OAuth2** vers Keycloak
|
||
(`forgejo admin auth add-oauth`, idempotent, realm `chezlepro`), client OIDC `forgejo`
|
||
enregistré via `serveur_keycloak_clients`. Auto-enregistrement OIDC (`[oauth2_client]
|
||
ENABLE_AUTO_REGISTRATION` + `ALLOW_ONLY_EXTERNAL_REGISTRATION` : identités depuis l'annuaire
|
||
seulement). **Prouvé** (flux authorization code headless) : `testmail` (LDAP) se connecte,
|
||
**compte auto-créé** (`testmail@lab.chezlepro.internal`), atterrit sur le tableau de bord.
|
||
**5 bugs de 1er déploiement corrigés** : dépendance périmée `serveur_sendmail`→`serveur_postfix`
|
||
(le vrai MTA) ; `app.ini` doit appartenir au user `git` (Forgejo persiste des secrets générés) ;
|
||
ordre admin/migrations (`flush_handlers` + `wait_for` avant `admin user create`) ; `HTTP_ADDR`
|
||
`127.0.0.1`→`0.0.0.0` (l'edge nginx est sur un autre hôte, 502 sinon) ; auto-enregistrement OIDC.
|
||
- **DRY : rôle utilitaire partagé `resoudre_base` (résolution BD depuis le registre).** Le bloc
|
||
copié-collé dans `serveur_keycloak`, `serveur_forgejo` et `serveur_icinga` (charger le registre,
|
||
filtrer par consommateur, déréférencer le secret via `lookup('vars', ...)`, résoudre hôte/port)
|
||
est extrait dans `roles/resoudre_base` (facts `resoudre_base_entree/db_password/db_host/db_port`,
|
||
`no_log`). Les 3 rôles l'incluent (`include_role`) et adoptent les facts. Le secret **ne quitte
|
||
toujours pas le rôle** (déréférencé au déploiement). **Fait « sur la preuve »** : re-déploiement
|
||
keycloak + forgejo `failed=0`, idempotent, `testmail` token Keycloak HTTP 200. Ferme le reste
|
||
noté de la Phase 2 des bindings (cf. `docs/bindings-conception.md`).
|
||
- **PowerDNS — A d'exposition auto-dérivés (le DNS de la Phase 3 des bindings).** `serveur_powerdns`
|
||
génère désormais, dans la zone interne, un enregistrement A pour chaque FQDN d'exposition (champ
|
||
`expose` des applications) vers l'**edge qui le sert** (`domaines.edge`) — via
|
||
`expositions_des_applications` (même source que les vhosts nginx et les SANs du cert edge).
|
||
Déclarer `expose` produit maintenant **vhost + SAN de cert + enregistrement DNS**, tout dérivé.
|
||
**Prouvé** : `dig @infra-dns-01 grafana.lab.chezlepro.internal` et `keycloak.…` → `192.168.15.21`
|
||
(edge). Option `serveur_powerdns_publier_expositions` (défaut true). **Limite / reste** : PowerDNS
|
||
est **autoritatif, pas récursif** — pour que les nœuds *utilisent* ces A sans casser la résolution
|
||
Internet, il faut un **récursif** (pdns-recursor : forward de la zone interne + récursion du reste)
|
||
ou garder le plancher `/etc/hosts`. Ne PAS repointer naïvement `client_dns` vers l'autoritatif.
|
||
- **`hosts_statiques` — alias d'exposition dans le plancher `/etc/hosts` (résolution client, sûre).**
|
||
Le plancher pose désormais, sur **chaque nœud**, `<IP edge> <FQDN exposé>` pour chaque `expose`
|
||
(dérivé de `domaines.edge`, même source que nginx/PowerDNS). Indépendant du DNS, aucun risque de
|
||
couper la résolution (choix retenu vs pdns-recursor). Chargement du plan **best-effort**
|
||
(`stat` **`delegate_to: localhost`** + `become: false` — les registres vivent sur le nœud de
|
||
contrôle ; ignoré si le plan est absent, ex. préparation du template). **Prouvé** : `/etc/hosts`
|
||
d'obs-01 régénéré avec `keycloak`/`grafana` → edge (ligne manuelle éliminée), `getent` OK, et le
|
||
**flux SSO Grafana fonctionne via la résolution du plancher** (`login: testmail`). Boucle Phase 3
|
||
fermée : déclarer `expose` → **vhost + SAN cert + A PowerDNS + alias plancher**, tout dérivé.
|
||
Bugs corrigés en chemin : `serveur_loki` (groupe `loki` manquant), `stat` sur cible→contrôle,
|
||
`become` inutile sur le contrôle.
|
||
- **Rôle `client_unbound` — résolveur local (DNS dynamique) : éprouvé sur un nœud.** Unbound par
|
||
nœud (`127.0.0.1`) avec **stub-zone** vers l'autoritatif interne (PowerDNS) + **récursion**
|
||
Internet (ou forward via `client_unbound_transitaires`). Alternative *dynamique* au plancher
|
||
`/etc/hosts` statique, sans casser Internet. Bascule de `/etc/resolv.conf` **protégée**
|
||
(`client_unbound_apply` + `client_unbound_confirm`) **et validée AVANT** (Unbound doit résoudre
|
||
interne + Internet, sinon pas de bascule → nœud jamais coupé). **Prouvé sur data-sql-01** (rayon
|
||
d'impact minimal) : `dig @127.0.0.1 keycloak/id-sso-01.lab.chezlepro.internal` → PowerDNS,
|
||
`deb.debian.org` → récursion, `apt` OK. Rôle sûr par défaut (`apply: false` : installe Unbound
|
||
sans toucher au resolver). Rollout flotte = opt-in par nœud. **Note direction** : OPNsense
|
||
embarque Unbound → à terme, l'Unbound *réseau* peut vivre sur l'appliance de bordure (nœud
|
||
public, Étape B) ; le rôle par-nœud reste portable et complémentaire (cache local).
|
||
- **Bindings — Phase 1 : résolveur de liens dans `instancier.py` (relations service→service
|
||
déclaratives).** Une application déclare ses `liens: [{vers, role}]` dans
|
||
`plan/applications.yml` ; chaque rôle décrit les liens qu'il accepte dans `meta/liens.yml`
|
||
(`setops_liens.accepte`, comme `meta/empreinte.yml`). `instancier` résout la cible (FQDN interne
|
||
**dérivé de la nomenclature** + `domaine_interne`), substitue les gabarits (`{cible.fqdn}`,
|
||
`{cible.hote}`, `{cible.ip}`) et **injecte les variables en host_vars du consommateur**.
|
||
Validation : rôle accepteur, cible existante, genre attendu. **Migration prouvée** : les liens
|
||
mail Postfix→Dovecot (`mailstore`) et Postfix→rspamd (`milter`) passent de group_vars codés en
|
||
dur à des liens déclaratifs — `make instancier` donne **DIFF VIDE** (mêmes variables générées),
|
||
puis les group_vars sont retirés. La topologie mail devient déclarative et portable.
|
||
Cf. `docs/bindings-conception.md`. Suite : bases (Phase 2), exposition/domaines (Phase 3), GUI (Phase 4).
|
||
- **Bindings — Phase 2 (bases) : constat + réconciliation de la note (`docs/bindings-conception.md` §5/§9).**
|
||
Inspection du code réel : le binding app→base **existe déjà** — **côté base** (`consommateur`/`portee`
|
||
dans `bases-donnees.yml`), résolu **dans le rôle** au déploiement (`include_vars` + filtre +
|
||
`lookup('vars', secret)`), sur 4 rôles (postgresql, forgejo, keycloak, icinga). **Délibérément
|
||
conservé** (le secret ne quitte jamais le rôle) — ne PAS dupliquer en app-side/instancier. Deux
|
||
directions assumées : app→app côté app (instancier), app→base côté base (registre). Reste (reporté
|
||
à l'épreuve de Keycloak) : factoriser le bloc de résolution copié-collé en include partagé (DRY).
|
||
- **`docs/carte-set-ops.md` — carte d'orientation (index + mécanismes transverses).** Après audit
|
||
du dépôt : point d'entrée « à lire d'abord » (index du corpus, ~22 docs), et **catalogue des
|
||
mécanismes** dispersés dans le code (les 2 directions de binding, pont de cert, résolution BD
|
||
par registre, socle-first, sûreté check-mode, voûte, dimensionnement) avec **où ils vivent**.
|
||
But : ne plus re-découvrir l'existant. Constat : la **cruft était déjà inventoriée** dans
|
||
`catalogue-services.md` (rôles-catégories inertes, échafaudages) — non dupliquée, référencée.
|
||
`catalogue-services.md` « État d'implémentation » **rafraîchi** (rôles éprouvés sur VM réelles :
|
||
socle, PKI, LDAP, DNS, nginx, pile courriel). Pointeur ajouté depuis `architecture-set-ops.md`.
|
||
- **`serveur_postgresql` et `serveur_keycloak` éprouvés sur VM réelles (🔧→⭐).** PostgreSQL
|
||
déployé (data-sql-01), écoute réseau + pg_hba VLAN, et **provisionne la base `keycloak` depuis
|
||
le registre** (`bases-donnees.yml`) — **binding app→base prouvé en réel** (base + rôle créés,
|
||
mot de passe = `vault_bd_keycloak`). Keycloak 26.0.7 déployé (id-sso-01), **mode prod**,
|
||
connecté à PostgreSQL (**87 tables** du realm master écrites), **token admin obtenu** (auth
|
||
adossée à la BD). Lacunes connues (documentées `catalogue-services.md`) : fédération LDAP et
|
||
edge nginx pas encore câblés. Reste : DRY du bloc de résolution BD (keycloak/forgejo/icinga).
|
||
- **`serveur_keycloak` — fédération LDAP (modèle d'identité A) : automatisée et prouvée.** Le rôle
|
||
configure, via `kcadm` (idempotent), un realm applicatif (`serveur_keycloak_realm`, déf.
|
||
`chezlepro`) et un **provider de stockage LDAP READ_ONLY** vers OpenLDAP (LDAPS, `uid`/`entryUUID`,
|
||
`inetOrgPerson`). TLS LDAPS validé via le **truststore système** (`truststore-paths` →
|
||
`/etc/ssl/certs/ca-certificates.crt`, racine step_ca posée par **client_pki**, désormais requis
|
||
sur le nœud). Secrets par `environment` + `no_log`. **Éprouvé avant codification** puis prouvé
|
||
par le rôle : un utilisateur LDAP (`testmail`) obtient un token via le realm (HTTP 200), et le
|
||
redéploiement est **idempotent** (`changed=0`). Nouveau : `tasks/federation-ldap.yml`. Reste :
|
||
edge nginx (accès HTTPS par nom), mappers d'attributs/groupes fins.
|
||
- **Edge nginx + exposition (Phase 3 des bindings) — prouvés avec Keycloak.** Sans changement de
|
||
code : la machinerie `serveur_nginx_publier_expositions` **existait déjà** (lit `expose` des
|
||
applications + `edge` de `domaines.yml`, dérive `amont = http://<IP hôte>:<port>`, génère le
|
||
vhost avec `X-Forwarded-*`). Le bac à sable déclare `keycloak.expose:
|
||
[keycloak.lab.chezlepro.internal]` + le domaine interne `lab.chezlepro.internal` (edge
|
||
`serveur_nginx`). **Prouvé** : le vhost s'auto-génère (`keycloak.lab.chezlepro.internal →
|
||
http://192.168.15.81:8080`), et la découverte OIDC via l'edge renvoie
|
||
`"issuer":"https://keycloak.lab.chezlepro.internal/..."` (les `X-Forwarded` passent, Keycloak
|
||
se sait derrière HTTPS). **Limite connue** : le cert TLS de l'edge est encore le snakeoil
|
||
auto-signé (avertissement navigateur). **Raffinement recommandé** (réutilise l'existant, pas de
|
||
nouveau mécanisme) : ajouter les FQDN d'exposition aux `client_pki_sans` de l'edge (client_pki
|
||
demande + renouvelle déjà le cert d'hôte), puis pointer `serveur_nginx_certificat` sur le cert
|
||
client_pki (`/etc/step/certs/<edge>.crt`).
|
||
- **Cert de l'edge : snakeoil → step_ca (HTTPS valide).** Appliqué le raffinement ci-dessus :
|
||
`client_pki` ajouté à l'edge, ses `client_pki_sans` incluent le FQDN d'exposition
|
||
(`keycloak.lab.chezlepro.internal`), et `serveur_nginx_certificat`/`_cle` pointent sur le cert
|
||
client_pki. **Prouvé** : HTTPS `HTTP 200` avec `ssl_verify_result=0` (chaîne validée contre la
|
||
racine step_ca, nom correct), émetteur `Set-OPS Internal CA`. Sans nouveau code (client_pki +
|
||
group_var). **Gaps notés** : (1) recharger nginx au **renouvellement** du cert (le cert-renewer
|
||
renouvelle en place, nginx ne recharge pas seul — hook à ajouter) ; (2) **auto-dériver** les SANs
|
||
d'exposition de l'edge depuis le plan (au lieu de les lister dans le group_var).
|
||
- **Grafana branché au SSO OIDC (« Se connecter avec Chezlepro ») — prouvé de bout en bout.**
|
||
`serveur_grafana` : config OIDC via `GF_AUTH_GENERIC_OAUTH_*` (client confidentiel `grafana`,
|
||
realm `chezlepro`, secret `vault_grafana_oidc`). `serveur_loki` + `serveur_prometheus` +
|
||
`serveur_grafana` déployés sur `obs-01` (🔧→⭐). **Bug de rôle corrigé** : `serveur_loki` créait
|
||
le répertoire en `group: loki` alors que le paquet crée l'utilisateur en `nogroup` sans groupe
|
||
`loki` → ajout de la création du groupe. **Prouvé (flux authorization code headless, via l'edge
|
||
HTTPS)** : `testmail` (user LDAP) se connecte à Grafana par le SSO — `/api/user` renvoie
|
||
`login: testmail`, email et nom **fédérés depuis LDAP**. Chaîne complète LDAP → Keycloak →
|
||
Grafana. **Gaps notés (pour rendre 100 % déclaratif)** : (1) l'**enregistrement du client OIDC**
|
||
dans Keycloak a été fait via `kcadm` **à la main** (à codifier — rôle grafana ou liste de clients
|
||
côté keycloak) ; (2) la **résolution** `keycloak.…internal → edge` sur obs-01 est un `/etc/hosts`
|
||
manuel (**PowerDNS devrait porter les A d'exposition** — chaînon récurrent) ; (3) mapping de
|
||
rôles Grafana (tous Viewer par défaut).
|
||
- **`serveur_keycloak` — enregistrement des clients OIDC codifié (gap précédent fermé).** Le rôle
|
||
gère une **liste déclarative** `serveur_keycloak_clients` (`clientId`, `redirect_uris`,
|
||
`web_origins`, `secret`) et enregistre chaque client confidentiel via **kcadm idempotent**
|
||
(`tasks/clients-oidc.yml`, create-si-absent, `no_log`). Décision : **côté Keycloak** (les creds
|
||
admin restent dans le seul rôle Keycloak, pas répandus dans chaque rôle app) ; le `secret`
|
||
référence la même variable de voûte que l'app. **Prouvé** : client `grafana` supprimé → rôle →
|
||
recréé → `testmail` se connecte à Grafana (`login: testmail`) ; redéploiement **idempotent**
|
||
(`changed=0`). Le déploiement de Grafana au SSO est désormais **autonome**.
|
||
|
||
## 2026-07-02
|
||
|
||
### Décidé
|
||
- **Bindings — conception des relations app/base/serveur/domaine (`docs/bindings-conception.md`).**
|
||
Les relations service→service sont aujourd'hui codées en dur, éparpillées dans des group_vars
|
||
(ex. Postfix→Dovecot/rspamd/LDAP), ce qui casse la portabilité multi-tenant. Direction retenue :
|
||
**liens déclarés côté application** (`liens: [{vers, role}]`), résolus par `instancier.py` en
|
||
variables Ansible ; chaque rôle décrit les liens qu'il accepte dans `meta/liens.yml` (comme
|
||
`meta/empreinte.yml`) ; FQDN cible **dérivé de la nomenclature** (jamais codé en dur). Domaines
|
||
publics traités comme lien `exposition` (écrit sur l'edge). Réconcilie l'existant (bases
|
||
`consommateur`, `domaines.edge`). Preuve de migration ciblée : les 3 liens mail. Implémentation
|
||
à suivre (phasée).
|
||
- **Licence : passage de CC BY-NC-SA 4.0 à AGPLv3.** Les licences Creative Commons ne sont pas
|
||
faites pour du logiciel (position de CC elle-même) et la clause **NonCommercial contredisait
|
||
le principe fondateur « tout est libre »** — en plus de bloquer les artisans/coopératives
|
||
visés. `LICENSE` remplacé par le **texte officiel intégral de l'AGPLv3** (verbatim, non
|
||
modifié). Attribution + modèle **libre + services/certification** documentés dans le `README`
|
||
(méthode d'attribution recommandée, sans toucher au texte de la licence). L'AGPLv3 protège la
|
||
souveraineté (anti-captation propriétaire en SaaS) **sans interdire l'usage commercial**.
|
||
- **Architecture d'identité/SSO (`docs/identite-sso.md`).** Modèle A : **OpenLDAP source de
|
||
vérité**, **Keycloak fédéré** (SSO web OIDC, MFA, self-service), **mail en bind LDAP
|
||
direct**. Une identité, un mot de passe, deux chemins d'auth (web→Keycloak, mail→LDAP),
|
||
tout adossé au même OpenLDAP. Sert la portabilité multi-tenant (chaque tenant = son LDAP +
|
||
son Keycloak fédéré). Pas Keycloak-source (casse-tête mail + moins portable).
|
||
- **Service courriel : pivot de Stalwart vers Postfix + Dovecot + rspamd.** La Phase 1
|
||
Stalwart (`serveur_stalwart`) avait été prototypée et déployée (v0.16.11, install +
|
||
démarrage en mode récupération). Le prototypage a révélé un projet **trop jeune/volatil
|
||
pour un pilier mail critique** : config cassée entre 0.15 et 0.16, outil IaC
|
||
`stalwart config apply` **annoncé mais non livré** dans le binaire, API REST supprimée
|
||
(JMAP), gros backlog. Pivot vers la stack **mature Postfix/Dovecot/rspamd**, en prime
|
||
**100 % configurable par fichiers** (alignée au modèle déclaratif Set-OPS). Le rôle
|
||
`serveur_stalwart` est **retiré** (git en garde la trace) ; `docs/courriel-conception.md`
|
||
mis à jour. Réévaluer Stalwart ~2028.
|
||
|
||
### Ajouté
|
||
- **`docs/pouvoirs-set-ops.md` — bilan des capacités du moteur.** Inventaire structuré
|
||
(moteur/plan, plan de contrôle GUI, socle durci, piliers d'infrastructure, services outillés,
|
||
patrons d'ingénierie), distinguant honnêtement « prouvé sur cluster réel » de « outillé ».
|
||
- **Rôle `serveur_rspamd` (rspamd 3.x) — antispam + DKIM, en milter sur Postfix.** Installé
|
||
sur le nœud edge-mta (avec Postfix), backend **Redis** local, worker proxy en **mode milter
|
||
auto-scan** (`:11332`), **signature DKIM** sortante (clé générée par le rôle de façon
|
||
idempotente ; enregistrement DNS public affiché pour l'Étape B). Config par surcharges
|
||
`/etc/rspamd/local.d/`. Postfix branché via `smtpd_milters` (option `serveur_postfix_rspamd_milter`,
|
||
`milter_default_action = accept` → tolérant si rspamd indisponible). **Prouvé** : un courriel
|
||
traversant le milter ressort **scanné** (`rspamc stat` : 1) et **signé DKIM** (`DKIM-Signature:
|
||
d=…`), puis livré et lu en IMAP. **Étape A (courriel interne) complète** : dovecot + postfix + rspamd.
|
||
- **Flux courriel interne PROUVÉ de bout en bout (Étape A).** Envoi → Postfix (`edge-mta`,
|
||
validation LDAP) → LMTP réseau → Dovecot (mail-store) → boîte Maildir → **lu en IMAP**
|
||
(auth LDAP, TLS step_ca) : `status=sent`, message lu (sujet + corps). Réglages Dovecot 2.4
|
||
qui débloquent la remise LMTP : `userdb static { static_allow_all_users = yes }` (sinon
|
||
NOTFOUND pour l'expéditeur/raw-mail-user externe), `mail_inbox_path =` vidé (le défaut mbox
|
||
`/var/mail` root refusait l'autocréation de l'INBOX), Maildir explicite (`mail_home` +
|
||
`mail_path = %{home}/Maildir`), chemin par nom d'utilisateur (home identique côté LMTP
|
||
local-part et IMAP adresse complète ; mono-domaine, multi-domaine = raffinement Étape B).
|
||
- **Rôle `serveur_postfix` (Postfix 3.x) — MTA du nœud edge-mta.** Réception `:25`, cartes
|
||
**LDAP** (validation des boîtes via l'attribut `mail`), remise **LMTP réseau** vers le nœud
|
||
mail-store Dovecot (`virtual_transport = lmtp:inet:[…]:24`), TLS via **step_ca** (pont de
|
||
cert), aucune boîte locale. Config `main.cf` + carte `ldap-mailboxes.cf`, validée par
|
||
`postfix check`. Secret de bind : `vault_openldap_admin`. Nécessite `serveur_postfix_mailstore_hote`
|
||
(FQDN du mail-store). Validé statiquement ; déploiement réel à suivre.
|
||
- **Rôle `serveur_dovecot` (Dovecot 2.4) — déployé et prouvé.** IMAP `:993`/`:143` +
|
||
LMTP, **auth/annuaire LDAP** (vers `serveur_openldap`, filtre `mail`), stockage Maildir
|
||
(user système `vmail`), **TLS via step_ca** (pont de cert + resync au renouvellement),
|
||
neutralisation de l'auth système par défaut. Config en drop-in **syntaxe Dovecot 2.4**
|
||
(`mail_driver`, `ssl_server_cert_file`, `passdb ldap`/`userdb static`, `%{user}`),
|
||
**validée par `doveconf`** au déploiement. Sockets d'intégration Postfix **conditionnels**
|
||
(rendus si l'utilisateur `postfix` est co-localisé). Prouvé : `doveadm auth test` — bon
|
||
mot de passe accepté, mauvais refusé, sur cert step_ca. Secret de bind : `vault_openldap_admin`.
|
||
- **`serveur_openldap` durci pour la prod : TLS via step_ca + organisation en intrant.**
|
||
- **TLS (LDAPS + STARTTLS)** : le certificat d'hôte step_ca (déposé par `client_pki`,
|
||
`root:root 600`) est synchronisé vers un emplacement lisible par `openldap` (`/etc/ldap/tls`)
|
||
par un script + une unité `path` systemd qui **re-synchronise et recharge slapd à chaque
|
||
renouvellement** ; `olcTLS*` configuré dans `cn=config`, `SLAPD_SERVICES` expose `ldaps://`.
|
||
Dégrade proprement (slapd en clair local) si `client_pki` n'a pas encore posé le cert.
|
||
- **Organisation** : nouvel intrant `chezlepro_organisation` (remplace le « Exemple Inc » codé).
|
||
- *Écrit en code de prod, éprouvé statiquement ; validation par déploiement réel à suivre.*
|
||
- **`docs/courriel-conception.md`** — cadrage du futur service de courriel souverain :
|
||
full self-host, suite **Stalwart** (adoptée, enveloppée par un rôle mince), topologie
|
||
MX primaire + MX secours, intégration identité (LDAP) / DNS / PKI (Let's Encrypt public
|
||
vs step_ca interne), enregistrements DNS publics, décisions ouvertes (IP/PTR, secours,
|
||
stockage) et phasage. **Conception seulement — aucun rôle livré.**
|
||
|
||
## 2026-07-01
|
||
|
||
### Ajouté
|
||
- **État RÉEL vs plan dans le GUI (sonde de vie + auto-actif).** Le badge `planifié`/
|
||
`actif` décrit l'*intention* du plan, pas l'existence de la VM — d'où la confusion
|
||
« serveur planifié mais vivant ». Deux ajouts :
|
||
- **Sonde de vie** : le GUI teste la joignabilité SSH de chaque hôte en arrière-plan
|
||
(`/api/sondes`, en parallèle) et affiche un état réel — **● vivante** / **● injoignable**
|
||
— sur les tuiles et dans le détail (« Plan : … · Réel : … »), distinct du plan.
|
||
- **Auto-actif** : un hôte qu'on **matérialise** (clone `creer` réussi) ou qu'on
|
||
**déploie** passe automatiquement `actif` dans le plan (matérialisé = actif), puis
|
||
l'inventaire est **régénéré** pour que le changement se voie partout (en-tête inclus).
|
||
- **Compteur « vivantes »** dans l'en-tête (depuis la sonde), à côté de actifs/planifiés.
|
||
|
||
### Corrigé
|
||
- **nginx ne validait pas sur Debian 13 (`server_tokens` en double).** Debian 13 livre
|
||
`server_tokens off;` **actif** dans `/etc/nginx/nginx.conf` (avant : commenté). Le
|
||
drop-in `conf.d/99-setops.conf` du rôle le redéclarait → `nginx -t` échouait
|
||
(« directive is duplicate ») et le déploiement plantait au handler de validation. Le
|
||
rôle `serveur_nginx` neutralise désormais la ligne distro (le drop-in reste l'unique
|
||
source). Trouvé en déployant nginx pour de vrai sur un hôte edge.
|
||
- **Une voûte chiffrée cassait `instancier` / « Appliquer le plan ».** `ansible-inventory
|
||
--list` (utilisé pour la comparaison sémantique du plan) tente de déchiffrer
|
||
`group_vars/all/vault.yml` et échoue sans mot de passe (`exit 4`) — alors que
|
||
l'opération est structurelle, sans secret. `instancier` utilise désormais
|
||
automatiquement le fichier conventionnel `~/.config/setops-vault-pass` (si
|
||
`ANSIBLE_VAULT_PASSWORD_FILE` n'est pas déjà défini).
|
||
- **Secrets des rôles non câblés à la voûte (échafaudage manquant).** Les rôles à
|
||
secrets déclaraient `serveur_X_password: ""` avec, en commentaire seulement, la
|
||
variable de voûte attendue (`{{ vault_X }}`) — sans mapping réel. Résultat : remplir
|
||
la voûte selon `vault.exemple.yml` ne suffisait pas, le secret restait vide et
|
||
l'assertion « secrets requis » échouait. Les 12 secrets des 8 rôles (`serveur_step_ca`,
|
||
`serveur_forgejo`, `serveur_grafana`, `serveur_keycloak`, `serveur_openldap`,
|
||
`serveur_redis`, `client_ldap`, `client_pki`) pointent désormais vers leur variable
|
||
de voûte : `serveur_X_password: "{{ vault_X | default('') }}"`. Chaque instance n'a
|
||
plus qu'à remplir ses `vault_*` dans sa voûte chiffrée ; aucun mapping par instance.
|
||
- **« Vérifier » (dry-run `--check`) échouait faussement sur un hôte frais.** Les tâches
|
||
« démarrer service » et les handlers « redémarrer / recharger / valider » des rôles
|
||
applicatifs touchent un paquet que `--check` n'installe pas réellement → le service
|
||
(ou le fichier de zone/conf) n'existe pas encore → faux `fatal`, qui **bloquait le
|
||
déploiement** (le dry-run doit réussir pour débloquer « Déployer »). Ajout de
|
||
`when: not ansible_check_mode` sur ces tâches et handlers des 13 rôles `serveur_*`
|
||
(29 gardes). En dry-run elles sont sautées ; en vrai déploiement, inchangées.
|
||
- **Le bouton ⚙ « Appliquer le plan » du GUI refusait en silence** dès que le plan
|
||
divergeait de l'inventaire (il appelait `instancier appliquer` **sans** `--force`).
|
||
Résultat : après une édition (disque, état, auto-actif), l'inventaire n'était jamais
|
||
régénéré et les compteurs restaient figés. Le clic « Appliquer » **est** l'intention
|
||
explicite → le GUI force désormais (git reste le filet).
|
||
|
||
- **Bouton « Pousser » dans le GUI — le flux devient 100 % cliquable.** Chaque objet
|
||
du détail se matérialise sur son hôte, en streaming console (avec mot de passe vault
|
||
+ confirmation renforcée en prod) :
|
||
- **Serveur** → `🖥 Pousser` clone la VM depuis le golden template (`make creer-vm`),
|
||
disponible même sur un hôte planifié — comble le trou : le GUI ne clonait pas.
|
||
- **Application** → `Pousser` déploie l'hôte porteur (`make deployer HOTE=<hôte>`).
|
||
- **Base** → `Pousser` déploie l'hôte du serveur de BD (crée la base).
|
||
Nouveaux modes `creer` / `pousser` dans `executer_flux` + routes `/api/creer` et
|
||
`/api/pousser`. Flux complet depuis l'interface : éditer → ⚙ Appliquer → 🖥 Pousser
|
||
→ Vérifier → Déployer.
|
||
- **Premier déploiement RÉEL validé de bout en bout** (cluster asgard) : flux canonique
|
||
plan-piloté clonant + durcissant + faisant tourner un PowerDNS qui résout. Voir les 3
|
||
correctifs ci-dessous.
|
||
|
||
## 2026-06-30
|
||
|
||
### Ajouté
|
||
- **Plancher de résolution `/etc/hosts` (indépendant du DNS).** Nouveau rôle de socle
|
||
`hosts_statiques` (dans `serveur_debian`) : génère `/etc/hosts` sur **chaque** VM
|
||
depuis l'inventaire (nom + FQDN interne → IP réelle). Tout l'écosystème se résout
|
||
par nom **même serveur DNS éteint**, et le bootstrap ne dépend plus du DNS. PowerDNS
|
||
devient une **commodité** (zone/externe/dynamique) ; `client_dns` est rendu tolérant
|
||
(inerte si aucun DNS interne) et sa dépendance à `serveur_powerdns` passe **molle**.
|
||
- **Adressage fédéré : index d'instance.** Le VMID n'est plus codé `9CSNN` en dur :
|
||
il prend le préfixe d'un `index` déclaré en tête de `plan/nomenclature.yml`
|
||
(`{index}{catégorie}{service}{séq}`). Convention : `supernet = 10.(10+index).0.0/16`,
|
||
`VMID = index·CSNN`. Permet à N écosystèmes de **coexister/s'interconnecter** sans
|
||
collision (Chezlepro=1 → `10.11`/`1xxxx`, Technolibre=2 → `10.12`/`2xxxx`). Sans
|
||
index → `9CSNN` (rétro-compatible ; bacs à sable, plages ad-hoc `172.19.x`).
|
||
Code mort retiré (`deriveServeur` JS). Voir `docs/multi-instances.md`.
|
||
- **Doc `docs/multi-instances.md`** : cadrage « un moteur, N écosystèmes » — l'instance
|
||
comme dépôt autonome, la bascule, l'isolation, et le socle multi-tenant.
|
||
- **Bascule d'instance (`make instance-utiliser NOM=…`).** Repointe le symlink
|
||
`instance` vers un autre dépôt d'instance (prod ↔ bac à sable) ;
|
||
`make instance-courante` affiche l'instance montée. Permet d'exploiter plusieurs
|
||
instances (séparation **par instance**) depuis un seul moteur.
|
||
- **Identité des intrants relative à l'inventaire.** Le panneau « Intrants » lit/écrit
|
||
l'identité dans `group_vars/all/10-intrants.yml` de l'inventaire monté (fichier réel
|
||
pour une instance autonome, symlink vers une source partagée sinon — l'écriture suit
|
||
le symlink). Fonctionne donc aussi bien pour une instance « par instance » que pour
|
||
l'ancien partage lab/production.
|
||
- **Inventaire d'instance neutre et configurable (`principal` / `SETOPS_INVENTAIRE`).**
|
||
Le moteur (Makefile + `inventory_gui`, `instancier`, `config_proxmox`, `serveurs`,
|
||
`applications`) ne code plus en dur `inventories/lab` / `inventories/production` :
|
||
il vise **un inventaire par instance**, détecté de façon **rétro-compatible**
|
||
(`principal` > `production` > `lab`) et surchargeable par `SETOPS_INVENTAIRE`. Les
|
||
instances existantes (découpage lab/production) continuent de fonctionner à
|
||
l'identique ; les nouvelles peuvent adopter `inventories/principal/`. Deuxième pierre
|
||
de la séparation **par instance** (la 1re étant le drapeau `setops_production`).
|
||
- **Garde-fou de prudence par instance (`setops_production`).** Le déploiement réel
|
||
est désormais possible sur **toute instance** (un bac à sable déploie sur *son*
|
||
infra lab — il est isolé **et** fonctionnel). Le drapeau `setops_production` dans
|
||
`group_vars/all/` ne **bloque** plus rien : il marque la PRODUCTION pour exiger une
|
||
**confirmation renforcée** au déploiement (bannière/badge rouge « PROD », bouton
|
||
Déployer en rouge, re-saisie du nom d'hôte) ; un bac à sable (`false`) affiche
|
||
« bac à sable » et déploie sans cette étape. Rétro-compatible (à défaut de drapeau,
|
||
ancien repère « inventaire production »). Le GUI affiche toujours quel type
|
||
d'instance est monté.
|
||
|
||
### Modifié
|
||
- **Voûte de secrets unique par environnement.** Fini les voûtes éparpillées : tous
|
||
les secrets de l'instance (token Proxmox + 17 `vault_*` pour PKI, LDAP/SSO, bases,
|
||
forge, observabilité) vivent dans **un seul fichier chiffré**,
|
||
`inventories/<env>/group_vars/all/vault.yml`. Gabarit committé
|
||
`exemples/vault.exemple.yml`. `make config` (`config_proxmox.py`) écrit/édite
|
||
désormais cette voûte (semée depuis le gabarit si absente). `.gitignore` durci
|
||
(`**/vault.yml`). Docs mises à jour (config-proxmox.md avec étapes de migration,
|
||
intrants-communs.md §H, QUICKSTART). Le GUI ne stocke toujours aucun secret.
|
||
|
||
### Corrigé
|
||
- **Trois bugs trouvés au premier déploiement réel (cluster asgard).**
|
||
- **Clonage/Makefile codaient `inventories/lab` en dur** (config Proxmox + voûte) —
|
||
vestige du modèle env qui cassait les instances `principal`. Le playbook de clonage
|
||
et le Makefile détectent maintenant l'inventaire (`lab` > `principal` > `production`).
|
||
- **Redimensionnement disque non idempotent** : quand le disque dérivé du plan est
|
||
plus petit que le golden template, Proxmox refuse (`shrinking disks is not
|
||
supported`) et le clone échouait. Le resize est désormais **grow-only** (tolère le
|
||
cas, la VM garde le disque du template — le dérivé est un minimum).
|
||
- **PowerDNS refusait de démarrer** (`multiple backends 'bind'`) : le rôle
|
||
redéclarait `launch+=bind` que le paquet `pdns-backend-bind` pose déjà. Le rôle ne
|
||
déclare plus `launch` (seulement `bind-config`).
|
||
- **`chezlepro_timezone` n'était appliqué nulle part.** Cet intrant de base global
|
||
était défini mais aucun rôle ne s'en servait. Le rôle `chrony` (appliqué à tout
|
||
hôte via `serveur_debian`) règle désormais le fuseau horaire à partir de
|
||
`chezlepro_timezone` (`chrony_timezone` par défaut, vide = ne pas toucher). Les
|
||
autres défauts globaux (nœud / stockage / pont Proxmox) étaient déjà réutilisés
|
||
comme valeurs par défaut, surchargeables par hôte.
|
||
|
||
### Modifié
|
||
- **Détail GUI : section « Groupes (dérivés) » retirée (redondante).** Depuis la fusion
|
||
en atelier maître-détail, elle ne répétait que le socle (universel), les rôles des
|
||
applications (déjà dans « Applications ici ») et les intégrations (déjà cochées). Son
|
||
seul signal unique — le prérequis bloquant — est désormais nommé dans le pied
|
||
(« Prérequis manquant : … ») ; le panneau Dépendances reste pour la vue d'ensemble.
|
||
Code mort retiré (`renduGroupe`, `detailGroupe`, `titreGroupe`, CSS `.groupe*`).
|
||
- **Nettoyage CSS/HTML du GUI** après la refonte : retrait des règles et éléments
|
||
morts (`.message`, `.chips-filtre`/`.chip-f`, `.onglet*`, `.base-ligne`,
|
||
`.bases-liste`, `.ch-grp*`/`.ch-fleche`/`.ch-roles`, `.champ-val`, divs `#message`
|
||
et `#chips`).
|
||
- **GUI refondu en atelier maître-détail unifié.** Toutes les vues suivent le même
|
||
motif : tuiles à gauche, **détail + saisie à droite**, le panneau droit reflétant
|
||
la sélection de la vue courante (fin du panneau « figé » au changement de vue).
|
||
Les vues **Applications** et **Bases** passent de tableaux pleine largeur à ce
|
||
motif ; la saisie se fait dans le panneau droit, avec **liens cliquables** entre
|
||
objets (serveur → application → base). Le panneau droit est élargi (~38 %).
|
||
- **Fusion Serveur/Hôte.** Les vues « Inventaire » (hôtes, lecture seule) et
|
||
« Serveurs » (plan) faisaient doublon : elles sont fusionnées en **une seule vue
|
||
Serveurs**. Sa tuile porte le statut de réconciliation (réconcilié / divergent /
|
||
non instancié) ; son détail réunit l'**identité éditable** (plan), les **dérivés**
|
||
(VMID/IP/VLAN), les **groupes**, les **applications et bases hébergées**, et
|
||
**Vérifier/Déployer**. La navigation clavier (`1-3`, `j/k`, `v/d`, `/`) et le
|
||
filtre opèrent désormais sur les serveurs. Un bandeau « Comment lire ce parc »
|
||
explicite le modèle serveur·hôte·groupe·application·base. Code mort retiré
|
||
(`carte`, `renduChaine`, `ONGLETS`, `champLecture`, sélection d'hôte, etc.).
|
||
|
||
### Ajouté
|
||
- **Fluidité d'exploitation du GUI** (sans dépendance, stdlib pure) :
|
||
- **Notifications empilées (toasts)** auto-effaçables au lieu d'une bannière unique
|
||
écrasée ; les erreurs restent plus longtemps, les opérations longues mettent à
|
||
jour leur propre toast.
|
||
- **Durée d'exécution** affichée en direct dans la console (Vérifier / Déployer) et
|
||
suivi vivant de « Appliquer le plan ».
|
||
- **Navigation clavier** : `1-5` changent de vue, `j/k` parcourent les hôtes,
|
||
`v`/`d` vérifient/déploient l'hôte sélectionné, `/` cible le filtre, `Ctrl+S`
|
||
sauvegarde la vue éditable courante (ignorés pendant la saisie).
|
||
- **Validation inline** des champs du plan (vue Serveurs) : nom `fonction-NN`,
|
||
mémoire, cœurs, disque — liseré rouge et blocage avant l'envoi.
|
||
- **Garde-fou anti-perte** : confirmation `beforeunload` si des éditions de plan ne
|
||
sont pas sauvegardées.
|
||
- Le bouton **Sauvegarder** de l'en-tête devient contextuel (sauve la vue éditable,
|
||
désactivé en lecture seule) — fin de l'impasse 409 ; suppression du code mort.
|
||
|
||
### Ajouté
|
||
- **Vue Serveurs : champs alimentés par les paramètres globaux.** À `+ Serveur`,
|
||
**Nœud** et **Stockage** deviennent des listes déroulantes (catalogues
|
||
`proxmox_noeuds` / `proxmox_stockages`, vide = défaut global) et **Intégrations**
|
||
une rangée de cases à cocher des rôles `client_*` disponibles (au lieu d'une saisie
|
||
texte). Les catalogues Nœuds / Stockages / Ponts sont de nouveaux intrants
|
||
(classe « Catalogue ») éditables dans le panneau « Intrants de base » et stockés
|
||
dans `group_vars/proxmox.yml` ; l'API expose `integrations_disponibles` (scan de
|
||
`roles/client_*`). Le type `liste` (chaîne virgulée → liste YAML dédoublonnée) est
|
||
ajouté au schéma des intrants.
|
||
|
||
### Supprimé
|
||
- **Vue « Chaîne » du bandeau (redondante).** Son contenu (`renduChaine`) était déjà
|
||
rendu, par hôte, dans l'onglet « Chaîne » du détail de la vue Inventaire ; la vue ne
|
||
faisait que le répéter pour tous les hôtes à la fois, sans réseau/proxmox ni boutons
|
||
d'opération. Retirée (bouton, rendu `dessinerArbre`, CSS `.arbre-*`) ; la chaîne
|
||
reste consultable hôte par hôte. Raccourcis clavier ramenés à 1-4.
|
||
|
||
### Modifié
|
||
- **Vue Inventaire (cartes) : détail converti en inspection lecture seule.** La vue
|
||
affichait « généré depuis le plan (lecture seule) » tout en exposant une surface
|
||
d'édition (+ Hôte, ✕, bascule actif/planifié, ✨ Proposer, champs et cases de
|
||
groupes éditables) qui ne pouvait pas être persistée (l'inventaire est généré ;
|
||
`/api/inventaire` renvoie 409). Ces contrôles orphelins sont retirés : nom, champs
|
||
Réseau/Proxmox et groupes en lecture seule, état affiché en badge. **Vérifier /
|
||
Déployer** et les onglets d'inspection sont conservés. L'édition reste dans les vues
|
||
**Serveurs / Applications / Bases**. Code mort supprimé (`ajouterHote`,
|
||
`supprimerHote`, `definir`, `definirNom`, `definirEtat`, `basculerGroupe`,
|
||
`proposer`, `autoProposer`, `prochainSeqLibre`, `marquerModifie`, état `modifie`).
|
||
- **Référence des paramètres de `make config`** (`docs/config-proxmox.md`).
|
||
Explique un par un les 16 paramètres Proxmox non sensibles + les secrets API
|
||
demandés par l'assistant : sens, valeur par défaut, quoi saisir, et quels champs
|
||
sont des constantes vs des défauts surchargeables par hôte. Renvois ajoutés depuis
|
||
`make help` et `QUICKSTART.md`. Comble un trou : ces invites n'étaient expliquées
|
||
nulle part de façon pérenne (impératif « exploitable sans IA »).
|
||
- **Panneau « Intrants de base » dans le GUI.** Un bouton ⚙ Intrants ouvre une fenêtre
|
||
unique pour saisir les valeurs communes à tout l'écosystème, avec une distinction
|
||
visible entre **constantes** (valeur unique, non surchargeable) et **défauts**
|
||
(valeurs proposées, surchargeables dans les instances). Les secrets ne sont **jamais**
|
||
saisis ni affichés ici (garde-fou `INTRANTS_CLES_INTERDITES` + filtrage par schéma) :
|
||
ils restent dans le Vault Ansible, édités en CLI ; le panneau les liste seulement à
|
||
titre informatif. Un changement de `domaine_interne` (clé de voûte) demande une
|
||
confirmation explicite ; un `domaine_interne` vide est refusé côté serveur. La
|
||
nomenclature reste en lecture seule (modifiable dans le plan). Voir
|
||
`docs/intrants-communs.md` et `docs/intrants-base-gui-conception.md`.
|
||
- **Source unique d'identité partagée.** `domaine_interne` et `chezlepro_timezone`
|
||
vivent désormais dans `inventories/partage/intrants-identite.yml`, référencé par
|
||
symlink depuis chaque environnement (`group_vars/all/10-intrants.yml`) — fin de la
|
||
duplication lab/production.
|
||
- **Info-bulles d'aide sur les champs du GUI.** Survoler la description d'un champ à
|
||
saisir affiche une bulle avec des instructions sommaires.
|
||
|
||
## 2026-06-28
|
||
|
||
### Ajouté
|
||
- **Document de présentation de l'écosystème Chezlepro** (`docs/ecosysteme-chezlepro.md`).
|
||
Description vulgarisée à destination client/partenaire : les piliers de l'écosystème
|
||
souverain, le modèle reproductible (plan → génération → clonage → conformité), et un
|
||
inventaire des **mesures de renforcement** réellement en place (SSH durci, fail2ban,
|
||
sysctl, nftables, AppArmor, auditd, mises à jour automatiques, gestion des secrets,
|
||
garde-fous destructifs, sécurité par l'architecture). Honnête sur le statut
|
||
« défini/validé vs déployé ».
|
||
|
||
### Ajouté
|
||
- **Dimensionnement dérivé des ressources VM.** Les cœurs/RAM/disque d'une VM sont
|
||
désormais **estimés depuis les logiciels hébergés + le socle SE**, au lieu d'hériter
|
||
des specs du golden template (CPU/RAM identiques pour toutes les VM auparavant).
|
||
Chaque rôle déclare son empreinte (`roles/<rôle>/meta/empreinte.yml`) ; le
|
||
générateur somme par hôte (marge + arrondis) et écrit `proxmox_coeurs`/
|
||
`proxmox_memoire`/`proxmox_disque_taille` ; le clonage passe `cores`/`memory` à
|
||
Proxmox (`omit` si absent → aucune régression). Override par hôte possible dans le
|
||
plan (`serveurs.yml`). Voir `docs/dimensionnement-ressources.md`.
|
||
|
||
### Corrigé
|
||
- **`make instancier-appliquer FORCE=1` n'honorait pas `--force`.** La recette Makefile lançait `instancier.py appliquer` sans relayer `FORCE` ; le message « Utilise FORCE=1 » était donc trompeur (un changement de plan intentionnel restait bloqué). La recette passe désormais `$(if $(FORCE),--force)`. Trouvé en dogfooding.
|
||
|
||
## 2026-06-24 — Première publication publique
|
||
|
||
Première mise à disposition publique de **Set-OPS**, moteur Ansible d'écosystèmes
|
||
numériques souverains sur Proxmox — offert à la communauté québécoise par
|
||
l'**Alliance Boréale**, à la Saint-Jean-Baptiste 2026.
|
||
|
||
- **Moteur générique, piloté par un plan déclaratif** : on édite le plan
|
||
(`instance/plan/*.yml`), l'inventaire Ansible se génère, les VM se clonent depuis
|
||
un golden template Debian 13, les rôles s'appliquent par groupes. Tout passe par
|
||
`make`, le GUI local et la documentation.
|
||
- **Piliers d'un écosystème souverain** : socle Debian durci, AC/PKI interne, DNS
|
||
interne, identité (LDAP + SSO), relais courriel, bases de données, observabilité,
|
||
forge.
|
||
- **Catalogue de modèles prêts à déployer** (`exemples/modeles/`) : un hébergeur
|
||
copie un modèle, le renseigne à ses couleurs, et instancie.
|
||
- **Souveraineté jusqu'au bout** : Set-OPS s'exploite entièrement à la main, sans
|
||
aucune IA.
|
||
|
||
Pour démarrer : **`QUICKSTART.md`**.
|