diff --git a/CHANGELOG.md b/CHANGELOG.md index a7fa2cd..b254276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # CHANGELOG — Set-OPS +## 2026-10-05 (93) — Étape 3 : la frontière lit la face réseau + +**Le chemin** (`docs/conception-contextes.md` §6), étape 3. Quatrième consommateur du site, le +plus lourd : le devis de la frontière (167 Ko). Il lisait chez chaque locataire ses réseaux +d'administration (`group_vars`), son inventaire (les adresses de chaque groupe, dont les +runners et les primaires DNS de tous les locataires), ses variables (conditions +`seulement_si`), son `plan/domaines.yml` et son `plan/acces.yml`. + +**Fait** (`devis_opnsense.py`) : quatre lecteurs, la face d'abord, l'ancienne lecture en +repli explicite — `cibles_du_tenant()` (adresses d'un groupe), `admin_du_tenant()` +(l'intrant), `applicable_chez()` (le verdict publié), et la face pour le tunnel (présent si +un pair est actif) et pour savoir qui publie des zones. Les copies en ligne du réseau et du +port du tunnel ne servent plus que le repli ; elles partiront avec lui. + +**Un faux succès, attrapé avant le commit** : la première modification s'est arrêtée sur une +assertion (un commentaire séparait deux lignes du motif) **avant d'écrire**. La comparaison +qui suivait a dit « identique » — l'ancien code comparé à lui-même. Relevé, refait ; la +comparaison avec l'ancien code se fait désormais contre la version de HEAD, lancée à part. + +**Le critère** : le devis de la frontière est **identique octet pour octet** depuis la face, +par le repli, et par l'ancien code (HEAD). `make frontiere-plan` contre la frontière réelle : +rien à créer, rien à retirer. + +**Éprouvé** : +- `test_contexte.py`, 102 contrôles : un verdict basculé dans la face publiée retire sa règle + WAN (soumission Postfix 587 : 1 règle, puis 0) — la frontière lit bien la face. +- `make verifier` conforme, 92/92. + +**Restent à basculer** : la découverte des locataires (index et catégories, lus par la +frontière, le SDN, les pools et le commutateur), le placement et le clonage. + ## 2026-10-05 (92) — Étape 3 : le pare-feu Proxmox lit la face réseau **Le chemin** (`docs/conception-contextes.md` §6), étape 3. Troisième consommateur du site : diff --git a/docs/conception-contextes.md b/docs/conception-contextes.md index bad1b92..1ed2326 100644 --- a/docs/conception-contextes.md +++ b/docs/conception-contextes.md @@ -182,7 +182,8 @@ les publie désormais (`sources_declarees`). La frontière, en trois temps : les (P87), les entrées publiques (P88), l'administration (P89) et les sorties (P90) sont faites. **L'étape 2 est terminée** (2026-10-05). Étape 3 : l'instancier lit la fiche déposée par le site, et l'inventaire d'un locataire se génère sans son site, à l'octet près (P91). Le locataire publie sa face -réseau (P92) ; les comptes de sauvegarde, le DNS public et le pare-feu Proxmox du site la lisent. +réseau (P92) ; les comptes de sauvegarde, le DNS public, le pare-feu Proxmox et la frontière du site +la lisent. Méthodes du contrat : `site.fiche_pour(locataire)`, `locataire.face_reseau()`. Une classe n'ouvre jamais les fichiers de l'autre ; une preuve vérifiera la règle. diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 7f78979..d63a56c 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -297,6 +297,57 @@ def cibles_par_role(role: str, inventaire: Path) -> list[str]: # # L'outil a bien refuse d'appliquer quoi que ce soit, mais le devis, lui, annoncait un # objet impossible. Un devis qui promet ce que la cible refusera n'est pas un devis. +# LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3). Le locataire +# publie `face-reseau.yml` : ses groupes et leurs adresses, son administration, son tunnel, ses +# zones publiques, le verdict de ses flux conditionnels. La frontiere ne lit plus son inventaire, +# ses variables ni son plan. Sans face publiee (transition), l'ancienne lecture, et on le dit. +_FACES: dict[str, dict | None] = {} + + +def face_publiee(nom: str) -> dict | None: + if nom not in _FACES: + try: + import contexte + from devis_reseau import DOSSIER_INSTANCES + _FACES[nom] = contexte.Locataire(DOSSIER_INSTANCES / nom).face_publiee() + except Exception: + _FACES[nom] = None + if _FACES[nom] is None: + print(f"note : {nom} n'a pas publie sa face reseau — lecture de ses fichiers " + f"internes (transition).", file=sys.stderr) + return _FACES[nom] + + +def cibles_du_tenant(role: str, nom: str) -> list[str]: + """IP des hotes qui portent ce role chez ce locataire : sa face, sinon son inventaire.""" + face = face_publiee(nom) + if face is not None: + ips = {h: m.get("ip") for h, m in (face.get("machines") or {}).items()} + return sorted({ips[h] for h in (face.get("groupes") or {}).get(role, []) if ips.get(h)}) + inv = inventaire_de(nom) + return cibles_par_role(role, inv) if inv else [] + + +def admin_du_tenant(nom: str) -> list[str]: + """Ses reseaux d'administration declares (l'intrant) : sa face, sinon ses `group_vars`.""" + face = face_publiee(nom) + if face is not None: + return list((face.get("administration") or {}).get("intrant") or []) + return admin_de(nom) + + +def applicable_chez(fl: dict, role: str, nom: str) -> bool: + """Le flux s'applique-t-il chez ce locataire ? Son verdict publie, sinon ses variables.""" + if not fl.get("seulement_si"): + return True + face = face_publiee(nom) + if face is not None: + import contexte + return bool(((face.get("conditions") or {}).get(role) or {}).get(contexte.cle_condition(fl), True)) + inv = inventaire_de(nom) + return flux_applicable(fl, role, inv.parent.parent.parent) if inv else True + + LONGUEUR_MAX_ALIAS = 31 # Meme abreviation que le pare-feu est-ouest (`devis_proxmox_fw` : serveur_ -> srv-), pour @@ -495,7 +546,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: admin_par_if: dict[str, dict[str, list[str]]] = {} for nom, pfx, n in tenants: etiquette = f"{pfx}{n['index']}" - cidrs = admin_de(nom) + cidrs = admin_du_tenant(nom) admin_par_tenant[etiquette] = cidrs par_vpn = [c for c in cidrs if _par_le_vpn(c)] locaux = [c for c in cidrs if c not in par_vpn and _porte_par(c, reseaux_gestion)] @@ -559,13 +610,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: 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: + if face_publiee(nom) is None and inventaire_de(nom) is None: tenants_sans_inventaire.append(nom) continue for role, fl in retenus: # Un flux conditionnel (`seulement_si`) ne vaut que la ou son reglage le dit. - if not flux_applicable(fl, role, inv.parent.parent.parent): + if not applicable_chez(fl, role, nom): continue # AVANT L'ALIAS (2026-09-17). Le saut plus bas arrivait APRES la creation de # l'alias du role : un flux vers la frontiere declare par `serveur_prometheus` @@ -576,7 +626,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: if role == ROLE_SOCLE: destination = alias_tenant else: - cibles = cibles_par_role(role, inv) + cibles = cibles_du_tenant(role, nom) if not cibles: continue # role absent de ce tenant : aucune regle a poser a = nom_alias(role, etiquette) @@ -887,9 +937,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # au SSH du site rendrait l'insemination indiscernable d'un droit d'entree # general — exactement ce que l'isolation refuse. _runners_tenants = sorted({ip for _n2, _p2, _x2 in tenants - for ip in (cibles_par_role("serveur_ops_tenant", - inventaire_de(_n2)) - if inventaire_de(_n2) else [])}) + for ip in cibles_du_tenant("serveur_ops_tenant", _n2)}) if _runners_tenants: alias["SETOPS_RUNNERS_TENANTS"] = { "type": "host", @@ -909,6 +957,9 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: # n'y ecoutait — mais un chemin ouvert vers une machine qui n'en a pas besoin est # exactement ce que le moindre privilege refuse. def _publie(_nom_tenant: str) -> bool: + _face = face_publiee(_nom_tenant) + if _face is not None: + return bool((_face.get("dns_public") or {}).get("zones")) try: import devis_reseau as _dr _f = _dr.DOSSIER_INSTANCES / _nom_tenant / "plan" / "domaines.yml" @@ -918,9 +969,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: except (OSError, ImportError): return False _primaires_dns = sorted({ip for _n2, _p2, _x2 in tenants if _publie(_n2) - for ip in (cibles_par_role("serveur_powerdns", - inventaire_de(_n2)) - if inventaire_de(_n2) else [])}) + for ip in cibles_du_tenant("serveur_powerdns", _n2)}) if _primaires_dns: alias["SETOPS_DNS_PRIMAIRES_TENANTS"] = { "type": "host", @@ -1574,15 +1623,21 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: _idx_t = _n_t.get("index") if _idx_t is None: continue - try: - _acces_t = (yaml.safe_load( - (_dr.DOSSIER_INSTANCES / _nom_t / "plan" / "acces.yml").read_text(encoding="utf-8")) - or {}).get("acces_admin_vpn") or {} - except (OSError, yaml.YAMLError): - continue - if not [n for n, c in _acces_t.items() - if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]: - continue # aucun pair actif : aucune route, aucune regle + _face_t = face_publiee(_nom_t) + if _face_t is not None: + # Le tunnel n'existe que s'il a un pair actif : la face le dit (`tunnel` vide sinon). + if not (_face_t.get("administration") or {}).get("tunnel"): + continue + else: + try: + _acces_t = (yaml.safe_load( + (_dr.DOSSIER_INSTANCES / _nom_t / "plan" / "acces.yml").read_text(encoding="utf-8")) + or {}).get("acces_admin_vpn") or {} + except (OSError, yaml.YAMLError): + continue + if not [n for n, c in _acces_t.items() + if isinstance(c, dict) and str(c.get("etat", "present")) == "present"]: + continue # aucun pair actif : aucune route, aucune regle _etiq_t = f"{_pfx_t}{_idx_t}" _alias_tunnel = f"SETOPS_ADMIN_{_etiq_t}_TUNNEL" alias[_alias_tunnel] = { diff --git a/scripts/tests/test_contexte.py b/scripts/tests/test_contexte.py index e3c3b5a..827eda8 100644 --- a/scripts/tests/test_contexte.py +++ b/scripts/tests/test_contexte.py @@ -510,6 +510,47 @@ def proxmox_lit_la_face() -> None: f"le devis Proxmox lit la face publiee : {g} {port}/tcp, {avant} regle(s), 0 une fois le verdict bascule") +def frontiere_lit_la_face() -> None: + """La frontiere lit la face publiee : un verdict bascule dans la face retire sa regle WAN.""" + import yaml as _yaml + couples = C.couples_hebergeur() + if not couples: + print("SAUTE aucun site frere : aucune frontiere a confronter") + return + site, loc = couples[0] + face = loc.face_publiee() or {} + cible = next(((g, k) for g, v in (face.get("conditions") or {}).items() for k, ok in v.items() + if ok and k.startswith("ingress|tcp|") and g == "serveur_postfix"), None) + if cible is None: + print("SAUTE aucune soumission Postfix active a basculer") + return + g, cle = cible + port = cle.split("|")[2] + + def regles_wan() -> int: + import devis_opnsense + devis_opnsense._FACES.clear() + _e, devis = C._devis_frontiere(site, loc) + return sum(1 for r in devis.get("regles") or [] + if r.get("tenant") == loc.nom and r.get("role") == g and port in (r.get("ports") or []) + and r.get("interface") == devis.get("if_wan")) + + chemin = loc.depot / C.NOM_FICHIER_FACE_RESEAU + original = chemin.read_text(encoding="utf-8") + avant = regles_wan() + try: + f = _yaml.safe_load(original) + f["conditions"][g][cle] = False + chemin.write_text(_yaml.safe_dump(f, allow_unicode=True, sort_keys=False), encoding="utf-8") + apres = regles_wan() + finally: + chemin.write_text(original, encoding="utf-8") + import devis_opnsense + devis_opnsense._FACES.clear() + verifier(avant > 0 and apres == 0, + f"la frontiere lit la face publiee : {g} {port}/tcp sur le WAN, {avant} regle(s), 0 une fois le verdict bascule") + + def main() -> int: with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) @@ -528,6 +569,7 @@ def main() -> int: fiche_deposee() face_publiee() proxmox_lit_la_face() + frontiere_lit_la_face() if ECHECS: print(f"\n{len(ECHECS)} echec(s).") return 1