frontière : les routes de retour couvrent tous les tenants, et la case WAN

Le devis frontière annonçait trois routes de retour « déjà émises par
devis-reseau ». Le devis switch n'en émettait qu'une : il lisait
`nftables_admin_ssh` de la seule instance active, alors que la frontière
était passée multi-tenant. Les réseaux d'administration de Technolibre
n'étaient routés nulle part — et une affirmation fausse est pire qu'un
silence, elle désamorce la vérification.

`admin_tous_tenants()` vit dans devis_reseau et devis_opnsense l'importe au
lieu d'en refaire une copie : routes de retour et règles lisent les mêmes
tenants par construction. Vérifié identiques.

Ajouté : l'avertissement « Block private networks ». Le SSH d'administration
a une source RFC1918 arrivant sur une interface WAN, où ce filtre est actif
par défaut et s'applique AVANT les règles — coché, il jette le paquet sans
qu'aucune règle ne soit consultée. Un réglage d'interface est invisible dans
les règles, il fallait l'écrire à part.

Prédicat exactement RFC1918, périmètre de cette case ; `is_private` aurait
été trop large (documentation, CGNAT) et l'avertissement se serait déclenché
à tort. Trois cas exercés : RFC1918 averti, 8.8.8.8 muet, 203.0.113.7 muet.

Preuves : 24 OK, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-02 16:17:25 -04:00
parent 26090e8acd
commit 91bbad0cdf
4 changed files with 101 additions and 33 deletions

View file

@ -1,5 +1,32 @@
# CHANGELOG — Set-OPS
## 2026-08-02 (suite 4) — deux devis qui se contredisaient, et une case à cocher
### Corrigé — le devis frontière certifiait des routes inexistantes
Sa section 0 annonçait trois routes de retour « DÉJÀ ÉMISES par `make devis-reseau` ». Le
devis switch n'en émettait **qu'une** : `devis_reseau` lisait `nftables_admin_ssh` de la
seule instance active, alors que la frontière était passée multi-tenant la veille. Les deux
réseaux d'administration de Technolibre n'étaient routés nulle part.
Pire qu'un silence : une affirmation fausse désamorce la vérification.
`admin_tous_tenants()` vit désormais dans `devis_reseau` et **`devis_opnsense` l'importe**
au lieu d'en refaire une copie. Les routes de retour et les règles lisent les mêmes tenants,
par construction. Vérifié : les deux listes sont identiques.
### Ajouté — l'avertissement « Block private networks »
Le SSH d'administration a une source RFC1918 arrivant sur une interface **WAN**. OPNsense
active par défaut ce filtre d'interface, qui s'applique **avant** les règles : coché, il jette
le paquet sans qu'aucune règle ne soit consultée. La config paraît juste, le SSH ne passe pas.
Le devis le signale dès qu'une source RFC1918 entre par le WAN — un réglage d'interface est
invisible dans les règles, il fallait donc l'écrire à part.
Le prédicat est **exactement** RFC1918, périmètre de cette case ; `ipaddress.is_private`
aurait été trop large (plages de documentation, CGNAT), et l'avertissement se serait déclenché
à tort. Les trois cas exercés : RFC1918 → averti ; `8.8.8.8/32` → muet ; `203.0.113.7/32`
(documentation) → muet.
## 2026-08-02 (suite 3) — chaque règle porte son interface, et l'octet est gardé
### Ajouté — l'interface d'arrivée, dérivée du sens du flux

View file

