From 3ef470be76d3c6d1470de3670ffef5c63ec81654 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 9 Aug 2026 16:44:58 -0400 Subject: [PATCH] frontiere : ne declarer que ce qui existe, et reconcilier les routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire les deux routes /16 (les douze /24 attribues suffisent) et retrecit les alias SETOPS_TENANT_* du supernet aux memes /24 : nos propres regles autorisaient jusqu'ici « admin -> tout le /16:22 ». Ajoute la garde qui l'aurait attrape : verifier() exige que l'ensemble des reseaux routes et l'ensemble des reseaux autorises coincident exactement. Attachee a P24, qui ne verifiait que la traduction NAT. Fait entrer les routes dans appliquer_opnsense.py — elles etaient posees a la main, donc reconciliees par rien : identite portee par la description (setopsroute::->), creation avant retrait, perimetre strict. Le nom de passerelle est resolu depuis l'adresse du prochain saut. Le symptome du connect() qui aboutit toujours subsiste et n'est pas de notre fait : l'etat pf porte la regle d'usine « Default allow LAN to any rule ». Mesure qui tranche : depuis une VM du tenant, 172.31.99.99 « s'etablit » en 1 ms sans rendre de banniere. Verifie : frontiere-plan sans ecart (12 routes), flotte 14/14, prouver.py 0. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 43 +++++++++++++++++++ docs/frontiere-opnsense.md | 23 +++++++--- scripts/appliquer_opnsense.py | 79 +++++++++++++++++++++++++++++++++-- scripts/devis_opnsense.py | 51 +++++++++++++++++++--- 4 files changed, 182 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7a5d7a..c7c52aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # CHANGELOG — Set-OPS +## 2026-08-09 — La frontière ne déclare plus que ce qui existe, et l'applicateur possède enfin ses routes + +Suite directe de l'enquête ci-dessous, menée jusqu'au bout à l'instrument plutôt qu'à +l'hypothèse. Trois corrections, dans l'ordre où la mesure les a imposées. + +**1. Retrait des deux routes `/16` de la frontière.** Les douze `/24` réellement attribués +étant en place, les supernets ne servaient plus qu'à envoyer vers le nœud de sortie des +destinations qui n'existent nulle part. Retirées par l'API après vérification que les +quatorze hôtes planifiés tombent tous dans les six `/24`. Flotte : 14/14 avant, 14/14 après. + +**2. Les alias de tenant valaient le supernet.** Le retrait des `/16` n'a rien changé au +symptôme, ce qui a désigné le vrai coupable : `SETOPS_TENANT_CHEZ17` valait `10.27.0.0/16`, +donc **nos propres règles** autorisaient `admin → tout le /16:22`. L'état pf portait la +description de notre règle. Les alias énumèrent désormais les sous-réseaux attribués — +même geste que pour les routes, et pour la même raison. Ils servent à la fois de +destination aux règles et de source au NAT sortant : les deux se resserrent ensemble. + +**3. Une garde pour que les deux ne divergent plus.** `verifier()` exige maintenant que +l'ensemble des réseaux **routés** et l'ensemble des réseaux **autorisés** coïncident +exactement. Un alias plus large laisse le filtre approuver l'inexistant ; un alias plus +étroit fait acheminer vers ce que le filtre refuse. Les deux pannes se voient au devis, +plus à l'usage. Attachée à P24, qui ne vérifiait jusqu'ici que la traduction NAT. + +**Et le symptôme, alors ?** Il subsiste, et il n'est ni dans nos règles ni dans nos routes. +L'état pf porte désormais le nom de la règle d'usine `Default allow LAN to any rule`, qui +répond au SYN à la place de la destination. Mesure qui tranche : **depuis une VM du +tenant**, `10.99.99.99` et `172.31.99.99` — des adresses qui n'appartiennent à personne — +« s'établissent » en 1 ms, et **aucune ne rend de bannière SSH**, quand la vraie VM rend +`SSH-2.0-OpenSSH_10.0p2`. Le `connect()` ne mesure rien sur ce chemin, quel que soit le +point de départ ; seule une requête applicative tranche. Les règles héritées appartiennent +à l'exploitant : Set-OPS n'y touche pas. + +**Les routes sont enfin réconciliées.** Je les avais posées avec un script hors dépôt : +rien ne les comparait au devis, et leur disparition n'aurait été vue par personne — le +défaut exact que cet applicateur existe pour empêcher. `appliquer_opnsense.py` les traite +maintenant comme les règles et le NAT : identité portée par la description +(`setopsroute::->`), création avant retrait, périmètre strict. Le nom +de la passerelle est résolu depuis l'adresse du prochain saut plutôt que redemandé en +intrant. Les douze routes existantes ont été réétiquetées en place — aucune coupure. + +Vérifié : `make frontiere-plan` → « la frontière dit déjà ce que le devis dit », 12 routes +inchangées, flotte 14/14, `prouver.py` code de sortie 0. + ## 2026-08-09 — Le `connect()` qui « ne prouvait rien » n'était pas de l'anti-usurpation L'exploitant : « je ne trouve pas ça normal ». Il avait raison, et pendant deux jours nous diff --git a/docs/frontiere-opnsense.md b/docs/frontiere-opnsense.md index bf9c4fe..a20083b 100644 --- a/docs/frontiere-opnsense.md +++ b/docs/frontiere-opnsense.md @@ -100,7 +100,7 @@ restait qu'à la dériver. C'est ce que fait `scripts/devis_opnsense.py`, à par - `roles/*/meta/flux.yml` — les flux `pair: externe`, qui donnent les règles ; - l'inventaire de l'instance active — quels hôtes portent quel rôle, donc les destinations ; -- `../*/plan/nomenclature.yml` — les supernets des tenants fédérés, donc les routes ; +- `../*/plan/nomenclature.yml` — les tenants fédérés et leurs zones, donc les routes ; - l'intrant `nftables_admin_ssh` — les réseaux d'administration. Aucun port, aucune adresse et aucun nom d'hôte n'est écrit dans le générateur. @@ -169,10 +169,23 @@ d'administration. Le trou ne peut plus passer inaperçu. ### Le lien vit dans l'underlay, pas dans un tenant -La frontière route vers **tous** les supernets tenants (`10.21.0.0/16`, `10.27.0.0/16`…) par -le **même** prochain saut. Le lien qui la relie au routeur est-ouest est donc *partagé* : il -n'appartient à aucun tenant et ne peut dériver d'aucun `index`. Sa place est l'underlay, -cluster-global, au même titre que le management, l'iSCSI et Ceph. +La frontière route vers **tous** les tenants fédérés par le **même** prochain saut. Le lien +qui la relie au routeur est-ouest est donc *partagé* : il n'appartient à aucun tenant et ne +peut dériver d'aucun `index`. Sa place est l'underlay, cluster-global, au même titre que le +management, l'iSCSI et Ceph. + +**Une route par sous-réseau attribué, jamais une par supernet** (2026-08-09). Router +`10.27.0.0/16` faisait porter à la frontière des destinations qui n'existent nulle part : +elles atteignaient le nœud de sortie, y arrivaient dans la table *principale* — le VRF n'est +atteint que par les `/24` annoncés en BGP — et repartaient vers la passerelle +d'administration. Les alias `SETOPS_TENANT_*` énumèrent exactement les mêmes `/24`, et +`verifier()` exige que les deux ensembles coïncident : un alias plus large laisse le filtre +approuver l'inexistant, un alias plus étroit fait acheminer vers ce que le filtre refuse. + +Les routes sont **réconciliées comme les règles** par `appliquer_opnsense.py` : leur identité +est portée par leur description (`setopsroute::->`), donc une route +retirée du devis est retirée de la frontière. Le nom de la passerelle OPNsense est résolu +depuis l'adresse du prochain saut — il n'y a pas d'intrant supplémentaire à tenir à jour. Il se déclare dans `underlay.yml` par la clé **`passerelle_sortie`** — l'adresse du pare-feu sur ce lien : diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index 9346879..b1f01ac 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -97,6 +97,40 @@ def cle_nat(n: dict) -> str: return f"setopsnat:{n['tenant']}:{n['interface']}:{n['source']}->{n['destination']}:{n['cible']}" +def cle_route(r: dict) -> str: + """Identite d'une route statique, meme principe que `cle_regle`. + + Les routes ont longtemps ete posees A LA MAIN a cote de ce script. Elles + fonctionnaient, mais rien ne les reconciliait : leur disparition n'aurait ete vue + par personne, et un devis qui change les laissait derriere lui. C'est exactement le + defaut que ce script existe pour empecher — il ne pouvait pas s'appliquer a + lui-meme tant qu'il ignorait les routes. + """ + return f"setopsroute:{r['tenant']}:{r['reseau']}->{r['prochain_saut']}" + + +def _corps_route(r: dict, k: str, passerelle: str) -> dict: + return { + "network": r["reseau"], + "gateway": passerelle, + "disabled": "0", + "descr": k, + } + + +def _passerelle_pour(api: Frontiere, saut: str) -> str | None: + """Nom de la passerelle OPNsense portant cette adresse. + + Une route se declare par NOM de passerelle, le devis raisonne en ADRESSE de + prochain saut. On resout ici plutot que d'exiger un intrant de plus : le nom est + deja sur le boitier, et le demander deux fois serait une occasion de divergence. + """ + for x in (api("/api/routes/gateway/status").get("items") or []): + if str(x.get("address") or "").strip() == str(saut).strip(): + return str(x.get("name")) + return None + + def _corps_nat(n: dict, k: str) -> dict: return { "enabled": "1", @@ -164,7 +198,18 @@ def plan(api: Frontiere, devis: dict) -> dict: if d.startswith("setopsnat:"): nat_poses[d.split(" — ")[0]] = x + rt_voulues = {cle_route(r): r for r in devis.get("routes") or []} + rt_posees = {} + for x in (api("/api/routes/routes/searchroute/", + {"current": 1, "rowCount": 1000}).get("rows") or []): + d = str(x.get("descr") or "") + if d.startswith("setopsroute:"): + rt_posees[d] = x + return { + "routes_creer": {k: r for k, r in rt_voulues.items() if k not in rt_posees}, + "routes_garder": {k for k in rt_voulues if k in rt_posees}, + "routes_retirer": {k: x for k, x in rt_posees.items() if k not in rt_voulues}, "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}, @@ -192,6 +237,9 @@ def afficher(p: dict) -> bool: 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, r in sorted(p["routes_creer"].items()): + print(f" + route {r['reseau']:<18} -> {r['prochain_saut']:<14} " + f"{r['tenant']}") 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} " @@ -199,15 +247,22 @@ def afficher(p: dict) -> bool: 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 k, x in sorted(p["routes_retirer"].items()): + print(f" - route PERIMEE {str(x.get('network')):<18} " + f"-> {str(x.get('gateway'))}") for n in sorted(p["alias_retirer"]): print(f" - alias ORPHELIN {n}") - 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"]) + creer = len(p["alias_creer"]) + len(p["regles_creer"]) + len(p["nat_creer"]) \ + + len(p["routes_creer"]) + retirer = len(p["regles_retirer"]) + len(p["alias_retirer"]) + len(p["nat_retirer"]) \ + + len(p["routes_retirer"]) print(f"\n a creer : {creer} | a retirer : {retirer}" - f" | inchange : {len(p['regles_garder']) + len(p['nat_garder'])}") + f" | inchange : {len(p['regles_garder']) + len(p['nat_garder'])}" + f" + {len(p['routes_garder'])} routes") return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer", "regles_creer", "regles_retirer", - "nat_creer", "nat_retirer")) + "nat_creer", "nat_retirer", + "routes_creer", "routes_retirer")) def appliquer(api: Frontiere, p: dict) -> int: @@ -242,6 +297,20 @@ def appliquer(api: Frontiere, p: dict) -> int: 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}") + # 2bis. Routes, meme ordre et pour une raison plus forte encore : une route + # manquante coupe la flotte, une route en trop ne fait qu'acheminer vers un + # VRF qui la jettera. En cas de doute, on reste large. + for k, r in sorted(p["routes_creer"].items()): + gw = _passerelle_pour(api, r["prochain_saut"]) + if gw is None: + echecs += 1 + print(f" ! ECHEC route {k} : aucune passerelle ne porte {r['prochain_saut']}") + continue + _fait(api("/api/routes/routes/addroute/", {"route": _corps_route(r, k, gw)}), + f"route {k}") + for k, x in sorted(p["routes_retirer"].items()): + _fait(api(f"/api/routes/routes/delroute/{x['uuid']}", {}), f"retrait route {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}") @@ -253,6 +322,8 @@ def appliquer(api: Frontiere, p: dict) -> int: if echecs: print(f"\n {echecs} echec(s) — RIEN N'EST APPLIQUE, la config reste en attente.") return 1 + if p["routes_creer"] or p["routes_retirer"]: + print(" routes :", api("/api/routes/routes/reconfigure/", {}).get("status", "?")) 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()) diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index c2c3ea2..737228c 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -261,12 +261,28 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: if_gestion = _ou_marqueur(_intr, "opnsense_if_gestion", IF_GESTION) nom_actif, nomenclature_active = _instance_active() + # L'alias d'un tenant vaut ses SOUS-RESEAUX ATTRIBUES, pas son supernet. + # + # Tant qu'il valait `10.27.0.0/16`, nos propres regles autorisaient + # `admin -> 10.27.0.0/16:22` : le filtre laissait donc passer une connexion vers + # n'importe quelle adresse du /16, y compris celles ou aucune machine n'existe. + # Mesure du 2026-08-09, apres avoir retire les routes /16 : un `connect()` vers + # 10.27.99.99:22 aboutissait encore, et l'etat pf portait la description de NOTRE + # regle. Le symptome que nous avons attribue deux jours durant a une fonction + # d'anti-usurpation de la frontiere venait d'abord de nos declarations trop larges. + # + # Meme geste que pour les routes, et pour la meme raison : ne declarer que ce qui + # existe. L'alias sert a la fois de DESTINATION aux regles de filtrage et de SOURCE + # au NAT sortant ; le retrecir resserre correctement les deux. alias: dict[str, dict] = {} for nom, pfx, n in tenants: alias[f"SETOPS_TENANT_{pfx}{n['index']}"] = { "type": "network", - "contenu": [supernet_de(n["index"])], - "description": f"Supernet du tenant {nom} (index {n['index']})", + "contenu": [ + sous_reseau_de(n["index"], int(z)) + for z in sorted((n.get("categories") or {}), key=int) + ], + "description": f"Sous-reseaux attribues du tenant {nom} (index {n['index']})", } # Reseaux d'administration : UN ALIAS PAR TENANT, jamais une union. Chaque tenant @@ -405,14 +421,15 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # deux jours a une fonction d'anti-usurpation de la frontiere, alors que c'etait un # routage trop large. # - # La frontiere ne route desormais QUE ce qui existe. Le NAT, lui, reste sur le supernet - # (alias `SETOPS_TENANT_*`) : il porte sur la SOURCE, et le supernet contient tous les - # sous-reseaux — la garde P24 reste donc satisfaite. + # La frontiere ne route desormais QUE ce qui existe. L'alias `SETOPS_TENANT_*`, qui + # porte le NAT sortant et sert de destination aux regles, enumere exactement les memes + # sous-reseaux : la garde P24 compare ces deux listes et reste donc satisfaite. _sdn = underlay_mod.routage_tenants(underlay_mod.charger()) == "sdn" routes = [ { "reseau": sous_reseau_de(n["index"], int(z)), "prochain_saut": saut, + "tenant": nom, "description": f"{nom} — {cat.get('libelle', f'zone{z}')} (zone {z}, index " f"{n['index']}) — " + ("zone EVPN du tenant" if _sdn else "switches L3"), @@ -669,6 +686,30 @@ def verifier(devis: dict) -> tuple[bool, list[str]]: "avec une source privee. Le filtre laisserait passer et rien ne reviendrait." ) + # Ce qui est ROUTE et ce qui est AUTORISE doivent designer exactement les memes + # reseaux. Un alias plus large que les routes laisse le filtre approuver des + # destinations qui n'existent nulle part — defaut mesure le 2026-08-09 : l'alias valait + # le supernet /16, nos regles autorisaient donc `admin -> tout le /16:22`, et un + # `connect()` vers une adresse inexistante aboutissait toujours. Un alias plus etroit + # que les routes est la panne symetrique : la frontiere achemine vers un reseau que + # le filtre refuse. Les deux se voient ici, pas a l'usage. + routes_declarees = {str(r["reseau"]) for r in devis.get("routes") or []} + autorises: set[str] = set() + for nom, a in devis["alias"].items(): + if nom.startswith("SETOPS_TENANT_"): + autorises |= {str(c) for c in a["contenu"]} + if routes_declarees != autorises: + for large in sorted(autorises - routes_declarees): + erreurs.append( + f"{large} est autorise par un alias de tenant mais n'est pas route : le " + "filtre approuverait une destination que la frontiere ne sait pas joindre." + ) + for etroit in sorted(routes_declarees - autorises): + erreurs.append( + f"{etroit} est route mais absent des alias de tenant : la frontiere y " + "acheminerait des paquets que le filtre refuserait." + ) + gestion = devis.get("reseaux_gestion") or [] for r in devis["regles"]: src = str(r.get("source") or "")