devis switch : un seul routeur, les autres en L2 pur

Sans MLAG, le routage est porté par un unique switch (`underlay.routeur`).
Le devis émettait un jeu unique de SVI sans dire à quel switch il
s'adressait : appliqué sur les trois, il aurait créé autant de conflits
d'adresses qu'il y a de zones.

Il se scinde désormais en deux :
- partie A (switch routeur) : VLANs, SVI, ACL, trunks, routes ;
- partie B (switches d'accès, L2 pur) : mêmes VLANs pour commuter les trames
  étiquetées, une adresse de gestion par switch tirée de `underlay.hotes`
  avec `ip default-gateway` vers le routeur, et les trunks. Aucun SVI de
  zone, aucune ACL, aucune route.

Gardes : `make underlay` refuse un `routeur` inconnu des hôtes déclarés ; la
partie B avertit qu'un seul de ses blocs de gestion va sur chaque machine.
Sans routeur désigné, l'en-tête signale le risque de duplication au lieu de
laisser croire que le devis est applicable partout.

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:04:38 -04:00
parent 7b2ba07ffc
commit 2d2c5c69fa
5 changed files with 130 additions and 5 deletions

View file

@ -1,5 +1,28 @@
# CHANGELOG — Set-OPS
## 2026-08-01 (suite 4) — un seul switch route, les autres en L2 pur
### Décidé — `underlay.routeur`
Sans MLAG, le routage est porté par un **unique** switch (`bifrost-01` chez Chezlepro) ; les
autres restent en L2 pur. Le devis émettait jusqu'ici un jeu unique de SVI sans dire à quel
switch il s'adressait : appliqué sur les trois, il aurait créé autant de conflits d'adresses
qu'il y a de zones.
### Ajouté — le devis se scinde en deux parties
- **Partie A — switch routeur** : VLANs, SVI, ACL d'isolation, trunks, routes. Va sur lui
seul, et l'en-tête le nomme.
- **Partie B — switches d'accès (L2 pur)** : mêmes VLANs pour commuter les trames étiquetées,
une adresse de gestion par switch (tirée de `underlay.hotes`) avec `ip default-gateway`
vers le routeur, et les trunks. Aucun SVI de zone, aucune ACL, aucune route.
Deux gardes : `make underlay` refuse un `routeur` qui ne nomme aucun hôte déclaré ; et la
partie B avertit qu'un seul de ses blocs de gestion va sur chaque machine — les coller tous
écraserait l'adresse. Sans `routeur` désigné, l'en-tête signale explicitement le risque de
duplication au lieu de laisser croire que le devis est applicable partout.
Reste ouvert et consigné : la syntaxe des `ip route` n'est pas dialecte-consciente,
contrairement aux ACL.
## 2026-08-01 (suite 3) — les tenants n'atteignent plus l'underlay
### Corrigé — ACL de switch : `deny` vers la fabric physique

View file

@ -12,13 +12,21 @@ Trois pare-feux coexistent dans le modèle, et chacun voit une chose différente
| Où | Quoi | Généré par |
|---|---|---|
| **Sur chaque hôte** | nftables `policy drop`, moindre privilège par IP source | `make flux` (`resoudre_flux.py`) |
| **Sur les switches L3** | ACL d'isolation inter-tenant, appliquées `in` sur les SVI | `make devis-reseau` |
| **Sur le switch routeur** | ACL d'isolation inter-tenant, appliquées `in` sur les SVI | `make devis-reseau` |
| **À la frontière (OPNsense)** | ce qui **entre et sort** de l'écosystème | `make devis-opnsense` |
**Décision : OPNsense est une frontière nord/sud, pas la passerelle des zones.** Les SVI
restent sur les switches L3, qui continuent d'assurer le routage inter-zone au débit ligne.
OPNsense ne porte **aucun SVI de tenant** et ne voit pas le trafic est-ouest.
**Décision (2026-08-01) : un seul switch route.** Sans MLAG, le routage est porté par un
unique switch, désigné par `underlay.routeur` ; les autres restent en **L2 pur** et n'ont
d'adresse IP que pour leur propre gestion. `make devis-reseau` émet en conséquence deux
configurations distinctes — *partie A* pour le routeur, *partie B* pour les switches d'accès —
plutôt qu'un devis unique que l'on serait tenté d'appliquer partout, ce qui dupliquerait les
SVI et créerait autant de conflits d'adresses qu'il y a de zones. `make underlay` refuse un
`routeur` qui ne nomme aucun hôte déclaré.
Conséquence assumée : l'isolation inter-zone repose sur les ACL de switch, moins expressives
que le registre des flux. Le zéro-confiance est-ouest reste porté par les nftables d'hôte et
le TLS mutuel — pas par le pare-feu de bordure.
@ -192,10 +200,9 @@ ansible-vault edit instance/inventories/principal/group_vars/all/vault.yml
interdit en bloc aux tenants. Si un besoin légitime apparaît (superviser l'hyperviseur
depuis `obs-01`, par exemple), il ne peut pas être *déclaré* : il faudrait ajouter un pair
`underlay` au registre plutôt qu'une exception manuelle dans l'ACL.
- **Trois switches, un seul devis**`devis_reseau.py` émet un jeu unique de SVI sans
désigner le switch qui les porte. Sans MLAG, il reste à trancher entre « un seul routeur,
les deux autres en L2 pur » et « SVI répliqués en VRRP ». Appliquer le devis tel quel sur
les trois créerait des conflits d'adresses.
- **La syntaxe des routes n'est pas dialecte-consciente** — les ACL le sont (masque normal
en `binardat`, wildcard en `cisco`), les `ip route` de la section 5 sortent en forme Cisco
quel que soit le dialecte. À vérifier sur le matériel réel.
- **La sortie générale** (mises à jour apt, ACME) n'est pas déclarée dans le registre : les
hôtes sortent aujourd'hui parce que la politique `output` des nftables est `accept` et que
l'ACL de switch se termine par `permit ip <tenant> any`. À trancher : la déclarer

