spanning-tree : RSTP dérivé de la topologie déclarée (étoile)

Le devis ne disait rien du spanning-tree. Sur une fabric convergée à trois
switches, une boucle par brassage accidentel est une tempête de diffusion.

La topologie se déclare (`stp: {mode: rstp, topologie: etoile}`) et le devis
en tire la configuration. Le routeur est désigné pont racine : il est le
centre de l'étoile, tous les chemins passent déjà par lui, donc l'arbre
logique suit le câblage physique plutôt qu'une élection arbitraire. Les
switches d'accès reçoivent une priorité haute — jamais racine.

Ports terminaux déclarés en bord de réseau. BPDU guard délibérément NON
émis : un pont Linux dont le STP serait activé enverrait des BPDU et ferait
tomber le port côté hyperviseur. Le devis dit pourquoi et à quelle condition
l'ajouter.

En étoile aucun lien n'est redondant : RSTP est un filet, pas une nécessité.
Le devis le dit au lieu de laisser croire à une protection indispensable.

Validation : `mode` et `topologie` contrôlés, `stp` sans `routeur` refusé
(aucun pont racine désignable). Sans `stp`, la section signale l'absence de
protection au lieu de disparaître.

Réserve : la forme `binardat` du spanning-tree n'est pas vérifiée sur le
matériel, comme les `ip route`.

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:50:55 -04:00
parent 42cd269d29
commit 8f61721cb3
5 changed files with 125 additions and 4 deletions

View file

@ -1,5 +1,32 @@
# CHANGELOG — Set-OPS
## 2026-08-01 (suite 8) — spanning-tree dérivé de la topologie déclarée
### Ajouté — `underlay.stp` et les sections 6 / B4
Le devis ne disait rien du spanning-tree. Sur une fabric convergée à trois switches, une
boucle par brassage accidentel n'est pas discrète : c'est une tempête de diffusion.
La topologie se déclare (`mode: rstp`, `topologie: etoile`) et le devis en tire la
configuration. Le **routeur est désigné pont racine** — il est le centre de l'étoile, tous
les chemins passent déjà par lui, donc l'arbre logique suit le câblage physique au lieu de
sortir d'une élection arbitraire. Les switches d'accès reçoivent une priorité volontairement
haute : ils ne doivent jamais devenir racine.
Les ports terminaux (hyperviseurs, frontière) sont déclarés en bord de réseau. **BPDU guard
n'est délibérément pas émis** : un pont Linux dont le STP serait activé enverrait des BPDU et
ferait tomber le port côté hyperviseur. Le devis dit pourquoi, et à quelle condition
l'ajouter.
En étoile, aucun lien n'est redondant : RSTP est un filet, pas une nécessité — le devis le
dit plutôt que de laisser croire à une protection indispensable.
`make underlay` valide `mode` et `topologie`, et refuse un `stp` déclaré sans `routeur` :
sans lui, aucun pont racine ne peut être désigné. Sans `stp`, la section signale l'absence
de protection au lieu de disparaître.
Réserve consignée : la forme `binardat` des lignes de spanning-tree n'a pas été confrontée au
matériel, comme les `ip route`.
## 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

View file

@ -223,7 +223,10 @@ ansible-vault edit instance/inventories/principal/group_vars/all/vault.yml
`underlay` au registre plutôt qu'une exception manuelle dans l'ACL.
- **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.
quel que soit le dialecte. Même réserve pour les lignes de spanning-tree, dont la forme
`binardat` n'a pas été confrontée au matériel. À vérifier sur un `show running-config`.
- **Les ports physiques restent à nommer**`<PORT-VERS-PROXMOX>`, `<PORT-VERS-FRONTIERE>`
et `<PORT-TRUNK>` ; rien dans le modèle ne peut les deviner.
- **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

