diff --git a/CHANGELOG.md b/CHANGELOG.md index 876322d..341f531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # CHANGELOG — Set-OPS +## 2026-08-03 — le SDN prend le routage tenant, le devis switch se vide + +Trois décisions, et le devis les applique déjà : **une zone EVPN par tenant** ; le routage +**et le filtrage** entre les zones d'un même tenant à ce niveau ; **l'inter-tenant +obligatoirement par l'OPNsense**. + +### `underlay.routage_tenants` — `switch` (défaut) ou `sdn` +En `sdn`, le devis switch cesse d'émettre VLAN tenants, SVI de zone et ACL, et les retire des +trunks. Chez Chezlepro, les trunks ne portent plus que **deux VLAN d'underlay** au lieu de +quinze : aucun VLAN de tenant ne circule sur le fil, seulement du VXLAN que le commutateur +transporte sans le lire. + +Les sections 1 à 3 sont remplacées par la raison, dont celle-ci : les passerelles `.1` n'ont +pas changé d'adresse, elles ont changé de **porteur** — passerelle anycast du VNet, présente +sur chaque hyperviseur. + +### Le MTU devient une garde, pas un conseil +VXLAN ajoute 50 octets. `make underlay` **refuse** un réseau de transport sous 1550 dès que +`routage_tenants: sdn`, en disant pourquoi : sous ce seuil, le ping passe et les transferts +échouent — la panne la plus coûteuse à diagnostiquer. Les deux réseaux de la fabric principale +passent à 9000. + +### Le partage des responsabilités, écrit +Une table dans `docs/sdn-evpn.md` dit qui route et qui filtre pour chaque nature de trafic. +Deux conséquences y sont nommées : **l'inter-tenant ne peut plus être oublié** — il doit sortir +du VRF, donc traverser une bordure en `block` par défaut ; et **le commutateur ne voit plus +rien du trafic tenant**, donc y chercher la trace d'un problème applicatif est une perte de +temps. + +Point ouvert ajouté : le registre des flux n'a **aucun mot-clé pour un flux inter-tenant**. +Défaut sûr aujourd'hui, mais il rend impossible de *déclarer* une exception légitime. + ## 2026-08-02 (suite 20) — décision : le routage passe aux hyperviseurs (SDN EVPN) `docs/sdn-evpn.md`. Les commutateurs ne savent pas lier une ACL à une interface de routage ; diff --git a/docs/sdn-evpn.md b/docs/sdn-evpn.md index 8a1e701..5240fe8 100644 --- a/docs/sdn-evpn.md +++ b/docs/sdn-evpn.md @@ -4,8 +4,9 @@ > commutateurs L3 par des zones EVPN de Proxmox SDN. Complète `docs/frontiere-opnsense.md` > (la bordure, inchangée) et `underlay.yml.example` (la fabric, très allégée). > -> **Rien n'est encore éprouvé.** Ce document fixe la cible et la projection du modèle ; le -> spike vient avant toute génération. +> **Décidé le 2026-08-03** : une zone EVPN **par tenant** ; le routage **et le filtrage** +> entre les VLAN d'un même tenant se font à ce niveau ; le trafic **inter-tenant passe +> obligatoirement par l'OPNsense**. ## 1. Ce que la décision résout @@ -42,9 +43,29 @@ Effet de bord favorable : un VNI est codé sur 24 bits là où un VLAN plafonne du nombre de tenants n'est plus l'espace de VLAN mais le second octet IPv4 du supernet — le plafond de 245 tenants reste, sa cause change. -## 3. Ce que le devis switch devient +## 3. Le partage des responsabilités -Il maigrit beaucoup. Disparaissent : les VLAN tenants, les SVI de zone, les ACL d'isolation, +| Trafic | Où il est routé | Où il est filtré | +|---|---|---| +| **entre zones d'un même tenant** | zone EVPN du tenant (VRF, sur les hyperviseurs) | **au même endroit** | +| **entre tenants** | sort du VRF → nœud de sortie → **OPNsense** | la frontière, et elle seule | +| **vers l'extérieur** | idem | la frontière | +| **de service à service, sur un hôte** | — | nftables d'hôte, `policy drop` | + +Deux conséquences qui méritent d'être dites. + +**L'inter-tenant ne peut plus être « oublié ».** Il ne circule pas latéralement : il doit +sortir du VRF, donc traverser la bordure, qui est en `block` par défaut. Un flux inter-tenant +légitime devra être **déclaré** pour exister — le registre des flux n'a pas encore de mot-clé +pour ça, c'est un point ouvert. + +**Le commutateur ne voit plus rien du trafic tenant.** Il transporte du VXLAN qu'il ne lit +pas. Y chercher une trace d'un problème applicatif serait perdre son temps : le miroir utile +est sur l'hyperviseur. + +## 4. Ce que le devis switch devient + +C'est fait, et piloté par `underlay.routage_tenants: sdn`. Disparaissent : les VLAN tenants, les SVI de zone, les ACL d'isolation, et les VLAN tenants dans les trunks. En EVPN, **aucun VLAN de tenant ne circule sur le fil** — seulement du VXLAN encapsulé dans de l'IP. @@ -55,22 +76,23 @@ IP**. `underlay.routeur` garde son sens, mais son rôle se réduit : il route l'underlay, plus les tenants. -## 4. Ce que ça change ailleurs +## 5. Ce que ça change ailleurs **Le nœud de sortie remplace le prochain saut.** En EVPN, le trafic quitte le VRF par un ou plusieurs *exit nodes* désignés. Ce sont eux, et non plus le commutateur, que l'OPNsense voit comme voisins. `underlay.passerelle_sortie` changera de nature : il désignera l'entrée du tenant vers la bordure, côté hyperviseur. -**Le MTU devient un prérequis, pas un détail.** VXLAN ajoute 50 octets. Le transport doit -passer à 1550 au minimum, jumbo de préférence. C'est le premier mur, et le plus déroutant : il -ne casse pas franchement, il casse **partiellement** — le ping passe, les transferts échouent. +**Le MTU est un prérequis vérifié, pas un conseil.** VXLAN ajoute 50 octets ; `make underlay` +**refuse** un réseau de transport sous 1550 dès que `routage_tenants: sdn`. C'est le premier +mur, et le plus déroutant : il ne casse pas franchement, il casse **partiellement** — le ping +passe, les transferts échouent. **Ce qui ne change pas** : le plan, le registre des flux, les nftables d'hôte, la frontière OPNsense et ses règles dérivées, la voûte, les preuves. La cible d'émission change ; le modèle non. -## 5. Ce qui reste à éprouver — avant toute génération +## 6. Ce qui reste à éprouver — avant toute génération Le principe du dépôt s'applique : **éprouver l'outil avant d'écrire le rôle.** L'EVPN est la partie la plus jeune de Proxmox SDN, et c'est là que vivent les bogues. @@ -86,13 +108,16 @@ Un tenant de labo, une zone EVPN, deux VNet, une VM dans chacun — et vérifier Ce n'est qu'après que la question « comment générer cette configuration » se pose. -## 6. Ce qui reste à trancher +## 7. Ce qui reste à trancher - **Où vit la configuration SDN.** Elle est *par cluster*, donc propriété de l'hébergeur — comme `underlay.yml`, et par le même raisonnement. - **Le contrôleur EVPN** : ASN, voisins, et si l'on fait du BGP avec la bordure ou des routes statiques comme aujourd'hui. - **Le nombre de nœuds de sortie** et leur redondance. +- **Le mot-clé d'un flux inter-tenant** dans le registre. Aujourd'hui aucun ne l'exprime, donc + tout inter-tenant tombe dans le `block` de la bordure — un défaut sûr, mais qui rend + impossible de *déclarer* une exception légitime. - **La migration depuis l'existant** : le tenant Chezlepro tourne déjà sur des VLAN. Passer à EVPN est un changement de plan de transport pour des VM en service — la recette de `docs/migration-tenant.md` s'applique-t-elle, ou faut-il un chemin plus court ? diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index 2245a05..628c7f2 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -469,68 +469,83 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) - "!", ] out += section_underlay(underlay) - out += ["! ----- 1. VLANs (tenants) -----"] - for nom, pfx, n in tenants: - out.append(f"! {nom} (index {n['index']})") - for zone in sorted(n["categories"]): - c = n["categories"][zone] - out.append(f"vlan {vlan_de(n['index'], zone)}") - out.append(f" name {pfx}{n['index']}-{c['libelle']}") - acl = underlay_mod.acl_inter_tenant(underlay) - out += ["!", "! ----- 2. Interfaces de routage (SVI = passerelle des hotes) -----"] - for nom, pfx, n in tenants: - m = masque(int(n.get("cidr_hote", 24))) - for zone in sorted(n["categories"]): - c = n["categories"][zone] - out.append(f"interface Vlan{vlan_de(n['index'], zone)}") - out.append(f" description {nom}-{c['libelle']}") - out.append(f" ip address {passerelle_de(n['index'], zone)} {m}") - if acl: - out.append(f" ip access-group {pfx}{n['index']}-ISOLATION in") - out.append(" no shutdown") - if not acl: - out += ["!", "! ----- 3. Isolation inter-tenant — PAS D'ACL SUR CETTE FABRIC -----", - "! `underlay.acl_inter_tenant: false` : le materiel ne sait pas lier une ACL", - "! a une interface de routage. Emettre des ACL qu'on ne peut pas lier serait", - "! pire que rien — elles auraient l'air d'isoler sans jamais filtrer.", + if underlay_mod.routage_tenants(underlay) == "sdn": + out += ["! ----- 1 a 3. VLANs, SVI et ACL des tenants — PORTES PAR LE SDN -----", + "! `underlay.routage_tenants: sdn` : une zone EVPN par tenant porte son VRF.", + "! Le routage ET le filtrage entre les zones d'un meme tenant vivent sur les", + "! hyperviseurs. AUCUN VLAN DE TENANT NE CIRCULE SUR CE FIL — seulement du", + "! VXLAN encapsule dans de l'IP, que ce commutateur transporte sans le lire.", "!", - "! L'isolation repose donc ENTIEREMENT sur les nftables de chaque hote", - "! (`make flux`), en `policy drop`, au moindre privilege par IP source.", + "! Ce commutateur ne porte donc ni VLAN tenant, ni SVI de zone, ni ACL. Les", + "! passerelles `.1` n'ont pas change d'adresse, elles ont change de porteur :", + "! passerelle anycast du VNet, presente sur chaque hyperviseur.", "!", - "! /!\\ CE QUI N'EST PLUS PROTEGE AU NIVEAU RESEAU : le plan de gestion de la", - "! fabric. Une VM qui emet vers l'underlay voit son paquet ROUTE localement par", - "! ce commutateur — mgmt des switches, mgmt Proxmox, OOB/IPMI. Les nftables des", - "! VM n'y peuvent rien (politique `output` permissive), et l'IPMI n'est pas un", - "! hote gere. Seule parade structurelle : sortir le management de la fabric", - "! routee des tenants, comme l'est deja le stockage.", + "! L'inter-tenant sort du VRF et passe par la FRONTIERE, qui le police", + "! (`make devis-opnsense`). Il n'est pas filtre ici : il ne passe pas ici.", "!"] else: - out += ["!", "! ----- 3. ACL d'isolation tenant (default-deny inter-tenant) -----"] + out += ["! ----- 1. VLANs (tenants) -----"] for nom, pfx, n in tenants: - reseau, m = masque_acl(supernet_de(n["index"]), dialecte) - out.append(f"ip access-list extended {pfx}{n['index']}-ISOLATION") - out += remarque(dialecte, f"Intra-tenant {nom} : routage local autorise") - out.append(f" permit ip {reseau} {m} {reseau} {m}") - for autre_nom, _, autre in tenants: - if autre_nom == nom: - continue - a_reseau, a_m = masque_acl(supernet_de(autre["index"]), dialecte) - out += remarque(dialecte, f"Bloquer le tenant {autre_nom}") - out.append(f" deny ip {reseau} {m} {a_reseau} {a_m}") - # L'underlay est la fabric physique : mgmt des switches, mgmt Proxmox, OOB/IPMI, - # iSCSI, Ceph. Le trafic d'un tenant vers ces reseaux est route LOCALEMENT par le - # switch : il ne passe jamais par la frontiere, donc il n'est jamais filtre. Sans - # ce deny, le `permit any` final l'autorise — une VM atteindrait la console - # physique des hyperviseurs. Aucun flux du registre ne vise l'underlay. - # TOUTES les fabrics y passent, meme celles portees par d'autres switches : la - # regle porte sur l'adresse de DESTINATION, pas sur le cablage. Si un jour un - # chemin s'ouvre vers le stockage, il est deja ferme. - for r in underlay_mod.reseaux(underlay): - u_reseau, u_m = masque_acl(r["sous_reseau"], dialecte) - out += remarque(dialecte, f"Bloquer l'underlay {r['nom']} (fabric physique)") - out.append(f" deny ip {reseau} {m} {u_reseau} {u_m}") - out += remarque(dialecte, "Reste (Internet / inter-tenant controle) -> passerelle OPNsense") - out.append(f" permit ip {reseau} {m} any") + out.append(f"! {nom} (index {n['index']})") + for zone in sorted(n["categories"]): + c = n["categories"][zone] + out.append(f"vlan {vlan_de(n['index'], zone)}") + out.append(f" name {pfx}{n['index']}-{c['libelle']}") + acl = underlay_mod.acl_inter_tenant(underlay) + out += ["!", "! ----- 2. Interfaces de routage (SVI = passerelle des hotes) -----"] + for nom, pfx, n in tenants: + m = masque(int(n.get("cidr_hote", 24))) + for zone in sorted(n["categories"]): + c = n["categories"][zone] + out.append(f"interface Vlan{vlan_de(n['index'], zone)}") + out.append(f" description {nom}-{c['libelle']}") + out.append(f" ip address {passerelle_de(n['index'], zone)} {m}") + if acl: + out.append(f" ip access-group {pfx}{n['index']}-ISOLATION in") + out.append(" no shutdown") + if not acl: + out += ["!", "! ----- 3. Isolation inter-tenant — PAS D'ACL SUR CETTE FABRIC -----", + "! `underlay.acl_inter_tenant: false` : le materiel ne sait pas lier une ACL", + "! a une interface de routage. Emettre des ACL qu'on ne peut pas lier serait", + "! pire que rien — elles auraient l'air d'isoler sans jamais filtrer.", + "!", + "! L'isolation repose donc ENTIEREMENT sur les nftables de chaque hote", + "! (`make flux`), en `policy drop`, au moindre privilege par IP source.", + "!", + "! /!\\ CE QUI N'EST PLUS PROTEGE AU NIVEAU RESEAU : le plan de gestion de la", + "! fabric. Une VM qui emet vers l'underlay voit son paquet ROUTE localement par", + "! ce commutateur — mgmt des switches, mgmt Proxmox, OOB/IPMI. Les nftables des", + "! VM n'y peuvent rien (politique `output` permissive), et l'IPMI n'est pas un", + "! hote gere. Seule parade structurelle : sortir le management de la fabric", + "! routee des tenants, comme l'est deja le stockage.", + "!"] + else: + out += ["!", "! ----- 3. ACL d'isolation tenant (default-deny inter-tenant) -----"] + for nom, pfx, n in tenants: + reseau, m = masque_acl(supernet_de(n["index"]), dialecte) + out.append(f"ip access-list extended {pfx}{n['index']}-ISOLATION") + out += remarque(dialecte, f"Intra-tenant {nom} : routage local autorise") + out.append(f" permit ip {reseau} {m} {reseau} {m}") + for autre_nom, _, autre in tenants: + if autre_nom == nom: + continue + a_reseau, a_m = masque_acl(supernet_de(autre["index"]), dialecte) + out += remarque(dialecte, f"Bloquer le tenant {autre_nom}") + out.append(f" deny ip {reseau} {m} {a_reseau} {a_m}") + # L'underlay est la fabric physique : mgmt des switches, mgmt Proxmox, OOB/IPMI, + # iSCSI, Ceph. Le trafic d'un tenant vers ces reseaux est route LOCALEMENT par le + # switch : il ne passe jamais par la frontiere, donc il n'est jamais filtre. Sans + # ce deny, le `permit any` final l'autorise — une VM atteindrait la console + # physique des hyperviseurs. Aucun flux du registre ne vise l'underlay. + # TOUTES les fabrics y passent, meme celles portees par d'autres switches : la + # regle porte sur l'adresse de DESTINATION, pas sur le cablage. Si un jour un + # chemin s'ouvre vers le stockage, il est deja ferme. + for r in underlay_mod.reseaux(underlay): + u_reseau, u_m = masque_acl(r["sous_reseau"], dialecte) + out += remarque(dialecte, f"Bloquer l'underlay {r['nom']} (fabric physique)") + out.append(f" deny ip {reseau} {m} {u_reseau} {u_m}") + out += remarque(dialecte, "Reste (Internet / inter-tenant controle) -> passerelle OPNsense") + out.append(f" permit ip {reseau} {m} any") out += ["!", "! ----- 4. Trunk vers les hyperviseurs -----", "! Ports TERMINAUX : rien derriere eux ne participe au spanning-tree.", "! Les liens vers les autres switches sont en section 4c, pas ici."] @@ -541,7 +556,9 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) - for r in underlay_mod.reseaux_de_fabric( underlay, underlay_mod.fabric_du_routeur(underlay)) if not (transit and r["vlan"] == transit["vlan"])] - vlans_tenants = [ + # En SDN, les VLAN tenants n'existent pas sur le fil : le trunk ne porte que + # l'underlay, qui transporte le VXLAN. + vlans_tenants = [] if underlay_mod.routage_tenants(underlay) == "sdn" else [ str(vlan_de(n["index"], zone)) for _, _, n in tenants for zone in sorted(n["categories"]) diff --git a/scripts/underlay.py b/scripts/underlay.py index 271f928..ff84ce1 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -81,6 +81,27 @@ STP_MODES = ("rstp", "mstp", "pvst") STP_TOPOLOGIES = ("etoile", "anneau", "maille") +ROUTAGE_TENANTS = ("switch", "sdn") +# VXLAN ajoute 50 octets d'encapsulation. Sous ce seuil, le transport casse +# PARTIELLEMENT : le ping passe, les transferts echouent — la panne la plus couteuse +# a diagnostiquer. On refuse donc un underlay qui ne peut pas porter le SDN. +MTU_MINIMAL_VXLAN = 1550 + + +def routage_tenants(underlay: dict | None) -> str: + """Qui route entre les zones d'un tenant : `switch` (SVI) ou `sdn` (EVPN). + + En `sdn`, une zone EVPN par tenant porte le VRF : le routage ET le filtrage + inter-zone d'un meme tenant vivent sur les hyperviseurs. Aucun VLAN de tenant ne + circule alors sur le fil — seulement du VXLAN encapsule dans de l'IP — et le devis + switch cesse d'emettre VLAN tenants, SVI et ACL. + + L'inter-tenant, lui, sort du VRF et passe par la frontiere, qui le police. + """ + v = str((underlay or {}).get("routage_tenants") or "switch") + return v if v in ROUTAGE_TENANTS else "switch" + + def acl_inter_tenant(underlay: dict | None) -> bool: """La fabric porte-t-elle l'isolation inter-tenant par ACL ? Vrai par defaut. @@ -339,6 +360,20 @@ def valider(underlay: dict | None, if v is not None and not isinstance(v, bool): erreurs.append(f"acl_inter_tenant doit etre un booleen (recu : {v!r})") + rt = (underlay or {}).get("routage_tenants") + if rt is not None and str(rt) not in ROUTAGE_TENANTS: + erreurs.append(f"routage_tenants '{rt}' inconnu (attendu : {', '.join(ROUTAGE_TENANTS)})") + if routage_tenants(underlay) == "sdn": + # Le transport du VXLAN doit encaisser l'encapsulation, sinon panne partielle. + fab = fabric_du_routeur(underlay) + for r in reseaux_de_fabric(underlay, fab): + mtu = int(r.get("mtu") or 1500) + if mtu < MTU_MINIMAL_VXLAN: + erreurs.append( + f"reseau '{r.get('nom','?')}': MTU {mtu} < {MTU_MINIMAL_VXLAN} alors que " + f"`routage_tenants: sdn` — VXLAN ajoute 50 octets ; sous ce seuil le ping " + f"passe et les transferts echouent") + d = dialecte(underlay) if d and d not in DIALECTES: erreurs.append(f"dialecte '{d}' inconnu (attendu : {', '.join(DIALECTES)})") diff --git a/underlay.yml.example b/underlay.yml.example index 0e22bdb..48c27c8 100644 --- a/underlay.yml.example +++ b/underlay.yml.example @@ -28,6 +28,13 @@ underlay: # d'adresses qu'il y a de zones. Doit nommer un hote declare dans `hotes:`. routeur: switch-01 + # Qui route entre les zones d'un tenant : `switch` (SVI sur le commutateur, defaut) + # ou `sdn` (une zone EVPN par tenant, portant son VRF sur les hyperviseurs). + # En `sdn` : aucun VLAN de tenant ne circule sur le fil, le devis switch cesse + # d'emettre VLAN tenants / SVI / ACL, et les reseaux de transport doivent avoir un + # MTU >= 1550 (VXLAN ajoute 50 octets) — `make underlay` le refuse sinon. + #routage_tenants: switch + # Isolation inter-tenant par ACL de commutateur. `true` par defaut. # Mettre `false` quand le materiel ne sait pas lier une ACL a une interface de # routage : le devis cesse alors d'emettre des ACL qui ne seraient jamais liees.