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:
parent
26090e8acd
commit
91bbad0cdf
4 changed files with 101 additions and 33 deletions
27
CHANGELOG.md
27
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
Loading…
Reference in a new issue