Set-OPS-Public/docs/decisions-architecture.md
Daniel Allaire fa69ec7c0f sortie des tenants : un saut emprunte (D-62) et le NAT derive (D-63)
Deux obstacles, aucun n'etait celui qu'on croyait.

Proxmox n'installe AUCUN defaut dans le VRF du tenant : `default-originate`
annonce une route aux autres noeuds, il n'en pose pas chez lui. Les deux zones
etaient dans cet etat. La sortie vient d'une strophe frr.conf.local, que Proxmox
fusionne a chaque regeneration (verifie : survit a `pvesh set /cluster/sdn` et a
un redemarrage de FRR).

`nexthop-vrf default` emprunte UNE adresse au lieu d'importer la table
principale : la route par defaut des hyperviseurs ne gouverne pas la sortie des
tenants. `import vrf default` l'aurait fait contourner la frontiere et aurait
fuite le transport VXLAN, la gestion et les VLAN herites dans le VRF.

Le NAT sortant en mode automatique ne couvre que les reseaux directement
attaches ; un supernet joint par route statique en sort en silence. L'etat
montrait `nat_addr` absent : le filtre passait, la traduction manquait.
`devis_opnsense` emet le NAT (section 2bis), le reconciliateur l'applique et le
retire, et P24 refuse tout supernet route mais non traduit.

Mesure : tenant -> frontiere 3/3, -> passerelle FAI 3/3, -> Internet 2/2 pour
les deux tenants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:59:11 -04:00

124 lines
18 KiB
Markdown

