# La frontière OPNsense (nord/sud) > Le pare-feu de bordure de l'écosystème. Ce document fixe les décisions d'architecture > prises le **2026-07-29**, et décrit le devis **dérivé** qui en découle > (`make devis-opnsense`). Il complète `docs/flux-conception.md` (le modèle des flux) et > `docs/registre-flux.md` (la matrice d'audit). ## 1. Le partage des rôles Trois pare-feux coexistent dans le modèle, et chacun voit une chose différente : | Où | Quoi | Généré par | |---|---|---| | **Sur chaque hôte** | nftables `policy drop`, moindre privilège par IP source | `make flux` (`resoudre_flux.py`) | | **Sur le switch routeur** | ACL d'isolation inter-tenant, appliquées `in` sur les SVI | `make devis-reseau` | | **À la frontière (OPNsense)** | ce qui **entre et sort** de l'écosystème | `make devis-opnsense` | **Décision : OPNsense est une frontière nord/sud, pas la passerelle des zones.** Les SVI restent sur les switches L3, qui continuent d'assurer le routage inter-zone au débit ligne. OPNsense ne porte **aucun SVI de tenant** et ne voit pas le trafic est-ouest. **Décision (2026-08-01) : un seul switch route.** Sans MLAG, le routage est porté par un unique switch, désigné par `underlay.routeur` ; les autres restent en **L2 pur** et n'ont d'adresse IP que pour leur propre gestion. `make devis-reseau` émet en conséquence deux configurations distinctes — *partie A* pour le routeur, *partie B* pour les switches d'accès — plutôt qu'un devis unique que l'on serait tenté d'appliquer partout, ce qui dupliquerait les SVI et créerait autant de conflits d'adresses qu'il y a de zones. `make underlay` refuse un `routeur` qui ne nomme aucun hôte déclaré. Conséquence assumée : l'isolation inter-zone repose sur les ACL de switch, moins expressives que le registre des flux. Le zéro-confiance est-ouest reste porté par les nftables d'hôte et le TLS mutuel — pas par le pare-feu de bordure. Corollaire, qui n'est pas anodin : **le trafic d'un tenant vers l'underlay est routé localement par le switch et ne passe jamais par la frontière.** Il n'est donc jamais filtré par elle. Quand la fabric sait porter des ACL, `make devis-reseau` émet un `deny` par sous-réseau underlay **avant** le `permit` final, dérivé de `underlay.yml`. **Décision (2026-08-02) : pas d'ACL sur cette fabric** (`underlay.acl_inter_tenant: false`). Les interfaces VLAN du Binardat n'offrent aucun `access-group` — on ne peut pas lier une ACL à un SVI. Émettre des ACL qu'on ne peut pas lier serait pire que rien : elles auraient l'air d'isoler sans jamais filtrer. L'isolation inter-tenant repose donc **entièrement sur les nftables d'hôte**, en `policy drop`. C'est défendable — c'est déjà là que vit le zéro-confiance est-ouest — mais il faut nommer ce qu'on perd : **le plan de gestion de la fabric n'a plus de protection réseau contre les tenants.** Une VM émettant vers `10.0.0.x` est routée localement vers le management des switches, celui de Proxmox et l'OOB/IPMI ; la politique `output` des VM est permissive, et l'IPMI n'est pas un hôte géré. Des **VRF** auraient donné cette isolation sans ACL, par séparation des tables de routage. Ce matériel n'en a pas — c'est le critère à retenir au prochain renouvellement. La seule parade structurelle disponible aujourd'hui : sortir le management de la fabric routée des tenants, comme l'est déjà le stockage. ## 2. Hébergeur et tenants — qui possède quoi Un **hébergeur** possède le matériel et sert **plusieurs tenants** ; il a en général son propre tenant par défaut. Chezlepro est les deux à la fois, ce qui masque la distinction — mais elle décide de l'emplacement de chaque chose : | Objet | Appartient à | Vit dans | |---|---|---| | plan des services, inventaire | le **tenant** | `OPS-` | | `underlay.yml` (fabric physique) | l'**hébergeur** | `OPS-`, monté par symlink | | `group_vars/opnsense.yml` (frontière) | l'**hébergeur** | idem — **une** frontière pour tous ses tenants | Conséquence pratique : `make instance-utiliser` bascule le **tenant** actif, jamais l'hébergeur. Le devis lit donc la fabric et les intrants de frontière chez l'hébergeur, quel que soit le tenant actif — sans quoi basculer sur un invité ferait disparaître l'URL de gestion, l'adresse publique et les interfaces, et le devis repartirait en marqueurs. L'hébergeur n'est **pas déclaré** : le symlink `underlay.yml` le désigne déjà, et une seconde déclaration ouvrirait la porte à deux valeurs contradictoires. ## 3. Ce que la frontière décide (et pourquoi rien n'est saisi à la main) **La frontière est un équipement partagé, comme les switches.** Elle route vers tous les tenants fédérés, elle porte donc aussi **leurs règles** — pas seulement celles de l'instance active. Un tenant routé sans règles serait joignable jusqu'à la bordure puis bloqué en silence : chemin présent, politique absente, exactement le mode de panne du 2026-07-29. Les alias d'hôtes sont préfixés du tenant (`SETOPS_CHEZ17_SERVEUR_NGINX`), et surtout **chaque tenant a son propre alias d'administration** : `SETOPS_ADMIN_CHEZ17` n'ouvre que `10.27.0.0/16`. Une union aurait laissé le plan de gestion d'un tenant entrer chez le voisin — ce que les ACL de switch interdisent par ailleurs. La bordure ne doit pas rouvrir ce que l'isolation inter-tenant ferme. Deux omissions sont **annoncées** plutôt que tues : un tenant sans inventaire généré (aucune règle) et un tenant dont `nftables_admin_ssh` est vide (règle SSH omise — l'ouvrir à `any` exposerait le SSH à Internet). Le registre des flux distingue les pairs par **mot-clé**. Or `resoudre_flux.py` **saute volontairement** le pair `externe` (`scripts/resoudre_flux.py:184`) : ces flux-là ne concernent pas le pare-feu d'hôte, ils relèvent de la bordure. Plusieurs `raison` le disent déjà noir sur blanc — « Frontière publique gérée à l'OPNsense ». Autrement dit : **la politique de la frontière était déjà écrite dans le registre**, et il ne restait qu'à la dériver. C'est ce que fait `scripts/devis_opnsense.py`, à partir de : - `roles/*/meta/flux.yml` — les flux `pair: externe`, qui donnent les règles ; - l'inventaire de l'instance active — quels hôtes portent quel rôle, donc les destinations ; - `../*/plan/nomenclature.yml` — les supernets des tenants fédérés, donc les routes ; - l'intrant `nftables_admin_ssh` — les réseaux d'administration. Aucun port, aucune adresse et aucun nom d'hôte n'est écrit dans le générateur. ## 4. 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. 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 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. 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 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`. ## 5. 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 la garde des nftables d'hôte. Source unique, donc pas de divergence possible entre « ce que le pare-feu d'hôte laisse passer » et « ce que la bordure laisse entrer ». `make devis-opnsense-verifier` (**preuve P24**) **refuse** un devis dont cet intrant est vide : 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. ## 6. 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.** ### Le lien vit dans l'underlay, pas dans un tenant La frontière route vers **tous** les supernets tenants (`10.21.0.0/16`, `10.27.0.0/16`…) par le **même** prochain saut. Le lien qui la relie au routeur est-ouest est donc *partagé* : il n'appartient à aucun tenant et ne peut dériver d'aucun `index`. Sa place est l'underlay, cluster-global, au même titre que le management, l'iSCSI et Ceph. Il se déclare dans `underlay.yml` par la clé **`passerelle_sortie`** — l'adresse du pare-feu sur ce lien : ```yaml - nom: transit-frontiere vlan: 40 sous_reseau: 10.0.4.0/29 passerelle: 10.0.4.6 # SVI du switch L3 (le commutateur) passerelle_sortie: 10.0.4.1 # bifrost-1 = sortie par défaut de la flotte ``` **Plan du `/29`.** Les frontières occupent le bas de la plage, le SVI du switch le haut : | Adresse | Qui | |---|---| | `10.0.4.1` | `bifrost-1` — frontière active, sortie par défaut de la flotte | | `10.0.4.2` | `bifrost-2` — seconde frontière | | `10.0.4.3` | libre, réservée à une IP virtuelle CARP si les deux passent en HA | | `10.0.4.4-.5` | libres | | `10.0.4.6` | SVI du switch routeur (`le commutateur`) | Le jour où les deux OPNsense passent en haute disponibilité, `passerelle_sortie` devra pointer sur l'**IP virtuelle CARP** et non sur un boîtier nommé — c'est le seul changement que la bascule exigera, et il se fait à un endroit. **Nommage.** `bifrost-1` et `bifrost-2` désignent les deux frontières — Bifröst est le pont vers l'extérieur. La fabric interne porte un autre nom, `le commutateur…03` : le cheval qui traverse les mondes, pas le pont qui en sort. Les deux boîtiers sont déclarés comme hôtes du lien de transit ; ils sont **hors flotte Ansible**, la déclaration ne sert qu'à documenter le lien et à réserver les noms. Le devis switch ne leur adresse aucune ligne — sa partie B ne retient que les hôtes du réseau de management. Un `/29` plutôt qu'un `/30` : pendant une transition, deux pare-feux cohabitent sur le lien, et un `/30` n'offre que deux adresses. `make underlay` (**preuve P23**) refuse une sortie hors du lien, confondue avec le SVI, sans SVI, ou déclarée deux fois. Ce seul bloc alimente **les deux devis** : le `passerelle` (`10.0.4.6`) devient le SVI côté switch *et* le prochain saut des routes tenants côté frontière ; le `passerelle_sortie` (`10.0.4.1`) devient la route par défaut du switch. Le prochain saut n'est donc **pas** un intrant du panneau — le saisir en doublon rouvrirait la possibilité de deux valeurs contradictoires pour un seul et même lien. ### Les deux routes, et pourquoi il en faut deux `make devis-reseau` émet alors, en section 5 : ``` ip route 0.0.0.0 0.0.0.0 10.0.4.1 # aller ip route 192.168.255.0 255.255.255.0 10.0.4.1 # retour (nftables_admin_ssh) ``` **L'aller** est la sortie générale : sans elle, aucun hôte de la flotte n'atteint quoi que ce soit hors de sa zone. ### L'ordre d'application, qui n'est pas indifférent Les sections 0 à 4b du devis switch sont sûres et posables à tout moment. **La section 5 ne l'est pas.** Ces routes déplacent la sortie du switch — y compris celle de ses propres réponses — vers l'adresse de la frontière. Tant que cette adresse ne répond pas, elles coupent l'accès d'administration **au switch lui-même**. C'est le même mécanisme que le piège ci-dessous, mais appliqué à l'équipement depuis lequel on travaille. Préalables, rappelés dans le devis : boîtier câblé, adressé sur le lien de transit, joignable depuis le switch, et une session console ouverte pendant l'opération. **Le retour** est le piège proprement dit. Un paquet d'administration entre par la frontière et atteint la VM ; la réponse part de la VM vers sa passerelle — un SVI **de switch**. Si le switch n'a pas de route vers le réseau d'administration, ou s'il l'atteint par un autre chemin (typiquement sa passerelle de *management*), la réponse revient au pare-feu **par une autre interface** que celle où l'état a été créé. Elle est alors jetée en silence : ni réponse, ni ICMP unreachable. Le symptôme est déroutant : la passerelle de zone répond au ping (elle, sa pile de management sait revenir), mais **aucun hôte derrière elle** n'est joignable. On croit à une règle de pare-feu ; c'est une route manquante à l'autre bout. Les réseaux d'administration ne sont pas saisis ici : ils viennent de l'intrant `nftables_admin_ssh`, **la même source unique** qui alimente la garde des nftables d'hôte et l'alias `SETOPS_ADMIN` de la frontière. Les trois pare-feux et les routes de retour ne peuvent donc pas diverger. ## 7. 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 devis sans clics : `devis_opnsense.py --json` produit déjà la structure destinée à cet usage (alias, routes, règles), et les endpoints visés sont `/api/firewall/alias/*`, `/api/firewall/filter/*` et `/api/routes/*`, avec le motif habituel « on prépare puis on applique ». ### Où vivent les identifiants Même partage que Proxmox — l'anodin en clair, le secret dans la voûte : | Quoi | Où | Réglable | |---|---|---| | URL de gestion, interfaces, prochain saut | `group_vars/opnsense.yml` (en clair) | **panneau « Intrants de base » du GUI**, section *Frontière* | | Clé et secret d'API | voûte unique de l'instance, sous `vault_opnsense_api_key` / `vault_opnsense_api_secret` | `ansible-vault edit` | Les valeurs non sensibles sont de **vrais intrants** : `opnsense_api_url`, `opnsense_api_verifier_certs`, `opnsense_if_wan`, `opnsense_if_transit` et `opnsense_prochain_saut` figurent au schéma du panneau (`INTRANTS_SCHEMA`) et s'éditent sans toucher au YAML — un opérateur règle la frontière depuis la console, sans IA. Tant qu'elles sont vides, le devis affiche des **marqueurs** (``, ``) ; il se complète tout seul dès qu'elles sont renseignées. > **Garde-fou** : `opnsense_api_key` et `opnsense_api_secret` sont dans > `INTRANTS_CLES_INTERDITES` — le GUI **refuse** de les écrire. Impossible de coller un > secret d'API dans le panneau par mégarde. Ils n'apparaissent qu'en lecture seule, par leur > nom, dans le rappel `SECRETS_ATTENDUS`. Le fichier en clair ne porte que des **références par nom** (`{{ vault_opnsense_api_key }}`). `scripts/voute.py` les recense automatiquement et la **preuve P18** vérifie qu'elles figurent au gabarit `vault.yml.example` — le gabarit est passé de 23 à 25 secrets sans intervention. Pour renseigner les vraies valeurs, une seule commande, jamais de secret sur disque en clair : ```sh ansible-vault edit instance/inventories/principal/group_vars/all/vault.yml ``` > Le secret d'API n'est affiché **qu'à sa création** dans OPNsense. S'il est perdu, il faut > révoquer la clé et en générer une nouvelle. > 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. ## 8. 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 (§6). **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 affichée, et `opt1` est l'**identifiant interne** — le seul que parle l'API REST, et le plus stable des trois : il survit à un changement de carte réseau comme à un renommage. - **Le second VPN** — pendant la transition, le VPN d'administration actuel (pfSense) reste en service et OPNsense en montera un second. Son sous-réseau devra être **ajouté à `nftables_admin_ssh`**, sans quoi il sera muet de la même façon. - **L'application par l'API** — `--json` produit la structure ; le client d'application reste à écrire. - **Pas de mot-clé `underlay` dans le registre des flux** — l'underlay est aujourd'hui interdit en bloc aux tenants. Si un besoin légitime apparaît (superviser l'hyperviseur depuis `obs-01`, par exemple), il ne peut pas être *déclaré* : il faudrait ajouter un pair `underlay` au registre plutôt qu'une exception manuelle dans l'ACL. - ~~La syntaxe des routes~~ — **réglé le 2026-08-02**, confronté à un `show running-config` Binardat : la plateforme écrit `ip route 0.0.0.0/0 ` en notation CIDR. Le générateur suit le dialecte. - ~~La syntaxe des trunks~~ — **réglé le 2026-08-02** par `switchport trunk allowed vlan ?`. Le devis émet la forme **sans mot-clé**, qui *définit* la liste : `add` l'*ajoute* à l'existante, et sur un port trunk neuf — qui autorise tous les VLAN — n'aurait rien retranché. Le devis aurait donné l'illusion de restreindre. - **Le spanning-tree, sur Binardat** (`show spanning-tree`, 2026-08-02) : la plateforme est en **MSTP** (IEEE 802.1s, *Force Version 3*) par défaut, priorité de pont **32768**, et il est **actif** — son absence du `show running-config` signifiait « valeurs par défaut », non « désactivé ». Deux conséquences : `underlay.stp.mode` doit dire `mstp` si l'on garde le comportement d'usine, et la ligne de priorité émise pour les switches d'accès (32768) est un non-opérant qui écrit ce qui est déjà vrai. - ~~La syntaxe des ACL~~ — **réglé le 2026-08-02** : une ACL générée s'applique telle quelle, ses règles dans l'ordre (`ip access-list extended `, masques normaux, `any`). Détail de lecture : le boîtier **affiche** `any-destination` là où l'on saisit `any` — comparer un `show access-lists` au devis fait apparaître une différence qui n'en est pas une. - ~~La syntaxe du spanning-tree~~ — **réglé le 2026-08-02** par `spanning-tree ?` en mode configuration : `spanning-tree` seul active le protocole, `mode` et `priority` s'acceptent au niveau **global**. La priorité n'a donc pas besoin d'être portée par une instance, même en MSTP — la réserve inverse, notée la veille, était infondée. **Vérifié contre le matériel** : VLAN, SVI, trunks (`switchport …`), routes, définition des ACL, spanning-tree **global** (`spanning-tree`, `mode`, `priority`). Deux de ces vérifications ont révélé un défaut réel plutôt que de confirmer l'existant — les routes (notation CIDR) et surtout les trunks, dont la forme `add` ne retranchait rien. **Restent non vérifiées, toutes au niveau interface ou hors de l'aide consultée** : | Ligne émise | Où | Risque | |---|---|---| | `spanning-tree portfast trunk` | interface | `trunk` est un mot-clé Cisco ; l'équivalent s'écrit souvent `spanning-tree portfast` seul, voire `edged-port` | | `ip access-group in` | interface VLAN | **probablement invalide** : `ip ?` sur une interface VLAN n'offre aucun `access-group` (vérifié le 2026-08-02). Le filtrage semble aussi conditionné globalement par `firewall enable` | | `ip default-gateway ` | global | forme des switches d'accès (partie B) | Sur une **interface VLAN**, ni `spanning-tree` ni `ip access-group` n'existent (liste complète des commandes vérifiée). Pour `portfast` c'est attendu — il vise un port physique. Pour la liaison des ACL, non : c'est le mécanisme qui rend l'isolation effective. **Conséquence, et elle est lourde** : appliqué tel quel, le devis produirait des ACL parfaitement définies et **jamais liées**. `show access-lists` afficherait « used 0 time(s) », et rien d'autre ne signalerait que l'isolation inter-tenant ne filtre pas. Le devis porte désormais un avertissement à cet endroit, en dialecte `binardat` uniquement. Reste à trancher sur une interface **physique** : `ip ?`, `access-group ?`, `spanning-tree ?` — et le rôle de `firewall enable`. - **Les ports physiques restent à nommer** — ``, `` et `` ; rien dans le modèle ne peut les deviner. - ~~**La sortie générale** n'est pas déclarée~~ — **réglé le 2026-08-02.** Elle est déclarée dans le registre, donc dérivée comme le reste : `serveur_debian` (le socle, porté par tous les hôtes) déclare 443, 80 et 123/udp — dépôts apt et horloge ; `client_unbound` déclare 53 en UDP et TCP, la récursion depuis la racine que le choix souverain implique. Le `block out` de la section 5 est donc un vrai default-deny **assumé**, et le devis l'énonce désormais au lieu de le poser en silence.