devis switch : l'ACL inter-tenant devient une capacité déclarée

Les interfaces VLAN du Binardat n'offrent aucun `access-group` : impossible
de lier une ACL à un SVI. Plutôt que d'émettre des règles jamais liées — qui
auraient l'air d'isoler sans jamais filtrer — la capacité se déclare :
`underlay.acl_inter_tenant`, `true` par défaut.

Ce n'est pas lié au dialecte de CLI mais au matériel : un autre commutateur
parlant la même CLI pourrait savoir lier des ACL.

À `false`, la section 3 ne contient plus de règles mais la raison, et surtout
ce qu'on perd : une VM émettant vers l'underlay est routée localement vers le
mgmt des switches, celui de Proxmox et l'OOB/IPMI. Les nftables des VM n'y
peuvent rien (politique `output` permissive), et l'IPMI n'est pas un hôte
géré.

Des VRF auraient donné cette isolation sans ACL — critère à retenir au
prochain renouvellement. Parade d'ici là : sortir le management de la fabric
routée des tenants, comme l'est déjà le stockage.

Preuves : 24 OK, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-03 00:45:56 -04:00
parent b5ca369ae7
commit 059d76a536
6 changed files with 180 additions and 41 deletions

View file

@ -1,5 +1,32 @@
# CHANGELOG — Set-OPS
## 2026-08-02 (suite 19) — pas d'ACL sur cette fabric : on route, et c'est tout
Les interfaces VLAN du Binardat n'offrent aucun `access-group` : impossible de lier une ACL à
un SVI. Plutôt que d'émettre des règles qui ne seraient jamais liées — elles auraient l'air
d'isoler sans jamais filtrer —, la capacité devient **déclarée** :
`underlay.acl_inter_tenant: false`.
Ce n'est pas lié au dialecte de CLI mais au **matériel** : un autre commutateur parlant la
même CLI pourrait savoir lier des ACL. Par défaut la valeur reste `true`, donc rien ne change
pour une fabric qui en est capable.
À `false`, la section 3 du devis ne contient plus de règles mais **la raison** — et surtout ce
qu'on perd :
> Une VM émettant vers l'underlay voit son paquet **routé localement** par le commutateur —
> mgmt des switches, mgmt Proxmox, OOB/IPMI. Les nftables des VM n'y peuvent rien (politique
> `output` permissive), et l'IPMI n'est pas un hôte géré.
L'isolation inter-tenant repose désormais 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 le plan de
gestion de la fabric perd sa seule protection réseau.
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. Parade
structurelle disponible d'ici là : sortir le management de la fabric routée des tenants,
comme l'est déjà le stockage.
## 2026-08-02 (suite 18) — la liaison des ACL n'existe pas sur une interface VLAN
`ip ?` sur une interface VLAN du Binardat n'offre **aucun `access-group`**, et la liste

View file

