Set-OPS-Public/scripts/devis_reseau.py
Daniel Allaire 1bc516fd92 routage : plus aucun commutateur ne route, la frontière est le seul L3 (D-49 à D-51)
Aucun des trois SVI de sleipnir-01 n'avait de consommateur : les VTEP sont dans
le même sous-réseau, les nœuds de sortie sont adjacents à la frontière, et les
commutateurs peuvent sortir par l'OPNsense qui a déjà une patte sur le VLAN 10.

Deux décisions séparées avaient vidé ce rôle sans qu'on regarde leur effet
cumulé : le passage à l'EVPN a retiré les VLAN tenants du fil, puis la fusion du
lien de sortie dans le VLAN 40 a rendu les nœuds de sortie adjacents.

sleipnir-01 disparaît, pas seulement son rôle : en étoile, le centre est sur tous
les chemins, donc un point de panne unique du plan de données — ce qui vidait
aussi de son sens l'ajout d'une seconde carte à bond3. Deux switches L2 reliés,
bond3 répartis. D-51 ; D-05 renversée.

Le devis perd trois SVI, quatre routes, et surtout sa section 5 — celle qui
coupait l'accès d'administration au switch en cas d'erreur.

D-50 : `passerelle` signifiait « adresse du SVI du switch », une hypothèse
déguisée en donnée. Elle signifie maintenant « la passerelle de ce sous-réseau,
où qu'elle vive », et le devis dérive s'il doit émettre une interface routée —
uniquement si le porteur déclaré a le rôle switch. Le même moteur sert les deux
postures : le modèle public démontre celle où le switch route.

Deux gardes remplacées, pas affaiblies. À la place de « passerelle_sortie exige
passerelle » et « routeur.ip == passerelle », une règle plus forte : une
passerelle doit être l'adresse d'un hôte déclaré sur ce réseau. Elle attrape en
plus les passerelles fantômes. Éprouvée par trois sabotages, tous attrapés — et
elle a trouvé une sous-déclaration dans le modèle public.

Quatre trous corrigés, tous de la même famille (une liste figée finit par
mentir) : port de frontière figé sur le transit, trunk Proxmox excluant le
transit, switches d'accès sautant sa déclaration, et le switch de tête privé
d'adresse de gestion par la suppression du SVI.

D-03 renversée : le /29 élargi en /24 fait tomber l'exemption d'invariant, le .1
revient à la passerelle.

30 preuves OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:23:57 -04:00

715 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 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.
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, sleipnir-02 et -03
# 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))
for port in ports_ou_marqueur(routeur_h, "hyperviseurs", "<PORT-VERS-PROXMOX>"):
out += bloc_trunk(port, vlans, bord=bord)
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()