Set-OPS-Public/scripts/devis_reseau.py

264 lines
13 KiB
Python
Raw Normal View History

#!/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
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
import underlay as underlay_mod # noqa: E402 (fabric physique, cluster-global, hors tenant)
frontière nord/sud : devis dérivé, lien de transit et les deux routes La bordure devient un artefact dérivé, comme le devis switch — et le chemin qui y mène est enfin déclaré. `make devis-opnsense` (+ preuve P24) dérive la politique de bordure du registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute volontairement parce qu'ils relèvent de la frontière et non du pare-feu d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur. Le lien manquait dans tous les fichiers : le devis switch ne contenait pas une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le déclare — il vit dans l'underlay et non dans un tenant parce que la frontière route vers TOUS les supernets tenants par le même saut, donc il ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes : l'aller (sortie générale) et le retour vers l'administration, dont l'absence a coûté la passe de déploiement du 2026-07-29 — la réponse revient au pare-feu par une autre interface que celle où l'état a été créé, et se fait jeter en silence. Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` : même source unique que la garde anti-lockout des nftables et l'alias SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger. La frontière est réglable depuis la console (section « Frontière » du panneau Intrants) ; les identifiants d'API restent interdits d'écriture par le GUI et vivent dans la voûte. Correctifs de la même passe : - le panneau refusait d'enregistrer les intrants de la frontière : le garde-fou confondait une référence de voûte `{{ vault_* }}` préservée avec un secret soumis. Il regarde désormais la valeur, pas le nom. - `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le playbook de clonage, voûte unique en dernier. - documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un reliquat de compatibilité. Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un par un ; résolution du jeton Proxmox vérifiée en exécution réelle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
# 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
2026-07-23 02:58:15 -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)
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 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']}",
]
frontière nord/sud : devis dérivé, lien de transit et les deux routes La bordure devient un artefact dérivé, comme le devis switch — et le chemin qui y mène est enfin déclaré. `make devis-opnsense` (+ preuve P24) dérive la politique de bordure du registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute volontairement parce qu'ils relèvent de la frontière et non du pare-feu d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur. Le lien manquait dans tous les fichiers : le devis switch ne contenait pas une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le déclare — il vit dans l'underlay et non dans un tenant parce que la frontière route vers TOUS les supernets tenants par le même saut, donc il ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes : l'aller (sortie générale) et le retour vers l'administration, dont l'absence a coûté la passe de déploiement du 2026-07-29 — la réponse revient au pare-feu par une autre interface que celle où l'état a été créé, et se fait jeter en silence. Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` : même source unique que la garde anti-lockout des nftables et l'alias SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger. La frontière est réglable depuis la console (section « Frontière » du panneau Intrants) ; les identifiants d'API restent interdits d'écriture par le GUI et vivent dans la voûte. Correctifs de la même passe : - le panneau refusait d'enregistrer les intrants de la frontière : le garde-fou confondait une référence de voûte `{{ vault_* }}` préservée avec un secret soumis. Il regarde désormais la valeur, pas le nom. - `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le playbook de clonage, voûte unique en dernier. - documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un reliquat de compatibilité. Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un par un ; résolution du jeton Proxmox vérifiée en exécution réelle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
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.",
"!",
frontière nord/sud : devis dérivé, lien de transit et les deux routes La bordure devient un artefact dérivé, comme le devis switch — et le chemin qui y mène est enfin déclaré. `make devis-opnsense` (+ preuve P24) dérive la politique de bordure du registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute volontairement parce qu'ils relèvent de la frontière et non du pare-feu d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur. Le lien manquait dans tous les fichiers : le devis switch ne contenait pas une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le déclare — il vit dans l'underlay et non dans un tenant parce que la frontière route vers TOUS les supernets tenants par le même saut, donc il ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes : l'aller (sortie générale) et le retour vers l'administration, dont l'absence a coûté la passe de déploiement du 2026-07-29 — la réponse revient au pare-feu par une autre interface que celle où l'état a été créé, et se fait jeter en silence. Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` : même source unique que la garde anti-lockout des nftables et l'alias SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger. La frontière est réglable depuis la console (section « Frontière » du panneau Intrants) ; les identifiants d'API restent interdits d'écriture par le GUI et vivent dans la voûte. Correctifs de la même passe : - le panneau refusait d'enregistrer les intrants de la frontière : le garde-fou confondait une référence de voûte `{{ vault_* }}` préservée avec un secret soumis. Il regarde désormais la valeur, pas le nom. - `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le playbook de clonage, voûte unique en dernier. - documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un reliquat de compatibilité. Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un par un ; résolution du jeton Proxmox vérifiée en exécution réelle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
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 {}
2026-07-23 02:58:15 -04:00
# 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']}")
2026-07-23 02:58:15 -04:00
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.
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(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)
frontière nord/sud : devis dérivé, lien de transit et les deux routes La bordure devient un artefact dérivé, comme le devis switch — et le chemin qui y mène est enfin déclaré. `make devis-opnsense` (+ preuve P24) dérive la politique de bordure du registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute volontairement parce qu'ils relèvent de la frontière et non du pare-feu d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur. Le lien manquait dans tous les fichiers : le devis switch ne contenait pas une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le déclare — il vit dans l'underlay et non dans un tenant parce que la frontière route vers TOUS les supernets tenants par le même saut, donc il ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes : l'aller (sortie générale) et le retour vers l'administration, dont l'absence a coûté la passe de déploiement du 2026-07-29 — la réponse revient au pare-feu par une autre interface que celle où l'état a été créé, et se fait jeter en silence. Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` : même source unique que la garde anti-lockout des nftables et l'alias SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger. La frontière est réglable depuis la console (section « Frontière » du panneau Intrants) ; les identifiants d'API restent interdits d'écriture par le GUI et vivent dans la voûte. Correctifs de la même passe : - le panneau refusait d'enregistrer les intrants de la frontière : le garde-fou confondait une référence de voûte `{{ vault_* }}` préservée avec un secret soumis. Il regarde désormais la valeur, pas le nom. - `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le playbook de clonage, voûte unique en dernier. - documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un reliquat de compatibilité. Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un par un ; résolution du jeton Proxmox vérifiée en exécution réelle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
out += ["!"] + section_routes(underlay)
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()