Les VLAN et les trunks étaient déjà filtrés par fabric ; le roster de la section 0, non. Un équipement d'une autre fabric — les switches de stockage, le jour où ils seront déclarés — serait apparu en commentaire dans un devis qui ne le concerne pas. Un devis est une configuration qu'on applique, pas un inventaire. Vérifié en déclarant un hôte de la fabric stockage : il reste absent, et la sortie du jour est inchangée. Preuves : 24 OK, 0 échec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
18 KiB
Python
373 lines
18 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)
|
||
# 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
|
||
|
||
|
||
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 partie_acces(underlay: dict | None, tenants: list, vlans: str) -> 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']}"]
|
||
out += ["!", "! ----- B2. Gestion (seule adresse IP du switch) -----",
|
||
"! /!\\ UN SEUL de ces blocs par switch : celui qui porte son nom.",
|
||
"! Les coller tous les deux sur la meme machine ecraserait son adresse."]
|
||
if mgmt:
|
||
net = ipaddress.ip_network(mgmt["sous_reseau"], strict=False)
|
||
for h in autres:
|
||
out += [f"! --- {h['nom']} ---",
|
||
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 += ["!", "! ----- B3. Trunks -----",
|
||
"! Vers les hyperviseurs ET vers le routeur : memes VLANs etiquetes.",
|
||
"interface <PORT-TRUNK>",
|
||
" switchport mode trunk",
|
||
f" switchport trunk allowed vlan add {vlans}",
|
||
"!", "end", "write memory"]
|
||
return out
|
||
|
||
|
||
def section_frontiere(transit: dict | 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']}.",
|
||
"interface <PORT-VERS-FRONTIERE>",
|
||
" switchport mode trunk",
|
||
f" switchport trunk allowed vlan add {transit['vlan']}",
|
||
]
|
||
|
||
|
||
def section_routes(underlay: dict | None) -> 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.",
|
||
f"ip route 0.0.0.0 0.0.0.0 {sortie}"]
|
||
admin = _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:
|
||
net = ipaddress.ip_network(cidr, strict=False)
|
||
out.append(f"ip route {net.network_address} {net.netmask} {sortie}"
|
||
f" ! 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 or DIALECTE_DEFAUT
|
||
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) -----"]
|
||
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 Proxmox + inter-switch (a adapter) -----"]
|
||
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)
|
||
out.append("interface <PORT-VERS-PROXMOX>")
|
||
out.append(" switchport mode trunk")
|
||
out.append(f" switchport trunk allowed vlan add {vlans}")
|
||
out += ["!"] + section_frontiere(transit)
|
||
out += ["!"] + section_routes(underlay)
|
||
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))
|
||
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()
|