@ -0,0 +1,60 @@
# Preuve de conformite — Set-OPS — 2026-08-03
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (24 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | 4 tests passes. |
| P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 30 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 29 rôles, 68 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 32 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 24 secret(s) exige(s), tous presents. |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-001 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. |
| 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-001 | ✅ OK | Underlay conforme : 5 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-001 | ✅ OK | CONFORME : frontiere nord/sud, 22 regles, 2 routes, admin=192.168.254.2/32,192.168.255.0/24,192.168.255.2/32. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-08-03._

View file

@ -33,10 +33,25 @@ 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`.
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

View file

@ -476,17 +476,8 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
c = n["categories"][zone]
out.append(f"vlan {vlan_de(n['index'], zone)}")
out.append(f" name {pfx}{n['index']}-{c['libelle']}")
acl = underlay_mod.acl_inter_tenant(underlay)
out += ["!", "! ----- 2. Interfaces de routage (SVI = passerelle des hotes) -----"]
if dialecte == "binardat":
out += [
"! /!\\ LA LIGNE `ip access-group ... in` CI-DESSOUS EST DOUTEUSE SUR CETTE",
"! PLATEFORME : `ip ?` sur une interface VLAN n'offre AUCUN `access-group`",
"! (verifie le 2026-08-02). Appliquee telle quelle, elle serait refusee — ou pire,",
"! ignoree : les ACL de la section 3 existeraient sans rien filtrer, exactement",
"! comme un `show access-lists` affichant « used 0 time(s) ».",
"! Le filtrage semble par ailleurs conditionne globalement (`firewall enable`).",
"! A trancher par `ip ?` et `access-group ?` sur une interface PHYSIQUE.",
]
for nom, pfx, n in tenants:
m = masque(int(n.get("cidr_hote", 24)))
for zone in sorted(n["categories"]):
@ -494,34 +485,52 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
out.append(f"interface Vlan{vlan_de(n['index'], zone)}")
out.append(f" description {nom}-{c['libelle']}")
out.append(f" ip address {passerelle_de(n['index'], zone)} {m}")
out.append(f" ip access-group {pfx}{n['index']}-ISOLATION in")
if acl:
out.append(f" ip access-group {pfx}{n['index']}-ISOLATION in")
out.append(" no shutdown")
out += ["!", "! ----- 3. ACL d'isolation tenant (default-deny inter-tenant) -----"]
for nom, pfx, n in tenants:
reseau, m = masque_acl(supernet_de(n["index"]), dialecte)
out.append(f"ip access-list extended {pfx}{n['index']}-ISOLATION")
out += remarque(dialecte, f"Intra-tenant {nom} : routage local autorise")
out.append(f" permit ip {reseau} {m} {reseau} {m}")
for autre_nom, _, autre in tenants:
if autre_nom == nom:
continue
a_reseau, a_m = masque_acl(supernet_de(autre["index"]), dialecte)
out += remarque(dialecte, f"Bloquer le tenant {autre_nom}")
out.append(f" deny ip {reseau} {m} {a_reseau} {a_m}")
# L'underlay est la fabric physique : mgmt des switches, mgmt Proxmox, OOB/IPMI,
# iSCSI, Ceph. Le trafic d'un tenant vers ces reseaux est route LOCALEMENT par le
# switch : il ne passe jamais par la frontiere, donc il n'est jamais filtre. Sans
# ce deny, le `permit any` final l'autorise — une VM atteindrait la console
# physique des hyperviseurs. Aucun flux du registre ne vise l'underlay.
# TOUTES les fabrics y passent, meme celles portees par d'autres switches : la
# regle porte sur l'adresse de DESTINATION, pas sur le cablage. Si un jour un
# chemin s'ouvre vers le stockage, il est deja ferme.
for r in underlay_mod.reseaux(underlay):
u_reseau, u_m = masque_acl(r["sous_reseau"], dialecte)
out += remarque(dialecte, f"Bloquer l'underlay {r['nom']} (fabric physique)")
out.append(f" deny ip {reseau} {m} {u_reseau} {u_m}")
out += remarque(dialecte, "Reste (Internet / inter-tenant controle) -> passerelle OPNsense")
out.append(f" permit ip {reseau} {m} any")
if not acl:
out += ["!", "! ----- 3. Isolation inter-tenant — PAS D'ACL SUR CETTE FABRIC -----",
"! `underlay.acl_inter_tenant: false` : le materiel ne sait pas lier une ACL",
"! a une interface de routage. Emettre des ACL qu'on ne peut pas lier serait",
"! pire que rien — elles auraient l'air d'isoler sans jamais filtrer.",
"!",
"! L'isolation repose donc ENTIEREMENT sur les nftables de chaque hote",
"! (`make flux`), en `policy drop`, au moindre privilege par IP source.",
"!",
"! /!\\ CE QUI N'EST PLUS PROTEGE AU NIVEAU RESEAU : le plan de gestion de la",
"! fabric. Une VM qui emet vers l'underlay voit son paquet ROUTE localement par",
"! ce commutateur — mgmt des switches, mgmt Proxmox, OOB/IPMI. Les nftables des",
"! VM n'y peuvent rien (politique `output` permissive), et l'IPMI n'est pas un",
"! hote gere. Seule parade structurelle : sortir le management de la fabric",
"! routee des tenants, comme l'est deja le stockage.",
"!"]
else:
out += ["!", "! ----- 3. ACL d'isolation tenant (default-deny inter-tenant) -----"]
for nom, pfx, n in tenants:
reseau, m = masque_acl(supernet_de(n["index"]), dialecte)
out.append(f"ip access-list extended {pfx}{n['index']}-ISOLATION")
out += remarque(dialecte, f"Intra-tenant {nom} : routage local autorise")
out.append(f" permit ip {reseau} {m} {reseau} {m}")
for autre_nom, _, autre in tenants:
if autre_nom == nom:
continue
a_reseau, a_m = masque_acl(supernet_de(autre["index"]), dialecte)
out += remarque(dialecte, f"Bloquer le tenant {autre_nom}")
out.append(f" deny ip {reseau} {m} {a_reseau} {a_m}")
# L'underlay est la fabric physique : mgmt des switches, mgmt Proxmox, OOB/IPMI,
# iSCSI, Ceph. Le trafic d'un tenant vers ces reseaux est route LOCALEMENT par le
# switch : il ne passe jamais par la frontiere, donc il n'est jamais filtre. Sans
# ce deny, le `permit any` final l'autorise — une VM atteindrait la console
# physique des hyperviseurs. Aucun flux du registre ne vise l'underlay.
# TOUTES les fabrics y passent, meme celles portees par d'autres switches : la
# regle porte sur l'adresse de DESTINATION, pas sur le cablage. Si un jour un
# chemin s'ouvre vers le stockage, il est deja ferme.
for r in underlay_mod.reseaux(underlay):
u_reseau, u_m = masque_acl(r["sous_reseau"], dialecte)
out += remarque(dialecte, f"Bloquer l'underlay {r['nom']} (fabric physique)")
out.append(f" deny ip {reseau} {m} {u_reseau} {u_m}")
out += remarque(dialecte, "Reste (Internet / inter-tenant controle) -> passerelle OPNsense")
out.append(f" permit ip {reseau} {m} any")
out += ["!", "! ----- 4. Trunk vers les hyperviseurs -----",
"! Ports TERMINAUX : rien derriere eux ne participe au spanning-tree.",
"! Les liens vers les autres switches sont en section 4c, pas ici."]

View file

@ -81,6 +81,22 @@ STP_MODES = ("rstp", "mstp", "pvst")
STP_TOPOLOGIES = ("etoile", "anneau", "maille")
def acl_inter_tenant(underlay: dict | None) -> bool:
"""La fabric porte-t-elle l'isolation inter-tenant par ACL ? Vrai par defaut.
A mettre a `false` quand le materiel ne sait pas lier une ACL a une interface de
routage cas du Binardat, dont les interfaces VLAN n'offrent aucun `access-group`.
Le devis cesse alors d'emettre des ACL qui ne seraient jamais liees : mieux vaut
aucune regle qu'une regle inoperante qu'on croit active.
Conséquence assumee, et ecrite dans le devis : l'isolation repose entierement sur
les nftables d'hote, et le plan de gestion de la fabric n'a plus de protection
RESEAU contre les tenants.
"""
v = (underlay or {}).get("acl_inter_tenant")
return True if v is None else bool(v)
def dialecte(underlay: dict | None) -> str:
"""Dialecte de CLI declare pour la fabric. '' si absent."""
return str((underlay or {}).get("dialecte") or "")
@ -319,6 +335,10 @@ def valider(underlay: dict | None,
f"les points de routage portent .{attendu} partout "
f"(nomenclature: reservations.passerelle)")
v = (underlay or {}).get("acl_inter_tenant")
if v is not None and not isinstance(v, bool):
erreurs.append(f"acl_inter_tenant doit etre un booleen (recu : {v!r})")
d = dialecte(underlay)
if d and d not in DIALECTES:
erreurs.append(f"dialecte '{d}' inconnu (attendu : {', '.join(DIALECTES)})")

View file

@ -28,6 +28,14 @@ underlay:
# d'adresses qu'il y a de zones. Doit nommer un hote declare dans `hotes:`.
routeur: switch-01
# Isolation inter-tenant par ACL de commutateur. `true` par defaut.
# Mettre `false` quand le materiel ne sait pas lier une ACL a une interface de
# routage : le devis cesse alors d'emettre des ACL qui ne seraient jamais liees.
# Mieux vaut aucune regle qu'une regle inoperante qu'on croit active.
# A `false`, l'isolation repose entierement sur les nftables d'hote — et le plan de
# gestion de la fabric perd sa protection RESEAU contre les tenants.
#acl_inter_tenant: true
# Dialecte de CLI du commutateur (cisco | binardat). Propriete du MATERIEL :
# decide la forme des masques d'ACL, des routes et du spanning-tree.
dialecte: cisco