frontiere : ne declarer que ce qui existe, et reconcilier les routes

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:<tenant>:<reseau>-><saut>), 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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-09 16:44:58 -04:00
parent df05d90059
commit 3ef470be76
4 changed files with 182 additions and 14 deletions

View file

@ -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:<tenant>:<réseau>-><saut>`), 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

View file

@ -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:<tenant>:<réseau>-><saut>`), 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 :

View file

@ -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())

View file

@ -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 "")