#!/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. DIALECTES = ("cisco", "binardat") DIALECTE_DEFAUT = os.environ.get("SETOPS_DIALECTE", "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) 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 physique. Vide si aucun underlay.""" reseaux = underlay_mod.reseaux(underlay) if not reseaux: return [] out = ["! ----- 0. Underlay (fabric physique, 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") for h in underlay_mod.hotes(underlay): out.append(f"! {h.get('nom','?')} = {h.get('ip','?')} ({h.get('reseau','?')})") 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 or DIALECTE_DEFAUT underlay = underlay_mod.charger() 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.", "! ============================================================", "!", ] 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) -----"] 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}") out += remarque(dialecte, "Reste (Internet / inter-tenant controle) -> passerelle OPNsense") out.append(f" permit ip {reseau} {m} any") out += ["!", "! ----- 4. Trunk vers Proxmox + inter-switch (a adapter) -----"] vlans_underlay = [str(r["vlan"]) for r in underlay_mod.reseaux(underlay)] vlans_tenants = [ str(vlan_de(n["index"], zone)) for _, _, n in tenants for zone in sorted(n["categories"]) ] vlans = ",".join(vlans_underlay + vlans_tenants) out.append("interface ") out.append(" switchport mode trunk") out.append(f" switchport trunk allowed vlan add {vlans}") out += ["!", "end", "write memory"] return "\n".join(out) def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--dialecte", choices=DIALECTES, default=DIALECTE_DEFAUT, help="CLI du commutateur (defaut : SETOPS_DIALECTE ou 'cisco')") 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()