@ -102,7 +102,58 @@ def section_underlay(underlay: dict | None) -> list[str]:
return out
def partie_acces(underlay: dict | None, tenants: list, vlans: str) -> list[str]:
def _stp_lignes(dialecte: str, mode: str, priorite: int) -> list[str]:
"""Mode + priorite de pont, dans la forme du dialecte.
Cisco IOS parle `rapid-pvst` (RSTP par VLAN) ; les plateformes generiques parlent
`rstp` et une priorite globale. NON VERIFIE sur Binardat : a confronter au
`show running-config` du materiel, comme les `ip route`.
"""
if dialecte == "cisco":
return [f"spanning-tree mode {'rapid-pvst' if mode == 'rstp' else mode}",
f"spanning-tree vlan 1-4094 priority {priorite}"]
return [f"spanning-tree mode {mode}", f"spanning-tree priority {priorite}"]
def section_stp(underlay: dict | None, dialecte: str, ports_bord: list[str]) -> list[str]:
"""Spanning-tree du switch routeur : il est la racine, par construction.
En etoile, tous les chemins passent deja par le centre : en faire le pont racine
aligne l'arbre sur la topologie physique au lieu de laisser une election arbitraire
le decider. Aucun lien redondant n'existe, donc aucune boucle — RSTP reste actif
comme filet contre un brassage accidentel, qui provoquerait sinon une tempete de
diffusion sur la fabric convergee.
"""
s = underlay_mod.stp(underlay)
if not s:
return ["! ----- 6. Spanning-tree -----",
"! Non declare (cle `stp` dans underlay.yml). Sur une fabric a plusieurs",
"! switches, un brassage accidentel peut alors boucler sans protection.", "!"]
mode, topo = str(s.get("mode", "rstp")), str(s.get("topologie", ""))
r_nom = underlay_mod.routeur(underlay)
out = [f"! ----- 6. Spanning-tree ({mode.upper()}, topologie {topo}) -----",
f"! {r_nom} est le CENTRE de l'etoile : tous les chemins passent par lui.",
"! On en fait le pont racine plutot que de laisser une election arbitraire",
"! le decider — l'arbre logique suit alors le cablage physique.",
"! Aucun lien redondant en etoile : RSTP est un filet, pas une necessite."]
out += _stp_lignes(dialecte, mode, underlay_mod.STP_PRIORITE_RACINE)
if ports_bord:
out += ["!",
"! Ports terminaux (hyperviseurs, frontiere) : rien derriere eux ne",
"! participe a l'arbre. Les declarer en bord evite d'attendre la",
"! convergence a chaque montee de lien."]
for p in ports_bord:
out += [f"interface {p}",
" spanning-tree portfast trunk"]
out += ["! BPDU guard non emis volontairement : un pont Linux dont le STP serait",
"! active enverrait des BPDU et ferait tomber le port cote hyperviseur.",
"! Ne l'ajouter qu'apres avoir verifie que ces ponts n'en emettent pas."]
out.append("!")
return out
def partie_acces(underlay: dict | None, tenants: list, vlans: str,
dialecte_b: str = DIALECTE_DEFAUT) -> 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
@ -164,7 +215,16 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str) -> list[str]:
"interface <PORT-TRUNK>",
" switchport mode trunk",
f" switchport trunk allowed vlan add {vlans}",
"!", "end", "write memory"]
"!"]
s = underlay_mod.stp(underlay)
if s:
mode = str(s.get("mode", "rstp"))
out += [f"! ----- B4. Spanning-tree ({mode.upper()}) -----",
f"! Priorite VOLONTAIREMENT haute : ces switches ne doivent jamais devenir",
f"! racine. Le centre de l'etoile, {r_nom}, la garde en toutes circonstances."]
out += _stp_lignes(dialecte_b, mode, underlay_mod.STP_PRIORITE_ACCES)
out += ["!"]
out += ["end", "write memory"]
return out
@ -350,9 +410,10 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
out.append(f" switchport trunk allowed vlan add {vlans}")
out += ["!"] + section_frontiere(transit)
out += ["!"] + section_routes(underlay)
out += section_stp(underlay, dialecte, ["<PORT-VERS-PROXMOX>", "<PORT-VERS-FRONTIERE>"])
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))
out += partie_acces(underlay, tenants, ",".join(vlans_underlay + vlans_tenants), dialecte)
return "\n".join(out)

View file

@ -60,6 +60,18 @@ def hotes(underlay: dict | None) -> list[dict]:
FABRIC_DEFAUT = "principal"
# Priorites de pont : le routeur est la racine (basse), les switches d'acces ne
# doivent jamais l'etre. 4096 laisse 0 disponible pour une reprise d'urgence.
STP_PRIORITE_RACINE = 4096
STP_PRIORITE_ACCES = 32768
STP_MODES = ("rstp", "mstp", "pvst")
STP_TOPOLOGIES = ("etoile", "anneau", "maille")
def stp(underlay: dict | None) -> dict:
"""Parametres de spanning-tree de la fabric principale. {} si non declares."""
return (underlay or {}).get("stp") or {}
def fabric_de(reseau: dict) -> str:
"""Fabric physique qui porte ce reseau. `principal` par defaut."""
@ -182,6 +194,16 @@ def valider(underlay: dict | None,
erreurs.append(f"reseau '{nom}': passerelle_sortie '{r['passerelle_sortie']}' invalide")
vus_sous.append((net, nom))
s = stp(underlay)
if s:
if str(s.get("mode", "")) not in STP_MODES:
erreurs.append(f"stp.mode '{s.get('mode')}' inconnu (attendu : {', '.join(STP_MODES)})")
if str(s.get("topologie", "")) not in STP_TOPOLOGIES:
erreurs.append(f"stp.topologie '{s.get('topologie')}' inconnue "
f"(attendu : {', '.join(STP_TOPOLOGIES)})")
if not routeur(underlay):
erreurs.append("stp declare sans `routeur` : impossible de designer le pont racine")
# Une seule sortie possible : deux transits donneraient deux routes par defaut.
transits = [r.get("nom", "?") for r in reseaux(underlay) if r.get("passerelle_sortie")]
if len(transits) > 1:

View file

@ -26,6 +26,14 @@ underlay:
# d'adresses qu'il y a de zones. Doit nommer un hote declare dans `hotes:`.
routeur: switch-01
# Spanning-tree de la fabric principale. `topologie` documente le cablage :
# `etoile` = rayons depuis le routeur (aucun lien redondant, donc aucune boucle),
# `anneau`/`maille` = liens redondants, RSTP devient indispensable. Le routeur est
# toujours designe pont racine : l'arbre logique suit alors le cablage physique.
stp:
mode: rstp # rstp | mstp | pvst
topologie: etoile # etoile | anneau | maille
reseaux:
- nom: management
description: Switches, mgmt Proxmox, OOB/IPMI