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:
parent
53f6af3f1a
commit
26090e8acd
5 changed files with 117 additions and 8 deletions
30
CHANGELOG.md
30
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 <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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']})"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)})")
|
||||
|
|
|
|||
Loading…
Reference in a new issue