frontiere : un reconciliateur qui sait aussi RETIRER
`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 <noreply@anthropic.com>
This commit is contained in:
parent
510568e3b4
commit
081c1ac545
3 changed files with 299 additions and 0 deletions
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -2,6 +2,34 @@
|
||||||
|
|
||||||
## 2026-08-06 — le chemin nord-sud devient dérivable
|
## 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 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à
|
La règle `any → any` d'`opt1` est retirée. Les 26 règles posées la veille n'étaient jusque-là
|
||||||
|
|
|
||||||
11
Makefile
11
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)
|
devis-opnsense: ansible-runtime ## Devis OPNsense (frontiere nord/sud), derive du registre des flux (pair: externe)
|
||||||
python3 scripts/devis_opnsense.py $(if $(JSON),--json,)
|
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
|
.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
|
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,)
|
python3 scripts/devis_proxmox_fw.py $(if $(JSON),--json,)
|
||||||
|
|
|
||||||
260
scripts/appliquer_opnsense.py
Normal file
260
scripts/appliquer_opnsense.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Reference in a new issue