Les deux commutateurs deviennent bifrost-3 et bifrost-4, avec 10.0.0.3 et 10.0.0.4. Tout l'ensemble de bordure porte un seul nom, et son numéro est son adresse : bifrost-1 = .1, bifrost-4 = .4. Plus de table de correspondance. D-12 disait « bifrost aux frontières, sleipnir à la fabric » — le nom portait le type de la machine. C'est le champ `role` qui le fait, et lui seul pilote le devis : aucune logique ne dépendait du nom, seulement des données et un commentaire. Le devis a suivi seul, jusqu'aux marqueurs de ports. Inconvénient assumé : bifrost-3 ne dit plus « commutateur », il faut lire `role`. La partie B du devis s'en charge à l'affichage. 30 preuves OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
768 lines
40 KiB
Python
768 lines
40 KiB
Python
#!/usr/bin/env python3
|
||
"""Devis de configuration switch (VLANs + SVIs + ACLs) du réseau convergé.
|
||
|
||
DÉRIVÉ des nomenclatures `ip-miroir` de toutes les instances fédérées (dossiers
|
||
frères `../*/plan/nomenclature.yml` ayant `vmid_schema: ip-miroir` + un `index`).
|
||
Rien n'est saisi à la main : le devis reste synchrone avec le plan.
|
||
|
||
VLAN = 1000 + index×10 + zone (4 chiffres, unique globalement sur le trunk convergé).
|
||
Sortie : config Cisco IOS-like (à adapter à la plateforme réelle : NX-OS/IOS-XE, HSRP, VRF).
|
||
"""
|
||
from __future__ import annotations
|
||
import argparse
|
||
import ipaddress
|
||
import os
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
# Dialecte de CLI du commutateur. Cisco exige un masque INVERSE (wildcard 0.0.255.255)
|
||
# dans les ACL ; Binardat exige un masque NORMAL (255.255.0.0). Le SVI (ip address) prend
|
||
# un masque normal sur les deux. Reglable via --dialecte ou SETOPS_DIALECTE.
|
||
# Liste des dialectes : source unique dans `underlay` (propriete du materiel).
|
||
DIALECTE_SECOURS = "cisco"
|
||
|
||
RACINE = Path(__file__).resolve().parent.parent
|
||
DOSSIER_INSTANCES = RACINE.parent
|
||
|
||
# Adressage et VLAN : derives du seul index, source unique dans inventory_rules.
|
||
sys.path.insert(0, str(RACINE / "scripts"))
|
||
from inventory_rules import passerelle_de, supernet_de, vlan_de # noqa: E402
|
||
import underlay as underlay_mod # noqa: E402 (fabric physique, cluster-global, hors tenant)
|
||
# Reseaux d'administration : SOURCE UNIQUE partagee avec les nftables d'hote et la
|
||
# frontiere (intrant `nftables_admin_ssh`). On reutilise le resolveur, pas une copie.
|
||
from resoudre_flux import _sources_admin_ssh # noqa: E402
|
||
|
||
DIALECTES = underlay_mod.DIALECTES
|
||
|
||
|
||
def dialecte_effectif(explicite: str | None = None) -> str:
|
||
"""Drapeau > variable d'environnement > intrant de fabric > secours.
|
||
|
||
L'intrant `underlay.dialecte` est la valeur DECLAREE (reglable depuis le panneau) ;
|
||
l'environnement reste un surcharge ponctuelle pour un essai.
|
||
"""
|
||
return (explicite or os.environ.get("SETOPS_DIALECTE")
|
||
or underlay_mod.dialecte(underlay_mod.charger()) or DIALECTE_SECOURS)
|
||
|
||
|
||
def masque(cidr: int) -> str:
|
||
return str(ipaddress.IPv4Network(f"0.0.0.0/{cidr}").netmask)
|
||
|
||
|
||
def reseau_wildcard(supernet: str) -> tuple[str, str]:
|
||
reseau = ipaddress.IPv4Network(supernet, strict=False)
|
||
return str(reseau.network_address), str(reseau.hostmask)
|
||
|
||
|
||
def masque_acl(supernet: str, dialecte: str) -> tuple[str, str]:
|
||
"""Reseau + masque au format ACL du dialecte : wildcard (cisco) ou normal (binardat)."""
|
||
reseau = ipaddress.IPv4Network(supernet, strict=False)
|
||
if dialecte == "binardat":
|
||
return str(reseau.network_address), str(reseau.netmask)
|
||
return str(reseau.network_address), str(reseau.hostmask)
|
||
|
||
|
||
def remarque(dialecte: str, texte: str) -> list[str]:
|
||
"""Commentaire d'ACL selon le dialecte. Binardat n'a pas de 'remark' : on l'omet."""
|
||
return [] if dialecte == "binardat" else [f" remark {texte}"]
|
||
|
||
|
||
def _bloc_gestion(underlay: dict | None, nom_switch: str | None) -> list[str]:
|
||
"""Interface de gestion d'un switch L2 : son adresse, et sa sortie par defaut.
|
||
|
||
La sortie est la `passerelle` du reseau de management — qui appartient desormais a
|
||
la frontiere, pas a un switch. Le commentaire NOMME son porteur : ecrire « le
|
||
routeur » serait faux depuis qu'aucun switch ne route.
|
||
"""
|
||
if not nom_switch:
|
||
return []
|
||
h = next((x for x in underlay_mod.hotes(underlay)
|
||
if x.get("nom") == nom_switch and x.get("role") == "switch"), None)
|
||
if not h:
|
||
return []
|
||
r = next((x for x in underlay_mod.reseaux(underlay) if x.get("nom") == h.get("reseau")), None)
|
||
if not r or not r.get("sous_reseau"):
|
||
return []
|
||
net = ipaddress.ip_network(r["sous_reseau"], strict=False)
|
||
out = [f"interface Vlan{r['vlan']}", f" description gestion {nom_switch}",
|
||
f" ip address {h['ip']} {net.netmask}", " no shutdown"]
|
||
gw = r.get("passerelle")
|
||
if gw:
|
||
porteur = next((x for x in underlay_mod.hotes(underlay)
|
||
if x.get("reseau") == r.get("nom") and str(x.get("ip")) == str(gw)), None)
|
||
qui = f"{porteur['nom']} ({porteur.get('role')})" if porteur else "porteur inconnu"
|
||
out.append(f"ip default-gateway {gw} ! {qui}")
|
||
return out
|
||
|
||
|
||
def section_underlay(underlay: dict | None) -> list[str]:
|
||
"""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 = [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']}")
|
||
if r.get("mtu") and int(r["mtu"]) > 1500:
|
||
out.append(f" ! jumbo MTU {r['mtu']} — a appliquer sur les ports + bridges Proxmox")
|
||
# Un SVI n'est emis que si la passerelle du reseau appartient a un SWITCH. Depuis
|
||
# que la frontiere est le seul equipement L3 (2026-08-04), la passerelle est
|
||
# ailleurs : emettre un SVI creerait une seconde adresse sur le meme sous-reseau,
|
||
# et un second routeur la ou on a decide qu'il n'y en aurait qu'un.
|
||
porteurs = {(h.get("reseau"), str(h.get("ip"))): h for h in underlay_mod.hotes(underlay)}
|
||
svi = 0
|
||
for r in reseaux:
|
||
gw = r.get("passerelle")
|
||
if not gw:
|
||
continue
|
||
h = porteurs.get((r.get("nom"), str(gw)))
|
||
if not h or h.get("role") != "switch":
|
||
out.append(f"! VLAN {r['vlan']} : passerelle {gw} portee par "
|
||
f"{h.get('nom') if h else '?'} ({h.get('role') if h else 'inconnu'}) "
|
||
f"— aucun SVI ici, ce switch ne route pas.")
|
||
continue
|
||
net = ipaddress.ip_network(r["sous_reseau"], strict=False)
|
||
out += [f"interface Vlan{r['vlan']}", f" description underlay {r['nom']}",
|
||
f" ip address {gw} {net.netmask}", " no shutdown"]
|
||
svi += 1
|
||
if not svi:
|
||
# Ce switch ne route pas — mais il faut quand meme pouvoir l'administrer.
|
||
# Tant qu'il portait le SVI, son adresse de gestion ETAIT la passerelle et
|
||
# sortait plus haut. Sans SVI, elle n'etait plus emise nulle part : le devis
|
||
# aurait laisse le switch de tete sans adresse, visible seulement en commentaire.
|
||
out += _bloc_gestion(underlay, underlay_mod.routeur(underlay))
|
||
# Roster : seuls les hotes de CETTE fabric. Un equipement d'une autre fabric
|
||
# (switches de stockage, par exemple) n'a rien a faire dans ce devis, meme en
|
||
# commentaire — c'est une configuration qu'on applique, pas un inventaire.
|
||
noms_reseaux = {r["nom"] for r in reseaux}
|
||
for h in underlay_mod.hotes(underlay):
|
||
if h.get("reseau") in noms_reseaux:
|
||
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
|
||
|
||
|
||
def port_vers(nom_hote: str) -> str:
|
||
"""Marqueur de port physique vers un equipement nomme."""
|
||
return f"<PORT-VERS-{nom_hote.upper()}>"
|
||
|
||
|
||
def hote_nomme(underlay: dict | None, nom: str | None) -> dict:
|
||
"""L'hote underlay portant ce nom, ou {}."""
|
||
return next((h for h in underlay_mod.hotes(underlay) if h.get("nom") == nom), {})
|
||
|
||
|
||
def ports_ou_marqueur(hote: dict, clef: str, marqueur: str) -> list[str]:
|
||
"""Ports declares pour cette nature de lien, sinon le marqueur a completer."""
|
||
valeurs = underlay_mod.ports_de(hote).get(clef) or []
|
||
if isinstance(valeurs, str):
|
||
valeurs = [valeurs]
|
||
return [str(v) for v in valeurs] or [marqueur]
|
||
|
||
|
||
def bloc_trunk(port: str, vlans: str, description: str = "", bord: bool = False) -> list[str]:
|
||
"""Un bloc d'interface trunk complet — l'interface se lit d'un seul tenant."""
|
||
out = [f"interface {port}"]
|
||
if description:
|
||
out.append(f" description {description}")
|
||
# `allowed vlan <liste>` DEFINIT la liste ; `allowed vlan add <liste>` l'AJOUTE a
|
||
# l'existante. Un port trunk neuf autorise tous les VLAN : `add` n'y retrancherait
|
||
# rien et le devis donnerait l'illusion de restreindre. La forme sans mot-cle est
|
||
# aussi atomique — `none` puis `add` couperait le trunk entre les deux commandes,
|
||
# ce qui suffit a perdre la session si on l'applique sur le port de gestion.
|
||
# Formes confirmees par `switchport trunk allowed vlan ?` (Binardat, 2026-08-02).
|
||
out += [" switchport mode trunk", f" switchport trunk allowed vlan {vlans}"]
|
||
if bord:
|
||
out.append(" spanning-tree portfast trunk")
|
||
return out
|
||
|
||
|
||
def section_rayons(underlay: dict | None, vlans: str) -> list[str]:
|
||
"""Les rayons de l'etoile : un trunk du routeur vers chaque switch d'acces.
|
||
|
||
Ils sont emis A PART du trunk vers les hyperviseurs parce qu'ils n'ont pas le meme
|
||
statut vis-a-vis du spanning-tree : un rayon porte les BPDU de la fabric et ne doit
|
||
JAMAIS etre declare en bord de reseau, sinon la protection tombe la ou elle sert.
|
||
"""
|
||
acces = underlay_mod.switches_acces(underlay)
|
||
if not acces:
|
||
return []
|
||
out = ["! ----- 4c. Rayons de l'etoile (vers les switches d'acces) -----",
|
||
"! /!\\ CES PORTS NE SONT PAS DES PORTS DE BORD. Ils portent les BPDU de la",
|
||
"! fabric : y appliquer `portfast` desactiverait la protection anti-boucle",
|
||
"! exactement la ou elle sert. La section 6 ne les declare volontairement pas."]
|
||
rayons = underlay_mod.ports_de(hote_nomme(underlay, underlay_mod.routeur(underlay))).get("rayons") or {}
|
||
for h in acces:
|
||
port = str(rayons.get(h["nom"]) or port_vers(h["nom"]))
|
||
out += bloc_trunk(port, vlans, f"rayon vers {h['nom']}")
|
||
return out
|
||
|
||
|
||
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`/`mstp` et une priorite globale.
|
||
|
||
Formes VERIFIEES sur Binardat (`spanning-tree ?` en mode configuration, 2026-08-02) :
|
||
`spanning-tree` seul active le protocole, `mode` et `priority` s'acceptent au niveau
|
||
GLOBAL — la priorite n'a pas besoin d'etre portee par une instance, meme en MSTP.
|
||
"""
|
||
if dialecte == "cisco":
|
||
return [f"spanning-tree mode {'rapid-pvst' if mode == 'rstp' else mode}",
|
||
f"spanning-tree vlan 1-4094 priority {priorite}"]
|
||
# `spanning-tree` seul garantit l'etat actif : sur un boitier ou il aurait ete
|
||
# desactive, les deux lignes suivantes ne suffiraient pas. Sans effet s'il l'est deja.
|
||
return ["spanning-tree",
|
||
f"spanning-tree mode {mode}",
|
||
f"spanning-tree priority {priorite}"]
|
||
|
||
|
||
def section_stp(underlay: dict | None, dialecte: 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.",
|
||
f"! Aucun lien redondant en etoile : {mode.upper()} est un filet, pas une necessite."]
|
||
out += _stp_lignes(dialecte, mode, underlay_mod.STP_PRIORITE_RACINE)
|
||
out += ["!",
|
||
"! Les ports de bord sont declares AVEC leur interface (sections 4 et 4b),",
|
||
"! pas ici : chaque interface se lit d'un seul tenant. Les rayons de la",
|
||
"! section 4c n'en sont volontairement pas.",
|
||
"! 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_SECOURS,
|
||
vlans_proxmox: str | None = None) -> 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 []
|
||
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)
|
||
# SOURCE UNIQUE : `switches_acces()` decide qui est un switch d'acces — reseau de
|
||
# management ET role `switch`. Une copie locale de ce filtre avait laisse les
|
||
# hyperviseurs recevoir une configuration de commutateur.
|
||
autres = underlay_mod.switches_acces(underlay)
|
||
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) -----",
|
||
]
|
||
# Le lien de frontiere N'EST PLUS reserve au routeur : depuis que les noeuds de
|
||
# sortie EVPN y ont une patte, un hyperviseur branche sur un switch d'acces doit
|
||
# pouvoir l'atteindre. Le sauter ici ferait tomber son trafic sortant en silence.
|
||
for r in underlay_mod.reseaux_de_fabric(underlay, fabric):
|
||
out += [f"vlan {r['vlan']}", f" name {r['nom']}"]
|
||
if underlay_mod.routage_tenants(underlay) == "sdn":
|
||
out.append("! Aucun VLAN de tenant : ils vivent dans le SDN, pas sur le fil.")
|
||
else:
|
||
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']}"]
|
||
bord = bool(underlay_mod.stp(underlay))
|
||
out += ["!", "! ----- B2. Configuration par switch -----",
|
||
"! /!\\ UN SEUL de ces blocs par machine : celui qui porte son nom. Adresse de",
|
||
"! gestion ET ports different d'un switch a l'autre — tout coller ecraserait."]
|
||
net = ipaddress.ip_network(mgmt["sous_reseau"], strict=False) if mgmt else None
|
||
for h in autres:
|
||
out += ["!", f"! --- {h['nom']} " + "-" * max(0, 48 - len(h["nom"])), "!"]
|
||
if net:
|
||
out += _bloc_gestion(underlay, h["nom"])
|
||
else:
|
||
out.append("! Aucun reseau underlay avec passerelle : gestion a definir a la main.")
|
||
out += ["! Montante vers le routeur — PAS un port de bord : elle porte les BPDU."]
|
||
montante = str(underlay_mod.ports_de(h).get("montante") or port_vers(r_nom))
|
||
out += bloc_trunk(montante, vlans, f"montante vers {r_nom}")
|
||
out += ["! Ports terminaux vers les hyperviseurs : ce que leurs hotes declarent,",
|
||
"! pas tout l'underlay — la gestion des equipements n'a rien a y faire."]
|
||
for port in ports_ou_marqueur(h, "hyperviseurs", "<PORT-VERS-PROXMOX>"):
|
||
out += bloc_trunk(port, vlans_proxmox or vlans, bord=bord)
|
||
s = underlay_mod.stp(underlay)
|
||
if s:
|
||
mode = str(s.get("mode", "rstp"))
|
||
out += ["!", f"! ----- B3. Spanning-tree ({mode.upper()}) — sur les deux -----",
|
||
"! 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
|
||
|
||
|
||
def reseaux_par_via(underlay: dict | None, role: str) -> dict[str, list[dict]]:
|
||
"""{interface de l'hote: [reseaux]} — un port de commutateur par interface.
|
||
|
||
Un hyperviseur n'a pas une seule patte : son administration arrive par `vmbr0`
|
||
(carte dediee), son transport et sa sortie par `bond3`. Les grouper toutes sur un
|
||
seul port remettrait le VLAN de gestion sur le trunk du transport — precisement le
|
||
domaine de diffusion qu'on vient d'en sortir.
|
||
|
||
Les hotes sans `via` tombent dans un groupe unique : rien ne change pour eux.
|
||
"""
|
||
par_via: dict[str, dict[str, dict]] = {}
|
||
fab = underlay_mod.fabric_du_routeur(underlay)
|
||
connus = {r.get("nom"): r for r in underlay_mod.reseaux_de_fabric(underlay, fab)
|
||
if r.get("vlan") is not None}
|
||
for h in underlay_mod.hotes(underlay):
|
||
if h.get("role") != role or not h.get("reseau"):
|
||
continue
|
||
r = connus.get(h["reseau"])
|
||
if r:
|
||
par_via.setdefault(str(h.get("via") or ""), {})[r["nom"]] = r
|
||
return {via: sorted(d.values(), key=lambda r: int(r["vlan"]))
|
||
for via, d in sorted(par_via.items())}
|
||
|
||
|
||
def vlans_du_role(underlay: dict | None, role: str,
|
||
repli: dict | None = None) -> list[dict]:
|
||
"""Reseaux qu'un port doit porter — DERIVES des rattachements declares du role.
|
||
|
||
Deux fois de suite une liste figee a produit un port muet : d'abord celui de la
|
||
frontiere quand les bifrost ont recu une patte sur la sortie tenant, puis celui
|
||
des hyperviseurs quand les noeuds de sortie ont rejoint le lien de frontiere. Le
|
||
devis avait l'air juste et le trafic ne serait jamais arrive. On derive donc, au
|
||
lieu d'enumerer : le port porte ce que ses hotes declarent, rien de plus.
|
||
"""
|
||
noms = {h.get("reseau") for h in underlay_mod.hotes(underlay)
|
||
if h.get("role") == role and h.get("reseau")}
|
||
fab = underlay_mod.fabric_du_routeur(underlay)
|
||
reseaux = [r for r in underlay_mod.reseaux_de_fabric(underlay, fab)
|
||
if r.get("nom") in noms and r.get("vlan") is not None]
|
||
if not reseaux and repli:
|
||
reseaux = [repli]
|
||
return sorted(reseaux, key=lambda r: int(r["vlan"]))
|
||
|
||
|
||
def vlans_de_la_frontiere(underlay: dict | None, transit: dict | None) -> list[dict]:
|
||
"""Reseaux que le port de la frontiere doit porter — DERIVES de ses rattachements.
|
||
|
||
Le transit ne suffit plus. Depuis la separation des plans (2026-08-04), la
|
||
frontiere a aussi une patte sur le VLAN de sortie tenant : c'est par la que le
|
||
trafic DECAPSULE lui parvient, sans traverser ni la gestion des noeuds ni celle
|
||
des equipements. Une liste figee sur le seul transit aurait produit un port muet
|
||
sur ce VLAN — le devis aurait eu l'air juste et le trafic ne serait jamais arrive.
|
||
"""
|
||
return vlans_du_role(underlay, "frontiere", transit)
|
||
|
||
|
||
def section_frontiere(transit: dict | None, bord: bool = False,
|
||
ports: list[str] | None = None,
|
||
underlay: dict | None = None) -> list[str]:
|
||
"""Port du switch vers le pare-feu de bordure.
|
||
|
||
Distinct du trunk vers Proxmox : le pare-feu n'a rien a faire des VLAN tenants —
|
||
il route vers eux, il ne les etiquette pas.
|
||
"""
|
||
if not transit:
|
||
return ["! ----- 4b. Port vers la frontiere -----",
|
||
"! Aucun reseau de transit declare dans underlay.yml (cle 'passerelle_sortie')."]
|
||
reseaux = vlans_de_la_frontiere(underlay, transit)
|
||
liste = ",".join(str(r["vlan"]) for r in reseaux)
|
||
detail = ", ".join(f"{r['vlan']} ({r['nom']})" for r in reseaux)
|
||
return [
|
||
"! ----- 4b. Port vers la frontiere nord/sud -----",
|
||
f"! Porte {detail}.",
|
||
"! Derive des rattachements declares des hotes `role: frontiere`.",
|
||
"! Port terminal : rien derriere lui ne participe au spanning-tree.",
|
||
] + [ligne
|
||
for port in (ports or ["<PORT-VERS-FRONTIERE>"])
|
||
for ligne in bloc_trunk(port, liste, bord=bord)]
|
||
|
||
|
||
def inventaire_de(nom_instance: str) -> Path | None:
|
||
"""hosts.yml d'une instance FEDEREE, active ou non. None si elle n'en a pas."""
|
||
base = DOSSIER_INSTANCES / nom_instance / "inventories"
|
||
for env in ("principal", "production", "lab"):
|
||
p = base / env / "hosts.yml"
|
||
if p.is_file():
|
||
return p
|
||
return None
|
||
|
||
|
||
def admin_de(nom_instance: str) -> list[str]:
|
||
"""Reseaux d'administration declares par une instance (intrant nftables_admin_ssh)."""
|
||
inv = inventaire_de(nom_instance)
|
||
if not inv:
|
||
return []
|
||
for fichier in sorted((inv.parent / "group_vars" / "all").glob("*.yml")):
|
||
if "vault" in fichier.name:
|
||
continue
|
||
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
|
||
if isinstance(data, dict) and data.get("nftables_admin_ssh"):
|
||
src = data["nftables_admin_ssh"]
|
||
return [str(s) for s in src] if isinstance(src, list) else [str(src)]
|
||
return []
|
||
|
||
|
||
def admin_tous_tenants() -> list[str]:
|
||
"""Union des reseaux d'administration de TOUS les tenants federes.
|
||
|
||
Le switch routeur est partage : il doit savoir revenir vers chaque plan de gestion,
|
||
pas seulement celui de l'instance active. Router n'est pas autoriser — le
|
||
cloisonnement se fait a la frontiere, par un alias distinct par tenant.
|
||
SOURCE UNIQUE : `devis_opnsense` importe cette fonction plutot que d'en refaire une.
|
||
"""
|
||
return sorted({c for nom, _pfx, _n in decouvrir() for c in admin_de(nom)})
|
||
|
||
|
||
def route_statique(reseau: str, saut: str, dialecte: str) -> str:
|
||
"""Une route statique, dans la forme du dialecte.
|
||
|
||
VERIFIE sur un `show running-config` Binardat (2026-08-02) : la plateforme ecrit
|
||
`ip route 0.0.0.0/0 192.168.10.254` — notation CIDR, pas de masque separe. Cisco IOS
|
||
veut `ip route <reseau> <masque> <saut>`.
|
||
"""
|
||
net = ipaddress.ip_network(reseau, strict=False)
|
||
if dialecte == "binardat":
|
||
return f"ip route {net.with_prefixlen} {saut}"
|
||
return f"ip route {net.network_address} {net.netmask} {saut}"
|
||
|
||
|
||
def switch_route(underlay: dict | None) -> bool:
|
||
"""Le switch routeur porte-t-il au moins un SVI ? Sinon il ne route rien.
|
||
|
||
Depuis que la frontiere est le seul equipement L3, plus aucune passerelle de
|
||
l'underlay n'appartient a un switch : ni SVI, ni routes statiques. La section 5 du
|
||
devis — la plus risquee, celle qui coupait l'acces d'administration au switch en
|
||
cas d'erreur — disparait d'elle-meme.
|
||
"""
|
||
r_nom = underlay_mod.routeur(underlay)
|
||
for r in underlay_mod.reseaux_de_fabric(underlay, underlay_mod.fabric_du_routeur(underlay)):
|
||
gw = r.get("passerelle")
|
||
if not gw:
|
||
continue
|
||
for h in underlay_mod.hotes(underlay):
|
||
if (h.get("reseau") == r.get("nom") and str(h.get("ip")) == str(gw)
|
||
and h.get("nom") == r_nom):
|
||
return True
|
||
return False
|
||
|
||
|
||
def section_routes(underlay: dict | None, dialecte: str = DIALECTE_SECOURS) -> list[str]:
|
||
"""Routes du switch vers la frontiere nord/sud. Vide si le switch ne route pas.
|
||
|
||
Deux routes, et il en faut IMPERATIVEMENT deux :
|
||
- l'ALLER : defaut vers la frontiere, sinon les hotes n'ont aucune sortie ;
|
||
- le RETOUR : vers les reseaux d'administration, sinon la reponse d'une VM part
|
||
par la passerelle de management et revient au pare-feu par une autre interface
|
||
que celle ou l'etat a ete cree — elle est jetee en silence. Symptome : le SVI
|
||
de zone repond au ping, mais aucun hote derriere lui n'est joignable.
|
||
|
||
Les reseaux d'administration viennent de l'intrant `nftables_admin_ssh` : la meme
|
||
source unique que la garde anti-lockout des nftables et l'alias SETOPS_ADMIN de la
|
||
frontiere. Aucune adresse n'est ecrite ici.
|
||
"""
|
||
transit = underlay_mod.reseau_transit(underlay)
|
||
if not switch_route(underlay):
|
||
return ["! ----- 5. Routes -----",
|
||
"! AUCUNE. Ce switch ne porte aucun SVI : il commute, il ne route pas.",
|
||
"! La frontiere est le seul equipement L3 — c'est elle qui route vers les",
|
||
"! tenants et vers l'exterieur. Les switches n'ont qu'une sortie par defaut,",
|
||
"! emise plus haut avec leur adresse de gestion.",
|
||
"!"]
|
||
if not transit:
|
||
return ["! ----- 5. Routes vers la frontiere -----",
|
||
"! Aucun reseau de transit declare dans underlay.yml (cle 'passerelle_sortie').",
|
||
"! Sans lui : pas de route par defaut, et pas de retour vers l'administration.",
|
||
"!"]
|
||
sortie = transit["passerelle_sortie"]
|
||
out = ["! ----- 5. Routes vers la frontiere nord/sud -----",
|
||
"! /!\\ A APPLIQUER EN DERNIER, ET SEULEMENT UNE FOIS LA FRONTIERE VIVANTE.",
|
||
f"! Ces routes deplacent la sortie du switch — y compris celle de ses propres",
|
||
f"! reponses — vers {sortie}. Tant que cette adresse ne repond pas, elles",
|
||
"! coupent l'acces d'administration AU SWITCH LUI-MEME.",
|
||
"! Prealables : boitier cable, adresse sur le lien de transit, et joignable",
|
||
f"! depuis le switch (ping {sortie} depuis Vlan{transit['vlan']}).",
|
||
"! Garder une session console ouverte pendant l'operation.",
|
||
"!",
|
||
f"! Transit '{transit['nom']}' (VLAN {transit['vlan']}) : SVI {transit['passerelle']}"
|
||
f" <-> frontiere {sortie}",
|
||
"! Aller : sortie generale de la flotte.",
|
||
route_statique("0.0.0.0/0", sortie, dialecte)]
|
||
admin = admin_tous_tenants() or _sources_admin_ssh()
|
||
if admin:
|
||
out.append("! Retour : sans ces routes, les reponses partent par une autre interface")
|
||
out.append("! que celle ou l'etat a ete cree, et le pare-feu les jette en silence.")
|
||
for cidr in admin:
|
||
out.append(route_statique(cidr, sortie, dialecte)
|
||
+ " ! administration (intrant nftables_admin_ssh)")
|
||
else:
|
||
out.append("! ATTENTION : intrant `nftables_admin_ssh` vide — aucune route de retour")
|
||
out.append("! vers l'administration ne peut etre derivee. Cf. preuve P24.")
|
||
out.append("!")
|
||
return out
|
||
|
||
|
||
def prefixe(nom_dossier: str) -> str:
|
||
base = re.sub(r"^OPS-", "", nom_dossier)
|
||
base = re.sub(r"-lab$", "", base)
|
||
return (re.sub(r"[^A-Za-z0-9]", "", base).upper() or "T")[:4]
|
||
|
||
|
||
def decouvrir() -> list[tuple[str, str, dict]]:
|
||
tenants = []
|
||
for chemin in sorted(DOSSIER_INSTANCES.glob("*/plan/nomenclature.yml")):
|
||
n = yaml.safe_load(chemin.read_text(encoding="utf-8")) or {}
|
||
# Un tenant federe = une nomenclature avec un index (l'adressage en decoule).
|
||
# `federe: false` exclut un bac a sable local (labo) du reseau converge :
|
||
# il ne partage pas la fabric de production, on ne provisionne pas ses VLAN.
|
||
if n.get("index") is not None and n.get("categories") and n.get("federe", True):
|
||
nom = chemin.parent.parent.name
|
||
tenants.append((nom, prefixe(nom), n))
|
||
tenants.sort(key=lambda t: t[2]["index"])
|
||
return tenants
|
||
|
||
|
||
def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -> str:
|
||
dialecte = dialecte_effectif(dialecte)
|
||
underlay = underlay_mod.charger()
|
||
r_nom = underlay_mod.routeur(underlay)
|
||
out = ["configure terminal", f"! dialecte CLI : {dialecte}", "!"]
|
||
out += [
|
||
"! ============================================================",
|
||
"! DEVIS SWITCH — reseau converge multi-tenant (Set-OPS)",
|
||
"! Genere par scripts/devis_reseau.py depuis les nomenclatures.",
|
||
"! VLAN = 1000 + index*10 + zone (unique globalement dans la federation).",
|
||
"! 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 += [
|
||
"! ============================================================",
|
||
"!",
|
||
]
|
||
out += section_underlay(underlay)
|
||
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.",
|
||
"!",
|
||
"! 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.",
|
||
"!",
|
||
"! 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 += ["! ----- 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.",
|
||
"!",
|
||
"! 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."]
|
||
transit = underlay_mod.reseau_transit(underlay)
|
||
# DERIVE des rattachements des hyperviseurs, pas d'une liste figee. Depuis que les
|
||
# noeuds de sortie EVPN ont une patte sur le lien de frontiere, exclure le transit
|
||
# « parce qu'aucun hyperviseur n'y est » serait faux — et le trunk laisserait
|
||
# tomber le trafic tenant sortant sans rien signaler.
|
||
# Un bloc de port par interface d'hyperviseur : `vmbr0` (administration, un seul
|
||
# VLAN -> port d'ACCES) et `bond3` (transport + sortie, plusieurs -> trunk).
|
||
par_via_hyp = reseaux_par_via(underlay, "hyperviseur")
|
||
vlans_underlay = [str(r["vlan"])
|
||
for r in vlans_du_role(underlay, "hyperviseur")]
|
||
# Les liens INTER-SWITCH portent tout l'underlay de la fabric, pas seulement ce
|
||
# dont les hyperviseurs ont besoin : sans le VLAN de management, les commutateurs d'acces
|
||
# perdraient leur propre adresse de gestion. Un port terminal porte ce que son hote
|
||
# declare ; un lien de fabric porte ce qui doit traverser.
|
||
vlans_fabric = [str(r["vlan"])
|
||
for r in underlay_mod.reseaux_de_fabric(
|
||
underlay, underlay_mod.fabric_du_routeur(underlay))
|
||
if r.get("vlan") is not None]
|
||
# 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"])
|
||
]
|
||
vlans = ",".join(vlans_underlay + vlans_tenants)
|
||
routeur_h = hote_nomme(underlay, underlay_mod.routeur(underlay))
|
||
bord = bool(underlay_mod.stp(underlay))
|
||
if not par_via_hyp:
|
||
# Aucun hyperviseur declare : on ne peut rien deriver de leurs rattachements.
|
||
# Emettre TOUT l'underlay est le repli sur, et il faut le dire — un devis muet
|
||
# ferait croire qu'il n'y a pas de port a configurer, ce qui est faux.
|
||
par_via_hyp = {"": [r for r in underlay_mod.reseaux_de_fabric(
|
||
underlay, underlay_mod.fabric_du_routeur(underlay)) if r.get("vlan") is not None]}
|
||
out += ["! Aucun hote `role: hyperviseur` declare dans underlay.yml : ce port porte",
|
||
"! TOUT l'underlay, faute de mieux. Les declarer rendrait ce bloc precis —",
|
||
"! et separerait l'administration du transport, comme il se doit."]
|
||
for via, reseaux_via in par_via_hyp.items():
|
||
etiq = f"-{via.upper()}" if via else ""
|
||
liste = ",".join(str(r["vlan"]) for r in reseaux_via)
|
||
detail = ", ".join(f"{r['vlan']} ({r['nom']})" for r in reseaux_via)
|
||
out += [f"! Interface {via or '(non precisee)'} des hyperviseurs — porte {detail}."]
|
||
for port in ports_ou_marqueur(routeur_h, "hyperviseurs",
|
||
f"<PORT-VERS-PROXMOX{etiq}>"):
|
||
if len(reseaux_via) == 1:
|
||
# Un seul VLAN sur cette interface : port d'ACCES, trame non etiquetee.
|
||
# C'est le cas de `vmbr0`, qui n'est pas VLAN-aware et porte l'adresse
|
||
# d'administration en clair sur le VLAN natif.
|
||
out += [f"interface {port}",
|
||
f" switchport mode access",
|
||
f" switchport access vlan {reseaux_via[0]['vlan']}"]
|
||
if bord:
|
||
out.append(" spanning-tree portfast")
|
||
else:
|
||
out += bloc_trunk(port, liste, bord=bord)
|
||
out += ["!"]
|
||
out += ["!"] + section_frontiere(
|
||
transit, bord=bool(underlay_mod.stp(underlay)),
|
||
ports=ports_ou_marqueur(hote_nomme(underlay, underlay_mod.routeur(underlay)),
|
||
"frontiere", "<PORT-VERS-FRONTIERE>"),
|
||
underlay=underlay)
|
||
out += ["!"] + section_rayons(underlay, ",".join(vlans_fabric + vlans_tenants))
|
||
out += ["!"] + section_routes(underlay, dialecte)
|
||
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_fabric + vlans_tenants), dialecte,
|
||
vlans_proxmox=",".join(vlans_underlay + vlans_tenants))
|
||
return "\n".join(out)
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("--dialecte", choices=DIALECTES, default=None,
|
||
help="CLI du commutateur (defaut : SETOPS_DIALECTE, sinon underlay.dialecte)")
|
||
args = ap.parse_args()
|
||
tenants = decouvrir()
|
||
if not tenants:
|
||
print("Aucune instance 'ip-miroir' federee trouvee (../*/plan/nomenclature.yml).",
|
||
file=sys.stderr)
|
||
sys.exit(1)
|
||
print(generer(tenants, args.dialecte))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|