L'ACL d'isolation bloquait l'autre tenant, puis se terminait par `permit ip <tenant> any`. Ce `any` autorisait 10.27.x -> 10.0.0.0/24 : le management des switches, celui de Proxmox et l'OOB/IPMI, plus iSCSI et Ceph. Une VM compromise atteignait la console physique des hyperviseurs. Le commentaire du générateur disait « Reste -> passerelle OPNsense », ce qui est faux pour l'underlay : ce trafic est routé LOCALEMENT par le switch et ne passe jamais par la frontière, donc elle ne le filtre jamais. Un `deny` par sous-réseau underlay est désormais émis avant le `permit` final, dérivé de underlay.yml, dialecte respecté (masque normal ou wildcard). Vérifié qu'aucun flux du registre ne vise l'underlay : rien de déclaré ne casse. Sans underlay déclaré, l'ACL retrouve sa forme d'avant. Consigné en §6 : le registre n'a pas de mot-clé `underlay` (un besoin légitime, superviser l'hyperviseur, ne pourrait pas être déclaré), et le devis émet un jeu unique de SVI pour trois switches sans MLAG. Preuves : 24 OK, 0 échec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
11 KiB
Markdown
202 lines
11 KiB
Markdown
# 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 les switches L3** | 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.
|
|
|
|
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. Sans précaution, le `permit ip <tenant> any` final de l'ACL laisserait une VM
|
|
atteindre le management des switches, celui de Proxmox et l'OOB/IPMI. `make devis-reseau`
|
|
émet pour cette raison un `deny` par sous-réseau underlay **avant** le `permit` final,
|
|
dérivé de `underlay.yml`.
|
|
|
|
## 2. Ce que la frontière décide (et pourquoi rien n'est saisi à la main)
|
|
|
|
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.
|
|
|
|
## 3. 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.
|
|
|
|
## 4. 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.1 # SVI du switch L3
|
|
passerelle_sortie: 10.0.4.2 # la frontière = sortie par défaut de la flotte
|
|
```
|
|
|
|
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.1`) devient le SVI côté
|
|
switch *et* le prochain saut des routes tenants côté frontière ; le `passerelle_sortie`
|
|
(`10.0.4.2`) 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.2 # aller
|
|
ip route 192.168.255.0 255.255.255.0 10.0.4.2 # 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.
|
|
|
|
## 5. 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** (`<IF-TRANSIT>`,
|
|
`<PROCHAIN-SAUT-SWITCH>`) ; 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.
|
|
|
|
## 6. 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`, le nom de 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 (voir §4).
|
|
- **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.
|
|
- **Trois switches, un seul devis** — `devis_reseau.py` émet un jeu unique de SVI sans
|
|
désigner le switch qui les porte. Sans MLAG, il reste à trancher entre « un seul routeur,
|
|
les deux autres en L2 pur » et « SVI répliqués en VRRP ». Appliquer le devis tel quel sur
|
|
les trois créerait des conflits d'adresses.
|
|
- **La sortie générale** (mises à jour apt, ACME) n'est pas déclarée dans le registre : les
|
|
hôtes sortent aujourd'hui parce que la politique `output` des nftables est `accept` et que
|
|
l'ACL de switch se termine par `permit ip <tenant> any`. À trancher : la déclarer
|
|
explicitement, ou l'assumer comme politique par défaut de la bordure.
|