From 081c1ac5459fb13b63eb76b46502fc52e806035a Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 6 Aug 2026 14:51:51 -0400 Subject: [PATCH] frontiere : un reconciliateur qui sait aussi RETIRER MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/appliquer_opnsense.py` remplace les scripts jetables du bac a sable. Il travaille dans les deux sens : ce que le devis demande et qui manque est cree, ce qu'il ne demande plus est retire. Sans ce second sens, un devis qui change laisse des regles mortes — elles n'ouvrent rien, mais decrivent une politique qui n'est plus la notre. Perimetre strict : seuls les objets marques `setops:` ou prefixes `SETOPS_` existent pour ce script. Ce qu'un humain a pose a la main lui est invisible. L'ordre porte une propriete : alias, creations, PUIS retraits. A aucun instant la politique n'est plus permissive qu'avant ; si un retrait echoue on reste en surcouverture, jamais avec un trou. Garde de la regle 4 : sans CONFIRMER=true, aucune ecriture. Le devis doit en outre passer P24 avant qu'une requete ne parte. Applique sur la frontiere : 3 alias et 3 regles crees, 2 regles perimees et 2 alias orphelins retires. Rejoue, le plan est vide — convergence prouvee. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 28 ++++ Makefile | 11 ++ scripts/appliquer_opnsense.py | 260 ++++++++++++++++++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 scripts/appliquer_opnsense.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 84a35d0..81d7f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## 2026-08-06 — le chemin nord-sud devient dérivable +### Le réconciliateur sait enfin retirer + +`scripts/appliquer_opnsense.py` remplace les scripts jetables qui appliquaient la frontière +depuis un bac à sable. Il travaille dans **les deux sens** : ce que le devis demande et qui +manque est créé ; ce que le devis ne demande plus est **retiré**. + +Sans ce second sens, un devis qui change laisse derrière lui des règles mortes. Elles +n'ouvrent rien — mais elles décrivent une politique qui n'est plus la nôtre, et une bordure +dont la lecture ment est pire qu'une bordure vide. C'est exactement ce que D-61 venait de +produire : deux règles SSH sur `wan` que plus aucun devis ne réclame. + +**Périmètre strict.** Seuls les objets marqués `setops:` (règles) ou préfixés `SETOPS_` +(alias) sont touchés. Ce qu'un humain a posé à la main dans l'interface n'existe pas pour ce +script — il ne peut donc pas le supprimer. + +**Ordre imposé par les dépendances**, et il porte une propriété : les alias d'abord (une +règle référençant un alias absent est refusée), puis les créations, **puis seulement** les +retraits. À aucun instant la politique n'est plus permissive qu'avant, et si un retrait +échoue on reste en surcouverture — jamais avec un trou. + +**Garde de la règle 4.** Sans `CONFIRMER=true`, aucune écriture : le script affiche le plan +et s'arrête. `make frontiere-plan` pour lire, `make frontiere-appliquer CONFIRMER=true` pour +agir. Le devis doit en outre passer sa propre garde P24 avant qu'une seule requête ne parte. + +Un alias encore référencé par une règle survivante n'est jamais retiré — la suppression +échouerait et laisserait le boîtier à moitié réconcilié. + + ### La frontière filtre pour de vrai La règle `any → any` d'`opt1` est retirée. Les 26 règles posées la veille n'étaient jusque-là diff --git a/Makefile b/Makefile index a10713b..6326ffd 100644 --- a/Makefile +++ b/Makefile @@ -348,6 +348,17 @@ devis-reseau: ansible-runtime ## Devis switch (VLANs/SVIs/ACLs) du reseau conver devis-opnsense: ansible-runtime ## Devis OPNsense (frontiere nord/sud), derive du registre des flux (pair: externe) python3 scripts/devis_opnsense.py $(if $(JSON),--json,) +.PHONY: frontiere-plan frontiere-appliquer +frontiere-plan: ansible-runtime ## Ecart entre la frontiere OPNsense et son devis (aucune ecriture) + python3 scripts/appliquer_opnsense.py + +frontiere-appliquer: ansible-runtime ## Reconcilie la frontiere : cree ce qui manque, RETIRE ce qui est perime. CONFIRMER=true + @if [[ "$(CONFIRMER)" != "true" ]]; then \ + printf '%s\n' "Refus: cette cible ECRIT sur le pare-feu de bordure et RETIRE les regles perimees." \ + "Relire d'abord 'make frontiere-plan', puis: make frontiere-appliquer CONFIRMER=true"; \ + exit 2; fi + CONFIRMER=true python3 scripts/appliquer_opnsense.py + .PHONY: devis-proxmox-fw devis-proxmox-fw-verifier devis-proxmox-fw: ansible-runtime ## Devis pare-feu Proxmox (est-ouest intra-tenant), derive du registre des flux python3 scripts/devis_proxmox_fw.py $(if $(JSON),--json,) diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py new file mode 100644 index 0000000..671bf0a --- /dev/null +++ b/scripts/appliquer_opnsense.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Reconcilie la frontiere OPNsense avec le devis (`devis_opnsense.py`). + +DEUX SENS, et c'est tout l'objet de ce script : + - ce que le devis demande et qui manque -> cree + - ce que le devis ne demande plus -> RETIRE + +Sans le second sens, un devis qui change laisse derriere lui des regles mortes : +elles n'ouvrent rien, mais elles decrivent une politique qui n'est plus la notre. +Une bordure dont la lecture ment est pire qu'une bordure vide. + +PERIMETRE STRICT : seuls les objets marques `setops:` (regles) ou prefixes +`SETOPS_` (alias) sont touches. Tout ce qu'un humain a pose a la main dans +l'interface reste intact — le script ne connait meme pas son existence. + +NON DESTRUCTIF PAR DEFAUT : sans `CONFIRMER=true`, il n'ecrit RIEN et se contente +d'afficher le plan. C'est la regle 4 du depot. + +Usage : + python3 scripts/appliquer_opnsense.py # plan seul, aucune ecriture + CONFIRMER=true python3 scripts/appliquer_opnsense.py # applique le plan +""" +from __future__ import annotations + +import base64 +import json +import os +import ssl +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE / "scripts")) + +import devis_opnsense as devis_mod # noqa: E402 + + +def _voute(base: Path) -> dict: + """Secrets d'API de la frontiere, lus par ansible-vault. Rien n'est journalise.""" + for nom in ("principal", "production", "lab"): + p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml" + if p.is_file(): + r = subprocess.run(["ansible-vault", "view", str(p)], + capture_output=True, text=True) + if r.returncode != 0: + raise SystemExit( + "Voute illisible : renseigner ANSIBLE_VAULT_PASSWORD_FILE.\n" + + r.stderr.strip()[:300]) + return yaml.safe_load(r.stdout) or {} + raise SystemExit(f"Aucune voute trouvee sous {base}/inventories/*/group_vars/all/.") + + +class Frontiere: + """Le minimum d'API pour reconcilier — et rien de plus.""" + + def __init__(self, url: str, cle: str, secret: str, verifier: bool): + self.base = url.rstrip("/") + self.auth = "Basic " + base64.b64encode(f"{cle}:{secret}".encode()).decode() + self.ctx = None if verifier else ssl._create_unverified_context() + + def __call__(self, chemin: str, corps: dict | None = None) -> dict: + data = json.dumps(corps).encode() if corps is not None else None + h = {"Authorization": self.auth} + if data: + h["Content-Type"] = "application/json" + req = urllib.request.Request(self.base + chemin, data=data, headers=h, + method="POST" if data is not None else "GET") + try: + with urllib.request.urlopen(req, context=self.ctx, timeout=45) as rep: + return json.loads(rep.read()) + except urllib.error.HTTPError as e: + return {"_erreur": f"{e.code} {e.read().decode()[:200]}"} + except OSError as e: + return {"_erreur": str(e)[:200]} + + +def cle_regle(r: dict) -> str: + """Identite d'une regle, portee par sa DESCRIPTION sur le boitier. + + C'est ce qui rend la reconciliation possible sans tenir un etat local : le + boitier porte lui-meme de quoi se comparer au devis. Tout changement de source, + de port ou d'interface produit une cle differente — donc une regle a creer et + une regle a retirer, ce qui est exactement la verite. + """ + ports = ",".join(map(str, r["ports"] or ["*"])) + return (f"setops:{r['tenant']}:{r['interface']}:{r['sens']}:{r['protocole']}:" + f"{r['source']}->{r['destination']}:{ports}") + + +def _corps_regle(r: dict, k: str) -> dict: + corps = { + "enabled": "1", + "sequence": "1", + "action": "pass", + "interface": r["interface"], + "direction": "in", # le devis raisonne en ARRIVEE sur l'interface (D-61) + "ipprotocol": "inet", + "protocol": "ICMP" if r["protocole"] == "icmp" else r["protocole"].upper(), + "source_net": r["source"], + "destination_net": r["destination"], + "description": f"{k} — {r['role']}", + } + if r["protocole"] in ("tcp", "udp") and r["ports"]: + corps["destination_port"] = ",".join(map(str, r["ports"])) + return corps + + +def _contenu(x) -> set[str]: + if isinstance(x, (list, tuple)): + return {str(v).strip() for v in x if str(v).strip()} + return {v.strip() for v in str(x or "").replace(",", "\n").split("\n") if v.strip()} + + +def plan(api: Frontiere, devis: dict) -> dict: + """Ce qu'il faudrait faire pour que le boitier dise ce que le devis dit.""" + voulues = {cle_regle(r): r for r in devis["regles"]} + posees = {} + for x in (api("/api/firewall/filter/search_rule/", + {"current": 1, "rowCount": 1000}).get("rows") or []): + d = str(x.get("description") or "") + if d.startswith("setops:"): + posees[d.split(" — ")[0]] = x + + al_voulus = devis["alias"] + al_poses = {x["name"]: x for x in (api("/api/firewall/alias/searchItem/", + {"rowCount": 1000}).get("rows") or []) + if str(x.get("name", "")).startswith("SETOPS_")} + + # Un alias encore reference par une regle qui SURVIT ne doit pas partir : la + # suppression echouerait, et le boitier resterait a moitie reconcilie. + survivants = {r["source"] for k, r in voulues.items()} + survivants |= {r["destination"] for r in devis["regles"]} + + return { + "alias_creer": {n: a for n, a in al_voulus.items() if n not in al_poses}, + "alias_majer": {n: a for n, a in al_voulus.items() + if n in al_poses + and _contenu(a["contenu"]) != _contenu(al_poses[n].get("content"))}, + "alias_retirer": {n: x for n, x in al_poses.items() + if n not in al_voulus and n not in survivants}, + "regles_creer": {k: r for k, r in voulues.items() if k not in posees}, + "regles_garder": {k for k in voulues if k in posees}, + "regles_retirer": {k: x for k, x in posees.items() if k not in voulues}, + } + + +def afficher(p: dict) -> bool: + """Rend le plan lisible. Retourne True s'il y a quelque chose a faire.""" + for nom, contenu in (("alias a creer", p["alias_creer"]), + ("alias a mettre a jour", p["alias_majer"])): + for n, a in sorted(contenu.items()): + print(f" + {nom:<22} {n:<32} {', '.join(a['contenu'])[:60]}") + for k, r in sorted(p["regles_creer"].items()): + print(f" + regle {r['interface']:<5} {r['protocole']:<4} " + f"{str(r['ports'] or ''):<9} {r['source'][:30]:<30} -> {r['destination'][:24]}") + for k, x in sorted(p["regles_retirer"].items()): + print(f" - regle PERIMEE {str(x.get('interface')):<5} " + f"{str(x.get('protocol')):<4} {str(x.get('destination_port') or ''):<9} " + f"{str(x.get('source_net'))[:30]:<30} -> {str(x.get('destination_net'))[:24]}") + for n in sorted(p["alias_retirer"]): + print(f" - alias ORPHELIN {n}") + print(f"\n a creer : {len(p['alias_creer']) + len(p['regles_creer'])}" + f" | a retirer : {len(p['regles_retirer']) + len(p['alias_retirer'])}" + f" | inchange : {len(p['regles_garder'])}") + return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer", + "regles_creer", "regles_retirer")) + + +def appliquer(api: Frontiere, p: dict) -> int: + """Ordre impose par les dependances : on n'enleve jamais un objet encore utilise.""" + echecs = 0 + + def _fait(rep, quoi): + nonlocal echecs + if rep.get("result") in ("saved", "deleted") or rep.get("status") == "ok": + return True + echecs += 1 + print(f" ! ECHEC {quoi} : {json.dumps(rep)[:160]}") + return False + + # 1. Les alias d'abord : une regle qui reference un alias absent est refusee. + for n, a in sorted(p["alias_creer"].items()): + _fait(api("/api/firewall/alias/addItem/", + {"alias": {"name": n, "type": a["type"], "enabled": "1", + "content": "\n".join(a["contenu"]), + "description": a.get("description", "")[:255]}}), f"alias {n}") + for n, a in sorted(p["alias_majer"].items()): + uuid = p["_uuid_alias"][n] + _fait(api(f"/api/firewall/alias/setItem/{uuid}", + {"alias": {"name": n, "type": a["type"], "enabled": "1", + "content": "\n".join(a["contenu"]), + "description": a.get("description", "")[:255]}}), f"alias {n}") + + # 2. Creer avant de retirer : a aucun instant la politique n'est plus permissive + # qu'avant, et si le retrait echoue on reste en surcouverture, jamais en trou. + for k, r in sorted(p["regles_creer"].items()): + _fait(api("/api/firewall/filter/add_rule/", {"rule": _corps_regle(r, k)}), f"regle {k}") + + # 3. Retrait des perimees, puis des alias devenus orphelins. + for k, x in sorted(p["regles_retirer"].items()): + _fait(api(f"/api/firewall/filter/del_rule/{x['uuid']}", {}), f"retrait {k}") + for n, x in sorted(p["alias_retirer"].items()): + _fait(api(f"/api/firewall/alias/delItem/{x['uuid']}", {}), f"retrait alias {n}") + + if echecs: + print(f"\n {echecs} echec(s) — RIEN N'EST APPLIQUE, la config reste en attente.") + return 1 + print(" alias :", api("/api/firewall/alias/reconfigure/", {}).get("status", "?")) + print(" regles :", str(api("/api/firewall/filter/apply/", {}).get("status", "?")).strip()) + return 0 + + +def main() -> int: + base = devis_mod.depot_hebergeur() + if base is None: + raise SystemExit("Aucun underlay ne designe d'hebergeur : pas de frontiere a piloter.") + intr = devis_mod.intrants_frontiere() + url = str(intr.get("opnsense_api_url") or "").strip() + if not url: + raise SystemExit("Intrant `opnsense_api_url` absent : rien a joindre.") + v = _voute(base) + api = Frontiere(url, v["vault_opnsense_api_key"], v["vault_opnsense_api_secret"], + bool(intr.get("opnsense_api_verifier_certs"))) + + sortie = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_opnsense.py"), + "--json"], cwd=RACINE, capture_output=True, text=True, + env={**os.environ, "SETOPS_INSTANCE": str(base)}) + if sortie.returncode != 0: + raise SystemExit("Le devis ne se genere pas :\n" + sortie.stderr.strip()[:400]) + devis = json.loads(sortie.stdout) + + ok, erreurs = devis_mod.verifier(devis) + if not ok: + print("Le devis ne passe pas sa propre garde — rien ne sera pousse :") + for e in erreurs: + print(" -", e) + return 2 + + print(f"Frontiere {url} — {len(devis['regles'])} regles au devis\n") + p = plan(api, devis) + p["_uuid_alias"] = {x["name"]: x["uuid"] for x in + (api("/api/firewall/alias/searchItem/", {"rowCount": 1000}).get("rows") or [])} + if not afficher(p): + print("\n La frontiere dit deja ce que le devis dit. Rien a faire.") + return 0 + + if os.environ.get("CONFIRMER") != "true": + print("\n PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.") + return 0 + print() + return appliquer(api, p) + + +if __name__ == "__main__": + sys.exit(main())