# Registre des décisions d'architecture
> **À quoi sert ce document.** Les décisions sont écrites là où elles s'appliquent —
> `frontiere-opnsense.md`, `sdn-evpn.md`, `migration-tenant.md`, `underlay.yml.example` — et
> leur histoire vit dans le `CHANGELOG`. Ce registre ne les répète pas : il dit **quelles
> décisions existent, pourquoi, et où lire le détail**. Sans lui, « pourquoi le `/29` et pas
> le `/30` ? » demande de relire vingt entrées de journal.
**Ce n'est pas** la doctrine de conduite (`AGENTS.md` §Principes), ni le registre des
affirmations prouvables (`docs/audit/affirmations.md`). Une décision peut n'être gardée par
aucune preuve : elle reste une décision.
**Qui décide.** Toutes ces décisions sont celles de l'opérateur du dépôt. Plusieurs ont été
prises sur recommandation — l'assistance propose et argumente, elle ne tranche pas. La
distinction compte pour la suite : une décision se renverse par celui qui l'a prise, et savoir
qu'elle a été *choisie* plutôt que *héritée* change ce qu'on s'autorise à en faire.
Les dates de la §5 sont celles de l'**historique git**, pas du moment de la conversation : ce
sont les seules vérifiables.
---
## 1. Le réseau
| # | Décision | Pourquoi | Détail | Garde |
|---|---|---|---|---|
| **D-01** | OPNsense est une frontière **nord/sud**, pas la passerelle des zones | le routage inter-zone reste au débit ligne ; la bordure ne voit pas l'est-ouest | `frontiere-opnsense.md` §1 | — |
| **D-02** | Le **lien de transit** vit dans l'underlay, pas dans un tenant | la frontière route vers tous les tenants par le même saut : il ne peut dériver d'aucun `index` | `frontiere-opnsense.md` §6 | P23 |
| **D-04** | Un point de routage porte **le même dernier octet** sur tous ses sous-réseaux — **dans l'adressage dérivé des tenants seulement** | on retient une adresse, pas treize. Dans l'underlay, l'adressage est dicté par les participants d'un lien physique : la règle y imposait un seuil arbitraire et empruntait sa valeur à la nomenclature d'un tenant | `frontiere-opnsense.md` §4 | P20 |
| **D-52** | Dans l'underlay, une passerelle doit être **l'adresse d'un hôte déclaré** sur ce réseau | remplace le comptage d'octets par une règle plus forte : elle attrape les passerelles fantômes | `underlay.py` | P23 |
| **D-49** | **Aucun commutateur ne route.** La frontière est le seul équipement L3 | l'EVPN a retiré les VLAN tenants du fil, puis la fusion du lien de sortie a rendu les nœuds de sortie **adjacents** à la frontière : plus un seul flux à router | `underlay.yml` | P23 |
| **D-50** | `passerelle` = la passerelle du sous-réseau, **où qu'elle vive** ; le SVI se **dérive** du rôle de son porteur | la clé signifiait « adresse du SVI du switch » — une hypothèse, pas une donnée. Un même moteur sert désormais les deux postures | `devis_reseau.py` | P23 |
| **D-51** | **Deux** commutateurs en paire, pas trois en étoile | le centre d'une étoile est sur **tous** les chemins : point de panne unique du plan de données. Deux liens vers deux commutateurs donnent la redondance qu'une étoile ne peut pas donner | `underlay.yml` | — |
| **D-06** | Les réseaux déclarent leur **fabric** ; un devis ne parle que de la sienne | le stockage jumbo vit sur ses propres commutateurs — un devis est une configuration, pas un inventaire | `sdn-evpn.md` §4 | P23 |
| **D-07** | **Pas d'ACL** sur cette fabric (`acl_inter_tenant: false`) | le matériel ne sait pas lier une ACL à un SVI ; des règles jamais liées auraient l'air d'isoler | `frontiere-opnsense.md` §1 | — |
| **D-43** | Les objets SDN d'un tenant (zone, VNets, sous-réseaux) sont **dérivés du seed** et émis par `make devis-sdn` | ajouter un tenant implique 13 objets de cluster ; les créer à la main dans un dépôt où tout dérive était la dernière lacune | `sdn-evpn.md` §2 | P30 |
| **D-44** | Le **nommage SDN dérive du tenant** : zone `<PRÉFIXE><index>`, VNet `<préfixe><index><zone>` | se lit sans table de correspondance ; les zones faites à la main (`VRF0017`) sont remplacées tant qu'elles sont vides. Seule contrainte conservée : **8 caractères**, imposée par Proxmox | `sdn-evpn.md` §2 | P30 |
| **D-08** | Le **routage tenant passe au SDN EVPN** — une zone par tenant | une zone EVPN est un VRF : l'isolation devient structurelle, pas réglementaire | `sdn-evpn.md` | P23 (MTU) |
| **D-09** | Le **filtrage inter-zone d'un tenant** se fait au même niveau (EVPN) | là où le routage a lieu | `sdn-evpn.md` §3 | — |
| **D-31** | Le filtrage est-ouest est appliqué **deux fois** : hyperviseur **puis** nftables d'hôte | défense en profondeur ; les deux dérivent du même registre par les mêmes fonctions, donc ne peuvent pas diverger | `sdn-evpn.md` §3 | P25 |
| **D-32** | **Tout** ce qui entre ou sort d'un tenant passe par la frontière | un VRF n'a qu'une sortie ; conséquence : la frontière devient un **prérequis de déploiement** | `sdn-evpn.md` §3 | — |
| **D-10** | L'**inter-tenant passe obligatoirement par la frontière** | il sort du VRF, donc traverse une bordure en `block` par défaut : il ne peut plus être oublié | `sdn-evpn.md` §3 | — |
| **D-11** | La **sortie générale est déclarée** dans le registre des flux | `block out` est un vrai default-deny ; un besoin oublié ne se manifeste pas par un refus clair | `frontiere-opnsense.md` §8 | P09 |
| **D-29** | L'**overlay EVPN plafonne à 1450** ; le transport doit donc dépasser 1500 | choix d'exploitation ; l'encapsulation VXLAN coûte 50 octets | `sdn-evpn.md` §5 | P23 (MTU du transport) |
| **D-30** | L'**ICMP « fragmentation nécessaire » est déclaré**, dans les deux sens | à 1450, tout ce qui traverse la frontière dépend de la découverte de MTU de chemin ; une bordure en default-deny la casse en silence | `roles/serveur_debian/meta/flux.yml` | P09 |
| **D-62** | La sortie d'un VRF tenant emprunte **un seul saut** (`nexthop-vrf default` vers la frontière), jamais la table principale | `import vrf default` ferait hériter au tenant le défaut de l'hyperviseur — sortie **contournant la frontière** — et lui donnerait au passage le transport VXLAN, la gestion et les VLAN hérités. Un saut emprunté n'est pas une table héritée | `devis_sdn.py` §4 | P30 |
| **D-63** | Tout supernet **routé** doit être **traduit** : le NAT sortant est dérivé, pas laissé en « automatique » | le mode automatique ne couvre que les réseaux *directement attachés* ; un supernet joint par route statique en sort sans avertissement. Panne muette : le filtre passe, un état s'ouvre, rien ne revient | `devis_opnsense.py` §2bis | P24 |
| **D-61** | L'**interface d'une règle se dérive de l'attachement réel** de sa source, jamais du sens du flux | une règle OPNsense est toujours `in` sur l'interface d'arrivée ; posée ailleurs elle ne peut **jamais** correspondre, et le devis affirme alors un accès qui n'existe pas | `frontiere-opnsense.md` §7 | P24 |
| **D-60** | Nommage : tout l'ensemble de bordure est **`bifrost-N`**, et **N est son dernier octet** | on retient un chiffre au lieu d'une table ; le nom ne dit plus le type de la machine, c'est le champ `role` qui le fait — et lui seul pilote le devis | `underlay.yml` | — |
## 2. Qui possède quoi
| # | Décision | Pourquoi | Détail | Garde |
|---|---|---|---|---|
| **D-13** | Un **hébergeur** sert plusieurs **tenants** et a son tenant par défaut | Chezlepro est les deux à la fois, ce qui masquait la distinction | `frontiere-opnsense.md` §2 | — |
| **D-14** | `underlay.yml` appartient à l'**hébergeur**, monté par symlink | ce sont ses commutateurs, ses câbles ; le moteur est générique, un tenant n'en possède pas | `sdn-evpn.md`, `underlay.yml.example` | — |
| **D-15** | Ce symlink **ne suit pas** `make instance-utiliser` | basculer le tenant actif ne change pas la fabric | `frontiere-opnsense.md` §2 | — |
| **D-16** | Les intrants de la **frontière** se lisent chez l'hébergeur | un hébergeur n'a qu'une frontière pour tous ses tenants | `frontiere-opnsense.md` §2 | — |
| **D-17** | L'hébergeur **n'est pas déclaré** : le symlink le désigne | une seconde déclaration ouvrirait deux valeurs contradictoires | `frontiere-opnsense.md` §2 | — |
| **D-35** | Le **cluster Proxmox** appartient à l'hébergeur ; seuls le **golden template** et les **défauts de placement** restent au tenant | recopié chez chaque tenant, l'inventaire du cluster avait déjà divergé — deux listes de stockages contradictoires pour le même matériel | `config-proxmox.md` | P27 |
| **D-36** | Le panneau **nomme le propriétaire** de chaque section d'intrants | éditer une section « hébergeur » vaut pour tous ses tenants ; l'écran ne le disait pas | `scripts/inventory_gui.py` (`INTRANTS_SCHEMA`) | — |
| **D-37** | Chaque tenant a son **pool Proxmox** ; les noms courts de VM restent **identiques** d'un tenant à l'autre | 11 serveurs sur 14 sont homonymes — c'est la preuve que la nomenclature est un gabarit ; le coût est humain (la console affiche le nom), et le pool le corrige sans rien renommer | `devis_proxmox_pools.py` | P28 |
| **D-45** | L'**affinité de VM** (garder un groupe sur le même hyperviseur) attend **Proxmox 9** ; tenue à la main d'ici là | les *resource affinity rules* n'existent qu'en 9 ; en 8.4 seuls les groupes HA épinglent à des **nœuds**, pas des VM entre elles. Sans ressource HA déclarée, rien ne déplace ni ne sépare les VM — le sujet ne devient réel qu'en activant la HA | `sdn-evpn.md` | — |
| **D-46** | Un hébergeur porte **trois** catégories, pas deux : son **tenant**, ses **opérations**, le **plan de contrôle** | Chezlepro est hébergeur ET tenant, ce qui masquait des besoins n'appartenant à aucun tenant | `hebergeur-exploitation.md` §2 | — |
| **D-47** | Les **services d'exploitation** de l'hébergeur vivent dans **son dépôt**, et leurs VM se rattachent à un **pont VLAN, jamais un VNet** | un service qui observe la fabric ne peut pas dépendre d'elle : l'EVPN tombe, et la supervision tombe avec la raison de la panne | `hebergeur-exploitation.md` §1, §4 | — |
| **D-48** | Les **hyperviseurs** sont gérables par Ansible ; « hors flotte » ne vaut que pour les **commutateurs** et la **frontière** | ce sont des Debian joignables en SSH ; c'est la seule façon d'y poser un exportateur de métriques | `hebergeur-exploitation.md` §5 | — |
| **D-55** | Le dépôt réseau porte une **interface normalisée** vers les tenants de l'Alliance, et abstrait le matériel en les encapsulant dans des zones EVPN | un tenant qui ne nomme aucun équipement se déplace d'un hébergeur à l'autre sans rien changer ; le VRF borne ce qu'il a le droit de connaître | `hebergeur-exploitation.md` §7 | — |
| **D-56** | Le **VNet d'une VM est dérivé** (`index` + zone), jamais déclaré ; l'étiquette VLAN est **vide** en SDN | déclaré, il faisait naître les VM sur `vmbr1` avec un tag — l'ancien monde, à rebrancher une par une | `instancier.py` | P02, P03 |
| **D-53** | Le **réseau et l'underlay** de l'hébergeur méritent leur **propre dépôt**, séparé de son tenant | `underlay.yml` et le cluster décrivent une infrastructure ; le dépôt de tenant décrit une organisation. Les mêler oblige à trancher qui possède quoi à chaque commit | `hebergeur-exploitation.md` §7 | — |
| **D-54** | `10.0.0.0/24` est réservé à l'**IPAM, la gestion des équipements et l'OOB** — accès sysadmin | aucune VM, aucun trafic tenant ; c'est la raison d'être des VLAN 11 et 40 | `underlay.yml` | — |
| **D-57** | L'interface **sysadmin** d'un hyperviseur (`vmbr0`) n'a **pas de route par défaut** ; celle-ci vit sur `vlan40`, vers la frontière | on n'atteint l'administration que depuis son propre domaine de diffusion — un accès distant doit être ouvert explicitement, il ne peut pas exister par accident. Et le trafic tenant ne touche plus la carte d'administration | `underlay.yml` | — |
| **D-58** | Un hôte déclare **par quelle interface** (`via`) chaque réseau lui arrive ; le devis en dérive un **port par interface** et son **type** | un hyperviseur a plusieurs pattes ; les grouper remettait la gestion sur le trunk du transport | `devis_reseau.py` | P23 |
| **D-59** | Un VLAN qui ne porte que des **adresses d'hôte** n'a **pas besoin de pont** | un pont sert à brancher des invités ; vide, il coûte une table MAC et un saut de plus sur le lien qui porte tout le trafic tenant | `underlay.yml` | — |
| **D-18** | Chaque tenant a un **responsable désigné** | sans lui, « qui peut décider de déménager cette organisation ? » se pose au pire moment | `migration-tenant.md` §3 | — |
| **D-38** | Toute authentification **web** passe par Keycloak ; LDAP est la **source unique** des comptes | une identité, un mot de passe ; aucun service ne tient son propre répertoire d'humains | `authentification.md` §1-2 | — |
| **D-39** | Les protocoles qui ne parlent pas OIDC (IMAP, SMTP) se lient **directement à LDAP** | le chemin varie, la source ne varie pas | `authentification.md` §2 | — |
| **D-40** | L'accès de secours passe par **`sudo` sur l'hôte**, pas par un compte web permanent | `service → Keycloak → LDAP` est en série : sans secours, une panne exclut tout le monde, y compris pour réparer | `authentification.md` §4 | — |
| **D-41** | Le formulaire de connexion locale n'est **pas proposé au repos** (`<rôle>_connexion_locale: false`) | il contourne la politique de mot de passe, le MFA et surtout la **révocation centrale** ; `sudo` est le mécanisme de réouverture | `authentification.md` §3 | — |
| **D-42** | Chaque rôle **déclare sa position** d'authentification (`meta/authentification.yml`), gardée par une preuve | une directive qu'aucune garde ne vérifie finit par ne plus être vraie — c'était le cas des 28 lignes d'intégration recopiées | `authentification.md` §5 | P29 |
## 3. Les secrets
| # | Décision | Pourquoi | Détail | Garde |
|---|---|---|---|---|
| **D-19** | **Une seule voûte** par instance (`group_vars/all/vault.yml`) | un mot de passe, un endroit | `config-proxmox.md` | P18 |
| **D-20** | La liste des secrets se **recense**, elle ne s'écrit pas | trois copies manuelles ont existé, toutes ont divergé | `intrants-communs.md` §H | P18 |
| **D-21** | L'**empreinte du root CA n'est pas un secret** : elle se dérive à chaud | un `from-zero` régénère l'AC ; figée en voûte, elle serait périmée | `roles/client_pki/README.md` | — |
| **D-22** | Migrer un tenant, c'est **révoquer**, pas transmettre | sinon l'ancien hébergeur garde à vie l'accès aux secrets d'un client parti | `migration-tenant.md` §6 | — |
## 4. La méthode
| # | Décision | Pourquoi | Détail | Garde |
|---|---|---|---|---|
| **D-23** | Les cibles **hors flotte** reçoivent un **devis**, pas un rôle | commutateurs, frontière et SDN sont des objets de cluster ; un rôle boucle sur des hôtes | `sdn-evpn.md` §7 | — |
| **D-24** | Un devis **n'écrit rien** : il se relit, puis s'applique | proportionné au risque — une config ratée partitionne tout un cluster | `frontiere-opnsense.md` §7 | — |
| **D-25** | Le dépôt **n'affirme pas que ses devis s'appliquent**, il affirme qu'ils **dérivent** | leur syntaxe dépend d'un matériel que le dépôt ne possède pas | `audit/affirmations.md` §10 | — |
| **D-26** | Un modèle peut porter un **underlay** ; tous les hébergeurs n'ont pas le même matériel | générique public, étoffés en privé | `exemples/modeles/socle/README.md` | P17 |
| **D-27** | **Migration** : le receveur est prouvé prêt **avant** tout gel | l'interruption se réduit au delta et à la propagation DNS | `migration-tenant.md` §4 | — |
| **D-28** | Le mandat de migration est **signé par le tenant**, pas convenu entre hébergeurs | il n'y a pas de registre central pour arbitrer ; une organisation n'est pas la propriété de son hébergeur | `migration-tenant.md` §2 | — |
| **D-33** | Une intégration **universelle** est déclarée par le **rôle**, jamais recopiée par serveur | 28 des 57 lignes du plan disaient oui à ce qui vaut pour tous : elles n'existaient que pour être oubliées — et quatre l'avaient été | `integrations-vm.md` §Politique | P26 |
| **D-34** | Une **exemption** se dérive du **service rendu** (`sauf_role`), jamais d'un nom d'hôte | l'AC ne s'enrôle pas auprès d'elle-même ; l'exemption doit suivre step-ca si on le déplace | `roles/client_pki/meta/integration.yml` | P26 |
---
## 5. Décisions renversées
Les garder évite de refaire le chemin, et explique pourquoi le code porte encore des branches
qui semblent inutiles.
| Décision | Tenue du … au … | Renversée par | Pourquoi | Ce qu'il en reste |
|---|---|---|---|---|
| L'isolation inter-tenant est portée par des **ACL de commutateur** | 2026-07-07 → 08-03 | `059d76a` | le matériel ne sait pas lier une ACL à une interface de routage | `acl_inter_tenant: true` reste le défaut : une autre fabric peut en être capable |
| Le **routage inter-zone** est porté par les commutateurs L3 | 2026-07-07 → 08-03 | `e5ce2b9` | sans ACL, l'isolation devenait déclarative ; EVPN la rend structurelle | `routage_tenants: switch` reste le défaut et reste généré |
| L'`underlay.yml` vit à la racine du moteur, gitignoré | 2026-07-24 → 08-02 | `0eae97c` | consommé par deux générateurs, validé par une preuve, versionné nulle part | `SETOPS_UNDERLAY` permet toujours de le pointer ailleurs |
| **D-05** — un seul commutateur route, les autres en L2 pur | 2026-07-07 → 08-04 | 2026-08-04 | deux décisions séparées (EVPN, puis fusion du lien de sortie) ont vidé le rôle sans qu'on regarde leur effet cumulé : les trois SVI n'avaient plus aucun consommateur | le moteur sait toujours émettre des SVI — le modèle public s'en sert |
| **D-03** — le `/29` de transit, SVI en `.6`, `.3` réservée CARP | 2026-07-24 → 08-04 | 2026-08-04 | élargi en `/24` pour y loger les nœuds de sortie ; l'exemption d'invariant sous le `/24` tombe alors, et le `.1` revient à la passerelle | le plan `.1` passerelle / `.2 .3` pare-feux survit, décalé d'un cran |
| **D-12**`bifrost` aux frontières, `sleipnir` à la fabric | 2026-07-24 → 08-04 | 2026-08-04 | le nom portait le type de la machine, ce que le champ `role` fait déjà et mieux ; et `sleipnir` ne plaisait pas | l'idée qu'un nom doit se retenir survit — mais elle passe par le **numéro**, aligné sur l'adresse |
> **Ce que ces renversements ont en commun.** Aucun 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.