View file

@ -82,6 +82,61 @@ def section_underlay(underlay: dict | None) -> list[str]:
return out
def partie_acces(underlay: dict | None, tenants: list, vlans: str) -> list[str]:
"""Devis des switches d'ACCES : L2 pur, aucun SVI de zone, aucune ACL, aucune route.
Sans MLAG, un seul switch route (cf. `underlay.routeur`). Les autres commutent les
trames etiquetees et n'ont d'adresse IP que pour leur propre gestion ils joignent
le reste par la passerelle de management, pas par un SVI a eux.
"""
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)
autres = [h for h in underlay_mod.hotes(underlay) if h.get("nom") != r_nom]
if not autres:
return []
out = [
"",
"! ============================================================",
f"! PARTIE B — SWITCHES D'ACCES (L2 pur) : {', '.join(h['nom'] for h in autres)}",
f"! Le routage est porte par {r_nom} SEUL (underlay.routeur). Ces switches",
"! commutent les trames etiquetees et ne portent AUCUN SVI de zone, AUCUNE",
"! ACL, AUCUNE route : dupliquer les SVI creerait autant de conflits d'IP.",
"! ============================================================",
"configure terminal",
"!",
"! ----- B1. Memes VLANs (pour commuter les trames etiquetees) -----",
]
for r in underlay_mod.reseaux(underlay):
out += [f"vlan {r['vlan']}", f" name {r['nom']}"]
for nom, pfx, n in tenants:
for zone in sorted(n["categories"]):
out += [f"vlan {vlan_de(n['index'], zone)}",
f" name {pfx}{n['index']}-{n['categories'][zone]['libelle']}"]
out += ["!", "! ----- B2. Gestion (seule adresse IP du switch) -----",
"! /!\\ UN SEUL de ces blocs par switch : celui qui porte son nom.",
"! Les coller tous les deux sur la meme machine ecraserait son adresse."]
if mgmt:
net = ipaddress.ip_network(mgmt["sous_reseau"], strict=False)
for h in autres:
out += [f"! --- {h['nom']} ---",
f"interface Vlan{mgmt['vlan']}",
f" description gestion {h['nom']}",
f" ip address {h['ip']} {net.netmask}",
" no shutdown",
f"ip default-gateway {mgmt['passerelle']} ! le routeur {r_nom}"]
else:
out.append("! Aucun reseau underlay avec passerelle : gestion a definir a la main.")
out += ["!", "! ----- B3. Trunks -----",
"! Vers les hyperviseurs ET vers le routeur : memes VLANs etiquetes.",
"interface <PORT-TRUNK>",
" switchport mode trunk",
f" switchport trunk allowed vlan add {vlans}",
"!", "end", "write memory"]
return out
def section_frontiere(transit: dict | None) -> list[str]:
"""Port du switch vers le pare-feu de bordure. Porte le VLAN de transit, et lui seul.
@ -174,6 +229,7 @@ def decouvrir() -> list[tuple[str, str, dict]]:
def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -> str:
dialecte = dialecte or DIALECTE_DEFAUT
underlay = underlay_mod.charger()
r_nom = underlay_mod.routeur(underlay)
out = ["configure terminal", f"! dialecte CLI : {dialecte}", "!"]
out += [
"! ============================================================",
@ -181,6 +237,22 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
"! Genere par scripts/devis_reseau.py depuis les nomenclatures.",
"! VLAN = 1000 + index*10 + zone (unique globalement sur le trunk).",
"! Underlay (VLAN < 1000) = fabric physique, cluster-global.",
]
if r_nom:
out += [
"!",
f"! PARTIE A — SWITCH ROUTEUR : {r_nom} (underlay.routeur)",
"! Sans MLAG, UN SEUL switch route. Cette partie ne va QUE sur lui.",
"! Les autres recoivent la partie B (L2 pur), plus bas.",
]
else:
out += [
"!",
"! /!\\ AUCUN ROUTEUR DESIGNE (cle `routeur` dans underlay.yml).",
"! Applique tel quel sur plusieurs switches, ce devis duplique les SVI",
"! et cree autant de conflits d'adresses. Designer le switch routeur.",
]
out += [
"! ============================================================",
"!",
]
@ -243,6 +315,8 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
out += ["!"] + section_frontiere(transit)
out += ["!"] + section_routes(underlay)
out += ["end", "write memory"]
# Les switches d'acces : memes VLANs, aucun SVI de zone, aucune ACL, aucune route.
out += partie_acces(underlay, tenants, ",".join(vlans_underlay + vlans_tenants))
return "\n".join(out)

View file

@ -58,6 +58,15 @@ def hotes(underlay: dict | None) -> list[dict]:
return (underlay or {}).get("hotes", []) or []
def routeur(underlay: dict | None) -> str | None:
"""Nom du switch qui porte les SVI, ou None si aucun n'est designe.
Sans MLAG, UN SEUL switch route : les autres restent en L2 pur. Appliquer le
meme devis sur les trois creerait autant de conflits d'adresses que de SVI.
"""
return (underlay or {}).get("routeur") or None
def reseau_transit(underlay: dict | None) -> dict | None:
"""Le reseau de TRANSIT vers la frontiere nord/sud, s'il est declare.
@ -151,6 +160,12 @@ def valider(underlay: dict | None,
erreurs.append(f"un seul reseau de transit autorise (passerelle_sortie), "
f"trouves : {', '.join(transits)}")
# Le routeur designe doit etre un hote declare : sinon le devis ne saurait pas
# quelle adresse de gestion donner aux switches d'acces.
r_nom = routeur(underlay)
if r_nom and r_nom not in {h.get("nom") for h in hotes(underlay)}:
erreurs.append(f"routeur '{r_nom}': aucun hote de ce nom n'est declare")
par_nom = {r.get("nom"): r for r in reseaux(underlay)}
for h in hotes(underlay):
hn = h.get("nom", "?")

View file

@ -15,6 +15,12 @@
# Storage/Ceph restent en general L2 pur (pas de passerelle).
---
underlay:
# 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
# d'adresses qu'il y a de zones. Doit nommer un hote declare dans `hotes:`.
routeur: switch-01
reseaux:
- nom: management
description: Switches, mgmt Proxmox, OOB/IPMI