Le modele derive l'adressage par tenant (VLAN 1000+index*10+zone), mais la fabric physique qui porte la flotte (mgmt switches/Proxmox/OOB, iSCSI, Ceph public+cluster) n'appartient a aucun tenant. Elle est desormais codifiee. - scripts/underlay.py + make underlay : charge/affiche/valide underlay.yml (VLAN < 1000, sous-reseaux hors des supernets tenant 10.(10+index).0.0/16). - underlay.yml gitignore (comme le vault) ; gabarit public underlay.yml.example ; surchargeable par SETOPS_UNDERLAY. - devis_reseau : section 0. Underlay + VLAN underlay sur le trunk, selon dialecte. - P23 : underlay.py --verifier ; sautee si underlay.yml absent (comme P16 sans vault). 23/23 preuves. Doc : docs/audit/README.md, wiki page reseau, CHANGELOG. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
176 lines
7.7 KiB
Python
176 lines
7.7 KiB
Python
#!/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 <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:
|
||
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()
|