diff --git a/CHANGELOG.md b/CHANGELOG.md index e27ecad..3609c36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # CHANGELOG — Set-OPS +## 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 +Dans OPNsense une règle est **toujours `in` sur l'interface d'arrivée** : posée ailleurs, elle +ne s'applique jamais et le trafic est bloqué sans que la configuration paraisse anormale. Les +règles n'en portaient aucune, alors que le champ est obligatoire dans l'API. + +L'attribution se dérive : un flux `ingress`/`externe` arrive par le **WAN**, un flux +`egress`/`externe` par le **lien de transit**. Le SSH d'administration suit la première ligne +— le VPN est hébergé sur le pfSense voisin et revient par l'adresse publique de la frontière. +C'était la dernière inconnue, et le montage parallèle décrit le 2026-08-02 la lève. + +Le rendu abandonne `pass out` pour `pass in on `, qui est l'idiome réel d'OPNsense +et ce que le futur client d'API devra envoyer. + +### Ajouté — `opnsense_wan_ip`, la face publique +L'adresse publique de la frontière (`69.70.26.62`, reprise du pfSense) est un intrant de la +section *Frontière* et s'affiche en section 1 du devis. + +### Ajouté — invariant du dernier octet (preuve P23) +Convention d'exploitation : 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 `.1` +partout. Le chiffre n'est pas codé en dur : il vient de `reservations.passerelle` dans la +nomenclature, et `make underlay` refuse une passerelle qui s'en écarte. + +Exemption assumée : les liens plus étroits qu'un `/24`. Sur le `/29` de transit, l'adressage +est dicté par les participants — les deux frontières prennent `.1` et `.2`, le switch `.6`. +Vérifié que l'invariant était **déjà respecté** sur les 13 sous-réseaux routés avant d'écrire +la garde. + ## 2026-08-02 (suite 2) — la frontière porte les règles de TOUS les tenants Le devis était multi-tenant pour ses routes et mono-tenant pour ses règles : il routait diff --git a/docs/frontiere-opnsense.md b/docs/frontiere-opnsense.md index b017be5..c82387c 100644 --- a/docs/frontiere-opnsense.md +++ b/docs/frontiere-opnsense.md @@ -70,7 +70,32 @@ restait qu'à la dériver. C'est ce que fait `scripts/devis_opnsense.py`, à par Aucun port, aucune adresse et aucun nom d'hôte n'est écrit dans le générateur. -## 3. La garde anti-lockout +## 3. L'interface de chaque règle, et l'invariant du dernier octet + +**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 : + +| 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. + +**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, +il vient de `reservations.passerelle` dans la nomenclature, et `make underlay` (**preuve +P23**) refuse une passerelle qui s'en écarte. + +Seule exception, assumée : les liens plus étroits qu'un `/24`. Sur le `/29` de transit, +l'adressage est dicté par les participants du lien — les deux frontières occupent `.1` et +`.2`, le switch prend `.6`. + +## 4. La garde anti-lockout Une seule règle entrante ne vient **pas** d'Internet : le SSH de gestion. Sa source est l'alias `SETOPS_ADMIN`, alimenté par l'intrant `nftables_admin_ssh` — **le même** qui nourrit @@ -81,7 +106,7 @@ le pare-feu d'hôte laisse passer » et « ce que la bordure laisse entrer ». sans lui, la règle SSH n'aurait aucune source et le `block in` final fermerait l'accès d'administration. Le trou ne peut plus passer inaperçu. -## 4. Le lien de transit, et les deux routes +## 5. Le lien de transit, et les deux routes **C'est le piège qui nous a coûté une passe de déploiement le 2026-07-29.** @@ -173,7 +198,7 @@ Les réseaux d'administration ne sont pas saisis ici : ils viennent de l'intrant l'alias `SETOPS_ADMIN` de la frontière. Les trois pare-feux et les routes de retour ne peuvent donc pas diverger. -## 5. Appliquer le devis +## 6. Appliquer le devis OPNsense expose une **API REST de première classe**, authentifiée par **clé + secret** (*System → Access → Users → l'utilisateur → API keys*). C'est ce qui permettra d'appliquer le @@ -220,12 +245,12 @@ ansible-vault edit instance/inventories/principal/group_vars/all/vault.yml > pfSense CE, lui, n'a **pas** d'API officielle (celle de Netgate n'existe que sur pfSense > Plus) : seul un paquet tiers en fournit une. C'est l'une des raisons du choix d'OPNsense. -## 6. Ce qui reste ouvert +## 7. Ce qui reste ouvert - **Le câblage** — le lien de transit est *décidé* (VLAN 40, `10.0.4.0/29`) et le devis switch émet déjà son SVI et ses routes. Reste à figer, une fois le boîtier raccordé, un seul intrant : `opnsense_if_transit`, l'interface qui porte le VLAN 40 sur le boîtier — la - seule valeur que rien ne peut deviner. Le prochain saut, lui, **dérive** du transit (§4). + seule valeur que rien ne peut deviner. Le prochain saut, lui, **dérive** du transit (§5). **Trois noms désignent le même port dans OPNsense**, et l'intrant en veut un seul : `igb1` est le périphérique FreeBSD, `TENANTS` (ou tout autre libellé) est la description diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 398c038..f12774e 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -182,6 +182,9 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: """Le devis, en structure — rendue ensuite en texte ou en JSON.""" flux_par_role = charger_flux() retenus = flux_frontiere(flux_par_role) + _intr = intrants_frontiere() + if_wan = _ou_marqueur(_intr, "opnsense_if_wan", IF_WAN) + if_transit = _ou_marqueur(_intr, "opnsense_if_transit", IF_TRANSIT) nom_actif, nomenclature_active = _instance_active() alias: dict[str, dict] = {} @@ -256,6 +259,11 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: 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", @@ -283,6 +291,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "instance_active": nom_actif, "supernet_actif": supernet_actif, "api_url": str(intrants.get("opnsense_api_url") or "").strip(), + "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), "alias": alias, @@ -331,7 +340,8 @@ def rendre(devis: dict) -> str: "", "## 1. Interfaces", f"# Gestion : {devis['api_url'] or '(intrant opnsense_api_url a renseigner)'}", - f"# WAN : {devis['if_wan']}", + 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)", ] if tr: @@ -355,7 +365,10 @@ def rendre(devis: dict) -> str: out += ["", "## 4. Regles — PAR TENANT FEDERE (defaut : deny + journalisation)", "# La frontiere route vers tous les tenants (section 2) : elle porte donc aussi", "# leurs regles. Chacun n'est joignable en SSH que depuis SES PROPRES reseaux", - "# d'administration — jamais ceux du voisin, que l'isolation inter-tenant ferme."] + "# d'administration — jamais ceux du voisin, que l'isolation inter-tenant ferme.", + "# Chaque regle est `in` sur son interface d'ARRIVEE : le WAN pour ce qui vient", + "# d'Internet (et du VPN d'administration, qui revient par l'adresse publique),", + "# le lien de transit pour ce que la flotte emet vers l'exterieur."] for nom in devis.get("tenants_sans_inventaire", []): out.append(f"# /!\\ {nom} : aucun inventaire genere — AUCUNE regle. Route, puis bloque.") for nom in devis.get("tenants_sans_admin", []): @@ -363,8 +376,11 @@ def rendre(devis: dict) -> str: f"`any` exposerait le SSH a Internet ; renseigner l'intrant.") for rg in devis["regles"]: ports = ",".join(rg["ports"]) + # Dans OPNsense une regle est TOUJOURS `in` sur l'interface d'arrivee : c'est + # la que le paquet penetre le pare-feu. Le sens du flux (entrant/sortant vis-a-vis + # de l'ecosysteme) se lit alors dans l'interface, pas dans un mot-cle. out.append( - f"pass {rg['sens']:3} proto {rg['protocole']:3} " + f"pass in on {rg['interface']:8} proto {rg['protocole']:3} " f"from {rg['source']:24} to {rg['destination']:24} port {ports:7} " f"# {rg['role']} ({rg['chiffrement']})" ) diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 30321ae..819e912 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -128,6 +128,7 @@ INTRANTS_SCHEMA = [ ("proxmox_ponts", "proxmox", "catalogue", "Proxmox", "Ponts réseau disponibles (liste)", "liste"), ("opnsense_api_url", "frontiere", "constante", "Frontière", "URL de gestion de la frontière OPNsense (API REST)", "str"), ("opnsense_api_verifier_certs", "frontiere", "defaut", "Frontière", "Valider le certificat TLS de la frontière", "bool"), + ("opnsense_wan_ip", "frontiere", "constante", "Frontière", "Adresse publique du WAN de la frontière (face Internet de l'écosystème)", "str"), ("opnsense_if_wan", "frontiere", "defaut", "Frontière", "Interface WAN — IDENTIFIANT OPNsense (wan), pas le libellé ni le périphérique", "str"), ("opnsense_if_transit", "frontiere", "defaut", "Frontière", "Interface de transit — IDENTIFIANT OPNsense (opt1, opt2…), pas « igb1 » ni le libellé affiché", "str"), ("underlay_routeur", "fabric", "constante", "Fabric", "Switch qui porte le routage (SVI, ACL, routes) — les autres restent en L2 pur", "str"), diff --git a/scripts/underlay.py b/scripts/underlay.py index c1ad9dd..be1b66a 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -171,6 +171,21 @@ def reseau_transit(underlay: dict | None) -> dict | None: return None +def octet_passerelle() -> int | None: + """Dernier octet reserve aux points de routage, lu dans la nomenclature active. + + Convention d'exploitation : un routeur porte LE MEME dernier octet sur tous les + sous-reseaux ou il participe — on retient une adresse, pas treize. Declare une + seule fois (`reservations.passerelle`), jamais code en dur ici. + """ + p = RACINE / "instance" / "plan" / "nomenclature.yml" + if not p.is_file(): + return None + n = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + v = (n.get("reservations") or {}).get("passerelle") + return int(v) if isinstance(v, int) else None + + def _tenants_reperes() -> tuple[set[int], list[str]]: """VLAN et supernets des tenants federes (import paresseux : evite le cycle avec devis).""" import devis_reseau @@ -263,6 +278,28 @@ def valider(underlay: dict | None, if p.get("montante") and hn == r_courant: erreurs.append(f"hote '{hn}': une `montante` alors qu'il EST le routeur") + # Invariant du dernier octet : tous les points de routage doivent le partager. + # Exemption des sous-reseaux plus etroits qu'un /24 : sur un /29 de transit, + # l'adressage est dicte par les participants du lien, pas par la convention. + attendu = octet_passerelle() + if attendu is not None: + for r in reseaux(underlay): + gw = r.get("passerelle") + if not gw: + continue + try: + net = ipaddress.ip_network(r["sous_reseau"], strict=False) + except ValueError: + continue + if net.prefixlen > 24: + continue # lien etroit : exempte, cf. le /29 de transit + octet = int(str(gw).rsplit(".", 1)[1]) + if octet != attendu: + erreurs.append( + f"reseau '{r.get('nom','?')}': passerelle {gw} finit par .{octet} alors que " + f"les points de routage portent .{attendu} partout " + f"(nomenclature: reservations.passerelle)") + d = dialecte(underlay) if d and d not in DIALECTES: erreurs.append(f"dialecte '{d}' inconnu (attendu : {', '.join(DIALECTES)})")