underlay : chaque réseau déclare sa fabric physique

Le stockage jumbo (iSCSI, Ceph) est porté par un réseau indépendant de deux
switches 10G, sans câble commun avec la fabric convergée des sleipnir. Le
modèle l'ignorait : le devis déclarait les VLAN 20/30/31 sur les switches
convergés et les mettait dans leurs trunks. C'était faux.

Chaque réseau porte une `fabric` (`principal` par défaut). Le devis ne
configure que celle du routeur, et énonce explicitement ce qu'il ne couvre
pas au lieu de le taire — VLAN, trunks et spanning-tree d'une autre fabric
ne le regardent pas.

Les `deny` de l'ACL couvrent en revanche toutes les fabrics : la règle porte
sur l'adresse de destination, pas sur le câblage. Si un chemin s'ouvre un
jour vers le stockage, il est déjà fermé.

Preuves : 24 OK, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-01 20:36:02 -04:00
parent e1bbeb78f8
commit 31e5121d68
4 changed files with 88 additions and 6 deletions

View file

@ -1,5 +1,29 @@
# CHANGELOG — Set-OPS
## 2026-08-01 (suite 7) — l'underlay connaît ses fabrics physiques
Le stockage jumbo (iSCSI, Ceph) est porté par un **réseau indépendant de deux switches
10G**, sans câble commun avec la fabric convergée des `sleipnir`. Le modèle l'ignorait :
le devis déclarait les VLAN 20/30/31 sur les switches convergés et les mettait dans leurs
trunks. C'était faux.
Chaque réseau de l'underlay porte désormais une `fabric` (`principal` par défaut). Le devis
ne configure que celle du routeur, et **énonce ce qu'il ne couvre pas** plutôt que de le
taire :
```
! HORS PERIMETRE — fabric 'stockage' : stockage-iscsi (VLAN 20), ceph-public (VLAN 30), …
! Portee par des switches distincts, sans cable commun avec celle-ci :
! ni VLAN a declarer ici, ni trunk, ni spanning-tree partage.
```
Conséquence directe : la question du spanning-tree ne se pose que sur la fabric principale —
trois switches — et pas sur le stockage, dont les deux switches forment un domaine séparé.
Les `deny` de l'ACL couvrent en revanche **toutes** les fabrics, y compris celles hors
périmètre : la règle porte sur l'adresse de destination, pas sur le câblage. Si un chemin
s'ouvre un jour vers le stockage, il est déjà fermé.
## 2026-08-01 (suite 6) — nommage : `bifrost` aux frontières, `sleipnir` à la fabric
`bifrost-1` et `bifrost-2` sont réservés aux deux frontières OPNsense — Bifröst est le pont

View file