@ -85,6 +85,13 @@ sens du flux :
Le SSH d'administration suit la première ligne : le VPN est hébergé sur le pfSense voisin, et
son trafic revient par l'adresse publique de la frontière.
**Le piège de la case à cocher.** Ce trafic d'administration a une source **RFC1918**
(`192.168.255.0/24`) alors qu'il arrive sur une interface WAN. Or OPNsense active par défaut
*Block private networks* sur le WAN, et ce filtre s'applique **avant** les règles : coché, il
jette le paquet sans qu'aucune règle ne soit consultée. La configuration paraît juste, le SSH
ne passe pas, et rien ne l'explique. Le devis le signale dès qu'une source RFC1918 entre par
le WAN — c'est un réglage d'interface, donc invisible dans les règles elles-mêmes.
**Invariant du dernier octet.** Un point de routage porte **le même dernier octet sur tous
les sous-réseaux où il participe** — on retient une adresse, pas treize. `sleipnir-01` est
donc `.1` partout : `10.0.0.1`, `10.27.16.1`, `10.27.21.1`… Le chiffre n'est pas codé en dur,

View file

@ -24,6 +24,7 @@ Usage :
from __future__ import annotations
import argparse
import ipaddress
import json
import sys
from pathlib import Path
@ -46,7 +47,10 @@ from resoudre_flux import ( # noqa: E402
_pairs,
_sources_admin_ssh,
)
from devis_reseau import decouvrir, prefixe # noqa: E402
# `inventaire_de` / `admin_de` viennent de devis_reseau : les routes de retour du
# switch et les regles de la frontiere doivent lire les MEMES tenants, sinon l'un
# certifie des routes que l'autre n'emet pas.
from devis_reseau import admin_de, decouvrir, inventaire_de, prefixe # noqa: E402
# Le lien de transit (et donc le prochain saut) vit dans l'underlay : il est PARTAGE
# par tous les tenants, il ne peut deriver d'aucun index. Source unique, pas un intrant.
import underlay as underlay_mod # noqa: E402
@ -122,21 +126,6 @@ def _ports(flux: dict) -> list[str]:
return [str(p) for p in (port if isinstance(port, list) else [port])]
def inventaire_de(nom_instance: str) -> Path | None:
"""hosts.yml d'une instance FEDEREE, active ou non. None si elle n'en a pas.
La frontiere est un equipement PARTAGE : elle route vers tous les tenants, elle
doit donc aussi porter leurs regles. Se limiter a l'instance active laisserait un
tenant route mais muet chemin present, politique absente.
"""
base = RACINE.parent / nom_instance / "inventories"
for env in ("principal", "production", "lab"):
p = base / env / "hosts.yml"
if p.is_file():
return p
return None
def cibles_par_role(role: str, inventaire: Path) -> list[str]:
"""IP des hotes qui portent ce role dans cette instance (actifs ET planifies).
@ -148,22 +137,6 @@ def cibles_par_role(role: str, inventaire: Path) -> list[str]:
return sorted({ips[h] for h in _hotes_du_groupe(data, role) if h in ips})
def admin_de(nom_instance: str) -> list[str]:
"""Reseaux d'administration declares par une instance (intrant nftables_admin_ssh)."""
inv = inventaire_de(nom_instance)
if not inv:
return []
dossier = inv.parent / "group_vars" / "all"
for fichier in sorted(dossier.glob("*.yml")):
if "vault" in fichier.name:
continue
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
if isinstance(data, dict) and data.get("nftables_admin_ssh"):
src = data["nftables_admin_ssh"]
return [str(s) for s in src] if isinstance(src, list) else [str(src)]
return []
def nom_alias(role: str, etiquette: str) -> str:
"""Alias d'hotes, PREFIXE par le tenant : deux tenants ont chacun leur nginx."""
return f"SETOPS_{etiquette}_{role.upper()}"
@ -216,6 +189,18 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# autoriser. Le switch doit savoir revenir vers tous les plans de gestion.
admin = sorted({c for cidrs in admin_par_tenant.values() for c in cidrs})
# Sources privees arrivant par le WAN : OPNsense active par defaut « Block private
# networks » sur cette interface, et ce filtre s'applique AVANT les regles. Une regle
# correcte ne serait donc jamais atteinte — panne muette, invisible dans le devis.
# RFC1918 exactement — c'est le perimetre de la case « Block private networks ».
# `ipaddress.is_private` serait trop large : il inclut les plages de documentation
# et le CGNAT, que la case voisine « Block bogon networks » traite separement.
rfc1918 = [ipaddress.ip_network(b) for b in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")]
admin_prives = sorted({
c for cidrs in admin_par_tenant.values() for c in cidrs
if any(ipaddress.ip_network(c, strict=False).subnet_of(b) for b in rfc1918)
})
supernet_actif = (
supernet_de(nomenclature_active["index"]) if nomenclature_active else None
)
@ -300,6 +285,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"admin": admin,
"tenants_sans_inventaire": tenants_sans_inventaire,
"tenants_sans_admin": sorted(set(tenants_sans_admin)),
"admin_prives": admin_prives,
"transit": {
"nom": (transit or {}).get("nom"),
"vlan": (transit or {}).get("vlan"),
@ -353,6 +339,18 @@ def rendre(devis: dict) -> str:
]
out += [
"",
]
if devis.get("admin_prives"):
out += [
f"# /!\\ DECOCHER « Block private networks » SUR {devis['if_wan'].upper()}.",
"# Des sources PRIVEES entrent par cette interface : "
+ ", ".join(devis["admin_prives"]) + ".",
"# Ce filtre d'interface s'applique AVANT les regles : coche, il jette le",
"# paquet d'administration sans qu'aucune regle ne soit consultee. La config",
"# parait juste, le SSH ne passe pas, et rien ne dit pourquoi.",
"",
]
out += [
"## 2. Routes statiques (vers les supernets tenants)",
]
for r in devis["routes"]:

View file

@ -308,6 +308,42 @@ def section_frontiere(transit: dict | None, bord: bool = False,
for ligne in bloc_trunk(port, str(transit["vlan"]), bord=bord)]
def inventaire_de(nom_instance: str) -> Path | None:
"""hosts.yml d'une instance FEDEREE, active ou non. None si elle n'en a pas."""
base = DOSSIER_INSTANCES / nom_instance / "inventories"
for env in ("principal", "production", "lab"):
p = base / env / "hosts.yml"
if p.is_file():
return p
return None
def admin_de(nom_instance: str) -> list[str]:
"""Reseaux d'administration declares par une instance (intrant nftables_admin_ssh)."""
inv = inventaire_de(nom_instance)
if not inv:
return []
for fichier in sorted((inv.parent / "group_vars" / "all").glob("*.yml")):
if "vault" in fichier.name:
continue
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
if isinstance(data, dict) and data.get("nftables_admin_ssh"):
src = data["nftables_admin_ssh"]
return [str(s) for s in src] if isinstance(src, list) else [str(src)]
return []
def admin_tous_tenants() -> list[str]:
"""Union des reseaux d'administration de TOUS les tenants federes.
Le switch routeur est partage : il doit savoir revenir vers chaque plan de gestion,
pas seulement celui de l'instance active. Router n'est pas autoriser le
cloisonnement se fait a la frontiere, par un alias distinct par tenant.
SOURCE UNIQUE : `devis_opnsense` importe cette fonction plutot que d'en refaire une.
"""
return sorted({c for nom, _pfx, _n in decouvrir() for c in admin_de(nom)})
def section_routes(underlay: dict | None) -> list[str]:
"""Routes du switch vers la frontiere nord/sud. Vide si aucun transit declare.
@ -342,7 +378,7 @@ def section_routes(underlay: dict | None) -> list[str]:
f" <-> frontiere {sortie}",
"! Aller : sortie generale de la flotte.",
f"ip route 0.0.0.0 0.0.0.0 {sortie}"]
admin = _sources_admin_ssh()
admin = admin_tous_tenants() or _sources_admin_ssh()
if admin:
out.append("! Retour : sans ces routes, les reponses partent par une autre interface")
out.append("! que celle ou l'etat a ete cree, et le pare-feu les jette en silence.")