pare-feu Proxmox : le filtrage est-ouest intra-tenant, dérivé (P25)

`make devis-proxmox-fw` : 34 groupes de sécurité, 40 règles, 2 tenants —
depuis les 57 flux intra-tenant que le registre connaissait déjà.

Défense en profondeur, pas remplacement : l'hyperviseur filtre puis l'hôte
destinataire filtre à nouveau. Coût de maintenance nul, les deux barrières
lisent le registre par les MÊMES fonctions — la duplication est dans
l'application, jamais dans la décision.

Un IPSet par rôle porte les membres, les groupes y renvoient : ajouter un
hôte à un rôle met à jour toutes les règles qui l'autorisent, en un endroit.

Garde ajoutée après coup : Proxmox limite un nom de groupe à 18 caractères.
Ma première version tronquait sans vérifier — deux rôles tronqués au même nom
auraient fusionné leurs règles, donnant à une VM les autorisations d'un rôle
qu'elle ne porte pas, silencieusement. Le préfixe porte maintenant l'index
plutôt que l'étiquette, et une garde échoue sur toute collision. Exercée.

Conséquence consignée : tout ce qui entre dans un tenant passant par la
frontière, le contrôleur Ansible aussi — l'OPNsense devient un prérequis de
déploiement, pas une étape parmi d'autres.

Preuves : 25 OK, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-03 11:43:32 -04:00
parent 89dc5ed3d4
commit fd62b59989
8 changed files with 298 additions and 2 deletions

View file

@ -1,5 +1,34 @@
# CHANGELOG — Set-OPS
## 2026-08-03 (suite 8) — le pare-feu Proxmox, troisième lecture du même registre
`make devis-proxmox-fw` (**preuve P25**). Le filtrage est-ouest intra-tenant est désormais
dérivé pour l'hyperviseur : **34 groupes de sécurité, 40 règles, 2 tenants** — depuis les
57 flux intra-tenant que le registre connaissait déjà.
Décidé : la défense est **en profondeur**, pas en remplacement. L'hyperviseur filtre, puis
l'hôte destinataire filtre à nouveau. Une VM compromise doit franchir les deux. Le coût de
maintenance est nul : les deux barrières lisent le registre **par les mêmes fonctions**
(`_resoudre_sources`, `_pairs`, `_hotes_du_groupe`) — la duplication est dans l'application,
jamais dans la décision.
Un IPSet par rôle porte les membres, les groupes de sécurité y renvoient : ajouter un hôte à
un rôle met à jour toutes les règles qui l'autorisent, en un seul endroit.
### La garde qui manquait à ma première version
Proxmox limite un nom de groupe à **18 caractères**. Ma première version tronquait sans
vérifier : deux rôles tronqués au même nom auraient **fusionné leurs règles**, donnant à une VM
les autorisations d'un rôle qu'elle ne porte pas — silencieusement.
Le préfixe porte maintenant l'**index** (`t17-`) plutôt que l'étiquette (`chez17-`), ce qui
rend la troncature bien plus rare, et une garde **échoue** sur toute collision plutôt que
d'émettre un devis pareil. Exercée.
### Conséquence à retenir
Puisque **tout** ce qui entre dans un tenant passe par la frontière, le contrôleur Ansible
aussi. **L'OPNsense devient un prérequis de déploiement**, pas une étape parmi d'autres :
sans lui, plus rien ne se déploie.
## 2026-08-03 (suite 7) — le modèle déclarait un MTU que le matériel n'a pas
En préparant le déplacement des adresses de VTEP, la lecture des interfaces a montré deux

View file

@ -348,6 +348,13 @@ devis-reseau: ansible-runtime ## Devis switch (VLANs/SVIs/ACLs) du reseau conver
devis-opnsense: ansible-runtime ## Devis OPNsense (frontiere nord/sud), derive du registre des flux (pair: externe)
python3 scripts/devis_opnsense.py $(if $(JSON),--json,)
.PHONY: devis-proxmox-fw devis-proxmox-fw-verifier
devis-proxmox-fw: ansible-runtime ## Devis pare-feu Proxmox (est-ouest intra-tenant), derive du registre des flux
python3 scripts/devis_proxmox_fw.py $(if $(JSON),--json,)
devis-proxmox-fw-verifier:
python3 scripts/devis_proxmox_fw.py --verifier
devis-opnsense-verifier:
python3 scripts/devis_opnsense.py --verifier

