# CHANGELOG — Set-OPS ## 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 `, 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_`, 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** : `` 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 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 `` 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 `` 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 ` — 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 (``, ``) 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 `