frontière : les règles couvrent tous les tenants fédérés
Le devis était multi-tenant pour ses routes, 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'une ligne dise pourquoi. La résolution est 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, les alias d'hôtes sont préfixés. 11 règles par tenant, 22 au total. Cloisonnement : la première version faisait de SETOPS_ADMIN l'union des réseaux d'administration — le plan de gestion d'un tenant serait entré chez le voisin, la bordure rouvrant ce que les ACL de switch ferment. Corrigé avant livraison : un alias par tenant, n'ouvrant que son propre supernet. L'union reste pour les routes de retour et P24 : router n'est pas autoriser. Deux omissions annoncées : tenant sans inventaire (aucune règle), tenant sans `nftables_admin_ssh` (règle SSH omise plutôt qu'ouverte à `any`, ce qui exposerait le SSH à Internet). Cas dégradé exercé. Preuves : 24 OK, 0 échec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4d37570c0c
commit
53f6af3f1a
4 changed files with 158 additions and 47 deletions
26
CHANGELOG.md
26
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_<TENANT>`, 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue