frontière : chaque règle porte son interface d'arrivée

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 du sens du flux : `ingress`/`externe` arrive par le
WAN, `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. Le montage parallèle décrit ce jour lève la dernière
inconnue.

Le rendu abandonne `pass out` pour `pass in on <interface>`, l'idiome réel
d'OPNsense et ce que le client d'API devra envoyer.

Ajouté aussi :
- `opnsense_wan_ip`, la face publique, en section Frontière du panneau ;
- invariant du dernier octet (P23) : un point de routage porte le même
  dernier octet sur tous ses sous-réseaux. Le chiffre vient de
  `reservations.passerelle`, pas d'une constante. Exemption des liens plus
  étroits qu'un /24 — sur le /29 de transit l'adressage est dicté par les
  participants. Vérifié que l'invariant tenait déjà sur les 13 sous-réseaux
  routés avant d'écrire la garde.

Preuves : 24 OK, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-02 16:06:55 -04:00
parent 53f6af3f1a
commit 26090e8acd
5 changed files with 117 additions and 8 deletions

View file

@ -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 <interface>`, 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

View file

@ -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

View file

@ -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']})"
)

View file

@ -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"),

View file

@ -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)})")