diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c24253..4a6bfab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/frontiere-opnsense.md b/docs/frontiere-opnsense.md index fca7275..1d320d7 100644 --- a/docs/frontiere-opnsense.md +++ b/docs/frontiere-opnsense.md @@ -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** — ``, `` + et `` ; 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 any`. À trancher : la déclarer diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index 88f2cd6..6a4a3bd 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -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 ", " 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, ["", ""]) 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) diff --git a/scripts/underlay.py b/scripts/underlay.py index 21af8bb..d3c6f78 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -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: diff --git a/underlay.yml.example b/underlay.yml.example index a38e9b8..0f87027 100644 --- a/underlay.yml.example +++ b/underlay.yml.example @@ -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