sortie des tenants : un saut emprunte (D-62) et le NAT derive (D-63)

Deux obstacles, aucun n'etait celui qu'on croyait.

Proxmox n'installe AUCUN defaut dans le VRF du tenant : `default-originate`
annonce une route aux autres noeuds, il n'en pose pas chez lui. Les deux zones
etaient dans cet etat. La sortie vient d'une strophe frr.conf.local, que Proxmox
fusionne a chaque regeneration (verifie : survit a `pvesh set /cluster/sdn` et a
un redemarrage de FRR).

`nexthop-vrf default` emprunte UNE adresse au lieu d'importer la table
principale : la route par defaut des hyperviseurs ne gouverne pas la sortie des
tenants. `import vrf default` l'aurait fait contourner la frontiere et aurait
fuite le transport VXLAN, la gestion et les VLAN herites dans le VRF.

Le NAT sortant en mode automatique ne couvre que les reseaux directement
attaches ; un supernet joint par route statique en sort en silence. L'etat
montrait `nat_addr` absent : le filtre passait, la traduction manquait.
`devis_opnsense` emet le NAT (section 2bis), le reconciliateur l'applique et le
retire, et P24 refuse tout supernet route mais non traduit.

Mesure : tenant -> frontiere 3/3, -> passerelle FAI 3/3, -> Internet 2/2 pour
les deux tenants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-06 15:59:11 -04:00
parent 081c1ac545
commit fa69ec7c0f
5 changed files with 184 additions and 5 deletions

View file

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

View file

@ -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` | — |

View file

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

View file

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

View file

@ -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 "<CONTROLEUR-EVPN-A-DECLARER>",
"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",