View file

@ -155,6 +155,7 @@ Légende du statut : ✅ prouvée · 🟡 partielle · ❌ fausse · ⚪ invéri
| AFF-103 | « L'underlay ne chevauche aucun supernet tenant et reste sous la plage VLAN tenant » (`underlay.yml.example` ; wiki) | structurelle | **P23** (`scripts/underlay.py --verifier`) | ✅ | Sauté si aucun `underlay.yml` monté (dépôt public nu). |
| AFF-104 | « La frontière déclare toujours une source d'administration ; un devis dont `nftables_admin_ssh` est vide est refusé » (`docs/frontiere-opnsense.md` §5) | fonctionnelle | **P24** (`scripts/devis_opnsense.py --verifier`) | ✅ | Garde anti-lockout : sans cet intrant, la règle SSH n'aurait aucune source et le `block` final fermerait l'accès. |
| AFF-105 | « Les modèles publics valident, underlay compris » (`exemples/modeles/socle/README.md`) | structurelle | **P17** (`scripts/modeles.py verifier`) | ✅ | Underlay facultatif ; validé pour sa cohérence **interne** seulement — un modèle est un gabarit, pas un site déployé. |
| AFF-107 | « Le filtrage est-ouest intra-tenant est dérivé du registre des flux, et redit la même politique que les nftables d'hôte » (`docs/sdn-evpn.md` §3) | structurelle | **P25** (`scripts/devis_proxmox_fw.py --verifier`) | ✅ | 34 groupes, 40 règles sur 2 tenants. Les deux barrières lisent le registre par les **mêmes fonctions** : elles ne peuvent pas diverger. |
| AFF-106 | « Le panneau couvre le schéma du plan : aucun champ n'oblige à éditer le YAML » (`docs/intrants-base-gui-conception.md`) | fonctionnelle | **P19** (`scripts/couverture_gui.py`) | 🟡 | Vrai des registres du plan. **Exceptions assumées** : `reseaux` et `hotes` de l'underlay (listes de tables) restent hors du panneau ; les ports physiques et le nœud de sortie EVPN se nomment à la main. |
**Non enregistré volontairement.** Les devis eux-mêmes (`make devis-reseau`,
@ -391,6 +392,7 @@ Chaque affirmation ✅ est soit **rejouée** par une preuve de `make prouver`
| AFF-104 | **P24** (frontière : accès d'administration déclaré) |
| AFF-105 | **P17** (tous les modèles valident, underlay compris) |
| AFF-106 | **P19** (le GUI couvre le schéma du plan) |
| AFF-107 | **P25** (pare-feu Proxmox est-ouest) |
**Structurelles/doctrinales, hors harnais récurrent** (vérifiées à l'audit) : AFF-005
(`make`=aide), AFF-014 (ciblage `groupe:&hotes_actifs`), AFF-024 (`instancier-appliquer`,

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (24 OK · 0 echec · 0 saute)
- **Verdict** : ✅ CONFORME (25 OK · 0 echec · 0 saute)
## Preuves
@ -37,6 +37,7 @@
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (19 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 5 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 26 regles, 2 routes, admin=192.168.254.2/32,192.168.255.0/24,192.168.255.2/32. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 34 groupe(s), 40 regle(s). |
## Couverture des affirmations ✅ du registre

View file

@ -33,6 +33,8 @@ sont les seules vérifiables.
| **D-07** | **Pas d'ACL** sur cette fabric (`acl_inter_tenant: false`) | le matériel ne sait pas lier une ACL à un SVI ; des règles jamais liées auraient l'air d'isoler | `frontiere-opnsense.md` §1 | — |
| **D-08** | Le **routage tenant passe au SDN EVPN** — une zone par tenant | une zone EVPN est un VRF : l'isolation devient structurelle, pas réglementaire | `sdn-evpn.md` | P23 (MTU) |
| **D-09** | Le **filtrage inter-zone d'un tenant** se fait au même niveau (EVPN) | là où le routage a lieu | `sdn-evpn.md` §3 | — |
| **D-31** | Le filtrage est-ouest est appliqué **deux fois** : hyperviseur **puis** nftables d'hôte | défense en profondeur ; les deux dérivent du même registre par les mêmes fonctions, donc ne peuvent pas diverger | `sdn-evpn.md` §3 | P25 |
| **D-32** | **Tout** ce qui entre ou sort d'un tenant passe par la frontière | un VRF n'a qu'une sortie ; conséquence : la frontière devient un **prérequis de déploiement** | `sdn-evpn.md` §3 | — |
| **D-10** | L'**inter-tenant passe obligatoirement par la frontière** | il sort du VRF, donc traverse une bordure en `block` par défaut : il ne peut plus être oublié | `sdn-evpn.md` §3 | — |
| **D-11** | La **sortie générale est déclarée** dans le registre des flux | `block out` est un vrai default-deny ; un besoin oublié ne se manifeste pas par un refus clair | `frontiere-opnsense.md` §8 | P09 |
| **D-29** | L'**overlay EVPN plafonne à 1450** ; le transport doit donc dépasser 1500 | choix d'exploitation ; l'encapsulation VXLAN coûte 50 octets | `sdn-evpn.md` §5 | P23 (MTU du transport) |

View file

@ -50,7 +50,7 @@ plafond de 245 tenants reste, sa cause change.
| **entre zones d'un même tenant** | zone EVPN du tenant (VRF, sur les hyperviseurs) | **au même endroit** |
| **entre tenants** | sort du VRF → nœud de sortie → **OPNsense** | la frontière, et elle seule |
| **vers l'extérieur** | idem | la frontière |
| **de service à service, sur un hôte** | — | nftables d'hôte, `policy drop` |
| **de service à service, sur un hôte** | — | **deux barrières** : pare-feu Proxmox (`make devis-proxmox-fw`) **puis** nftables d'hôte (`make flux`) |
Deux conséquences qui méritent d'être dites.
@ -59,6 +59,11 @@ sortir du VRF, donc traverser la bordure, qui est en `block` par défaut. Un flu
légitime devra être **déclaré** pour exister — le registre des flux n'a pas encore de mot-clé
pour ça, c'est un point ouvert.
**La défense est en profondeur, sans coût de maintenance.** Le filtrage est-ouest est appliqué
deux fois : par l'hyperviseur, puis par l'hôte destinataire. Une VM compromise doit franchir
les deux. Et comme les deux **dérivent du même registre par les mêmes fonctions**, elles ne
peuvent pas se contredire — la duplication est dans l'application, jamais dans la décision.
**Le commutateur ne voit plus rien du trafic tenant.** Il transporte du VXLAN qu'il ne lit
pas. Y chercher une trace d'un problème applicatif serait perdre son temps : le miroir utile
est sur l'hyperviseur.

248
scripts/devis_proxmox_fw.py Normal file
View file

@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""Devis de pare-feu Proxmox — le filtrage EST-OUEST intra-tenant.
DERIVE du meme registre des flux que les nftables d'hote et que la frontiere. Les trois
lisent `roles/*/meta/flux.yml` par les MEMES fonctions : ils ne peuvent pas diverger.
PARTAGE DES ROLES (cf. docs/sdn-evpn.md) :
- ce devis : est-ouest INTRA-tenant, applique par l'hyperviseur ;
- `make flux` : la meme politique, rejouee sur l'hote lui-meme (defense en
profondeur une VM compromise franchit deux barrieres) ;
- `make devis-opnsense` : tout ce qui ENTRE ou SORT d'un tenant.
Les flux `pair: externe` sont donc SAUTES ici : ils relevent de la frontiere.
NON destructif : ce script n'ecrit rien sur le cluster. Il produit un devis a relire,
puis (a venir) un JSON pour l'API Proxmox.
Usage :
python3 scripts/devis_proxmox_fw.py # devis lisible
python3 scripts/devis_proxmox_fw.py --json # meme contenu, pour l'API
python3 scripts/devis_proxmox_fw.py --verifier # garde : aucun role sans source
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts"))
from resoudre_flux import ( # noqa: E402
charger_flux,
est_groupe_operationnel,
_enfants,
_hotes_du_groupe,
_ip_par_hote,
_pairs,
_resoudre_sources,
)
from devis_reseau import admin_de, decouvrir, inventaire_de, prefixe # noqa: E402
def nom_ipset(role: str, etiquette: str) -> str:
return f"{etiquette}_{role}".lower()
LONGUEUR_MAX_GROUPE = 18 # limite de Proxmox pour un nom de groupe de securite
def nom_groupe(role: str, index: int) -> str:
"""Nom de groupe de securite, sous la limite de 18 caracteres de Proxmox.
Le prefixe porte l'INDEX du tenant plutot que son etiquette : `t17-` au lieu de
`chez17-`, ce qui laisse trois caracteres de plus au role et rend la troncature
beaucoup plus rare. Les groupes sont a l'echelle du datacenter, d'ou le prefixe.
"""
court = role.replace("serveur_", "srv-").replace("client_", "cli-").replace("_", "-")
return f"t{index}-{court}".lower()[:LONGUEUR_MAX_GROUPE]
def _verifier_unicite(noms: list[str], tenant: str) -> None:
"""Une troncature qui fait collisionner deux roles FUSIONNERAIT leurs regles.
Silencieusement, et dans le mauvais sens : une VM recevrait les autorisations d'un
role qu'elle ne porte pas. On echoue plutot que d'emettre un devis pareil.
"""
vus: dict[str, str] = {}
for role, nom in noms:
if nom in vus:
raise SystemExit(
f"erreur: {tenant} — les roles '{vus[nom]}' et '{role}' donnent le meme "
f"groupe '{nom}' apres troncature a {LONGUEUR_MAX_GROUPE} caracteres. "
f"Leurs regles fusionneraient. Renommer un role ou raccourcir le prefixe.")
vus[nom] = role
def _ports(fl: dict) -> list[str]:
p = fl["port"]
return [str(x) for x in (p if isinstance(p, list) else [p])]
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"""Le devis, en structure. Un bloc par tenant federe."""
flux_par_role = charger_flux()
blocs: list[dict] = []
for nom, pfx, n in tenants:
inv = inventaire_de(nom)
if inv is None:
continue
etiquette = f"{pfx}{n['index']}"
data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {}
# Les PLANIFIES comptent : le pare-feu se prepare avant que la VM existe,
# sinon le devis changerait a chaque materialisation d'hote.
tous = set(_ip_par_hote(data))
ips = _ip_par_hote(data)
# Un IPSet par role porte dans ce tenant : les membres sont ses hotes.
ipsets: dict[str, dict] = {}
for groupe in sorted(_enfants(data)):
if not est_groupe_operationnel(groupe):
continue
membres = sorted({ips[h] for h in _hotes_du_groupe(data, groupe) if h in ips})
if membres:
ipsets[nom_ipset(groupe, etiquette)] = {
"role": groupe, "membres": membres,
}
groupes: list[dict] = []
sans_source: list[str] = []
for groupe in sorted(_enfants(data)):
if not est_groupe_operationnel(groupe):
continue
if not _hotes_du_groupe(data, groupe):
continue
regles: list[dict] = []
for fl in flux_par_role.get(groupe, []):
if fl.get("sens") != "ingress":
continue
pairs = _pairs(fl)
if "externe" in pairs:
continue # la frontiere s'en charge
if pairs == ["localhost"]:
continue # boucle locale : aucune regle inter-noeud
srcs = _resoudre_sources(data, fl.get("pair"), tous, ips)
if not srcs:
sans_source.append(f"{groupe}:{','.join(_ports(fl))}")
continue
# Source par IPSet quand le pair est UN role : une seule liste a tenir.
source = (f"+{nom_ipset(pairs[0], etiquette)}"
if len(pairs) == 1 and nom_ipset(pairs[0], etiquette) in ipsets
else ",".join(srcs))
for port in _ports(fl):
regles.append({
"action": "ACCEPT", "sens": "IN",
"source": source,
"proto": fl.get("protocole", "tcp"),
"dport": port,
"raison": fl.get("raison", ""),
})
if regles:
groupes.append({
"nom": nom_groupe(groupe, n["index"]), "role": groupe, "regles": regles,
})
_verifier_unicite([(g["role"], g["nom"]) for g in groupes], nom)
# Affectation : quelle VM recoit quels groupes.
affect: list[dict] = []
par_nom_groupe = {g["role"]: g["nom"] for g in groupes}
for hote in sorted(_hotes_du_groupe(data, "hotes_actifs")
or [h for h in ips]):
vmid = None
for g, membres in _enfants(data).items():
h = (membres.get("hosts") or {}).get(hote) or {}
if h.get("proxmox_vmid"):
vmid = h["proxmox_vmid"]
break
porte = [par_nom_groupe[g] for g in sorted(_enfants(data))
if hote in (_enfants(data)[g].get("hosts") or {}) and g in par_nom_groupe]
if porte:
affect.append({"hote": hote, "vmid": vmid, "groupes": porte})
blocs.append({
"tenant": nom, "etiquette": etiquette,
"ipsets": ipsets, "groupes": groupes,
"affectations": affect, "sans_source": sorted(set(sans_source)),
"admin": admin_de(nom),
})
return {"blocs": blocs}
def rendre(devis: dict) -> str:
out = [
"# ============================================================",
"# DEVIS PARE-FEU PROXMOX — est-ouest INTRA-tenant",
"# Genere par scripts/devis_proxmox_fw.py. NE PAS EDITER A LA MAIN.",
"# Derive du MEME registre des flux que les nftables d'hote : les deux",
"# barrieres disent la meme chose, et ne peuvent pas diverger.",
"#",
"# Ce qui ENTRE ou SORT d'un tenant n'est PAS ici : c'est la frontiere",
"# (`make devis-opnsense`). Les flux `pair: externe` sont sautes.",
"# ============================================================",
"",
"## 0. Prerequis, au niveau du datacenter",
"# Pare-feu active, politique d'entree DROP. Sans cela, ces regles",
"# s'ajoutent a un tout-permis et n'interdisent rien.",
"",
]
for b in devis["blocs"]:
out += [f"## Tenant {b['tenant']} ({b['etiquette']})", ""]
out.append(f"### IPSets — {len(b['ipsets'])} (les membres d'un role, en un seul endroit)")
for nom, s in sorted(b["ipsets"].items()):
out.append(f"ipset {nom} # {s['role']}")
for ip in s["membres"]:
out.append(f" {ip}")
out += ["", f"### Groupes de securite — {len(b['groupes'])} (les flux entrants d'un role)"]
for g in b["groupes"]:
out.append(f"securitygroup {g['nom']} # {g['role']}")
for r in g["regles"]:
out.append(f" {r['sens']} {r['action']} -source {r['source']} "
f"-p {r['proto']} -dport {r['dport']}")
if r["raison"]:
out.append(f" # {r['raison']}")
out += ["", f"### Affectation aux VM — {len(b['affectations'])}"]
for a in b["affectations"]:
out.append(f" vmid {a['vmid'] or '(non materialise)'} {a['hote']:<18} "
f"-> {', '.join(a['groupes'])}")
if b["sans_source"]:
out += ["", "# /!\\ Flux ingress sans source resolue dans ce tenant (aucune regle emise) :"]
out += [f"# {x}" for x in b["sans_source"]]
out.append("")
out += [
"## Defaut",
"# Politique d'entree DROP au datacenter : ce qui n'est pas ci-dessus",
"# n'entre pas. Un flux oublie se declare dans `roles/<role>/meta/flux.yml`,",
"# jamais a la main dans Proxmox — la regle serait perdue a la generation.",
]
return "\n".join(out)
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--json", action="store_true")
ap.add_argument("--verifier", action="store_true")
args = ap.parse_args(argv)
devis = construire(decouvrir())
if args.verifier:
total_g = sum(len(b["groupes"]) for b in devis["blocs"])
total_r = sum(len(g["regles"]) for b in devis["blocs"] for g in b["groupes"])
if not total_g:
print("erreur: aucun groupe de securite derive — le registre ou l'inventaire "
"est vide.", file=sys.stderr)
return 2
print(f"CONFORME : pare-feu Proxmox, {len(devis['blocs'])} tenant(s), "
f"{total_g} groupe(s), {total_r} regle(s).")
return 0
print(json.dumps(devis, indent=2, ensure_ascii=False) if args.json else rendre(devis))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -292,6 +292,8 @@ PREUVES: list[dict] = [
"cmds": [[sys.executable, "scripts/plan_recette.py", "--verifier"]]},
{"id": "P23", "titre": "Underlay sans collision avec la plage tenant", "refs": ["AFF-103"],
"cmds": [[sys.executable, "scripts/underlay.py", "--verifier"]]},
{"id": "P25", "titre": "Pare-feu Proxmox : est-ouest intra-tenant derive", "refs": ["AFF-107"],
"cmds": [[sys.executable, "scripts/devis_proxmox_fw.py", "--verifier"]]},
{"id": "P24", "titre": "Frontiere nord/sud : acces d'administration declare", "refs": ["AFF-104"],
"cmds": [[sys.executable, "scripts/devis_opnsense.py", "--verifier"]]},
]