From 510568e3b45e075480e0ba9ac9d9e3672a3e5b16 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 6 Aug 2026 14:39:51 -0400 Subject: [PATCH] frontiere : l'interface d'une regle se derive de l'attachement (D-61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le devis rangeait tout flux entrant sur le WAN, en supposant que l'administration revenait par l'adresse publique. Vrai pour 192.168.255.0/24, faux depuis que c'est 10.0.0.0/24 — directement attache sur `lan`. Les deux regles SSH etaient mortes deux fois : mauvaise interface, et « Block private networks » les aurait filtrees. Le devis conseillait meme de decocher ce filtre sur le WAN, ce qui aurait affaibli l'interface publique pour rien. `reseaux_locaux_frontiere()` derive de l'underlay les sous-reseaux ou la frontiere porte une adresse, hors transit. Un alias par interface : Technolibre a les deux cotes, Chezlepro seulement la gestion. Sans underlay, tout retombe sur le WAN — comportement inchange. P24 confronte desormais chaque regle d'administration a l'attachement de sa source, et refuse l'ancien comportement. Nouvel intrant `opnsense_if_gestion`, au catalogue du GUI. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 51 +++++++++++ docs/decisions-architecture.md | 1 + docs/frontiere-opnsense.md | 35 +++++--- scripts/devis_opnsense.py | 150 +++++++++++++++++++++++++++------ scripts/inventory_gui.py | 1 + 5 files changed, 203 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 651132f..84a35d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,57 @@ ## 2026-08-06 — le chemin nord-sud devient dérivable +### La frontière filtre pour de vrai + +La règle `any → any` d'`opt1` est retirée. Les 26 règles posées la veille n'étaient jusque-là +qu'une intention derrière un laissez-passer ; elles sont maintenant la politique. + +Prouvé dans les deux sens, pas seulement constaté : + +``` +frontière → asgard sonde de passerelle Online, 0 %, 0.2 ms +asgard → 10.0.4.1 ping depuis vlan40 2 transmis, 0 reçu, 100 % perte +``` + +Le lien L2 est sain — c'est le pare-feu qui jette. Et la sonde tient : le trafic **émis par le +pare-feu lui-même** passe par `let out anything from firewall host itself`, sa réponse revient +par l'état, et les règles `pass in on opt1` ne le concernent pas. J'avais annoncé un risque +là-dessus ; il n'existait pas. + +**Conséquence ferme, désormais mesurée.** Les 14 règles d'`opt1` n'autorisent que les supernets +tenants en source. Rien n'autorise `10.0.4.41/.43/.47`. Poser la route par défaut des +hyperviseurs sur `vlan40` les couperait donc immédiatement : cette tâche dépend maintenant de +l'inventaire d'exploitation de l'hébergeur (D-46/48), qui n'avait pas d'échéance. + +### L'interface d'une règle se dérive de l'attachement, pas du sens du flux (D-61) + +Le passage de l'administration au VLAN 10 a révélé une hypothèse devenue fausse. Le devis +rangeait tout flux entrant sur le WAN, en supposant que l'administration revenait par +l'adresse publique. Vrai pour `192.168.255.0/24` ; faux pour `10.0.0.0/24`, qui est +directement attaché sur `lan` (igb0, « GESTION »). + +Les deux règles SSH étaient donc **mortes deux fois** : mauvaise interface, et +*Block private networks* les aurait filtrées de toute façon. Elles ne fonctionnaient que grâce +au `Default allow LAN to any` hérité. Pire, le devis en tirait un conseil nuisible — +« décocher *Block private networks* sur WAN » — qui aurait affaibli l'interface publique pour +admettre un réseau qui n'y arrive jamais. + +`reseaux_locaux_frontiere()` dérive de l'underlay les sous-réseaux où la frontière porte +elle-même une adresse, hors lien de transit. Chaque CIDR d'administration est rangé de ce +côté-là ou du WAN, avec **un alias par interface** : Technolibre a les deux, Chezlepro n'a que +la gestion. L'avertissement RFC1918 ne compte plus que les sources réellement côté WAN. + +Sans underlay, tout retombe sur le WAN — le comportement d'avant, inchangé. + +**La garde tient la décision.** P24 confronte chaque règle d'administration à l'attachement de +sa source : une règle sur la gestion dont la source n'est attachée nulle part, ou une règle sur +le WAN dont la source est locale, font échouer le devis. Vérifié en rejouant l'ancien +comportement : la garde le refuse. + +Nouvel intrant `opnsense_if_gestion` (`lan` ici), au catalogue du GUI. 27 règles au lieu de 26 +— Technolibre en gagne une, ayant des sources des deux côtés. + + ### Le réseau d'administration est le VLAN 10, partout `nftables_admin_ssh` déclarait encore `192.168.255.0/24` chez Chezlepro — l'ancien monde. diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index 2ff0627..d851aba 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -43,6 +43,7 @@ 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-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` | — | ## 2. Qui possède quoi diff --git a/docs/frontiere-opnsense.md b/docs/frontiere-opnsense.md index 95381f1..bf9c4fe 100644 --- a/docs/frontiere-opnsense.md +++ b/docs/frontiere-opnsense.md @@ -109,23 +109,38 @@ Aucun port, aucune adresse et aucun nom d'hôte n'est écrit dans le générateu **Dans OPNsense, une règle est toujours `in` sur l'interface d'arrivée** — celle par laquelle le paquet pénètre le pare-feu. Posée ailleurs, elle ne s'applique jamais, et le trafic est -bloqué sans que rien ne paraisse anormal dans la configuration. L'attribution se dérive du -sens du flux : +bloqué sans que rien ne paraisse anormal dans la configuration. + +Pour le trafic ordinaire, l'attribution se dérive du **sens du flux** : | Flux déclaré | Arrive par | Règle | |---|---|---| | `sens: ingress`, `pair: externe` | le **WAN** (`69.70.26.62`) | `pass in on wan …` | | `sens: egress`, `pair: externe` | le **lien de transit** (`opt1`) | `pass in on opt1 …` | -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 SSH d'administration ne suit aucune de ces deux lignes** (D-61). Un plan de gestion peut +être **distant** — il revient alors par l'adresse publique, donc par le WAN — ou +**directement attaché** à la frontière, et il arrive alors par l'interface qui porte ce +réseau. Le sens du flux ne le dit pas ; seul l'attachement réel le dit. -**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. +L'interface se dérive donc de l'underlay : la frontière y déclare une adresse par réseau, et +tout ce qui est contenu dans l'un de ces sous-réseaux — hors lien de transit — arrive par +`opnsense_if_gestion`. Le reste arrive par le WAN. Un tenant peut avoir les deux, et reçoit +alors **deux règles et deux alias**, chacun ne portant que les sources qui peuvent réellement +emprunter ce chemin. + +``` +SETOPS_ADMIN_CHEZ17_GESTION 10.0.0.0/24 → pass in on lan +SETOPS_ADMIN_TECH11_GESTION 10.0.0.0/24 → pass in on lan +SETOPS_ADMIN_TECH11_WAN 192.168.255.2/32, 192.168.254.2/32 → pass in on wan +``` + +**Le piège de la case à cocher.** Une source **RFC1918** qui arrive par le WAN se heurte à +*Block private networks*, activé par défaut, 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 — mais **seulement pour +les sources réellement rangées côté WAN**. Conseiller de décocher ce filtre pour un réseau qui +entre par la gestion affaiblirait l'interface publique sans rien ouvrir du tout. **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. `le commutateur` est diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index f44d046..429ce63 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -63,6 +63,7 @@ PROCHAIN_SAUT = "" NOEUD_SORTIE = "" IF_TRANSIT = "" IF_WAN = "" +IF_GESTION = "" def depot_hebergeur() -> Path | None: @@ -138,6 +139,44 @@ def prochain_saut(transit: dict | None) -> str: return str((transit or {}).get("passerelle") or "").strip() or PROCHAIN_SAUT +def reseaux_locaux_frontiere(underlay: dict | None, transit: dict | None) -> list[str]: + """Sous-reseaux DIRECTEMENT ATTACHES a la frontiere, hors lien de transit. + + C'est le plan de gestion vu du pare-feu : les reseaux ou la frontiere porte elle-meme + une adresse. Un paquet venu de la n'arrive donc NI par le WAN NI par le transit, mais + par l'interface de gestion — et c'est a cette interface que sa regle doit etre posee. + + Derive de l'underlay, jamais saisi : la frontiere y declare deja ses adresses, une par + reseau. Un intrant supplementaire ouvrirait la porte a deux valeurs contradictoires. + """ + nom = nom_frontiere(transit) + if not nom: + return [] + sur = {str(h.get("reseau")) for h in underlay_mod.hotes(underlay) + if str(h.get("nom") or "") == nom} + sur.discard(str((transit or {}).get("nom"))) + cidrs = [] + for r in underlay_mod.reseaux(underlay): + if str(r.get("nom")) in sur and r.get("sous_reseau"): + cidrs.append(str(r["sous_reseau"])) + return sorted(cidrs) + + +def _porte_par(cidr: str, reseaux: list[str]) -> bool: + """Vrai si `cidr` est contenu dans l'un des `reseaux` (ou lui est egal).""" + try: + r = ipaddress.ip_network(cidr, strict=False) + except ValueError: + return False + for n in reseaux: + try: + if r.subnet_of(ipaddress.ip_network(n, strict=False)): + return True + except (ValueError, TypeError): + continue + return False + + def adresse_noeud_de_sortie(underlay: dict | None, transit: dict | None) -> str: """Adresse du noeud de sortie PRIMAIRE sur le lien de frontiere. '' si indeterminee. @@ -219,6 +258,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: _intr = intrants_frontiere() if_wan = _ou_marqueur(_intr, "opnsense_if_wan", IF_WAN) if_transit = _ou_marqueur(_intr, "opnsense_if_transit", IF_TRANSIT) + if_gestion = _ou_marqueur(_intr, "opnsense_if_gestion", IF_GESTION) nom_actif, nomenclature_active = _instance_active() alias: dict[str, dict] = {} @@ -234,18 +274,35 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # une union laisserait le plan de gestion d'un tenant entrer chez le voisin, ce que # les ACL de switch interdisent par ailleurs. La frontiere ne doit pas rouvrir ce que # l'isolation inter-tenant ferme. + # + # Chaque CIDR est en outre RANGE PAR INTERFACE D'ARRIVEE. Une regle OPNsense est + # toujours `in` sur l'interface par ou le paquet entre : posee ailleurs, elle ne peut + # tout simplement jamais correspondre. Un plan de gestion directement attache a la + # frontiere (`10.0.0.0/24` ici) arrive par l'interface de GESTION ; un plan distant, + # qui revient par l'adresse publique ou un VPN, arrive par le WAN. + reseaux_gestion = reseaux_locaux_frontiere(underlay_mod.charger(), transit_underlay()) admin_par_tenant: dict[str, list[str]] = {} + admin_par_if: dict[str, dict[str, list[str]]] = {} for nom, pfx, n in tenants: etiquette = f"{pfx}{n['index']}" cidrs = admin_de(nom) admin_par_tenant[etiquette] = cidrs - if cidrs: - alias[f"SETOPS_ADMIN_{etiquette}"] = { - "type": "network", - "contenu": list(cidrs), - "description": f"Reseaux d'administration de {nom} (intrant " - f"nftables_admin_ssh) — source unique, partagee avec ses nftables", - } + locaux = [c for c in cidrs if _porte_par(c, reseaux_gestion)] + distants = [c for c in cidrs if c not in locaux] + admin_par_if[etiquette] = {"gestion": locaux, "wan": distants} + # UN ALIAS PAR INTERFACE, et le nom le dit. Un alias unique couvrant les deux + # cotes forcerait a poser la meme regle deux fois avec des sources qui ne peuvent + # pas arriver la : la moitie serait morte, sans que rien ne le signale. + for cle, portee in (("gestion", "GESTION"), ("wan", "WAN")): + part = admin_par_if[etiquette][cle] + if part: + alias[f"SETOPS_ADMIN_{etiquette}_{portee}"] = { + "type": "network", + "contenu": list(part), + "description": f"Reseaux d'administration de {nom} arrivant par " + f"{portee.lower()} (intrant nftables_admin_ssh) — " + f"source unique, partagee avec ses nftables", + } # Union, pour les routes de RETOUR cote switch et la garde P24 : router n'est pas # 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}) @@ -256,9 +313,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # 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. + # + # SEULS les CIDR ranges cote WAN comptent : conseiller de decocher le filtre pour un + # reseau qui arrive par la gestion affaiblirait l'interface publique sans rien ouvrir. 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 + c for parts in admin_par_if.values() for c in parts["wan"] if any(ipaddress.ip_network(c, strict=False).subnet_of(b) for b in rfc1918) }) @@ -302,23 +362,32 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # l'omettre ferme l'acces, l'ouvrir a `any` exposerait le SSH a Internet. tenants_sans_admin.append(nom) continue - source = f"SETOPS_ADMIN_{etiquette}" if ssh_gestion else "any" - regles.append({ - "sens": "in" if entrant else "out", - # Interface d'ARRIVEE du paquet, celle a laquelle OPNsense attache la - # regle. Entrant (depuis Internet ou le VPN d'admin, qui revient par - # l'adresse publique) : le WAN. Sortant (la flotte vers Internet) : le - # lien de transit, par ou le paquet penetre le pare-feu. - "interface": if_wan if entrant else if_transit, - "protocole": fl.get("protocole", "tcp"), - "source": source if entrant else destination, - "destination": destination if entrant else "any", - "ports": _ports(fl), - "chiffrement": fl.get("chiffrement"), - "role": role, - "tenant": nom, - "raison": fl.get("raison", ""), - }) + # Interface d'ARRIVEE du paquet, celle a laquelle OPNsense attache la regle. + # Sortant (la flotte vers Internet) : le lien de transit, par ou le paquet + # penetre le pare-feu. Entrant depuis Internet : le WAN. Entrant depuis un + # plan d'administration : l'interface ou ce plan est REELLEMENT attache — + # une regle posee sur la mauvaise interface ne correspond jamais. + if ssh_gestion: + portees = [] + if admin_par_if[etiquette]["gestion"]: + portees.append((f"SETOPS_ADMIN_{etiquette}_GESTION", if_gestion)) + if admin_par_if[etiquette]["wan"]: + portees.append((f"SETOPS_ADMIN_{etiquette}_WAN", if_wan)) + else: + portees = [("any", if_wan if entrant else if_transit)] + for source, interface in portees: + regles.append({ + "sens": "in" if entrant else "out", + "interface": interface, + "protocole": fl.get("protocole", "tcp"), + "source": source if entrant else destination, + "destination": destination if entrant else "any", + "ports": _ports(fl), + "chiffrement": fl.get("chiffrement"), + "role": role, + "tenant": nom, + "raison": fl.get("raison", ""), + }) intrants = intrants_frontiere() transit = transit_underlay() @@ -347,6 +416,8 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "wan_ip": str(intrants.get("opnsense_wan_ip") or "").strip(), "if_wan": _ou_marqueur(intrants, "opnsense_if_wan", IF_WAN), "if_transit": _ou_marqueur(intrants, "opnsense_if_transit", IF_TRANSIT), + "if_gestion": _ou_marqueur(intrants, "opnsense_if_gestion", IF_GESTION), + "reseaux_gestion": reseaux_gestion, "alias": alias, "routes": routes, "regles": regles, @@ -422,6 +493,9 @@ def rendre(devis: dict) -> str: f"# WAN : {devis['if_wan']}" + (f" — {devis['wan_ip']} (face publique de l'ecosysteme)" if devis.get("wan_ip") else ""), f"# TRANSIT : {devis['if_transit']} (trunk 802.1Q vers les switches)", + f"# GESTION : {devis['if_gestion']}" + + (f" — plan(s) attache(s) : {', '.join(devis['reseaux_gestion'])}" + if devis.get("reseaux_gestion") else " — aucun plan attache a la frontiere"), ] if tr: out += [ @@ -530,6 +604,32 @@ def verifier(devis: dict) -> tuple[bool, list[str]]: for nom, a in devis["alias"].items(): if not a["contenu"]: erreurs.append(f"Alias {nom} vide : une regle le referencerait dans le vide.") + + # Une regle est TOUJOURS `in` sur l'interface d'arrivee du paquet. Posee ailleurs elle + # 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. + gestion = devis.get("reseaux_gestion") or [] + for r in devis["regles"]: + src = str(r.get("source") or "") + if not src.startswith("SETOPS_ADMIN_"): + continue + cidrs = (devis["alias"].get(src) or {}).get("contenu") or [] + locaux = [c for c in cidrs if _porte_par(c, gestion)] + if r["interface"] == devis.get("if_gestion") and len(locaux) != len(cidrs): + erreurs.append( + f"Regle sur {r['interface']} (gestion) avec la source {src} : " + + ", ".join(c for c in cidrs if c not in locaux) + + " n'est attache a aucun plan local de la frontiere — le paquet " + "arriverait par une autre interface et la regle ne matcherait jamais." + ) + if r["interface"] == devis.get("if_wan") and locaux: + erreurs.append( + f"Regle sur {r['interface']} (WAN) avec la source {src} : " + + ", ".join(locaux) + + " est directement attache a la frontiere, donc arrive par " + f"{devis.get('if_gestion')} — cette regle est morte." + ) return (not erreurs), erreurs diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index e857724..616b319 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -179,6 +179,7 @@ INTRANTS_SCHEMA = [ ("opnsense_wan_ip", "frontiere", "constante", "Frontière", "Adresse publique du WAN de la frontière (face Internet de l'écosystème)", "str", "hebergeur"), ("opnsense_if_wan", "frontiere", "defaut", "Frontière", "Interface WAN — IDENTIFIANT OPNsense (wan), pas le libellé ni le périphérique", "str", "hebergeur"), ("opnsense_if_transit", "frontiere", "defaut", "Frontière", "Interface de transit — IDENTIFIANT OPNsense (opt1, opt2…), pas « igb1 » ni le libellé affiché", "str", "hebergeur"), + ("opnsense_if_gestion", "frontiere", "defaut", "Frontière", "Interface où arrivent les plans d'administration attachés à la frontière — IDENTIFIANT OPNsense (lan). Une règle SSH posée ailleurs ne peut jamais correspondre", "str", "hebergeur"), ("underlay_routeur", "fabric", "constante", "Fabric", "Switch qui porte le routage (SVI, ACL, routes) — les autres restent en L2 pur", "str", "hebergeur"), ("underlay_dialecte", "fabric", "constante", "Fabric", "Dialecte de CLI du commutateur (cisco | binardat)", "str", "hebergeur"), ("underlay_stp_mode", "fabric", "defaut", "Fabric", "Mode de spanning-tree (rstp | mstp | pvst)", "str", "hebergeur"),