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 # 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 ## 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 `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é 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é 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 par elle. Quand la fabric sait porter des ACL, `make devis-reseau` émet un `deny` par
atteindre le management des switches, celui de Proxmox et l'OOB/IPMI. `make devis-reseau` sous-réseau underlay **avant** le `permit` final, dérivé de `underlay.yml`.
émet pour cette raison 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 ## 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] c = n["categories"][zone]
out.append(f"vlan {vlan_de(n['index'], zone)}") out.append(f"vlan {vlan_de(n['index'], zone)}")
out.append(f" name {pfx}{n['index']}-{c['libelle']}") 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) -----"] 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: for nom, pfx, n in tenants:
m = masque(int(n.get("cidr_hote", 24))) m = masque(int(n.get("cidr_hote", 24)))
for zone in sorted(n["categories"]): for zone in sorted(n["categories"]):
@ -494,8 +485,26 @@ 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"interface Vlan{vlan_de(n['index'], zone)}")
out.append(f" description {nom}-{c['libelle']}") out.append(f" description {nom}-{c['libelle']}")
out.append(f" ip address {passerelle_de(n['index'], zone)} {m}") out.append(f" ip address {passerelle_de(n['index'], zone)} {m}")
if acl:
out.append(f" ip access-group {pfx}{n['index']}-ISOLATION in") out.append(f" ip access-group {pfx}{n['index']}-ISOLATION in")
out.append(" no shutdown") out.append(" no shutdown")
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) -----"] out += ["!", "! ----- 3. ACL d'isolation tenant (default-deny inter-tenant) -----"]
for nom, pfx, n in tenants: for nom, pfx, n in tenants:
reseau, m = masque_acl(supernet_de(n["index"]), dialecte) reseau, m = masque_acl(supernet_de(n["index"]), dialecte)

View file

@ -81,6 +81,22 @@ STP_MODES = ("rstp", "mstp", "pvst")
STP_TOPOLOGIES = ("etoile", "anneau", "maille") 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: def dialecte(underlay: dict | None) -> str:
"""Dialecte de CLI declare pour la fabric. '' si absent.""" """Dialecte de CLI declare pour la fabric. '' si absent."""
return str((underlay or {}).get("dialecte") or "") 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"les points de routage portent .{attendu} partout "
f"(nomenclature: reservations.passerelle)") 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) d = dialecte(underlay)
if d and d not in DIALECTES: if d and d not in DIALECTES:
erreurs.append(f"dialecte '{d}' inconnu (attendu : {', '.join(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:`. # d'adresses qu'il y a de zones. Doit nommer un hote declare dans `hotes:`.
routeur: switch-01 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 : # Dialecte de CLI du commutateur (cisco | binardat). Propriete du MATERIEL :
# decide la forme des masques d'ACL, des routes et du spanning-tree. # decide la forme des masques d'ACL, des routes et du spanning-tree.
dialecte: cisco dialecte: cisco