devis switch : le SDN prend le routage tenant, le devis se vide

Trois décisions appliquées : une zone EVPN par tenant, routage ET filtrage
inter-zone à ce niveau, inter-tenant obligatoirement par l'OPNsense.

`underlay.routage_tenants` (`switch` par défaut, `sdn`) : en SDN le devis
cesse d'émettre VLAN tenants, SVI et ACL, et les retire des trunks. Chez
Chezlepro les trunks passent de quinze VLAN à deux — seul du VXLAN circule,
que le commutateur transporte sans le lire.

Le MTU devient une garde : `make underlay` refuse un transport sous 1550 en
mode SDN, en disant pourquoi — sous ce seuil le ping passe et les transferts
échouent.

Le partage des responsabilités est écrit dans docs/sdn-evpn.md : qui route,
qui filtre, pour chaque nature de trafic. Deux conséquences nommées —
l'inter-tenant ne peut plus être oublié (il traverse une bordure en block par
défaut), et le commutateur ne voit plus rien du trafic tenant.

Point ouvert : le registre des flux n'a aucun mot-clé pour un flux
inter-tenant. Défaut sûr, mais on ne peut pas déclarer d'exception légitime.

Preuves : 24 OK, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-03 08:26:21 -04:00
parent c8b43f1a70
commit e5ce2b93b1
5 changed files with 184 additions and 68 deletions

View file

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

View file

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

View file

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

View file

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

View file

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