SDN : make devis-sdn — zone, VNets et sous-réseaux dérivés du seed (D-43/44, P30)

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<index> pour
la zone, t<index><zone abrégée> 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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-04 11:06:42 -04:00
parent 22ef279464
commit 3b1d9b6660
11 changed files with 499 additions and 4 deletions

View file

@ -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<index>`** pour la zone, **`t<index><zone abrégée>`** pour le
VNet — `t17`, `t17serv`, `t17obse`. C'est le préfixe que le **pare-feu Proxmox
utilisait déjà** (`t17-cli-metrique`, `t17-flotte`) : un seul schéma se lit dans tout
le dépôt. L'abréviation vient des 4 premières lettres du libellé, accents retirés,
donc dérivée.
Pas de tiret entre l'index et la zone, contrairement aux IPSets : `t245-serv` ferait
9 caractères. Sans séparateur, `t245serv` en fait 8 — la forme reste **uniforme
jusqu'au dernier index de la fédération**.
### La contrainte qui a tout cadré
Zones et VNets sont **limités à 8 caractères** par Proxmox — l'identifiant sert de
base aux noms de bridge, veth et tap. Le tableau de `sdn-evpn.md` annonçait
`chez17-services-infra`, soit 21 : il aurait été **refusé à l'application**. **P30**
refuse désormais tout dépassement, sur les deux objets, et toute collision de nom, de
VNI ou de sous-réseau entre tenants.
Éprouvé aux bornes (`t1serv` 6, `t17serv` 7, `t245serv` 8) et par sabotage : deux
libellés partageant leurs 4 premières lettres produisent le même VNet, et la garde
l'attrape.
### Vérification la plus forte disponible
Avant renommage, la dérivation **reproduisait à l'identique** les deux zones déjà
créées à la main — nom, VNI de VRF, MTU, contrôleur. La dérivation retombait sur ce
qu'un humain avait posé.
### `voute.py saisir` : le pendant de la génération
On **génère** un secret dont le dépôt est la source ; on **saisit** celui dont un tiers
est la source. Inventer une clé d'API OPNsense donnerait une valeur syntaxiquement
correcte, refusée à la première requête — et P18 au vert sur une voûte inutilisable.
Saisie sans écho, double confirmation, rien sur la ligne de commande. Éprouvée sur une
voûte jetable : écrit, préserve l'existant, refuse d'écraser sans `--remplacer`.
### Ce qui reste ouvert
Aucune zone ne déclare de **nœud de sortie**, et le devis émet un marqueur plutôt
qu'une valeur plausible. Deux points à trancher avant :
- le nœud de sortie route selon **sa propre table** ; la passerelle par défaut des
trois hyperviseurs est `192.168.11.254`, pas la frontière ;
- l'**entrée n'est pas redondante** : elle dépend d'une route statique d'OPNsense vers
**un** nœud. Deux nœuds de sortie ne donnent aucune redondance entrante.
**D-43**, **D-44**, **D-45**, **AFF-112**. 30 preuves OK.
## 2026-08-03 (suite 16) — la directive d'authentification est gardée (P29)
Une règle qu'aucune garde ne vérifie finit par ne plus être vraie : c'est exactement ce

View file

@ -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

View file

@ -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 `<rôle>_oidc`, ou `ldap-direct` sans variable `<rôle>_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`,

View file

@ -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

View file

@ -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é | `<rôle>_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` |

View file

@ -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 <clés>` |
```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

View file

@ -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 `<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 |
@ -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 | — |

View file

@ -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 : `<PRÉFIXE><index>` pour la zone,
> `<préfixe><index><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

282
scripts/devis_sdn.py Normal file
View file

@ -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<index>` DEJA utilise par le pare-feu Proxmox
(`t17-cli-metrique`, `t17-flotte`) : un seul schema se lit dans tout le depot.
- zone : `t<index>` -> t17, t11
- VNet : `t<index><zone>` -> 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
`<depot hebergeur>/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 = "<NOEUDS-DE-SORTIE-A-DESIGNER>"
# 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<index>` — 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<index><zone abregee>` — 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 "<CONTROLEUR-EVPN-A-DECLARER>",
"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:]))

View file

@ -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"]]},
]

View file

@ -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.")