From 3b1d9b66608a45a1f053f3ddc4711d0d847c7b4e Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 4 Aug 2026 11:06:42 -0400 Subject: [PATCH] =?UTF-8?q?SDN=20:=20make=20devis-sdn=20=E2=80=94=20zone,?= =?UTF-8?q?=20VNets=20et=20sous-r=C3=A9seaux=20d=C3=A9riv=C3=A9s=20du=20se?= =?UTF-8?q?ed=20(D-43/44,=20P30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajouter un tenant implique 1 zone EVPN + 6 VNets + 6 sous-réseaux sur le cluster. Aucun générateur ne les produisait : dernière lacune dans un dépôt où tout dérive. 26 objets pour les deux tenants, VNI = 1000 + index×10 + zone, sous-réseau et passerelle par les mêmes fonctions que l'inventaire. Nommage, en deux temps. D'abord VRF0017 / v1174, alignés sur ce que le cluster portait — réflexe inverse du bon : cette convention venait d'une création à la main et ne disait pas de quel tenant il s'agissait. Forme retenue : t pour la zone, t pour le VNet (t17, t17serv). C'est le préfixe que le pare-feu Proxmox utilisait déjà (t17-cli-metrique), donc un seul schéma dans tout le dépôt. Abréviation = 4 premières lettres du libellé, accents retirés. Pas de tiret entre index et zone, contrairement aux IPSets : t245-serv ferait 9 caractères, t245serv en fait 8 — la forme reste uniforme jusqu'au dernier index. Contrainte cadrante : zones ET VNets sont limités à 8 caractères par Proxmox. sdn-evpn.md annonçait chez17-services-infra (21) : il aurait été refusé à l'application. P30 refuse tout dépassement et toute collision de nom, de VNI ou de sous-réseau. Éprouvé aux bornes et par sabotage. Vérification la plus forte : avant renommage, la dérivation reproduisait à l'identique les deux zones créées à la main — nom, VNI de VRF, MTU, contrôleur. 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 refusée à la première requête, avec P18 au vert sur une voûte inutilisable. Sans écho, double confirmation, rien sur la ligne de commande. Reste ouvert : aucun nœud de sortie déclaré. Le devis émet un marqueur, pas une valeur plausible. Deux points à trancher — le nœud de sortie route selon sa propre table (défaut actuel : 192.168.11.254, pas la frontière), et l'entrée n'est pas redondante puisqu'elle dépend d'une route statique d'OPNsense vers un seul nœud. D-45 : l'affinité de VM attend Proxmox 9 (cluster en 8.4.19), tenue à la main. 30 preuves OK. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 66 ++++++++ Makefile | 7 + docs/audit/affirmations.md | 2 + docs/audit/preuve-2026-08-03.md | 5 +- docs/carte-set-ops.md | 1 + docs/config-proxmox.md | 22 +++ docs/decisions-architecture.md | 3 + docs/sdn-evpn.md | 27 ++- scripts/devis_sdn.py | 282 ++++++++++++++++++++++++++++++++ scripts/prouver.py | 2 + scripts/voute.py | 86 ++++++++++ 11 files changed, 499 insertions(+), 4 deletions(-) create mode 100644 scripts/devis_sdn.py diff --git a/CHANGELOG.md b/CHANGELOG.md index abb24d6..a0b041b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,71 @@ # CHANGELOG — Set-OPS +## 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`** pour la zone, **`t`** 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 diff --git a/Makefile b/Makefile index 9fd5160..a981e4f 100644 --- a/Makefile +++ b/Makefile @@ -362,6 +362,13 @@ devis-proxmox-pools: ansible-runtime ## Devis des pools Proxmox (un par tenant), devis-proxmox-pools-verifier: python3 scripts/devis_proxmox_pools.py --verifier +.PHONY: devis-sdn devis-sdn-verifier +devis-sdn: ansible-runtime ## Devis SDN EVPN (zone + VNets + sous-reseaux par tenant), derive du seed + python3 scripts/devis_sdn.py $(if $(JSON),--json,) + +devis-sdn-verifier: + python3 scripts/devis_sdn.py --verifier + devis-opnsense-verifier: python3 scripts/devis_opnsense.py --verifier diff --git a/docs/audit/affirmations.md b/docs/audit/affirmations.md index 5ebc4cf..8cc24e8 100644 --- a/docs/audit/affirmations.md +++ b/docs/audit/affirmations.md @@ -160,6 +160,7 @@ Légende du statut : ✅ prouvée · 🟡 partielle · ❌ fausse · ⚪ invéri | AFF-109 | « Ce qui appartient à l'hébergeur (cluster, fabric, frontière) vit dans son dépôt, jamais recopié chez un tenant » (`docs/decisions-architecture.md` D-35) | structurelle | **P27** (`scripts/prouver.py`) | ✅ | Le chemin se dérive du symlink `underlay.yml` : l'hébergeur n'est toujours pas déclaré (D-17). Sautée si aucun underlay n'est monté. | | AFF-110 | « Chaque tenant a son pool Proxmox, dérivé, sans collision de nom ni de VMID » (`docs/decisions-architecture.md` D-37) | structurelle | **P28** (`scripts/devis_proxmox_pools.py --verifier`) | ✅ | Le nom dérive du dossier d'instance et de l'`index`, déjà unique par P21 — aucun registre de plus. Le devis ne lit pas le cluster : il affirme la cohérence du **plan**, pas l'état appliqué. | | AFF-111 | « Chaque rôle `serveur_*` déclare sa position d'authentification, et la déclaration est cohérente avec son code » (`docs/authentification.md`, D-38 à D-41) | structurelle | **P29** (`scripts/prouver.py`) | ✅ | 23 rôles déclarés. La preuve refuse l'oubli **et** le mensonge : une déclaration `web-sso natif` sans variable `_oidc`, ou `ldap-direct` sans variable `_ldap`, échoue. Les indices sont **nommés** — une première version cherchait les mots « ldap »/« oidc » et le mot LDAP dans un commentaire suffisait à valider un mensonge. Les 2 portées `interne-sans-auth` (Prometheus, Loki) sont des lacunes **comptées, pas masquées**. | +| AFF-112 | « Ajouter un tenant implique une zone EVPN, ses VNets et ses sous-réseaux, tous dérivés du seed » (`docs/sdn-evpn.md` §2) | structurelle | **P30** (`scripts/devis_sdn.py --verifier`) | ✅ | 2 zones, 12 VNets, 12 sous-réseaux. La dérivation **reproduit à l'identique** les deux zones déjà présentes sur le cluster (nom, VNI de VRF, MTU, contrôleur) — vérifié par lecture de l'API. La garde refuse un identifiant de VNet de plus de 8 caractères, limite Proxmox qui aurait fait échouer le devis à l'application. Les **nœuds de sortie manquants** sont signalés, pas inventés. | | AFF-106 | « Le panneau couvre le schéma du plan : aucun champ n'oblige à éditer le YAML » (`docs/intrants-base-gui-conception.md`) | fonctionnelle | **P19** (`scripts/couverture_gui.py`) | 🟡 | Vrai des registres du plan. **Exceptions assumées** : `reseaux` et `hotes` de l'underlay (listes de tables) restent hors du panneau ; les ports physiques et le nœud de sortie EVPN se nomment à la main. | **Non enregistré volontairement.** Les devis eux-mêmes (`make devis-reseau`, @@ -401,6 +402,7 @@ Chaque affirmation ✅ est soit **rejouée** par une preuve de `make prouver` | AFF-109 | **P27** (propriété des intrants : hébergeur et tenant séparés) | | AFF-110 | **P28** (pools Proxmox : un par tenant) | | AFF-111 | **P29** (authentification : chaque rôle déclare sa position) | +| AFF-112 | **P30** (SDN EVPN : zones, VNets et sous-réseaux dérivés) | **Structurelles/doctrinales, hors harnais récurrent** (vérifiées à l'audit) : AFF-005 (`make`=aide), AFF-014 (ciblage `groupe:&hotes_actifs`), AFF-024 (`instancier-appliquer`, diff --git a/docs/audit/preuve-2026-08-03.md b/docs/audit/preuve-2026-08-03.md index 0ba3beb..6358563 100644 --- a/docs/audit/preuve-2026-08-03.md +++ b/docs/audit/preuve-2026-08-03.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (29 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (30 OK · 0 echec · 0 saute) ## Preuves @@ -39,9 +39,10 @@ | P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 26 regles, 2 routes, admin=192.168.254.2/32,192.168.255.0/24,192.168.255.2/32. | | P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 36 groupe(s), 56 regle(s). | | P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 3 integration(s) universelle(s) : aucune lacune, aucune recopie (1 exemption(s) derivee(s) du service rendu). | -| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 7 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. | +| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. | | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | +| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision ; /!\ aucun noeud de sortie declare (VRF sans chemin vers l'exterieur). | ## Couverture des affirmations ✅ du registre diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 77fa9fd..be518b7 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -49,6 +49,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis | Multi-instance | un dépôt par écosystème ; l'active = symlink `instance/`, les autres **découvertes par convention** (dossiers frères, aucun registre) | active : symlink `instance/` ; découverte : `scripts/instances.py` / `devis_reseau.py` (glob `../*/plan/nomenclature.yml` avec `index`) ; garde-fou collision : preuve **P21** | `multi-instances.md` | | Exposition → edge | app expose un FQDN public servi par un edge | `plan/domaines.yml` + `expose` (applications) | `bindings-conception.md` §4 | | Authentification | web → Keycloak ; LDAP source unique ; secours par `sudo`, formulaire local non annoncé | `_connexion_locale: false` (grafana, forgejo, nextcloud) ; garde de version Forgejo ≥ 10 | `authentification.md` | +| SDN EVPN | ajouter un tenant implique **1 zone + 6 VNets + 6 sous-réseaux**, tous dérivés du seed | `scripts/devis_sdn.py` (`make devis-sdn`) ; nommage dérivé du tenant (`CHEZ17`, `chez174`), ≤ 8 caractères ; garde **P30** | `sdn-evpn.md` §2 | | Pools Proxmox | un pool par tenant : les noms courts de VM sont **volontairement identiques** d'un tenant à l'autre (même fonction, même nom), et seule la console Proxmox en souffrait | `scripts/devis_proxmox_pools.py` (`make devis-proxmox-pools`) ; nom dérivé de l'`index` ; garde de collision = preuve **P28** | `decisions-architecture.md` D-37 | | Frontière nord/sud | les flux `pair: externe` — **sautés** par le pare-feu d'hôte — sont la politique de bordure | `scripts/devis_opnsense.py` (`make devis-opnsense`) ; garde d'accès admin = preuve **P24** | `frontiere-opnsense.md` | diff --git a/docs/config-proxmox.md b/docs/config-proxmox.md index 419e95d..6c17393 100644 --- a/docs/config-proxmox.md +++ b/docs/config-proxmox.md @@ -86,6 +86,28 @@ L'assistant demande « Configurer la voûte de secrets maintenant ». Si `oui` : - Ces secrets ne transitent **jamais** par le GUI ni par aucun fichier en clair ; le GUI n'en affiche que les **noms** (panneau « Intrants »). +### Deux façons de remplir la voûte, et il ne faut pas les confondre + +| | Qui est la source | Comment | +|---|---|---| +| **Générer** | le **dépôt** — Ansible configure les deux côtés depuis la même variable (ex. `vault_nextcloud_oidc` : le client Keycloak le déclare, le rôle Nextcloud le lit) | valeur aléatoire, 32 octets | +| **Saisir** | un **tiers** — le secret existe déjà ailleurs et ne s'invente pas (clé d'API OPNsense, jeton Proxmox) | `python3 scripts/voute.py saisir ` | + +```bash +ANSIBLE_VAULT_PASSWORD_FILE=~/.config/setops-vault-pass \ + python3 scripts/voute.py saisir vault_opnsense_api_key vault_opnsense_api_secret +``` + +Saisie **sans écho**, double confirmation, rien sur la ligne de commande — donc ni +dans l'historique du shell, ni dans la liste des processus. Rien n'est écrit en clair +sur disque : la voûte est déchiffrée en mémoire, complétée, reparsée et re-déchiffrée +pour contrôle **avant** d'être posée. Une clé déjà renseignée est ignorée, sauf +`--remplacer`. + +> **Pourquoi la distinction compte.** Inventer une clé d'API OPNsense produirait une +> valeur syntaxiquement correcte, refusée à la première requête — et **P18 passerait +> au vert** sur une voûte inutilisable. Pire qu'une absence : un faux confort. + ### `proxmox.vault.yml` n'est plus lue (retirée le 2026-08-03) Les playbooks ne chargent plus ce fichier. Tolérée « en compatibilité », elle était diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index 10a2246..67323de 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -31,6 +31,8 @@ sont les seules vérifiables. | **D-05** | **Un seul commutateur route** ; les autres restent en L2 pur | sans MLAG, dupliquer les SVI créerait autant de conflits d'adresses que de zones | `underlay.yml.example` | P23 | | **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 ``, VNet `` | 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 | @@ -53,6 +55,7 @@ sont les seules vérifiables. | **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-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 | — | diff --git a/docs/sdn-evpn.md b/docs/sdn-evpn.md index d498510..d7e9b7e 100644 --- a/docs/sdn-evpn.md +++ b/docs/sdn-evpn.md @@ -30,11 +30,34 @@ Vérifiée sur les deux tenants fédérés, elle ne demande **aucun changement d | Objet Proxmox SDN | Vient de | Exemple (Chezlepro, zone Services-infra) | |---|---|---| | **zone** (un VRF) | le tenant | `CHEZ17` | -| **VNet** | la zone de sécurité | `chez17-services-infra` | +| **VNet** | la zone de sécurité | `chez174` | | **tag** (VNI) | `vlan_de(index, zone)` | `1174` | | **subnet** | `sous_reseau_de(index, zone)` | `10.27.19.0/24` | | **gateway** | `passerelle_de(index, zone)` | `10.27.19.1` | +> **Rectification du 2026-08-03.** Ce tableau annonçait `chez17-services-infra`, qui +> aurait été **refusé à l'application** : zones et VNets sont limités à **8 caractères** +> par Proxmox — l'identifiant sert de base aux noms de bridge, veth et tap. Message +> amont : *« zone ID … can't be more length than 8 characters »*. +> +> Le nommage dérive du tenant, comme tout le reste : `` pour la zone, +> `` pour le VNet. Le préfixe vient de `devis_reseau.prefixe()` — +> la même fonction que le devis des commutateurs, donc un seul endroit fabrique le nom +> court d'un tenant. Éprouvé jusqu'au pire cas de la fédération : `COOP245` = 7, +> `coop2459` = 8. **P30** refuse tout dépassement, sur les deux objets. +> +> **Les zones créées à la main (`VRF0011`, `VRF0017`) sont remplacées.** Ce nommage ne +> disait ni de quel tenant il s'agissait, ni rien qu'on puisse relier au plan : il +> fallait une table de correspondance pour le lire. Le remplacement se fait **pendant +> que les zones sont vides** — les supprimer ne débranche rien. Avec des VM attachées, +> ce serait une migration ; le devis émet donc une §0 qui les retire d'abord. + +**`make devis-sdn`** émet ces objets, tenant par tenant : la zone, ses six VNets, ses six +sous-réseaux, puis `pvesh set /cluster/sdn` pour pousser. Non destructif, à relire. +Vérification la plus forte disponible : la dérivation **reproduit à l'identique** les deux +zones déjà présentes sur le cluster — même nom, même VNI de VRF, même MTU, même +contrôleur. + Le seed `index` reste la source unique. 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 — donc plus proche de la VM, et sans point unique de défaillance. @@ -141,7 +164,7 @@ Lecture seule par l'API Proxmox. **Le plan de contrôle existe, le plan de donn | Contrôleur | `EVPN0017`, ASN 65000 | | Zones | `VRF0011`, `VRF0017` — un VRF par tenant, VNI = index, MTU 1450 | | VNets | **aucun** | -| Nœuds de sortie | **aucun** | +| Nœuds de sortie | **aucun** — un VRF sans sortie n'a aucun chemin vers la frontière | ### Deux blocages à lever avant d'aller plus loin diff --git a/scripts/devis_sdn.py b/scripts/devis_sdn.py new file mode 100644 index 0000000..d107444 --- /dev/null +++ b/scripts/devis_sdn.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Devis SDN EVPN — le plan de donnees d'un tenant. + +Ajouter un tenant n'ajoute pas qu'un plan et un inventaire : cela implique, sur le +cluster, UNE ZONE EVPN (le VRF du tenant), UN VNET par zone de securite, et UN +SOUS-RESEAU par VNet avec sa passerelle anycast. Sans ces objets, aucune VM ne peut +etre branchee — le plan de controle existe, le plan de donnees non. + +Tout DERIVE du seed `index` (cf. docs/sdn-evpn.md §2) : le VNI est +`1000 + index*10 + zone`, le sous-reseau et la passerelle viennent des memes +fonctions que l'inventaire. Rien n'est saisi deux fois. + +NOMMAGE — aligne sur le prefixe `t` DEJA utilise par le pare-feu Proxmox +(`t17-cli-metrique`, `t17-flotte`) : un seul schema se lit dans tout le depot. + - zone : `t` -> t17, t11 + - VNet : `t` -> t17fron, t17iden, t17donn, + t17serv, t17obse, t17appl + +L'abreviation de zone vient des 4 premieres lettres du libelle de la nomenclature, +sans accent : elle DIT la zone, la ou un simple chiffre demandait la table des +categories. `chez174` n'etait pas lisible ; `t17serv` l'est. + +Pas de tiret entre l'index et la zone, contrairement aux IPSets : `t245-serv` ferait +9 caracteres. Sans separateur, `t245serv` en fait 8 — la forme reste UNIFORME jusqu'au +dernier index de la federation, ce qu'un separateur qui saute selon la taille de +l'index ne permettrait pas. + +Le cluster portait `VRF0011` / `VRF0017`, herites d'une creation a la main. Ce +nommage-la ne dit ni de quel tenant il s'agit, ni rien qu'on puisse relier au plan : +il fallait un tableau de correspondance pour le lire. On le remplace pendant que les +zones sont VIDES — le cout est nul aujourd'hui, ce serait une migration une fois des +VM branchees dessus. + +LA SEULE CONTRAINTE QUI SURVIT, parce qu'elle vient du produit et non d'une habitude : +zones ET VNets sont **limites a 8 caracteres** (l'identifiant sert de base aux noms de +bridge, veth, tap ; message amont : « zone ID ... can't be more length than 8 +characters »). Le nommage retenu tient : `CHEZ245` = 7, `chez2456` = 8 au pire cas de +la federation. P30 refuse tout depassement — la faute ne se decouvrirait qu'a +l'application. + +CE QUI APPARTIENT A L'HEBERGEUR : le controleur EVPN, son ASN et les noeuds de +sortie sont des objets de CLUSTER (D-35). Ils se lisent dans +`/proxmox-hebergeur.yml`, jamais chez le tenant. + +NON destructif : ce script n'ecrit rien sur le cluster. + +Usage : + python3 scripts/devis_sdn.py # devis lisible + python3 scripts/devis_sdn.py --json # meme contenu, pour l'API + python3 scripts/devis_sdn.py --verifier # garde : aucune collision, noms valides +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import unicodedata +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RACINE / "scripts")) + +from inventory_rules import passerelle_de, sous_reseau_de, vlan_de # noqa: E402 +from devis_reseau import decouvrir # noqa: E402 +import underlay as underlay_mod # noqa: E402 + + +# Limite Proxmox, pour les zones ET les VNets : l'identifiant sert de base aux noms +# de bridge, veth et tap, auxquels le produit ajoute prefixes et suffixes. +LONGUEUR_MAX_ID = 8 +MARQUEUR_SORTIE = "" +# Nommages precedents, a retirer : VRF00xx (cree a la main) puis CHEZ17/TECH11 +# (premiere derivation, remplacee — les chiffres de zone n'etaient pas lisibles). +ANCIEN_NOMMAGE = ("VRF0011", "VRF0017", "CHEZ17", "TECH11") + + +def zone_de(index: int) -> str: + """Zone EVPN : `t` — meme prefixe que les IPSets du pare-feu Proxmox.""" + return f"t{int(index)}" + + +def abreger(libelle: str, taille: int = 4) -> str: + """4 premieres lettres du libelle, sans accent ni ponctuation, en minuscules. + + Mecanique, donc derivee : « Services-infra » -> `serv`, « Observabilite » -> + `obse`. Deux libelles partageant leurs 4 premieres lettres produiraient le meme + VNet — la garde de collision l'attrape et exige de renommer la categorie, plutot + que d'inventer un suffixe illisible. + """ + plat = unicodedata.normalize("NFKD", str(libelle)) + plat = "".join(c for c in plat if not unicodedata.combining(c)) + plat = "".join(c for c in plat if c.isalnum()) + return plat.lower()[:taille] or "zone" + + +def vnet_de(index: int, libelle: str) -> str: + """VNet : `t` — t17serv, t17obse. Minuscules : cet + identifiant devient une base de nom d'interface.""" + return f"{zone_de(index)}{abreger(libelle)}" + + +def sdn_hebergeur() -> dict: + """Controleur, ASN et noeuds de sortie — propriete de l'HEBERGEUR (D-35). + + Trouve par derivation du symlink `underlay.yml`, qui designe deja l'hebergeur : + rien de nouveau n'est declare (D-17). Vide si aucun hebergeur n'est monte. + """ + lien = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml")) + if not lien.exists(): + return {} + fichier = lien.resolve().parent / "proxmox-hebergeur.yml" + if not fichier.is_file(): + return {} + data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {} + return data.get("proxmox_sdn") or {} + + +def construire(tenants: list[tuple[str, str, dict]]) -> dict: + sdn = sdn_hebergeur() + u = underlay_mod.charger() + mtu = underlay_mod.mtu_overlay(u) + blocs = [] + for nom_instance, _prefixe, nomenclature in tenants: + index = int(nomenclature["index"]) + vnets = [] + for cle, cat in sorted((nomenclature.get("categories") or {}).items(), + key=lambda x: int(x[0])): + z = int(cle) + vnets.append({ + "vnet": vnet_de(index, cat.get("libelle", f"zone{z}")), + "libelle": str(cat.get("libelle", f"zone-{z}")), + "tag": vlan_de(index, z), + "sous_reseau": sous_reseau_de(index, z), + "passerelle": passerelle_de(index, z), + }) + blocs.append({ + "tenant": nom_instance, + "index": index, + "zone": zone_de(index), + "vrf_vxlan": index, + "mtu": mtu, + "vnets": vnets, + }) + return { + "controleur": sdn.get("controleur") or "", + "asn": sdn.get("asn"), + "noeuds_de_sortie": sdn.get("noeuds_de_sortie") or [], + "anciennes": list(ANCIEN_NOMMAGE), + "zones": blocs, + } + + +def rendre(devis: dict) -> str: + sortie = devis["noeuds_de_sortie"] + out = [ + "# Devis SDN EVPN — une zone, ses VNets, ses sous-reseaux, par tenant", + "#", + "# Tout derive du seed `index` : VNI = 1000 + index*10 + zone ; le sous-reseau et", + "# la passerelle viennent des memes fonctions que l'inventaire.", + "#", + "# Le `.1` ne change pas d'adresse, il change de PORTEUR : du SVI d'un commutateur", + "# vers la passerelle anycast du VNet, presente sur chaque hyperviseur.", + "", + f"# Controleur (hebergeur) : {devis['controleur']}" + + (f" ASN {devis['asn']}" if devis.get("asn") else ""), + "", + ] + if devis.get("anciennes"): + out += [ + "## 0. Retirer l'ancien nommage (fait a la main, remplace)", + "#", + "# Ces zones ne portent AUCUN VNet : les supprimer ne debranche rien. C'est", + "# precisement pourquoi on renomme maintenant — avec des VM attachees, ce", + "# serait une migration. Verifier l'absence de VNet avant de supprimer :", + "# pvesh get /cluster/sdn/vnets --output-format json", + ] + for z in devis["anciennes"]: + out.append(f"pvesh delete /cluster/sdn/zones/{z}") + out.append("") + for b in devis["zones"]: + out += [ + f"## {b['tenant']} — zone `{b['zone']}` (index {b['index']}, VNI de VRF {b['vrf_vxlan']})", + "", + "### 1. La zone (le VRF du tenant)", + f"pvesh create /cluster/sdn/zones --zone {b['zone']} --type evpn \\", + f" --controller {devis['controleur']} --vrf-vxlan {b['vrf_vxlan']} \\", + f" --mtu {b['mtu']} --ipam pve \\", + f" --exitnodes {','.join(sortie) if sortie else MARQUEUR_SORTIE}", + ] + if not sortie: + out += [ + "# /!\\ AUCUN noeud de sortie declare chez l'hebergeur.", + "# Un VRF sans sortie n'a AUCUN chemin vers l'exterieur : ni frontiere,", + "# ni inter-tenant, ni Internet. Le trafic intra-tenant fonctionnerait,", + "# le reste echouerait sans message clair. Declarer", + "# `proxmox_sdn.noeuds_de_sortie` dans proxmox-hebergeur.yml.", + ] + out += ["", "### 2. Les VNets (une par zone de securite)"] + for v in b["vnets"]: + out.append(f"pvesh create /cluster/sdn/vnets --vnet {v['vnet']} " + f"--zone {b['zone']} --tag {v['tag']} # {v['libelle']}") + out += ["", "### 3. Les sous-reseaux (passerelle anycast sur chaque hyperviseur)"] + for v in b["vnets"]: + out.append(f"pvesh create /cluster/sdn/vnets/{v['vnet']}/subnets " + f"--subnet {v['sous_reseau']} --type subnet " + f"--gateway {v['passerelle']} --snat 0") + out += ["", "# VNet tag/VNI sous-reseau passerelle zone"] + for v in b["vnets"]: + out.append(f"# {v['vnet']:<8} {v['tag']:<8} {v['sous_reseau']:<18} " + f"{v['passerelle']:<17} {v['libelle']}") + out.append("") + out += [ + "## 4. Appliquer", + "# Les objets ci-dessus ne sont que DECLARES tant que la configuration SDN n'est", + "# pas poussee sur les noeuds. Rien ne change avant cette ligne.", + "pvesh set /cluster/sdn", + "", + "## Ce que ce devis ne fait pas", + "# Il ne branche aucune VM : le `bridge` d'une VM se regle au clonage, et vient du", + "# plan (proxmox_clone_pont). Il ne lit pas non plus le cluster — il dit l'etat", + "# cible, pas l'ecart. Les commandes echouent si l'objet existe deja, ce qui est", + "# le comportement voulu pour un devis qu'on relit avant d'appliquer.", + ] + return "\n".join(out) + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--json", action="store_true") + ap.add_argument("--verifier", action="store_true") + args = ap.parse_args(argv) + + devis = construire(decouvrir()) + if args.verifier: + zones = devis["zones"] + if not zones: + print("erreur: aucun tenant federe decouvert.", file=sys.stderr) + return 2 + fautes: list[str] = [] + vus_zone: dict[str, str] = {} + vus_vnet: dict[str, str] = {} + vus_reseau: dict[str, str] = {} + for b in zones: + if len(b["zone"]) > LONGUEUR_MAX_ID: + fautes.append(f"zone {b['zone']} : {len(b['zone'])} caracteres " + f"(max {LONGUEUR_MAX_ID})") + if b["zone"] in vus_zone: + fautes.append(f"zone {b['zone']} revendiquee par {vus_zone[b['zone']]} " + f"et {b['tenant']}") + vus_zone[b["zone"]] = b["tenant"] + for v in b["vnets"]: + # La limite de 8 caracteres est la faute la plus couteuse : Proxmox + # refuse l'objet, et un devis invalide se decouvre a l'application. + if len(v["vnet"]) > LONGUEUR_MAX_ID: + fautes.append(f"VNet {v['vnet']} : {len(v['vnet'])} caracteres " + f"(max {LONGUEUR_MAX_ID})") + if v["vnet"] in vus_vnet: + fautes.append(f"VNet {v['vnet']} revendique par {vus_vnet[v['vnet']]} " + f"et {b['tenant']}") + vus_vnet[v["vnet"]] = b["tenant"] + if v["sous_reseau"] in vus_reseau: + fautes.append(f"sous-reseau {v['sous_reseau']} revendique par " + f"{vus_reseau[v['sous_reseau']]} et {b['tenant']}") + vus_reseau[v["sous_reseau"]] = b["tenant"] + if fautes: + print("erreur: " + " ; ".join(fautes[:6]), file=sys.stderr) + return 2 + total_v = sum(len(b["vnets"]) for b in zones) + manque = "" if devis["noeuds_de_sortie"] else \ + " ; /!\\ aucun noeud de sortie declare (VRF sans chemin vers l'exterieur)" + print(f"CONFORME : SDN EVPN, {len(zones)} zone(s), {total_v} VNet(s), " + f"{total_v} sous-reseau(x), aucune collision{manque}.") + return 0 + print(json.dumps(devis, indent=2, ensure_ascii=False) if args.json else rendre(devis)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/prouver.py b/scripts/prouver.py index c918bef..8b02d38 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -470,6 +470,8 @@ PREUVES: list[dict] = [ "cmds": [[sys.executable, "scripts/devis_proxmox_pools.py", "--verifier"]]}, {"id": "P29", "titre": "Authentification : chaque role declare sa position", "refs": ["AFF-111"], "func": preuve_authentification}, + {"id": "P30", "titre": "SDN EVPN : zones, VNets et sous-reseaux derives", "refs": ["AFF-112"], + "cmds": [[sys.executable, "scripts/devis_sdn.py", "--verifier"]]}, ] diff --git a/scripts/voute.py b/scripts/voute.py index c3431e9..0eb5820 100644 --- a/scripts/voute.py +++ b/scripts/voute.py @@ -22,6 +22,7 @@ Usage : from __future__ import annotations import argparse +import getpass import os import re import sys @@ -154,6 +155,84 @@ def cles_voute_reelle() -> set[str] | None: return None +def _voute_reelle() -> Path | None: + for nom in ("principal", "production", "lab"): + p = INSTANCE / "inventories" / nom / "group_vars" / "all" / "vault.yml" + if p.is_file(): + return p + return None + + +def saisir(noms: list[str], remplacer: bool = False) -> int: + """Ecrit dans la voute des secrets EMIS PAR UN TIERS, saisis sans echo. + + Pendant de la generation, et sa contrepartie exacte : on GENERE un secret dont + le depot est la source (Ansible configure les deux cotes depuis la meme + variable) ; on SAISIT un secret dont un tiers est la source. Inventer une cle + d'API OPNsense donnerait une valeur syntaxiquement correcte et refusee a la + premiere requete — pire qu'une absence, puisque la garde passerait au vert. + + Rien n'est affiche, rien n'est ecrit en clair sur disque, rien ne passe par la + ligne de commande (donc ni par l'historique du shell, ni par la liste des + processus). Le resultat est reparse et redechiffre avant d'etre pose. + """ + from ansible.parsing.vault import VaultLib, VaultSecret + + cible = _voute_reelle() + if cible is None: + print("erreur: aucune voute group_vars/all/vault.yml dans cette instance.", + file=sys.stderr) + return 2 + fichier_mdp = os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") + if not fichier_mdp or not Path(os.path.expanduser(fichier_mdp)).is_file(): + print("erreur: ANSIBLE_VAULT_PASSWORD_FILE absent ou introuvable.", file=sys.stderr) + return 2 + mdp = Path(os.path.expanduser(fichier_mdp)).read_bytes().strip() + vault = VaultLib([("default", VaultSecret(mdp))]) + + brut = cible.read_bytes() + texte = (vault.decrypt(brut) if brut.startswith(b"$ANSIBLE_VAULT") else brut).decode("utf-8") + data = yaml.safe_load(texte) or {} + + a_poser: dict[str, str] = {} + for nom in noms: + if str(data.get(nom, "")).strip() and not remplacer: + print(f" {nom} : deja renseigne, ignore (--remplacer pour ecraser).") + continue + while True: + v = getpass.getpass(f" {nom} : ") + if not v.strip(): + print(" valeur vide refusee.") + continue + if getpass.getpass(f" {nom} (confirmer) : ") != v: + print(" les deux saisies different, on recommence.") + continue + break + a_poser[nom] = v + if not a_poser: + print("Rien a ecrire.") + return 0 + + nouveau = (texte.rstrip("\n") + "\n\n" + + "# --- Saisis a la main (secrets emis par un tiers) ---\n" + + yaml.safe_dump(a_poser, default_flow_style=False, + allow_unicode=True, sort_keys=True)) + attendu = {**data, **a_poser} + if yaml.safe_load(nouveau) != attendu: + print("erreur: le YAML fusionne ne reproduit pas l'union attendue.", file=sys.stderr) + return 2 + chiffre = vault.encrypt(nouveau.encode("utf-8")) + if not chiffre.endswith(b"\n"): + chiffre += b"\n" + if yaml.safe_load(vault.decrypt(chiffre).decode()) != attendu: + print("erreur: aller-retour de chiffrement infidele, rien n'a ete ecrit.", file=sys.stderr) + return 2 + cible.write_bytes(chiffre) + print(f"Ecrit et verifie : {len(a_poser)} secret(s) -> {cible} " + f"({len(attendu)} cle(s) au total).") + return 0 + + def main() -> int: parser = argparse.ArgumentParser(description="Couverture du gabarit de voute Set-OPS.") sub = parser.add_subparsers(dest="commande", required=True) @@ -161,8 +240,15 @@ def main() -> int: pv = sub.add_parser("verifier", help="Le gabarit couvre-t-il les secrets exiges ?") pv.add_argument("--strict", action="store_true", help="Echouer aussi si le gabarit porte des cles inutiles.") + ps = sub.add_parser("saisir", help="Saisir sans echo des secrets emis par un tiers.") + ps.add_argument("noms", nargs="+", help="Noms des cles a saisir (ex. vault_opnsense_api_key).") + ps.add_argument("--remplacer", action="store_true", + help="Ecraser une cle deja renseignee.") args = parser.parse_args() + if args.commande == "saisir": + return saisir(args.noms, args.remplacer) + g = gabarit() if not g: print("Aucun gabarit vault.yml.example : rien a verifier.")