diff --git a/CHANGELOG.md b/CHANGELOG.md index 852529b..a92cf42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # CHANGELOG — Set-OPS +## 2026-08-01 (suite 4) — un seul switch route, les autres en L2 pur + +### Décidé — `underlay.routeur` +Sans MLAG, le routage est porté par un **unique** switch (`bifrost-01` chez Chezlepro) ; les +autres restent en L2 pur. Le devis émettait jusqu'ici un jeu unique de SVI sans dire à quel +switch il s'adressait : appliqué sur les trois, il aurait créé autant de conflits d'adresses +qu'il y a de zones. + +### Ajouté — le devis se scinde en deux parties +- **Partie A — switch routeur** : VLANs, SVI, ACL d'isolation, trunks, routes. Va sur lui + seul, et l'en-tête le nomme. +- **Partie B — switches d'accès (L2 pur)** : mêmes VLANs pour commuter les trames étiquetées, + une adresse de gestion par switch (tirée de `underlay.hotes`) avec `ip default-gateway` + vers le routeur, et les trunks. Aucun SVI de zone, aucune ACL, aucune route. + +Deux gardes : `make underlay` refuse un `routeur` qui ne nomme aucun hôte déclaré ; et la +partie B avertit qu'un seul de ses blocs de gestion va sur chaque machine — les coller tous +écraserait l'adresse. Sans `routeur` désigné, l'en-tête signale explicitement le risque de +duplication au lieu de laisser croire que le devis est applicable partout. + +Reste ouvert et consigné : la syntaxe des `ip route` n'est pas dialecte-consciente, +contrairement aux ACL. + ## 2026-08-01 (suite 3) — les tenants n'atteignent plus l'underlay ### Corrigé — ACL de switch : `deny` vers la fabric physique diff --git a/docs/frontiere-opnsense.md b/docs/frontiere-opnsense.md index f0daf7e..b3223d5 100644 --- a/docs/frontiere-opnsense.md +++ b/docs/frontiere-opnsense.md @@ -12,13 +12,21 @@ Trois pare-feux coexistent dans le modèle, et chacun voit une chose différente | Où | Quoi | Généré par | |---|---|---| | **Sur chaque hôte** | nftables `policy drop`, moindre privilège par IP source | `make flux` (`resoudre_flux.py`) | -| **Sur les switches L3** | ACL d'isolation inter-tenant, appliquées `in` sur les SVI | `make devis-reseau` | +| **Sur le switch routeur** | ACL d'isolation inter-tenant, appliquées `in` sur les SVI | `make devis-reseau` | | **À la frontière (OPNsense)** | ce qui **entre et sort** de l'écosystème | `make devis-opnsense` | **Décision : OPNsense est une frontière nord/sud, pas la passerelle des zones.** Les SVI restent sur les switches L3, qui continuent d'assurer le routage inter-zone au débit ligne. OPNsense ne porte **aucun SVI de tenant** et ne voit pas le trafic est-ouest. +**Décision (2026-08-01) : un seul switch route.** Sans MLAG, le routage est porté par un +unique switch, désigné par `underlay.routeur` ; les autres restent en **L2 pur** et n'ont +d'adresse IP que pour leur propre gestion. `make devis-reseau` émet en conséquence deux +configurations distinctes — *partie A* pour le routeur, *partie B* pour les switches d'accès — +plutôt qu'un devis unique que l'on serait tenté d'appliquer partout, ce qui dupliquerait les +SVI et créerait autant de conflits d'adresses qu'il y a de zones. `make underlay` refuse un +`routeur` qui ne nomme aucun hôte déclaré. + Conséquence assumée : l'isolation inter-zone repose sur les ACL de switch, moins expressives que le registre des flux. Le zéro-confiance est-ouest reste porté par les nftables d'hôte et le TLS mutuel — pas par le pare-feu de bordure. @@ -192,10 +200,9 @@ ansible-vault edit instance/inventories/principal/group_vars/all/vault.yml interdit en bloc aux tenants. Si un besoin légitime apparaît (superviser l'hyperviseur depuis `obs-01`, par exemple), il ne peut pas être *déclaré* : il faudrait ajouter un pair `underlay` au registre plutôt qu'une exception manuelle dans l'ACL. -- **Trois switches, un seul devis** — `devis_reseau.py` émet un jeu unique de SVI sans - désigner le switch qui les porte. Sans MLAG, il reste à trancher entre « un seul routeur, - les deux autres en L2 pur » et « SVI répliqués en VRRP ». Appliquer le devis tel quel sur - les trois créerait des conflits d'adresses. +- **La syntaxe des routes n'est pas dialecte-consciente** — les ACL le sont (masque normal + en `binardat`, wildcard en `cisco`), les `ip route` de la section 5 sortent en forme Cisco + quel que soit le dialecte. À vérifier sur le matériel réel. - **La sortie générale** (mises à jour apt, ACME) n'est pas déclarée dans le registre : les hôtes sortent aujourd'hui parce que la politique `output` des nftables est `accept` et que l'ACL de switch se termine par `permit ip any`. À trancher : la déclarer diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index 97ea029..a3efb83 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -82,6 +82,61 @@ def section_underlay(underlay: dict | None) -> list[str]: 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 [] + mgmt = next((r for r in underlay_mod.reseaux(underlay) if r.get("passerelle")), None) + autres = [h for h in underlay_mod.hotes(underlay) if h.get("nom") != r_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) -----", + ] + for r in underlay_mod.reseaux(underlay): + 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 ", + " 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. @@ -174,6 +229,7 @@ def decouvrir() -> list[tuple[str, str, dict]]: 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 += [ "! ============================================================", @@ -181,6 +237,22 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) - "! 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 += [ "! ============================================================", "!", ] @@ -243,6 +315,8 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) - 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) diff --git a/scripts/underlay.py b/scripts/underlay.py index ef53834..a63349a 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -58,6 +58,15 @@ def hotes(underlay: dict | None) -> list[dict]: return (underlay or {}).get("hotes", []) or [] +def routeur(underlay: dict | None) -> str | None: + """Nom du switch qui porte les SVI, ou None si aucun n'est designe. + + Sans MLAG, UN SEUL switch route : les autres restent en L2 pur. Appliquer le + meme devis sur les trois creerait autant de conflits d'adresses que de SVI. + """ + return (underlay or {}).get("routeur") or None + + def reseau_transit(underlay: dict | None) -> dict | None: """Le reseau de TRANSIT vers la frontiere nord/sud, s'il est declare. @@ -151,6 +160,12 @@ def valider(underlay: dict | None, erreurs.append(f"un seul reseau de transit autorise (passerelle_sortie), " f"trouves : {', '.join(transits)}") + # Le routeur designe doit etre un hote declare : sinon le devis ne saurait pas + # quelle adresse de gestion donner aux switches d'acces. + r_nom = routeur(underlay) + if r_nom and r_nom not in {h.get("nom") for h in hotes(underlay)}: + erreurs.append(f"routeur '{r_nom}': aucun hote de ce nom n'est declare") + par_nom = {r.get("nom"): r for r in reseaux(underlay)} for h in hotes(underlay): hn = h.get("nom", "?") diff --git a/underlay.yml.example b/underlay.yml.example index 35eab46..1818064 100644 --- a/underlay.yml.example +++ b/underlay.yml.example @@ -15,6 +15,12 @@ # Storage/Ceph restent en general L2 pur (pas de passerelle). --- underlay: + # Switch qui porte le routage : SVI de zone, ACL d'isolation, routes. Sans MLAG, + # UN SEUL switch route ; les autres restent en L2 pur et reçoivent la partie B du + # devis. Dupliquer les SVI sur plusieurs switches creerait autant de conflits + # d'adresses qu'il y a de zones. Doit nommer un hote declare dans `hotes:`. + routeur: switch-01 + reseaux: - nom: management description: Switches, mgmt Proxmox, OOB/IPMI