diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d7f04..ee644a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ ## 2026-08-06 — le chemin nord-sud devient dérivable +### Les tenants sortent — et le chemin est entièrement dérivé + +Bout en bout, mesuré depuis `vrf_t17` puis `vrf_t11` sur `asgard` : + +``` +tenant -> 10.0.4.1 (frontière) 3/3 0.14 ms +tenant -> 69.70.26.49 (passerelle FAI) 3/3 0.31 ms +tenant -> 9.9.9.9 (Internet) 2/2 11 ms (Chezlepro) + 2/2 17 ms (Technolibre) +``` + +Il a fallu lever **deux** obstacles, et aucun des deux n'était celui qu'on croyait. + +**Proxmox n'installe aucun défaut dans le VRF du tenant (D-62).** Déclarer des nœuds de +sortie ne suffit pas : `default-originate` *annonce* une route aux autres nœuds, il n'en pose +pas chez lui. `show ip route vrf vrf_tNN 0.0.0.0/0` était vide sur les **deux** zones — et +`t11` était configuré depuis plus longtemps, donc ce n'était pas un oubli récent. + +La sortie vient d'une strophe dans `/etc/frr/frr.conf.local`, que Proxmox **fusionne** à +chaque régénération (`EvpnPlugin.pm`, `read_local_frr_config`). Vérifié : la ligne se retrouve +dans le `frr.conf` généré, survit à `pvesh set /cluster/sdn` **et** à un +`systemctl restart frr`. + +Le choix de construction est le cœur de l'affaire. `nexthop-vrf default` emprunte **une +adresse** — celle de la frontière, connectée sur `vlan40` — au lieu d'importer la table +principale. Conséquence voulue et vérifiée : **la route par défaut des hyperviseurs ne +gouverne pas la sortie des tenants**, et peut rester où elle est. `import vrf default`, le +geste « simple », aurait fait sortir les tenants par `192.168.11.254` en contournant la +frontière, tout en leur donnant `10.0.5.0/24` (transport VXLAN), la gestion et les VLAN +hérités. + +**Le NAT sortant ne couvrait pas les supernets tenants (D-63).** Le mode « automatique » +d'OPNsense ne traduit que les réseaux *directement attachés* ; un supernet joint par route +statique en sort silencieusement. Le diagnostic est venu d'un ping vers la passerelle du FAI +— un seul saut, donc aucune ambiguïté sur l'origine de la panne — puis de la table d'états : + +``` +état 10.27.19.1 -> 69.70.26.49 icmp 0:0 nat_addr : absent +``` + +Le filtre **laissait passer** (un état n'existe que si une règle a autorisé) ; c'est la +traduction qui manquait. `devis_opnsense` émet désormais une règle de NAT par tenant +(section 2bis), le réconciliateur les applique et les retire par +`/api/firewall/source_nat/*`, et **P24 refuse tout supernet routé mais non traduit** — la +garde qui aurait nommé la panne du premier coup. + +`devis_sdn` §4 émet la strophe FRR : le nom du VRF vient de l'`index`, l'adresse de la +frontière vient de `passerelle_sortie` dans l'underlay. Rien n'est saisi. + + ### Le réconciliateur sait enfin retirer `scripts/appliquer_opnsense.py` remplace les scripts jetables qui appliquaient la frontière diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index d851aba..f61abe8 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -43,6 +43,8 @@ sont les seules vérifiables. | **D-11** | La **sortie générale est déclarée** dans le registre des flux | `block out` est un vrai default-deny ; un besoin oublié ne se manifeste pas par un refus clair | `frontiere-opnsense.md` §8 | P09 | | **D-29** | L'**overlay EVPN plafonne à 1450** ; le transport doit donc dépasser 1500 | choix d'exploitation ; l'encapsulation VXLAN coûte 50 octets | `sdn-evpn.md` §5 | P23 (MTU du transport) | | **D-30** | L'**ICMP « fragmentation nécessaire » est déclaré**, dans les deux sens | à 1450, tout ce qui traverse la frontière dépend de la découverte de MTU de chemin ; une bordure en default-deny la casse en silence | `roles/serveur_debian/meta/flux.yml` | P09 | +| **D-62** | La sortie d'un VRF tenant emprunte **un seul saut** (`nexthop-vrf default` vers la frontière), jamais la table principale | `import vrf default` ferait hériter au tenant le défaut de l'hyperviseur — sortie **contournant la frontière** — et lui donnerait au passage le transport VXLAN, la gestion et les VLAN hérités. Un saut emprunté n'est pas une table héritée | `devis_sdn.py` §4 | P30 | +| **D-63** | Tout supernet **routé** doit être **traduit** : le NAT sortant est dérivé, pas laissé en « automatique » | le mode automatique ne couvre que les réseaux *directement attachés* ; un supernet joint par route statique en sort sans avertissement. Panne muette : le filtre passe, un état s'ouvre, rien ne revient | `devis_opnsense.py` §2bis | P24 | | **D-61** | L'**interface d'une règle se dérive de l'attachement réel** de sa source, jamais du sens du flux | une règle OPNsense est toujours `in` sur l'interface d'arrivée ; posée ailleurs elle ne peut **jamais** correspondre, et le devis affirme alors un accès qui n'existe pas | `frontiere-opnsense.md` §7 | P24 | | **D-60** | Nommage : tout l'ensemble de bordure est **`bifrost-N`**, et **N est son dernier octet** | on retient un chiffre au lieu d'une table ; le nom ne dit plus le type de la machine, c'est le champ `role` qui le fait — et lui seul pilote le devis | `underlay.yml` | — | diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index 671bf0a..9346879 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -92,6 +92,25 @@ def cle_regle(r: dict) -> str: f"{r['source']}->{r['destination']}:{ports}") +def cle_nat(n: dict) -> str: + """Identite d'une regle de NAT sortant, meme principe que `cle_regle`.""" + return f"setopsnat:{n['tenant']}:{n['interface']}:{n['source']}->{n['destination']}:{n['cible']}" + + +def _corps_nat(n: dict, k: str) -> dict: + return { + "enabled": "1", + "sequence": "100", + "interface": n["interface"], + "ipprotocol": "inet", + "protocol": "any", + "source_net": n["source"], + "destination_net": n["destination"], + "target": n["cible"], + "description": f"{k} — sortie tenant", + } + + def _corps_regle(r: dict, k: str) -> dict: corps = { "enabled": "1", @@ -135,8 +154,20 @@ def plan(api: Frontiere, devis: dict) -> dict: # suppression echouerait, et le boitier resterait a moitie reconcilie. survivants = {r["source"] for k, r in voulues.items()} survivants |= {r["destination"] for r in devis["regles"]} + survivants |= {n["source"] for n in devis.get("nat") or []} # references par le NAT + + nat_voulus = {cle_nat(n): n for n in devis.get("nat") or []} + nat_poses = {} + for x in (api("/api/firewall/source_nat/search_rule/", + {"current": 1, "rowCount": 1000}).get("rows") or []): + d = str(x.get("description") or "") + if d.startswith("setopsnat:"): + nat_poses[d.split(" — ")[0]] = x return { + "nat_creer": {k: n for k, n in nat_voulus.items() if k not in nat_poses}, + "nat_garder": {k for k in nat_voulus if k in nat_poses}, + "nat_retirer": {k: x for k, x in nat_poses.items() if k not in nat_voulus}, "alias_creer": {n: a for n, a in al_voulus.items() if n not in al_poses}, "alias_majer": {n: a for n, a in al_voulus.items() if n in al_poses @@ -158,17 +189,25 @@ def afficher(p: dict) -> bool: for k, r in sorted(p["regles_creer"].items()): print(f" + regle {r['interface']:<5} {r['protocole']:<4} " f"{str(r['ports'] or ''):<9} {r['source'][:30]:<30} -> {r['destination'][:24]}") + for k, n in sorted(p["nat_creer"].items()): + print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} " + f"-> {n['cible']}") for k, x in sorted(p["regles_retirer"].items()): print(f" - regle PERIMEE {str(x.get('interface')):<5} " f"{str(x.get('protocol')):<4} {str(x.get('destination_port') or ''):<9} " f"{str(x.get('source_net'))[:30]:<30} -> {str(x.get('destination_net'))[:24]}") + for k, x in sorted(p["nat_retirer"].items()): + print(f" - NAT PERIME {str(x.get('interface')):<5} " + f"{str(x.get('source_net'))[:30]:<30} -> {str(x.get('target'))}") for n in sorted(p["alias_retirer"]): print(f" - alias ORPHELIN {n}") - print(f"\n a creer : {len(p['alias_creer']) + len(p['regles_creer'])}" - f" | a retirer : {len(p['regles_retirer']) + len(p['alias_retirer'])}" - f" | inchange : {len(p['regles_garder'])}") + creer = len(p["alias_creer"]) + len(p["regles_creer"]) + len(p["nat_creer"]) + retirer = len(p["regles_retirer"]) + len(p["alias_retirer"]) + len(p["nat_retirer"]) + print(f"\n a creer : {creer} | a retirer : {retirer}" + f" | inchange : {len(p['regles_garder']) + len(p['nat_garder'])}") return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer", - "regles_creer", "regles_retirer")) + "regles_creer", "regles_retirer", + "nat_creer", "nat_retirer")) def appliquer(api: Frontiere, p: dict) -> int: @@ -200,10 +239,14 @@ def appliquer(api: Frontiere, p: dict) -> int: # qu'avant, et si le retrait echoue on reste en surcouverture, jamais en trou. for k, r in sorted(p["regles_creer"].items()): _fait(api("/api/firewall/filter/add_rule/", {"rule": _corps_regle(r, k)}), f"regle {k}") + for k, n in sorted(p["nat_creer"].items()): + _fait(api("/api/firewall/source_nat/add_rule/", {"rule": _corps_nat(n, k)}), f"nat {k}") # 3. Retrait des perimees, puis des alias devenus orphelins. for k, x in sorted(p["regles_retirer"].items()): _fait(api(f"/api/firewall/filter/del_rule/{x['uuid']}", {}), f"retrait {k}") + for k, x in sorted(p["nat_retirer"].items()): + _fait(api(f"/api/firewall/source_nat/del_rule/{x['uuid']}", {}), f"retrait nat {k}") for n, x in sorted(p["alias_retirer"].items()): _fait(api(f"/api/firewall/alias/delItem/{x['uuid']}", {}), f"retrait alias {n}") @@ -212,6 +255,7 @@ def appliquer(api: Frontiere, p: dict) -> int: return 1 print(" alias :", api("/api/firewall/alias/reconfigure/", {}).get("status", "?")) print(" regles :", str(api("/api/firewall/filter/apply/", {}).get("status", "?")).strip()) + print(" nat :", str(api("/api/firewall/source_nat/apply/", {}).get("status", "?")).strip()) return 0 diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 429ce63..9eaa53e 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -404,6 +404,21 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: for nom, _pfx, n in tenants ] + # NAT sortant : une regle par tenant, source = son alias de supernet, cible = l'adresse + # du WAN. Le mode automatique d'OPNsense ne couvre QUE les reseaux directement attaches ; + # un supernet joint par route statique en sort sans avertissement. Mesure du 2026-08-06 : + # le filtre laissait passer, un etat s'ouvrait sans `nat_addr`, et rien ne revenait. + nat = [ + { + "interface": if_wan, + "source": f"SETOPS_TENANT_{pfx}{n['index']}", + "destination": "any", + "cible": "wanip", + "tenant": nom, + } + for nom, pfx, n in tenants + ] + # Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois : # elle sert au prochain saut des routes ET au commentaire qui l'explique. _ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit) @@ -420,6 +435,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "reseaux_gestion": reseaux_gestion, "alias": alias, "routes": routes, + "nat": nat, "regles": regles, "admin": admin, "tenants_sans_inventaire": tenants_sans_inventaire, @@ -544,6 +560,22 @@ def rendre(devis: dict) -> str: ] for r in devis["routes"]: out.append(f"route add {r['reseau']} via {r['prochain_saut']} # {r['description']}") + out += ["", "## 2bis. NAT sortant (traduction des supernets tenants)"] + if devis.get("nat"): + out += [ + "# Une route ne suffit pas : sans traduction, le paquet tenant quitte le WAN avec", + "# sa source PRIVEE et rien ne peut lui repondre. La panne est muette — le filtre", + "# laisse passer, un etat s'ouvre, et aucune reponse n'arrive jamais.", + "#", + "# Le mode « automatique » d'OPNsense ne traduit que les reseaux DIRECTEMENT", + "# ATTACHES. Un supernet tenant est joint par une ROUTE STATIQUE : il tombe hors", + "# de ce perimetre sans que rien ne le signale.", + ] + for n in devis["nat"]: + out.append(f"nat on {n['interface']:8} from {n['source']:28} to any -> {n['cible']}" + f" # {n['tenant']}") + else: + out.append("# Aucun tenant federe : rien a traduire.") out += ["", "## 3. Alias"] for nom, a in devis["alias"].items(): contenu = ", ".join(a["contenu"]) if a["contenu"] else "(VIDE)" @@ -609,6 +641,17 @@ def verifier(devis: dict) -> tuple[bool, list[str]]: # ne correspond jamais : le devis affirme un acces qui n'existe pas, et la panne ne se # voit qu'a l'usage. On confronte donc chaque source d'administration a l'endroit ou # son reseau est reellement attache. + # Une route vers un supernet sans NAT correspondant est le defaut MUET du 2026-08-06 : + # le filtre autorise, un etat s'ouvre, et aucune reponse ne revient jamais. On exige donc + # que tout supernet route soit aussi traduit. + traduits = {n["source"] for n in devis.get("nat") or []} + for nom, a in devis["alias"].items(): + if nom.startswith("SETOPS_TENANT_") and nom not in traduits: + erreurs.append( + f"Supernet {nom} route mais NON traduit : ses paquets quitteraient le WAN " + "avec une source privee. Le filtre laisserait passer et rien ne reviendrait." + ) + gestion = devis.get("reseaux_gestion") or [] for r in devis["regles"]: src = str(r.get("source") or "") diff --git a/scripts/devis_sdn.py b/scripts/devis_sdn.py index d107444..3030aaf 100644 --- a/scripts/devis_sdn.py +++ b/scripts/devis_sdn.py @@ -144,11 +144,15 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "mtu": mtu, "vnets": vnets, }) + # Adresse de la frontiere sur le lien de transit — le seul saut que le VRF d'un tenant + # a le droit d'emprunter. DERIVEE de l'underlay, jamais saisie ici. + _tr = underlay_mod.reseau_transit(underlay_mod.charger()) or {} return { "controleur": sdn.get("controleur") or "", "asn": sdn.get("asn"), "noeuds_de_sortie": sdn.get("noeuds_de_sortie") or [], "anciennes": list(ANCIEN_NOMMAGE), + "frontiere": str(_tr.get("passerelle_sortie") or "").strip(), "zones": blocs, } @@ -213,7 +217,43 @@ def rendre(devis: dict) -> str: f"{v['passerelle']:<17} {v['libelle']}") out.append("") out += [ - "## 4. Appliquer", + "## 4. La sortie du VRF — /etc/frr/frr.conf.local, SUR CHAQUE NOEUD DE SORTIE", + "#", + "# Proxmox declare les noeuds de sortie mais n'installe AUCUN defaut dans le VRF du", + "# tenant : `default-originate` ANNONCE une route aux autres noeuds, il n'en pose pas", + "# chez lui. Mesure du 2026-08-06 : `show ip route vrf vrf_tNN 0.0.0.0/0` etait vide", + "# sur les deux zones, et aucun tenant ne pouvait sortir.", + "#", + "# `nexthop-vrf default` emprunte UNE adresse a la table principale — celle de la", + "# frontiere, connectee sur le lien de transit — au lieu d'importer cette table.", + "# Consequence voulue : la route par defaut des hyperviseurs ne gouverne PAS la sortie", + "# des tenants et peut changer sans effet ici.", + "#", + "# `import vrf default` ferait l'inverse : le VRF heriterait de la table principale,", + "# donc du defaut de l'hyperviseur — sortie CONTOURNANT la frontiere — et verrait au", + "# passage le transport VXLAN, la gestion et les VLAN herites. Ne pas l'utiliser.", + "#", + "# Proxmox FUSIONNE ce fichier a chaque regeneration (EvpnPlugin.pm,", + "# read_local_frr_config) : la strophe survit a `pvesh set /cluster/sdn` et a un", + "# redemarrage de FRR. Verifie le 2026-08-06.", + ] + if not devis.get("frontiere"): + out += [ + "# /!\\ AUCUNE `passerelle_sortie` dans le reseau de transit de l'underlay :", + "# impossible de deriver le saut de sortie. Strophe OMISE plutot que fausse.", + ] + elif not sortie: + out += ["# /!\\ Aucun noeud de sortie : strophe OMISE (elle n'aurait aucun porteur)."] + else: + out += [f"# A poser sur : {', '.join(sortie)}", "cat >> /etc/frr/frr.conf.local <<'EOF'"] + for b in devis["zones"]: + out += [f"vrf vrf_{b['zone']}", + f" ip route 0.0.0.0/0 {devis['frontiere']} nexthop-vrf default", + "exit-vrf"] + out += ["EOF", "systemctl reload frr"] + out += [ + "", + "## 5. Appliquer", "# Les objets ci-dessus ne sont que DECLARES tant que la configuration SDN n'est", "# pas poussee sur les noeuds. Rien ne change avant cette ligne.", "pvesh set /cluster/sdn",