From 91bbad0cdf76eac246751afda60bf393b67d1722 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 2 Aug 2026 16:17:25 -0400 Subject: [PATCH] =?UTF-8?q?fronti=C3=A8re=20:=20les=20routes=20de=20retour?= =?UTF-8?q?=20couvrent=20tous=20les=20tenants,=20et=20la=20case=20WAN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 27 +++++++++++++++++ docs/frontiere-opnsense.md | 7 +++++ scripts/devis_opnsense.py | 62 ++++++++++++++++++-------------------- scripts/devis_reseau.py | 38 ++++++++++++++++++++++- 4 files changed, 101 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3609c36..2cc355d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/frontiere-opnsense.md b/docs/frontiere-opnsense.md index c82387c..891aafd 100644 --- a/docs/frontiere-opnsense.md +++ b/docs/frontiere-opnsense.md @@ -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, diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index f12774e..404183d 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -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"]: diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index 9204300..1f4c1f0 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -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.")