Some checks are pending
verifier / verifier (push) Waiting to run
Le site ne servait aucun PTR. `serveur_powerdns_zone_inverse` derivait d'un supernet /16 — la forme d'un TENANT, qui tire tout de son index. Un site ne derive pas : il declare plusieurs /24 et n'a pas de supernet unique, si bien que la derivation rendait une chaine vide et qu'aucune zone n'etait generee. Le site revendique desormais 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 ete plus simple et faux : cette zone couvre aussi la frontiere, le transit et les hyperviseurs, qui ne sont pas a lui. Une autorite qu'on s'attribue sans l'exercer est une panne differee — le resolveur repondrait NXDOMAIN pour des adresses qu'un autre sait nommer. LA DERIVATION VIT DANS UN FILTRE (`zones_inverses`) parce qu'elle a DEUX appelants : `serveur_powerdns` ecrit ces zones, `serveur_resolveur` les delegue a l'autoritatif. Deux calculs separes finiraient par diverger, et la divergence ne se verrait qu'au premier PTR interroge. Le nom d'une zone dit sa profondeur — trois etiquettes numeriques valent un /24, deux valent un /16 — et le modele en deduit seul la forme du PTR. DEUX CHEMINS MORTS TROUVES EN ROUTE. Les zones etaient servies, et personne ne les demandait. `serveur_resolveur` deleguait la zone directe par une `stub-zone` mais pas les inverses : `dig -x` rendait vide depuis les cinq machines alors que la meme requete posee directement a l'autoritatif repondait juste. Un service correct derriere un chemin que rien n'emprunte. Puis, les stubs poses, Unbound repondait toujours NXDOMAIN avec le drapeau `aa` — une reponse AUTORITAIRE, sans jamais consulter le stub. Il embarque des `local-zone` pour tout l'espace RFC1918 inverse. `nodefault` n'y change rien : ce mode n'agit que si le nom correspond EXACTEMENT a une zone par defaut, et la sienne est `10.in-addr.arpa`, le /8 entier. C'est `transparent` qui laisse la requete suivre son cours — pour nos quatre zones seulement, la ou `unblock-lan-zones` aurait ouvert tout l'espace prive. Rien ne distinguait ce blocage d'une absence : le meme NXDOMAIN qu'un nom qui n'existe pas. AUSSI : les zones inverses sont desormais validees par `named-checkzone` comme la directe (un fichier mal forme etait refuse en silence par PowerDNS), et une zone qu'un ecosysteme cesse de revendiquer est retiree du repertoire. VERIFICATION. Les cinq PTR resolvent depuis les cinq hotes par le resolveur, les huit noms directs par le plancher ET par le DNS, et les deux roles sont idempotents (changed=0). P47 evalue la derivation sur cinq cas — un site a quatre zones, un tenant a une, deux machines d'un meme /24 qui n'en font qu'une, aucune adresse, une adresse illisible. Controle negatif verifie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10514 lines
604 KiB
Markdown
10514 lines
604 KiB
Markdown
# CHANGELOG — Set-OPS
|
||
|
||
## 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`**.
|