Set-OPS-Public/scripts/devis_reseau.py
Daniel Allaire b5ca369ae7 devis switch : la liaison des ACL n'existe pas sur une interface VLAN
`ip ?` sur une interface VLAN du Binardat n'offre aucun `access-group`, et la
liste complète des commandes de ce mode n'en contient pas davantage. La ligne
`ip access-group <NOM> in` posée sur les douze SVI n'existe pas sur cette
plateforme.

C'est la ligne qui rend l'isolation effective. Sans elle, les ACL de la
section 3 sont parfaitement définies et jamais liées : `show access-lists`
afficherait « used 0 time(s) », et rien d'autre ne signalerait que
l'isolation inter-tenant ne filtre rien. Même signature que le défaut du
trunk — une configuration qui a l'air juste et n'agit pas.

Le `firewall disable` aperçu dans un `show running-config` prend
rétrospectivement du sens : le filtrage semble conditionné globalement.

Aucune forme de remplacement n'est devinée. Le devis porte un avertissement à
cet endroit, en dialecte `binardat` uniquement — après trois syntaxes
supposées dont deux fausses, marquer l'incertitude vaut mieux qu'un quatrième
pari.

Preuves : 24 OK, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:11:15 -04:00

572 lines
29 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 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")
for r in reseaux:
if r.get("passerelle"):
net = ipaddress.ip_network(r["sous_reseau"], strict=False)
out.append(f"interface Vlan{r['vlan']}")
out.append(f" description underlay {r['nom']}")
out.append(f" ip address {r['passerelle']} {net.netmask}")
out.append(" no shutdown")
# 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) -> 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)
# UNIQUEMENT les hotes du reseau de management : les autres equipements declares
# (frontieres sur le lien de transit, par exemple) ne sont pas des switches et ne
# doivent recevoir aucune de ces lignes.
autres = [h for h in underlay_mod.hotes(underlay)
if h.get("nom") != r_nom and mgmt and h.get("reseau") == mgmt["nom"]]
if not autres:
return []
out = [
"",
"! ============================================================",
f"! PARTIE B — SWITCHES D'ACCES (L2 pur) : {', '.join(h['nom'] for h in autres)}",
f"! Le routage est porte par {r_nom} SEUL (underlay.routeur). Ces switches",
"! commutent les trames etiquetees et ne portent AUCUN SVI de zone, AUCUNE",
"! ACL, AUCUNE route : dupliquer les SVI creerait autant de conflits d'IP.",
"! ============================================================",
"configure terminal",
"!",
"! ----- B1. Memes VLANs (pour commuter les trames etiquetees) -----",
]
# Le transit ne relie que le routeur a la frontiere : aucune trame ne le traverse
# ici. Le declarer serait un VLAN qui ne verra jamais de trafic.
transit = underlay_mod.reseau_transit(underlay)
for r in underlay_mod.reseaux_de_fabric(underlay, fabric):
if transit and r["vlan"] == transit["vlan"]:
continue
out += [f"vlan {r['vlan']}", f" name {r['nom']}"]
for nom, pfx, n in tenants:
for zone in sorted(n["categories"]):
out += [f"vlan {vlan_de(n['index'], zone)}",
f" name {pfx}{n['index']}-{n['categories'][zone]['libelle']}"]
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 += [f"interface Vlan{mgmt['vlan']}",
f" description gestion {h['nom']}",
f" ip address {h['ip']} {net.netmask}",
" no shutdown",
f"ip default-gateway {mgmt['passerelle']} ! le routeur {r_nom}"]
else:
out.append("! Aucun reseau underlay avec passerelle : gestion a definir a la main.")
out += ["! 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."]
for port in ports_ou_marqueur(h, "hyperviseurs", "<PORT-VERS-PROXMOX>"):
out += bloc_trunk(port, 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 section_frontiere(transit: dict | None, bord: bool = False,
ports: list[str] | None = None) -> list[str]:
"""Port du switch vers le pare-feu de bordure. Porte le VLAN de transit, et lui seul.
Distinct du trunk vers Proxmox : les hyperviseurs n'ont aucune interface sur le
transit, et 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')."]
return [
"! ----- 4b. Port vers la frontiere nord/sud -----",
f"! Porte le seul VLAN {transit['vlan']} ({transit['nom']}). En lien dedie,",
f"! remplacer par : switchport access vlan {transit['vlan']}.",
"! 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, str(transit["vlan"]), 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 section_routes(underlay: dict | None, dialecte: str = DIALECTE_SECOURS) -> list[str]:
"""Routes du switch vers la frontiere nord/sud. Vide si aucun transit declare.
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 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 sur le trunk).",
"! Underlay (VLAN < 1000) = fabric physique, cluster-global.",
]
if r_nom:
out += [
"!",
f"! PARTIE A — SWITCH ROUTEUR : {r_nom} (underlay.routeur)",
"! Sans MLAG, UN SEUL switch route. Cette partie ne va QUE sur lui.",
"! Les autres recoivent la partie B (L2 pur), plus bas.",
]
else:
out += [
"!",
"! /!\\ AUCUN ROUTEUR DESIGNE (cle `routeur` dans underlay.yml).",
"! Applique tel quel sur plusieurs switches, ce devis duplique les SVI",
"! et cree autant de conflits d'adresses. Designer le switch routeur.",
]
out += [
"! ============================================================",
"!",
]
out += section_underlay(underlay)
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']}")
out += ["!", "! ----- 2. Interfaces de routage (SVI = passerelle des hotes) -----"]
if dialecte == "binardat":
out += [
"! /!\\ LA LIGNE `ip access-group ... in` CI-DESSOUS EST DOUTEUSE SUR CETTE",
"! PLATEFORME : `ip ?` sur une interface VLAN n'offre AUCUN `access-group`",
"! (verifie le 2026-08-02). Appliquee telle quelle, elle serait refusee — ou pire,",
"! ignoree : les ACL de la section 3 existeraient sans rien filtrer, exactement",
"! comme un `show access-lists` affichant « used 0 time(s) ».",
"! Le filtrage semble par ailleurs conditionne globalement (`firewall enable`).",
"! A trancher par `ip ?` et `access-group ?` sur une interface PHYSIQUE.",
]
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}")
out.append(f" ip access-group {pfx}{n['index']}-ISOLATION in")
out.append(" no shutdown")
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)
# Le VLAN de transit ne va PAS vers Proxmox : aucun hyperviseur n'a d'interface
# dessus. Il sort par le port de la frontiere (ci-dessous), et lui seul.
vlans_underlay = [str(r["vlan"])
for r in underlay_mod.reseaux_de_fabric(
underlay, underlay_mod.fabric_du_routeur(underlay))
if not (transit and r["vlan"] == transit["vlan"])]
vlans_tenants = [
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>"))
out += ["!"] + section_rayons(underlay, ",".join(vlans_underlay + 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_underlay + vlans_tenants), dialecte)
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()