From 059d76a536aeaf73a31d2f4143173abcca7dac1f Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 3 Aug 2026 00:45:56 -0400 Subject: [PATCH] =?UTF-8?q?devis=20switch=20:=20l'ACL=20inter-tenant=20dev?= =?UTF-8?q?ient=20une=20capacit=C3=A9=20d=C3=A9clar=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 27 +++++++++++ docs/audit/preuve-2026-08-03.md | 60 ++++++++++++++++++++++++ docs/frontiere-opnsense.md | 23 +++++++-- scripts/devis_reseau.py | 83 ++++++++++++++++++--------------- scripts/underlay.py | 20 ++++++++ underlay.yml.example | 8 ++++ 6 files changed, 180 insertions(+), 41 deletions(-) create mode 100644 docs/audit/preuve-2026-08-03.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 013110b..130918c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/audit/preuve-2026-08-03.md b/docs/audit/preuve-2026-08-03.md new file mode 100644 index 0000000..bc3c699 --- /dev/null +++ b/docs/audit/preuve-2026-08-03.md @@ -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._ diff --git a/docs/frontiere-opnsense.md b/docs/frontiere-opnsense.md index 5055cd2..3f4bba9 100644 --- a/docs/frontiere-opnsense.md +++ b/docs/frontiere-opnsense.md @@ -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 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 diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index 1026281..2245a05 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -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."] diff --git a/scripts/underlay.py b/scripts/underlay.py index d64830f..271f928 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -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)})") diff --git a/underlay.yml.example b/underlay.yml.example index d68c34d..0e22bdb 100644 --- a/underlay.yml.example +++ b/underlay.yml.example @@ -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