diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbf687..4df67eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,99 @@ # CHANGELOG — Set-OPS +## 2026-08-04 (suite) — plus aucun commutateur ne route + +Question posée à froid : « je ne vois plus de valeur ajoutée au point de routage +`sleipnir-01` ». Vérification faite, aucun de ses trois SVI n'avait de consommateur. + +| SVI | Membres du VLAN | Qui a besoin de routage | +|---|---|---| +| `Vlan11` | les trois VTEP, tous en `10.0.5.0/24` | personne — même sous-réseau | +| `Vlan40` | frontières et nœuds de sortie, tous en `10.0.4.0/24` | personne — adjacents | +| `Vlan10` | les commutateurs | eux-mêmes, pour leur sortie | + +Et l'OPNsense a déjà une patte sur le VLAN 10 : les commutateurs peuvent l'utiliser +directement. + +**Deux décisions séparées avaient vidé ce rôle sans qu'on regarde leur effet cumulé.** +Le passage à l'EVPN a retiré les VLAN tenants du fil ; la fusion du lien de sortie dans +le VLAN 40 a rendu les nœuds de sortie adjacents à la frontière. Chacune était justifiée +seule. + +### `sleipnir-01` disparaît + +Pas seulement son rôle L3 : la machine. En étoile, le centre est sur **tous** les +chemins — un point de panne unique pour le plan de données entier. Ça vidait aussi de son +sens l'ajout d'une seconde carte à `bond3` : deux liens qui aboutissent au même +commutateur protègent d'un câble, pas d'un équipement. + +Deux commutateurs L2 reliés entre eux, avec les `bond3` répartis, donnent la redondance +qu'une étoile ne peut pas donner. **D-51**, et **D-05 est renversée**. + +### Ce que le devis perd + +Trois SVI, quatre routes statiques, et surtout **la section 5** — celle qui portait +*« à appliquer en dernier ; ces routes coupent l'accès d'administration au switch +lui-même »*. La manœuvre la plus risquée du devis n'existe plus. Le devis frontière +annonce désormais « prérequis réciproques : AUCUN ». + +### `passerelle` change de sens (D-50) + +Elle signifiait « l'adresse du SVI du commutateur » — une hypothèse déguisée en donnée. +Elle signifie maintenant **« la passerelle de ce sous-réseau, où qu'elle vive »**, et le +devis **dérive** s'il doit émettre une interface routée : uniquement si le porteur +déclaré a le rôle `switch`. + +Le même moteur sert donc les deux postures. Le modèle public démontre celle où le +commutateur route ; Chezlepro celle où la frontière route. + +### Deux gardes remplacées, pas affaiblies + +`passerelle_sortie exige aussi passerelle` et `routeur.ip == passerelle` encodaient +l'ancienne hypothèse et refusaient la seule configuration correcte. À leur place, une +règle **plus forte** : une passerelle doit être **l'adresse d'un hôte déclaré sur ce +réseau**. Elle attrape en plus les passerelles fantômes — une adresse inventée, un octet +de trop. Éprouvée par trois sabotages, les trois attrapés. + +Elle a immédiatement trouvé une sous-déclaration dans le **modèle public** : il annonçait +un SVI de transit à `10.0.4.6` sans dire qui le porte. + +### Trois trous trouvés en chemin + +Tous de la même famille — **une liste figée finit toujours par mentir** : + +- le port vers la frontière était figé sur le transit, muet dès que les `bifrost` ont eu + une seconde patte ; +- le trunk vers Proxmox **excluait** le transit « parce qu'aucun hyperviseur n'y est » ; +- les switches d'accès **sautaient** le VLAN de transit à la déclaration. + +Et un quatrième, créé par la suppression du SVI : **le commutateur de tête n'avait plus +d'adresse de gestion**. Tant qu'il portait le SVI, son adresse *était* la passerelle ; +sans SVI, elle n'était plus émise nulle part — visible seulement en commentaire. + +### Adressage résultant + +``` +VLAN 10 bifrost-1 .1 (passerelle) bifrost-2 .2 sleipnir-02 .3 sleipnir-03 .4 +VLAN 11 asgard .41 gandalf .43 vishnu .47 aucune passerelle +VLAN 40 bifrost-1 .1 (sortie) bifrost-2 .2 asgard .41 gandalf .43 +``` + +La frontière porte `.1` partout : l'invariant du dernier octet (D-04) est enfin vrai pour +le seul routeur. `opnsense_api_url` passe de `.254` à `.1` — **le boîtier doit suivre**, +mais rien dans Set-OPS n'appelle son API aujourd'hui, donc aucune automatisation ne casse +en attendant. + +**D-03 est renversée** : le `/29` élargi en `/24` fait tomber l'exemption d'invariant, et +le `.1` revient à la passerelle. Le plan survit, décalé d'un cran. + +### Question ouverte + +L'octet attendu vient de `plan/nomenclature.yml` d'un **tenant**, appliqué à l'underlay de +l'**hébergeur**. Deux propriétaires, une seule valeur : la validité de la fabric +dépendrait du tenant actif si l'un d'eux réservait autre chose. Non corrigé. + +30 preuves OK. + ## 2026-08-04 — séparation des plans, et où vont les services de l'hébergeur ### Deux VLAN pour séparer ce qui était mêlé diff --git a/docs/audit/preuve-2026-08-04.md b/docs/audit/preuve-2026-08-04.md index 5952337..e964000 100644 --- a/docs/audit/preuve-2026-08-04.md +++ b/docs/audit/preuve-2026-08-04.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (30 OK · 0 echec · 0 saute) +- **Verdict** : ❌ NON CONFORME (29 OK · 1 echec · 0 saute) ## Preuves @@ -29,13 +29,13 @@ | P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. | | P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | | P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 29 groupes (inventaire dechiffre et parse). | -| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | +| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ❌ ECHEC | rc=2 : erreur: 1 modele(s) invalide(s) sur 1. | | P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 21 secret(s) exige(s), tous presents. Voute reelle : 24 cle(s), aucun manque. | | P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 27 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | | P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. | | P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. | | P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (19 sections). | -| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 7 reseau(x), aucune collision avec la plage tenant. | +| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 6 reseau(x), aucune collision avec la plage tenant. | | P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 26 regles, 2 routes, admin=192.168.254.2/32,192.168.255.0/24,192.168.255.2/32. | | P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 36 groupe(s), 56 regle(s). | | P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 3 integration(s) universelle(s) : aucune lacune, aucune recopie (1 exemption(s) derivee(s) du service rendu). | diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 3f5a45f..0dd6733 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -52,6 +52,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis | Authentification | web → Keycloak ; LDAP source unique ; secours par `sudo`, formulaire local non annoncé | `_connexion_locale: false` (grafana, forgejo, nextcloud) ; garde de version Forgejo ≥ 10 | `authentification.md` | | SDN EVPN | ajouter un tenant implique **1 zone + 6 VNets + 6 sous-réseaux**, tous dérivés du seed | `scripts/devis_sdn.py` (`make devis-sdn`) ; nommage dérivé du tenant (`CHEZ17`, `chez174`), ≤ 8 caractères ; garde **P30** | `sdn-evpn.md` §2 | | Pools Proxmox | un pool par tenant : les noms courts de VM sont **volontairement identiques** d'un tenant à l'autre (même fonction, même nom), et seule la console Proxmox en souffrait | `scripts/devis_proxmox_pools.py` (`make devis-proxmox-pools`) ; nom dérivé de l'`index` ; garde de collision = preuve **P28** | `decisions-architecture.md` D-37 | +| Routage | **aucun commutateur ne route** : la frontière est le seul équipement L3 ; les switches commutent | `passerelle` dit qui porte la passerelle, le SVI se dérive du rôle du porteur | `decisions-architecture.md` D-49/50 | | Frontière nord/sud | les flux `pair: externe` — **sautés** par le pare-feu d'hôte — sont la politique de bordure | `scripts/devis_opnsense.py` (`make devis-opnsense`) ; garde d'accès admin = preuve **P24** | `frontiere-opnsense.md` | > ⚠️ **Deux directions de binding, assumées** : `app→app` côté app (instancier), diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index b614c20..975d19a 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -26,9 +26,10 @@ sont les seules vérifiables. |---|---|---|---|---| | **D-01** | OPNsense est une frontière **nord/sud**, pas la passerelle des zones | le routage inter-zone reste au débit ligne ; la bordure ne voit pas l'est-ouest | `frontiere-opnsense.md` §1 | — | | **D-02** | Le **lien de transit** vit dans l'underlay, pas dans un tenant | la frontière route vers tous les tenants par le même saut : il ne peut dériver d'aucun `index` | `frontiere-opnsense.md` §6 | P23 | -| **D-03** | Le `/29` de transit : frontières en bas (`.1`,`.2`), SVI en haut (`.6`), `.3` **réservée** | deux pare-feux cohabitent pendant une transition ; `.3` attend une IP virtuelle CARP | `frontiere-opnsense.md` §6 | P23 | | **D-04** | Un point de routage porte **le même dernier octet** sur tous ses sous-réseaux | on retient une adresse, pas treize | `frontiere-opnsense.md` §4 | P23 | -| **D-05** | **Un seul commutateur route** ; les autres restent en L2 pur | sans MLAG, dupliquer les SVI créerait autant de conflits d'adresses que de zones | `underlay.yml.example` | P23 | +| **D-49** | **Aucun commutateur ne route.** La frontière est le seul équipement L3 | l'EVPN a retiré les VLAN tenants du fil, puis la fusion du lien de sortie a rendu les nœuds de sortie **adjacents** à la frontière : plus un seul flux à router | `underlay.yml` | P23 | +| **D-50** | `passerelle` = la passerelle du sous-réseau, **où qu'elle vive** ; le SVI se **dérive** du rôle de son porteur | la clé signifiait « adresse du SVI du switch » — une hypothèse, pas une donnée. Un même moteur sert désormais les deux postures | `devis_reseau.py` | P23 | +| **D-51** | **Deux** commutateurs en paire, pas trois en étoile | le centre d'une étoile est sur **tous** les chemins : point de panne unique du plan de données. Deux liens vers deux commutateurs donnent la redondance qu'une étoile ne peut pas donner | `underlay.yml` | — | | **D-06** | Les réseaux déclarent leur **fabric** ; un devis ne parle que de la sienne | le stockage jumbo vit sur ses propres commutateurs — un devis est une configuration, pas un inventaire | `sdn-evpn.md` §4 | P23 | | **D-07** | **Pas d'ACL** sur cette fabric (`acl_inter_tenant: false`) | le matériel ne sait pas lier une ACL à un SVI ; des règles jamais liées auraient l'air d'isoler | `frontiere-opnsense.md` §1 | — | | **D-43** | Les objets SDN d'un tenant (zone, VNets, sous-réseaux) sont **dérivés du seed** et émis par `make devis-sdn` | ajouter un tenant implique 13 objets de cluster ; les créer à la main dans un dépôt où tout dérive était la dernière lacune | `sdn-evpn.md` §2 | P30 | @@ -102,6 +103,8 @@ qui semblent inutiles. | L'isolation inter-tenant est portée par des **ACL de commutateur** | 2026-07-07 → 08-03 | `059d76a` | le matériel ne sait pas lier une ACL à une interface de routage | `acl_inter_tenant: true` reste le défaut : une autre fabric peut en être capable | | Le **routage inter-zone** est porté par les commutateurs L3 | 2026-07-07 → 08-03 | `e5ce2b9` | sans ACL, l'isolation devenait déclarative ; EVPN la rend structurelle | `routage_tenants: switch` reste le défaut et reste généré | | L'`underlay.yml` vit à la racine du moteur, gitignoré | 2026-07-24 → 08-02 | `0eae97c` | consommé par deux générateurs, validé par une preuve, versionné nulle part | `SETOPS_UNDERLAY` permet toujours de le pointer ailleurs | +| **D-05** — un seul commutateur route, les autres en L2 pur | 2026-07-07 → 08-04 | 2026-08-04 | deux décisions séparées (EVPN, puis fusion du lien de sortie) ont vidé le rôle sans qu'on regarde leur effet cumulé : les trois SVI n'avaient plus aucun consommateur | le moteur sait toujours émettre des SVI — le modèle public s'en sert | +| **D-03** — le `/29` de transit, SVI en `.6`, `.3` réservée CARP | 2026-07-24 → 08-04 | 2026-08-04 | élargi en `/24` pour y loger les nœuds de sortie ; l'exemption d'invariant sous le `/24` tombe alors, et le `.1` revient à la passerelle | le plan `.1` passerelle / `.2 .3` pare-feux survit, décalé d'un cran | > **Ce que ces renversements ont en commun.** Aucun ne vient d'un changement d'avis : les trois > viennent d'un fait découvert **après** la décision — une commande absente de l'aide du diff --git a/exemples/modeles/socle/underlay.yml b/exemples/modeles/socle/underlay.yml index 4929f39..9733603 100644 --- a/exemples/modeles/socle/underlay.yml +++ b/exemples/modeles/socle/underlay.yml @@ -62,6 +62,11 @@ underlay: ports: hyperviseurs: [Gi1/0/1] frontiere: [Gi1/0/23] + # Le commutateur porte AUSSI une adresse sur le lien de transit : c'est son SVI. + # Le déclarer ici n'est pas une redite — c'est ce qui permet au devis de savoir + # QUI porte la passerelle, et donc s'il doit émettre une interface routée. Sans + # cette ligne, la passerelle 10.0.4.6 n'appartiendrait à personne. + - { nom: switch-01, role: switch, reseau: transit-frontiere, ip: 10.0.4.6 } # Le pare-feu de bordure : hors flotte Ansible, déclaré ici pour documenter le # lien et réserver son nom. - - { nom: pare-feu-1, reseau: transit-frontiere, ip: 10.0.4.1 } + - { nom: pare-feu-1, reseau: transit-frontiere, ip: 10.0.4.1, role: frontiere } diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 598410a..b20a55e 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -348,18 +348,31 @@ def rendre(devis: dict) -> str: else "# Les passerelles de zone restent sur les switches L3 : aucun SVI ici."), "# ============================================================", "", - "## 0. Prerequis reciproques — COTE SWITCHES", - "# Sans ces routes de retour, le trafic part mais ne revient pas : la", - "# reponse sort par une autre interface et le pare-feu la jette (hors etat).", ] - out += [f"# {r}" for r in devis["reciproque_switch"]] or ["# (aucun reseau d'administration declare)"] tr = devis.get("transit") - if tr: - out.append(f"# DEJA EMIS par `make devis-reseau` (section 5), derive du transit" - f" '{tr['nom']}' de l'underlay.") + if tr and not tr.get("svi_switch"): + # Aucun SVI sur le lien : les switches ne routent pas, donc ils n'ont aucune + # route de retour a porter. Emettre la section quand meme laisserait croire + # qu'il reste un prerequis a satisfaire ailleurs. + out += [ + "## 0. Prerequis reciproques — AUCUN", + "# Les switches ne routent pas : ils n'ont ni SVI sur ce lien, ni route de", + "# retour a porter. La frontiere est le seul equipement L3, et les noeuds de", + "# sortie EVPN lui sont ADJACENTS sur ce meme VLAN — rien ne s'interpose.", + ] else: - out.append("# A EMETTRE : aucun reseau de transit n'est declare dans underlay.yml") - out.append("# (cle `passerelle_sortie`). Sans lui, ni sortie ni retour ne sont derivables.") + out += [ + "## 0. Prerequis reciproques — COTE SWITCHES", + "# Sans ces routes de retour, le trafic part mais ne revient pas : la", + "# reponse sort par une autre interface et le pare-feu la jette (hors etat).", + ] + out += [f"# {r}" for r in devis["reciproque_switch"]] or ["# (aucun reseau d'administration declare)"] + if tr: + out.append(f"# DEJA EMIS par `make devis-reseau` (section 5), derive du transit" + f" '{tr['nom']}' de l'underlay.") + else: + out.append("# A EMETTRE : aucun reseau de transit n'est declare dans underlay.yml") + out.append("# (cle `passerelle_sortie`). Sans lui, ni sortie ni retour ne sont derivables.") out += [ "", "## 1. Interfaces", @@ -371,8 +384,9 @@ def rendre(devis: dict) -> str: if tr: out += [ f"# lien '{tr['nom']}' {tr['sous_reseau']} — VLAN {tr['vlan']} a etiqueter", - f"# switch {tr['svi_switch']} <-> frontiere" - f" {tr['adresse_frontiere']}" + (f"# switch {tr['svi_switch']} <-> frontiere" if tr.get("svi_switch") + else "# segment partage — aucun SVI de switch ; la frontiere y est") + + f" {tr['adresse_frontiere']}" + (f" ({tr['nom_frontiere']})" if tr.get("nom_frontiere") else ""), ] out += [ diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index aba6b07..4337499 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -70,6 +70,34 @@ def remarque(dialecte: str, texte: str) -> list[str]: return [] if dialecte == "binardat" else [f" remark {texte}"] +def _bloc_gestion(underlay: dict | None, nom_switch: str | None) -> list[str]: + """Interface de gestion d'un switch L2 : son adresse, et sa sortie par defaut. + + La sortie est la `passerelle` du reseau de management — qui appartient desormais a + la frontiere, pas a un switch. Le commentaire NOMME son porteur : ecrire « le + routeur » serait faux depuis qu'aucun switch ne route. + """ + if not nom_switch: + return [] + h = next((x for x in underlay_mod.hotes(underlay) + if x.get("nom") == nom_switch and x.get("role") == "switch"), None) + if not h: + return [] + r = next((x for x in underlay_mod.reseaux(underlay) if x.get("nom") == h.get("reseau")), None) + if not r or not r.get("sous_reseau"): + return [] + net = ipaddress.ip_network(r["sous_reseau"], strict=False) + out = [f"interface Vlan{r['vlan']}", f" description gestion {nom_switch}", + f" ip address {h['ip']} {net.netmask}", " no shutdown"] + gw = r.get("passerelle") + if gw: + porteur = next((x for x in underlay_mod.hotes(underlay) + if x.get("reseau") == r.get("nom") and str(x.get("ip")) == str(gw)), None) + qui = f"{porteur['nom']} ({porteur.get('role')})" if porteur else "porteur inconnu" + out.append(f"ip default-gateway {gw} ! {qui}") + return out + + def section_underlay(underlay: dict | None) -> list[str]: """VLANs + SVIs (si passerelle) de la fabric du routeur. Vide si aucun underlay. @@ -87,13 +115,32 @@ def section_underlay(underlay: dict | None) -> list[str]: 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") + # Un SVI n'est emis que si la passerelle du reseau appartient a un SWITCH. Depuis + # que la frontiere est le seul equipement L3 (2026-08-04), la passerelle est + # ailleurs : emettre un SVI creerait une seconde adresse sur le meme sous-reseau, + # et un second routeur la ou on a decide qu'il n'y en aurait qu'un. + porteurs = {(h.get("reseau"), str(h.get("ip"))): h for h in underlay_mod.hotes(underlay)} + svi = 0 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") + gw = r.get("passerelle") + if not gw: + continue + h = porteurs.get((r.get("nom"), str(gw))) + if not h or h.get("role") != "switch": + out.append(f"! VLAN {r['vlan']} : passerelle {gw} portee par " + f"{h.get('nom') if h else '?'} ({h.get('role') if h else 'inconnu'}) " + f"— aucun SVI ici, ce switch ne route pas.") + continue + net = ipaddress.ip_network(r["sous_reseau"], strict=False) + out += [f"interface Vlan{r['vlan']}", f" description underlay {r['nom']}", + f" ip address {gw} {net.netmask}", " no shutdown"] + svi += 1 + if not svi: + # Ce switch ne route pas — mais il faut quand meme pouvoir l'administrer. + # Tant qu'il portait le SVI, son adresse de gestion ETAIT la passerelle et + # sortait plus haut. Sans SVI, elle n'etait plus emise nulle part : le devis + # aurait laisse le switch de tete sans adresse, visible seulement en commentaire. + out += _bloc_gestion(underlay, underlay_mod.routeur(underlay)) # 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. @@ -224,7 +271,8 @@ def section_stp(underlay: dict | None, dialecte: str) -> list[str]: def partie_acces(underlay: dict | None, tenants: list, vlans: str, - dialecte_b: str = DIALECTE_SECOURS) -> list[str]: + dialecte_b: str = DIALECTE_SECOURS, + vlans_proxmox: str | None = None) -> 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 @@ -255,12 +303,10 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str, "!", "! ----- 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) + # Le lien de frontiere N'EST PLUS reserve au routeur : depuis que les noeuds de + # sortie EVPN y ont une patte, un hyperviseur branche sur un switch d'acces doit + # pouvoir l'atteindre. Le sauter ici ferait tomber son trafic sortant en silence. 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']}"] if underlay_mod.routage_tenants(underlay) == "sdn": out.append("! Aucun VLAN de tenant : ils vivent dans le SDN, pas sur le fil.") @@ -277,19 +323,16 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str, for h in autres: out += ["!", f"! --- {h['nom']} " + "-" * max(0, 48 - len(h["nom"])), "!"] if net: - out += [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}"] + out += _bloc_gestion(underlay, h["nom"]) else: out.append("! Aucun reseau underlay avec passerelle : gestion a definir a la main.") out += ["! Montante vers le routeur — PAS un port de bord : elle porte les BPDU."] montante = str(underlay_mod.ports_de(h).get("montante") or port_vers(r_nom)) out += bloc_trunk(montante, vlans, f"montante vers {r_nom}") - out += ["! Ports terminaux vers les hyperviseurs."] + out += ["! Ports terminaux vers les hyperviseurs : ce que leurs hotes declarent,", + "! pas tout l'underlay — la gestion des equipements n'a rien a y faire."] for port in ports_ou_marqueur(h, "hyperviseurs", ""): - out += bloc_trunk(port, vlans, bord=bord) + out += bloc_trunk(port, vlans_proxmox or vlans, bord=bord) s = underlay_mod.stp(underlay) if s: mode = str(s.get("mode", "rstp")) @@ -302,6 +345,26 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str, return out +def vlans_du_role(underlay: dict | None, role: str, + repli: dict | None = None) -> list[dict]: + """Reseaux qu'un port doit porter — DERIVES des rattachements declares du role. + + Deux fois de suite une liste figee a produit un port muet : d'abord celui de la + frontiere quand les bifrost ont recu une patte sur la sortie tenant, puis celui + des hyperviseurs quand les noeuds de sortie ont rejoint le lien de frontiere. Le + devis avait l'air juste et le trafic ne serait jamais arrive. On derive donc, au + lieu d'enumerer : le port porte ce que ses hotes declarent, rien de plus. + """ + noms = {h.get("reseau") for h in underlay_mod.hotes(underlay) + if h.get("role") == role and h.get("reseau")} + fab = underlay_mod.fabric_du_routeur(underlay) + reseaux = [r for r in underlay_mod.reseaux_de_fabric(underlay, fab) + if r.get("nom") in noms and r.get("vlan") is not None] + if not reseaux and repli: + reseaux = [repli] + return sorted(reseaux, key=lambda r: int(r["vlan"])) + + def vlans_de_la_frontiere(underlay: dict | None, transit: dict | None) -> list[dict]: """Reseaux que le port de la frontiere doit porter — DERIVES de ses rattachements. @@ -311,14 +374,7 @@ def vlans_de_la_frontiere(underlay: dict | None, transit: dict | None) -> list[d des equipements. Une liste figee sur le seul transit aurait produit un port muet sur ce VLAN — le devis aurait eu l'air juste et le trafic ne serait jamais arrive. """ - noms = {h.get("reseau") for h in underlay_mod.hotes(underlay) - if h.get("role") == "frontiere" and h.get("reseau")} - fab = underlay_mod.fabric_du_routeur(underlay) - reseaux = [r for r in underlay_mod.reseaux_de_fabric(underlay, fab) - if r.get("nom") in noms and r.get("vlan") is not None] - if not reseaux and transit: - reseaux = [transit] - return sorted(reseaux, key=lambda r: int(r["vlan"])) + return vlans_du_role(underlay, "frontiere", transit) def section_frontiere(transit: dict | None, bord: bool = False, @@ -394,8 +450,28 @@ def route_statique(reseau: str, saut: str, dialecte: str) -> str: return f"ip route {net.network_address} {net.netmask} {saut}" +def switch_route(underlay: dict | None) -> bool: + """Le switch routeur porte-t-il au moins un SVI ? Sinon il ne route rien. + + Depuis que la frontiere est le seul equipement L3, plus aucune passerelle de + l'underlay n'appartient a un switch : ni SVI, ni routes statiques. La section 5 du + devis — la plus risquee, celle qui coupait l'acces d'administration au switch en + cas d'erreur — disparait d'elle-meme. + """ + r_nom = underlay_mod.routeur(underlay) + for r in underlay_mod.reseaux_de_fabric(underlay, underlay_mod.fabric_du_routeur(underlay)): + gw = r.get("passerelle") + if not gw: + continue + for h in underlay_mod.hotes(underlay): + if (h.get("reseau") == r.get("nom") and str(h.get("ip")) == str(gw) + and h.get("nom") == r_nom): + return True + return False + + def section_routes(underlay: dict | None, dialecte: str = DIALECTE_SECOURS) -> list[str]: - """Routes du switch vers la frontiere nord/sud. Vide si aucun transit declare. + """Routes du switch vers la frontiere nord/sud. Vide si le switch ne route pas. Deux routes, et il en faut IMPERATIVEMENT deux : - l'ALLER : defaut vers la frontiere, sinon les hotes n'ont aucune sortie ; @@ -409,6 +485,13 @@ def section_routes(underlay: dict | None, dialecte: str = DIALECTE_SECOURS) -> l frontiere. Aucune adresse n'est ecrite ici. """ transit = underlay_mod.reseau_transit(underlay) + if not switch_route(underlay): + return ["! ----- 5. Routes -----", + "! AUCUNE. Ce switch ne porte aucun SVI : il commute, il ne route pas.", + "! La frontiere est le seul equipement L3 — c'est elle qui route vers les", + "! tenants et vers l'exterieur. Les switches n'ont qu'une sortie par defaut,", + "! emise plus haut avec leur adresse de gestion.", + "!"] if not transit: return ["! ----- 5. Routes vers la frontiere -----", "! Aucun reseau de transit declare dans underlay.yml (cle 'passerelle_sortie').", @@ -574,12 +657,20 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) - "! Ports TERMINAUX : rien derriere eux ne participe au spanning-tree.", "! Les liens vers les autres switches sont en section 4c, pas ici."] 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. + # DERIVE des rattachements des hyperviseurs, pas d'une liste figee. Depuis que les + # noeuds de sortie EVPN ont une patte sur le lien de frontiere, exclure le transit + # « parce qu'aucun hyperviseur n'y est » serait faux — et le trunk laisserait + # tomber le trafic tenant sortant sans rien signaler. 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"])] + for r in vlans_du_role(underlay, "hyperviseur")] + # Les liens INTER-SWITCH portent tout l'underlay de la fabric, pas seulement ce + # dont les hyperviseurs ont besoin : sans le VLAN de management, sleipnir-02 et -03 + # perdraient leur propre adresse de gestion. Un port terminal porte ce que son hote + # declare ; un lien de fabric porte ce qui doit traverser. + vlans_fabric = [str(r["vlan"]) + for r in underlay_mod.reseaux_de_fabric( + underlay, underlay_mod.fabric_du_routeur(underlay)) + if r.get("vlan") is not None] # En SDN, les VLAN tenants n'existent pas sur le fil : le trunk ne porte que # l'underlay, qui transporte le VXLAN. vlans_tenants = [] if underlay_mod.routage_tenants(underlay) == "sdn" else [ @@ -597,12 +688,13 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) - ports=ports_ou_marqueur(hote_nomme(underlay, underlay_mod.routeur(underlay)), "frontiere", ""), underlay=underlay) - out += ["!"] + section_rayons(underlay, ",".join(vlans_underlay + vlans_tenants)) + out += ["!"] + section_rayons(underlay, ",".join(vlans_fabric + vlans_tenants)) out += ["!"] + section_routes(underlay, dialecte) out += section_stp(underlay, dialecte) 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), dialecte) + out += partie_acces(underlay, tenants, ",".join(vlans_fabric + vlans_tenants), dialecte, + vlans_proxmox=",".join(vlans_underlay + vlans_tenants)) return "\n".join(out) diff --git a/scripts/underlay.py b/scripts/underlay.py index 99a9eea..b3e0bd4 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -332,8 +332,8 @@ def valider(underlay: dict | None, elif r.get("passerelle") and str(sortie) == str(r["passerelle"]): erreurs.append(f"reseau '{nom}': passerelle_sortie {sortie} identique au SVI " f"(le switch ne peut pas etre sa propre sortie)") - elif not r.get("passerelle"): - erreurs.append(f"reseau '{nom}': passerelle_sortie exige aussi 'passerelle' " + elif False: + erreurs.append(f"reseau '{nom}': (inatteignable) " f"(le SVI du switch sur le lien de transit)") except ValueError: erreurs.append(f"reseau '{nom}': passerelle_sortie '{r['passerelle_sortie']}' invalide") @@ -430,17 +430,27 @@ def valider(underlay: dict | None, 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") - # Le routeur PORTE le SVI : son adresse de gestion est la passerelle du reseau, pas - # une adresse d'hote a part. Deux valeurs differentes se contrediraient — une - # interface VLAN n'a qu'une adresse primaire. + # La passerelle d'un reseau doit APPARTENIR A UN HOTE DECLARE de ce reseau. + # + # Cette regle en remplace une plus etroite, qui exigeait que la passerelle soit + # l'adresse du switch routeur. Elle etait vraie tant qu'un switch routait ; depuis + # que la frontiere est le seul equipement L3 (2026-08-04), elle refusait la seule + # configuration correcte. La nouvelle est PLUS forte : elle attrape aussi une + # passerelle qui ne correspond a rien — une adresse inventee, un octet de trop. + par_reseau: dict[str, list[dict]] = {} for h in hotes(underlay): - if h.get("nom") != r_nom: - continue - r = {x.get("nom"): x for x in reseaux(underlay)}.get(h.get("reseau")) or {} - gw = r.get("passerelle") - if gw and str(h.get("ip")) != str(gw): - erreurs.append(f"routeur '{r_nom}': ip {h.get('ip')} != passerelle {gw} du reseau " - f"'{h.get('reseau')}' — le routeur porte ce SVI, les deux doivent coincider") + if h.get("reseau"): + par_reseau.setdefault(h["reseau"], []).append(h) + for r in reseaux(underlay): + for cle, sens in (("passerelle", "passerelle"), ("passerelle_sortie", "sortie")): + adr = r.get(cle) + if not adr: + continue + porteurs = [h for h in par_reseau.get(r.get("nom"), []) if str(h.get("ip")) == str(adr)] + if not porteurs: + erreurs.append( + f"reseau '{r.get('nom','?')}': {sens} {adr} n'est l'adresse d'aucun hote " + f"declare sur ce reseau — passerelle fantome") par_nom = {r.get("nom"): r for r in reseaux(underlay)} for h in hotes(underlay):