From 97670dd27c7b429c474d4150cd99e379dbf1c73d Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 7 Jul 2026 15:50:18 -0400 Subject: [PATCH] make devis-reseau : config switch derivee du plan scripts/devis_reseau.py decouvre les instances federees (ip-miroir) et emet VLANs + SVIs + ACLs d'isolation inter-tenant, derives des nomenclatures. Plus jamais saisi a la main. Eprouve sur Chezlepro + Technolibre. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 ++ Makefile | 4 ++ scripts/devis_reseau.py | 119 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 scripts/devis_reseau.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a6ff0c3..f73ae76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ VLANs 1021-1026 / 10.12.x. Chezlepro quitte le bac-à-sable plat 192.168.15/VLAN 15. ### Ajouté +- **`make devis-reseau` : config switch dérivée du plan.** Nouveau `scripts/devis_reseau.py` + qui découvre les instances fédérées (`../*/plan/nomenclature.yml`, schéma `ip-miroir`) et + émet la config Cisco-like du réseau convergé — **VLANs + SVIs (passerelles) + ACLs d'isolation + inter-tenant** — dérivée des nomenclatures, jamais saisie à la main (toujours synchrone avec + le plan). One-shot précédemment ; maintenant reproductible. - **GUI : erreurs de déploiement en LANGAGE CLAIR.** Quand une action (créer/vérifier/déployer) échoue, le GUI n'oblige plus à lire le dump Ansible : un encadré résume les tâches en erreur — **étape · hôte · type · message clair** — avec un bouton **« Copier »** (pour transmettre le diff --git a/Makefile b/Makefile index a3dbf2a..32eeb9a 100644 --- a/Makefile +++ b/Makefile @@ -305,6 +305,10 @@ flux: ansible-runtime python3 scripts/resoudre_flux.py registre python3 scripts/resoudre_flux.py nftables +.PHONY: devis-reseau +devis-reseau: ansible-runtime ## Devis switch (VLANs/SVIs/ACLs) du reseau converge, derive des nomenclatures + python3 scripts/devis_reseau.py + flux-verifier: python3 scripts/resoudre_flux.py verifier diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py new file mode 100644 index 0000000..8808d44 --- /dev/null +++ b/scripts/devis_reseau.py @@ -0,0 +1,119 @@ +#!/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 re +import sys +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parent.parent +DOSSIER_INSTANCES = RACINE.parent + + +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 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 {} + if n.get("vmid_schema") == "ip-miroir" and n.get("index") is not None: + nom = chemin.parent.parent.name + tenants.append((nom, prefixe(nom), n)) + tenants.sort(key=lambda t: t[2]["index"]) + return tenants + + +def vlan_de(index: int, zone: int) -> int: + return 1000 + index * 10 + zone + + +def generer(tenants: list[tuple[str, str, dict]]) -> str: + out = ["configure terminal", "!"] + 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).", + "! ============================================================", + "!", + "! ----- 1. VLANs -----", + ] + 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}-{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 {c['passerelle']} {m}") + out.append(f" ip access-group {pfx}-ISOLATION in") + out.append(" no shutdown") + out += ["!", "! ----- 3. ACL d'isolation tenant (default-deny inter-tenant) -----"] + for nom, pfx, n in tenants: + reseau, wild = reseau_wildcard(n["supernet"]) + out.append(f"ip access-list extended {pfx}-ISOLATION") + out.append(f" remark Intra-tenant {nom} : routage local autorise") + out.append(f" permit ip {reseau} {wild} {reseau} {wild}") + for autre_nom, _, autre in tenants: + if autre_nom == nom: + continue + a_reseau, a_wild = reseau_wildcard(autre["supernet"]) + out.append(f" remark Bloquer le tenant {autre_nom}") + out.append(f" deny ip {reseau} {wild} {a_reseau} {a_wild}") + out.append(" remark Reste (Internet / inter-tenant controle) -> passerelle OPNsense") + out.append(f" permit ip {reseau} {wild} any") + out += ["!", "! ----- 4. Trunk vers Proxmox + inter-switch (a adapter) -----"] + vlans = ",".join( + str(vlan_de(n["index"], zone)) + for _, _, n in tenants + for zone in sorted(n["categories"]) + ) + 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: + argparse.ArgumentParser(description=__doc__).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)) + + +if __name__ == "__main__": + main()