2026-07-07 15:50:18 -04:00
|
|
|
|
#!/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
|
2026-07-23 17:15:27 -04:00
|
|
|
|
import os
|
2026-07-07 15:50:18 -04:00
|
|
|
|
import re
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
|
2026-07-23 17:15:27 -04:00
|
|
|
|
# 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")
|
|
|
|
|
|
|
2026-07-07 15:50:18 -04:00
|
|
|
|
RACINE = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
DOSSIER_INSTANCES = RACINE.parent
|
|
|
|
|
|
|
Adressage derive du seul seed index (rupture, mode compact retire)
Principe : les valeurs de configuration se derivent des intrants, elles ne se
reecrivent pas a la main. La nomenclature dupliquait ce qu'index determine deja
(supernet, sous-reseaux, passerelles, VLAN). Corrige en rupture nette.
- inventory_rules : source unique de derivation — supernet_de, base3_de,
sous_reseau_de, passerelle_de, vlan_de. Modele 6 zones encode une fois
(2e octet = 10+index, 3e octet zone = 15+categorie, VLAN = 1000+index*10+zone).
deriver_nomenclature ne lit plus aucun adressage stocke ; mode compact supprime.
- devis_reseau : importe ces helpers (fin de la duplication) ; decouvre les
tenants sur `index` present (filtre vmid_schema retire).
- GUI : `index` devient un INTRANT (section Reseau). Il vit dans la nomenclature
(plan reseau uniforme, contrairement aux intrants des modeles heterogenes) et
le GUI l'ecrit chirurgicalement (une ligne, sans reformater). Le miroir JS
derive le VLAN du seed (fin de la lecture de c.vlan stocke).
- socle public : nomenclature au format maigre.
Preuve P20 (preuve_nomenclature_derivee) : aucune nomenclature ne stocke
d'adressage — garde-fou permanent, teste en negatif.
Valide : DIFF VIDE sur les 3 instances (la derivation reproduit exactement
l'adressage stocke), 7 modeles valident, devis_reseau genere les memes VLAN
(1011-1016 derives), make verifier rc=0 CONFORME 20/20, node --check du GUI OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:58:15 -04:00
|
|
|
|
# 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
|
2026-07-24 14:56:38 -04:00
|
|
|
|
import underlay as underlay_mod # noqa: E402 (fabric physique, cluster-global, hors tenant)
|
Adressage derive du seul seed index (rupture, mode compact retire)
Principe : les valeurs de configuration se derivent des intrants, elles ne se
reecrivent pas a la main. La nomenclature dupliquait ce qu'index determine deja
(supernet, sous-reseaux, passerelles, VLAN). Corrige en rupture nette.
- inventory_rules : source unique de derivation — supernet_de, base3_de,
sous_reseau_de, passerelle_de, vlan_de. Modele 6 zones encode une fois
(2e octet = 10+index, 3e octet zone = 15+categorie, VLAN = 1000+index*10+zone).
deriver_nomenclature ne lit plus aucun adressage stocke ; mode compact supprime.
- devis_reseau : importe ces helpers (fin de la duplication) ; decouvre les
tenants sur `index` present (filtre vmid_schema retire).
- GUI : `index` devient un INTRANT (section Reseau). Il vit dans la nomenclature
(plan reseau uniforme, contrairement aux intrants des modeles heterogenes) et
le GUI l'ecrit chirurgicalement (une ligne, sans reformater). Le miroir JS
derive le VLAN du seed (fin de la lecture de c.vlan stocke).
- socle public : nomenclature au format maigre.
Preuve P20 (preuve_nomenclature_derivee) : aucune nomenclature ne stocke
d'adressage — garde-fou permanent, teste en negatif.
Valide : DIFF VIDE sur les 3 instances (la derivation reproduit exactement
l'adressage stocke), 7 modeles valident, devis_reseau genere les memes VLAN
(1011-1016 derives), make verifier rc=0 CONFORME 20/20, node --check du GUI OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:58:15 -04:00
|
|
|
|
|
2026-07-07 15:50:18 -04:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 17:15:27 -04:00
|
|
|
|
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}"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 14:56:38 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 15:50:18 -04:00
|
|
|
|
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 {}
|
Adressage derive du seul seed index (rupture, mode compact retire)
Principe : les valeurs de configuration se derivent des intrants, elles ne se
reecrivent pas a la main. La nomenclature dupliquait ce qu'index determine deja
(supernet, sous-reseaux, passerelles, VLAN). Corrige en rupture nette.
- inventory_rules : source unique de derivation — supernet_de, base3_de,
sous_reseau_de, passerelle_de, vlan_de. Modele 6 zones encode une fois
(2e octet = 10+index, 3e octet zone = 15+categorie, VLAN = 1000+index*10+zone).
deriver_nomenclature ne lit plus aucun adressage stocke ; mode compact supprime.
- devis_reseau : importe ces helpers (fin de la duplication) ; decouvre les
tenants sur `index` present (filtre vmid_schema retire).
- GUI : `index` devient un INTRANT (section Reseau). Il vit dans la nomenclature
(plan reseau uniforme, contrairement aux intrants des modeles heterogenes) et
le GUI l'ecrit chirurgicalement (une ligne, sans reformater). Le miroir JS
derive le VLAN du seed (fin de la lecture de c.vlan stocke).
- socle public : nomenclature au format maigre.
Preuve P20 (preuve_nomenclature_derivee) : aucune nomenclature ne stocke
d'adressage — garde-fou permanent, teste en negatif.
Valide : DIFF VIDE sur les 3 instances (la derivation reproduit exactement
l'adressage stocke), 7 modeles valident, devis_reseau genere les memes VLAN
(1011-1016 derives), make verifier rc=0 CONFORME 20/20, node --check du GUI OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:58:15 -04:00
|
|
|
|
# Un tenant federe = une nomenclature avec un index (l'adressage en decoule).
|
2026-07-23 10:21:34 -04:00
|
|
|
|
# `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):
|
2026-07-07 15:50:18 -04:00
|
|
|
|
nom = chemin.parent.parent.name
|
|
|
|
|
|
tenants.append((nom, prefixe(nom), n))
|
|
|
|
|
|
tenants.sort(key=lambda t: t[2]["index"])
|
|
|
|
|
|
return tenants
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 17:15:27 -04:00
|
|
|
|
def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -> str:
|
|
|
|
|
|
dialecte = dialecte or DIALECTE_DEFAUT
|
2026-07-24 14:56:38 -04:00
|
|
|
|
underlay = underlay_mod.charger()
|
2026-07-23 17:15:27 -04:00
|
|
|
|
out = ["configure terminal", f"! dialecte CLI : {dialecte}", "!"]
|
2026-07-07 15:50:18 -04:00
|
|
|
|
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).",
|
2026-07-24 14:56:38 -04:00
|
|
|
|
"! Underlay (VLAN < 1000) = fabric physique, cluster-global.",
|
2026-07-07 15:50:18 -04:00
|
|
|
|
"! ============================================================",
|
|
|
|
|
|
"!",
|
|
|
|
|
|
]
|
2026-07-24 14:56:38 -04:00
|
|
|
|
out += section_underlay(underlay)
|
|
|
|
|
|
out += ["! ----- 1. VLANs (tenants) -----"]
|
2026-07-07 15:50:18 -04:00
|
|
|
|
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)}")
|
2026-07-23 10:21:34 -04:00
|
|
|
|
out.append(f" name {pfx}{n['index']}-{c['libelle']}")
|
2026-07-07 15:50:18 -04:00
|
|
|
|
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']}")
|
Adressage derive du seul seed index (rupture, mode compact retire)
Principe : les valeurs de configuration se derivent des intrants, elles ne se
reecrivent pas a la main. La nomenclature dupliquait ce qu'index determine deja
(supernet, sous-reseaux, passerelles, VLAN). Corrige en rupture nette.
- inventory_rules : source unique de derivation — supernet_de, base3_de,
sous_reseau_de, passerelle_de, vlan_de. Modele 6 zones encode une fois
(2e octet = 10+index, 3e octet zone = 15+categorie, VLAN = 1000+index*10+zone).
deriver_nomenclature ne lit plus aucun adressage stocke ; mode compact supprime.
- devis_reseau : importe ces helpers (fin de la duplication) ; decouvre les
tenants sur `index` present (filtre vmid_schema retire).
- GUI : `index` devient un INTRANT (section Reseau). Il vit dans la nomenclature
(plan reseau uniforme, contrairement aux intrants des modeles heterogenes) et
le GUI l'ecrit chirurgicalement (une ligne, sans reformater). Le miroir JS
derive le VLAN du seed (fin de la lecture de c.vlan stocke).
- socle public : nomenclature au format maigre.
Preuve P20 (preuve_nomenclature_derivee) : aucune nomenclature ne stocke
d'adressage — garde-fou permanent, teste en negatif.
Valide : DIFF VIDE sur les 3 instances (la derivation reproduit exactement
l'adressage stocke), 7 modeles valident, devis_reseau genere les memes VLAN
(1011-1016 derives), make verifier rc=0 CONFORME 20/20, node --check du GUI OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:58:15 -04:00
|
|
|
|
out.append(f" ip address {passerelle_de(n['index'], zone)} {m}")
|
2026-07-23 10:21:34 -04:00
|
|
|
|
out.append(f" ip access-group {pfx}{n['index']}-ISOLATION in")
|
2026-07-07 15:50:18 -04:00
|
|
|
|
out.append(" no shutdown")
|
|
|
|
|
|
out += ["!", "! ----- 3. ACL d'isolation tenant (default-deny inter-tenant) -----"]
|
|
|
|
|
|
for nom, pfx, n in tenants:
|
2026-07-23 17:15:27 -04:00
|
|
|
|
reseau, m = masque_acl(supernet_de(n["index"]), dialecte)
|
2026-07-23 10:21:34 -04:00
|
|
|
|
out.append(f"ip access-list extended {pfx}{n['index']}-ISOLATION")
|
2026-07-23 17:15:27 -04:00
|
|
|
|
out += remarque(dialecte, f"Intra-tenant {nom} : routage local autorise")
|
|
|
|
|
|
out.append(f" permit ip {reseau} {m} {reseau} {m}")
|
2026-07-07 15:50:18 -04:00
|
|
|
|
for autre_nom, _, autre in tenants:
|
|
|
|
|
|
if autre_nom == nom:
|
|
|
|
|
|
continue
|
2026-07-23 17:15:27 -04:00
|
|
|
|
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")
|
2026-07-07 15:50:18 -04:00
|
|
|
|
out += ["!", "! ----- 4. Trunk vers Proxmox + inter-switch (a adapter) -----"]
|
2026-07-24 14:56:38 -04:00
|
|
|
|
vlans_underlay = [str(r["vlan"]) for r in underlay_mod.reseaux(underlay)]
|
|
|
|
|
|
vlans_tenants = [
|
2026-07-07 15:50:18 -04:00
|
|
|
|
str(vlan_de(n["index"], zone))
|
|
|
|
|
|
for _, _, n in tenants
|
|
|
|
|
|
for zone in sorted(n["categories"])
|
2026-07-24 14:56:38 -04:00
|
|
|
|
]
|
|
|
|
|
|
vlans = ",".join(vlans_underlay + vlans_tenants)
|
2026-07-07 15:50:18 -04:00
|
|
|
|
out.append("interface <PORT-VERS-PROXMOX>")
|
|
|
|
|
|
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:
|
2026-07-23 17:15:27 -04:00
|
|
|
|
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()
|
2026-07-07 15:50:18 -04:00
|
|
|
|
tenants = decouvrir()
|
|
|
|
|
|
if not tenants:
|
|
|
|
|
|
print("Aucune instance 'ip-miroir' federee trouvee (../*/plan/nomenclature.yml).",
|
|
|
|
|
|
file=sys.stderr)
|
|
|
|
|
|
sys.exit(1)
|
2026-07-23 17:15:27 -04:00
|
|
|
|
print(generer(tenants, args.dialecte))
|
2026-07-07 15:50:18 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|