diff --git a/CHANGELOG.md b/CHANGELOG.md index 32025a8..e27ecad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # CHANGELOG — Set-OPS +## 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 +`10.21.0.0/16` et `10.27.0.0/16`, mais ne filtrait que l'instance active. Technolibre aurait +été routé jusqu'à la bordure puis bloqué dans les deux sens, SSH d'administration compris, +sans qu'aucune ligne ne dise pourquoi. Chemin présent, politique absente — le mode de panne +du 2026-07-29, transposé. + +La résolution est désormais paramétrée par tenant : `inventaire_de()` lit le `hosts.yml` de +chaque instance fédérée, `cibles_par_role()` prend l'inventaire en argument, et les alias +d'hôtes sont préfixés (`SETOPS_CHEZ17_SERVEUR_NGINX`). 11 règles par tenant, 22 au total. + +### Cloisonnement du plan de gestion +Première version de ce correctif : `SETOPS_ADMIN` devenait l'**union** des réseaux +d'administration. Le plan de gestion de Technolibre aurait alors pu entrer en SSH chez +Chezlepro — la bordure rouvrait ce que les ACL de switch ferment. Corrigé avant livraison : +**un alias par tenant**, `SETOPS_ADMIN_`, n'ouvrant que son propre supernet. + +L'union est conservée pour les routes de **retour** côté switch et la garde P24 : router +n'est pas autoriser, et le switch doit savoir revenir vers tous les plans de gestion. + +### Deux omissions annoncées au lieu d'être tues +Un tenant sans inventaire généré : aucune règle, et le devis le dit. Un tenant dont +`nftables_admin_ssh` est vide : la règle SSH est **omise** plutôt qu'ouverte à `any`, ce qui +exposerait le SSH à Internet. Cas dégradé exercé. + ## 2026-08-02 (suite) — la sortie générale est déclarée, pas subie Le devis frontière se terminait par `block out log all` avec **une seule** règle sortante diff --git a/docs/audit/preuve-2026-08-02.md b/docs/audit/preuve-2026-08-02.md index d5e3573..dad5912 100644 --- a/docs/audit/preuve-2026-08-02.md +++ b/docs/audit/preuve-2026-08-02.md @@ -36,7 +36,7 @@ | 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, 11 regles, 2 routes, admin=192.168.255.0/24. | +| 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 diff --git a/docs/frontiere-opnsense.md b/docs/frontiere-opnsense.md index 95dacb8..b017be5 100644 --- a/docs/frontiere-opnsense.md +++ b/docs/frontiere-opnsense.md @@ -40,6 +40,21 @@ dérivé de `underlay.yml`. ## 2. Ce que la frontière décide (et pourquoi rien n'est saisi à la main) +**La frontière est un équipement partagé, comme les switches.** Elle route vers tous les +tenants fédérés, elle porte donc aussi **leurs règles** — pas seulement celles de l'instance +active. Un tenant routé sans règles serait joignable jusqu'à la bordure puis bloqué en +silence : chemin présent, politique absente, exactement le mode de panne du 2026-07-29. + +Les alias d'hôtes sont préfixés du tenant (`SETOPS_CHEZ17_SERVEUR_NGINX`), et surtout +**chaque tenant a son propre alias d'administration** : `SETOPS_ADMIN_CHEZ17` n'ouvre que +`10.27.0.0/16`. Une union aurait laissé le plan de gestion d'un tenant entrer chez le voisin +— ce que les ACL de switch interdisent par ailleurs. La bordure ne doit pas rouvrir ce que +l'isolation inter-tenant ferme. + +Deux omissions sont **annoncées** plutôt que tues : un tenant sans inventaire généré (aucune +règle) et un tenant dont `nftables_admin_ssh` est vide (règle SSH omise — l'ouvrir à `any` +exposerait le SSH à Internet). + Le registre des flux distingue les pairs par **mot-clé**. Or `resoudre_flux.py` **saute volontairement** le pair `externe` (`scripts/resoudre_flux.py:184`) : ces flux-là ne concernent pas le pare-feu d'hôte, ils relèvent de la bordure. Plusieurs `raison` le disent diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 7d61574..398c038 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -122,19 +122,51 @@ def _ports(flux: dict) -> list[str]: return [str(p) for p in (port if isinstance(port, list) else [port])] -def cibles_par_role(role: str) -> list[str]: - """IP des hotes qui portent ce role, dans l'instance active (actifs ET planifies). +def inventaire_de(nom_instance: str) -> Path | None: + """hosts.yml d'une instance FEDEREE, active ou non. None si elle n'en a pas. + + La frontiere est un equipement PARTAGE : elle route vers tous les tenants, elle + doit donc aussi porter leurs regles. Se limiter a l'instance active laisserait un + tenant route mais muet — chemin present, politique absente. + """ + base = RACINE.parent / nom_instance / "inventories" + for env in ("principal", "production", "lab"): + p = base / env / "hosts.yml" + if p.is_file(): + return p + return None + + +def cibles_par_role(role: str, inventaire: Path) -> list[str]: + """IP des hotes qui portent ce role dans cette instance (actifs ET planifies). On inclut les planifies a dessein : la frontiere se prepare avant que les VM existent, sinon le devis changerait a chaque materialisation d'hote. """ - data = yaml.safe_load(_inventaire().read_text(encoding="utf-8")) or {} + data = yaml.safe_load(inventaire.read_text(encoding="utf-8")) or {} ips = _ip_par_hote(data) return sorted({ips[h] for h in _hotes_du_groupe(data, role) if h in ips}) -def nom_alias(role: str) -> str: - return f"SETOPS_{role.upper()}" +def admin_de(nom_instance: str) -> list[str]: + """Reseaux d'administration declares par une instance (intrant nftables_admin_ssh).""" + inv = inventaire_de(nom_instance) + if not inv: + return [] + dossier = inv.parent / "group_vars" / "all" + for fichier in sorted(dossier.glob("*.yml")): + if "vault" in fichier.name: + continue + data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {} + if isinstance(data, dict) and data.get("nftables_admin_ssh"): + src = data["nftables_admin_ssh"] + return [str(s) for s in src] if isinstance(src, list) else [str(src)] + return [] + + +def nom_alias(role: str, etiquette: str) -> str: + """Alias d'hotes, PREFIXE par le tenant : deux tenants ont chacun leur nginx.""" + return f"SETOPS_{etiquette}_{role.upper()}" def _instance_active() -> tuple[str, dict] | tuple[None, None]: @@ -150,7 +182,6 @@ 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) - admin = _sources_admin_ssh() nom_actif, nomenclature_active = _instance_active() alias: dict[str, dict] = {} @@ -160,51 +191,80 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "contenu": [supernet_de(n["index"])], "description": f"Supernet du tenant {nom} (index {n['index']})", } - alias["SETOPS_ADMIN"] = { - "type": "network", - "contenu": list(admin), - "description": "Reseaux d'administration (intrant nftables_admin_ssh) — " - "source unique, partagee avec les nftables d'hote", - } + + # Reseaux d'administration : UN ALIAS PAR TENANT, jamais une union. Chaque tenant + # declare les siens (intrant `nftables_admin_ssh`) et ils n'ouvrent QUE son supernet : + # une union laisserait le plan de gestion d'un tenant entrer chez le voisin, ce que + # les ACL de switch interdisent par ailleurs. La frontiere ne doit pas rouvrir ce que + # l'isolation inter-tenant ferme. + admin_par_tenant: dict[str, list[str]] = {} + for nom, pfx, n in tenants: + etiquette = f"{pfx}{n['index']}" + cidrs = admin_de(nom) + admin_par_tenant[etiquette] = cidrs + if cidrs: + alias[f"SETOPS_ADMIN_{etiquette}"] = { + "type": "network", + "contenu": list(cidrs), + "description": f"Reseaux d'administration de {nom} (intrant " + f"nftables_admin_ssh) — source unique, partagee avec ses nftables", + } + # Union, pour les routes de RETOUR cote switch et la garde P24 : router n'est pas + # autoriser. Le switch doit savoir revenir vers tous les plans de gestion. + admin = sorted({c for cidrs in admin_par_tenant.values() for c in cidrs}) supernet_actif = ( supernet_de(nomenclature_active["index"]) if nomenclature_active else None ) - alias_tenant_actif = next( - (a for a in alias if a.startswith("SETOPS_TENANT_") and nom_actif - and supernet_de(nomenclature_active["index"]) in alias[a]["contenu"]), - None, - ) + # Les regles sont emises POUR CHAQUE TENANT FEDERE, pas seulement l'actif : la + # frontiere route deja vers eux tous (section 2). N'en filtrer qu'un laisserait les + # autres routes menant a un `block` muet. regles: list[dict] = [] - for role, fl in retenus: - if role == ROLE_SOCLE: - destination = alias_tenant_actif or "any" - else: - cibles = cibles_par_role(role) - if not cibles: - continue # role absent de cette instance : aucune regle a poser - a = nom_alias(role) - alias[a] = { - "type": "host", - "contenu": cibles, - "description": f"Hotes portant {role} (instance {nom_actif})", - } - destination = a - entrant = fl.get("sens") == "ingress" - # Le SSH de gestion est le SEUL flux `externe` a ne pas venir d'Internet : - # sa `raison` dit « l'acces depuis l'exterieur est filtre a l'OPNsense ». - source = "SETOPS_ADMIN" if (entrant and "22" in _ports(fl)) else "any" - regles.append({ - "sens": "in" if entrant else "out", - "protocole": fl.get("protocole", "tcp"), - "source": source if entrant else destination, - "destination": destination if entrant else "any", - "ports": _ports(fl), - "chiffrement": fl.get("chiffrement"), - "role": role, - "raison": fl.get("raison", ""), - }) + tenants_sans_inventaire: list[str] = [] + tenants_sans_admin: list[str] = [] + for nom, pfx, n in tenants: + etiquette = f"{pfx}{n['index']}" + alias_tenant = f"SETOPS_TENANT_{etiquette}" + inv = inventaire_de(nom) + if inv is None: + tenants_sans_inventaire.append(nom) + continue + for role, fl in retenus: + if role == ROLE_SOCLE: + destination = alias_tenant + else: + cibles = cibles_par_role(role, inv) + if not cibles: + continue # role absent de ce tenant : aucune regle a poser + a = nom_alias(role, etiquette) + alias[a] = { + "type": "host", + "contenu": cibles, + "description": f"Hotes portant {role} (instance {nom})", + } + destination = a + entrant = fl.get("sens") == "ingress" + # Le SSH de gestion est le SEUL flux `externe` a ne pas venir d'Internet : + # sa `raison` dit « l'acces depuis l'exterieur est filtre a l'OPNsense ». + ssh_gestion = entrant and "22" in _ports(fl) + if ssh_gestion and not admin_par_tenant.get(etiquette): + # Sans reseau d'administration declare, la regle n'aurait pas de source : + # l'omettre ferme l'acces, l'ouvrir a `any` exposerait le SSH a Internet. + tenants_sans_admin.append(nom) + continue + source = f"SETOPS_ADMIN_{etiquette}" if ssh_gestion else "any" + regles.append({ + "sens": "in" if entrant else "out", + "protocole": fl.get("protocole", "tcp"), + "source": source if entrant else destination, + "destination": destination if entrant else "any", + "ports": _ports(fl), + "chiffrement": fl.get("chiffrement"), + "role": role, + "tenant": nom, + "raison": fl.get("raison", ""), + }) intrants = intrants_frontiere() transit = transit_underlay() @@ -229,6 +289,8 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "routes": routes, "regles": regles, "admin": admin, + "tenants_sans_inventaire": tenants_sans_inventaire, + "tenants_sans_admin": sorted(set(tenants_sans_admin)), "transit": { "nom": (transit or {}).get("nom"), "vlan": (transit or {}).get("vlan"), @@ -290,7 +352,15 @@ def rendre(devis: dict) -> str: contenu = ", ".join(a["contenu"]) if a["contenu"] else "(VIDE)" out.append(f"alias {nom:32} {a['type']:8} {contenu}") out.append(f"# {a['description']}") - out += ["", "## 4. Regles (defaut : deny + journalisation)"] + 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."] + 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", []): + out.append(f"# /!\\ {nom} : `nftables_admin_ssh` vide — regle SSH OMISE. L'ouvrir a " + f"`any` exposerait le SSH a Internet ; renseigner l'intrant.") for rg in devis["regles"]: ports = ",".join(rg["ports"]) out.append(