@ -59,11 +59,17 @@ def remarque(dialecte: str, texte: str) -> list[str]:
def section_underlay(underlay: dict | None) -> list[str]:
"""VLANs + SVIs (si passerelle) de la fabric physique. Vide si aucun underlay."""
reseaux = underlay_mod.reseaux(underlay)
"""VLANs + SVIs (si passerelle) de la fabric du routeur. Vide si aucun underlay.
Seule la fabric du routeur est configuree ici : les autres (stockage jumbo sur ses
propres switches, par exemple) ne partagent aucun cable avec celle-ci. Declarer
leurs VLAN sur ces switches serait faux, et les mettre dans leurs trunks aussi.
"""
fabric = underlay_mod.fabric_du_routeur(underlay)
reseaux = underlay_mod.reseaux_de_fabric(underlay, fabric)
if not reseaux:
return []
out = ["! ----- 0. Underlay (fabric physique, cluster-global, hors tenant) -----"]
out = [f"! ----- 0. Underlay — fabric '{fabric}' (cluster-global, hors tenant) -----"]
for r in reseaux:
out.append(f"vlan {r['vlan']}")
out.append(f" name {r['nom']}")
@ -78,6 +84,15 @@ def section_underlay(underlay: dict | None) -> list[str]:
out.append(" no shutdown")
for h in underlay_mod.hotes(underlay):
out.append(f"! {h.get('nom','?')} = {h.get('ip','?')} ({h.get('reseau','?')})")
# Ce que ce devis NE couvre PAS : les fabrics portees par d'autres switches.
for autre in underlay_mod.fabriques(underlay):
if autre == fabric:
continue
r_autres = underlay_mod.reseaux_de_fabric(underlay, autre)
out.append(f"! HORS PERIMETRE — fabric '{autre}' : "
+ ", ".join(f"{r['nom']} (VLAN {r['vlan']})" for r in r_autres))
out.append("! Portee par des switches distincts, sans cable commun avec celle-ci :")
out.append("! ni VLAN a declarer ici, ni trunk, ni spanning-tree partage.")
out.append("!")
return out
@ -92,7 +107,9 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str) -> list[str]:
r_nom = underlay_mod.routeur(underlay)
if not r_nom:
return []
mgmt = next((r for r in underlay_mod.reseaux(underlay) if r.get("passerelle")), None)
fabric = underlay_mod.fabric_du_routeur(underlay)
mgmt = next((r for r in underlay_mod.reseaux_de_fabric(underlay, fabric)
if r.get("passerelle")), None)
# UNIQUEMENT les hotes du reseau de management : les autres equipements declares
# (frontieres sur le lien de transit, par exemple) ne sont pas des switches et ne
# doivent recevoir aucune de ces lignes.
@ -115,7 +132,7 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str) -> list[str]:
# Le transit ne relie que le routeur a la frontiere : aucune trame ne le traverse
# ici. Le declarer serait un VLAN qui ne verra jamais de trafic.
transit = underlay_mod.reseau_transit(underlay)
for r in underlay_mod.reseaux(underlay):
for r in underlay_mod.reseaux_de_fabric(underlay, fabric):
if transit and r["vlan"] == transit["vlan"]:
continue
out += [f"vlan {r['vlan']}", f" name {r['nom']}"]
@ -300,6 +317,9 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
# 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)")
@ -310,7 +330,9 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
transit = underlay_mod.reseau_transit(underlay)
# Le VLAN de transit ne va PAS vers Proxmox : aucun hyperviseur n'a d'interface
# dessus. Il sort par le port de la frontiere (ci-dessous), et lui seul.
vlans_underlay = [str(r["vlan"]) for r in underlay_mod.reseaux(underlay)
vlans_underlay = [str(r["vlan"])
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 = [
str(vlan_de(n["index"], zone))

View file

@ -58,6 +58,34 @@ def hotes(underlay: dict | None) -> list[dict]:
return (underlay or {}).get("hotes", []) or []
FABRIC_DEFAUT = "principal"
def fabric_de(reseau: dict) -> str:
"""Fabric physique qui porte ce reseau. `principal` par defaut."""
return str(reseau.get("fabric") or FABRIC_DEFAUT)
def fabriques(underlay: dict | None) -> list[str]:
"""Les fabrics declarees, `principal` en tete."""
vues = {fabric_de(r) for r in reseaux(underlay)}
return [FABRIC_DEFAUT] * (FABRIC_DEFAUT in vues) + sorted(vues - {FABRIC_DEFAUT})
def reseaux_de_fabric(underlay: dict | None, fabric: str) -> list[dict]:
return [r for r in reseaux(underlay) if fabric_de(r) == fabric]
def fabric_du_routeur(underlay: dict | None) -> str:
"""Fabric du switch routeur : celle du reseau ou il est declare comme hote."""
r_nom = routeur(underlay)
par_nom = {r.get("nom"): r for r in reseaux(underlay)}
for h in hotes(underlay):
if h.get("nom") == r_nom and h.get("reseau") in par_nom:
return fabric_de(par_nom[h["reseau"]])
return FABRIC_DEFAUT
def routeur(underlay: dict | None) -> str | None:
"""Nom du switch qui porte les SVI, ou None si aucun n'est designe.

View file

@ -15,6 +15,11 @@
# Storage/Ceph restent en general L2 pur (pas de passerelle).
---
underlay:
# FABRICS PHYSIQUES. Chaque reseau appartient a une fabric (`principal` par defaut).
# Deux fabrics ne partagent aucun cable : le devis d'une fabric ne declare ni ne
# transporte les VLAN d'une autre, et leurs spanning-tree sont independants.
# Cas typique : le stockage jumbo (iSCSI, Ceph) sur ses propres switches 10G.
#
# Switch qui porte le routage : SVI de zone, ACL d'isolation, routes. Sans MLAG,
# UN SEUL switch route ; les autres restent en L2 pur et reçoivent la partie B du
# devis. Dupliquer les SVI sur plusieurs switches creerait autant de conflits
@ -29,16 +34,19 @@ underlay:
passerelle: 10.0.0.1 # SVI (retirer pour du L2 pur)
mtu: 1500
- nom: stockage-iscsi
fabric: stockage # switches dedies, hors fabric principale
description: iSCSI MPIO
vlan: 20
sous_reseau: 10.0.1.0/24
mtu: 9000 # jumbo
- nom: ceph-public
fabric: stockage # switches dedies, hors fabric principale
description: Clients <-> MON/OSD
vlan: 30
sous_reseau: 10.0.2.0/24
mtu: 9000
- nom: ceph-cluster
fabric: stockage # switches dedies, hors fabric principale
description: OSD <-> OSD (replication, backfill, recovery)
vlan: 31
sous_reseau: 10.0.3.0/24