Set-OPS-Public/scripts/appliquer_opnsense.py
Daniel Allaire c1d0d9b1a7 frontiere : publier un service du site, port public et redirection
Un flux externe porte port (la machine) et port_public (l'Internet). Le devis emet la
redirection et sa regle WAN, garde qu'elles aillent ensemble ; l'applicateur reconcilie
d_nat et encode les champs imbriques. DNS public : WAN:53 -> site-dnspub-01:1053 (dnsdist).
Plan lu : 4 objets a creer, rien d'autre ; pas encore applique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:38:39 -04:00

658 lines
33 KiB
Python

#!/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.parse
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
from proxmox_api import voute as voute_proxmox # noqa: E402
def _voute(base: Path) -> dict:
"""Secrets d'API de la frontiere — SOURCE UNIQUE, partagee avec l'acces au cluster.
La frontiere appartient au MONDE PHYSIQUE : sa cle d'API vit dans la voute de
l'underlay, chez l'hebergeur, pas chez un locataire (2026-08-22, cf.
docs/frontiere-physique-virtuel.md). `proxmox_api.voute` porte deja cette regle et son
repli sur la voute du tenant pendant la migration : la reecrire ici aurait fait une
dixieme copie le jour meme ou l'on refermait les neuf autres.
"""
return voute_proxmox(base)
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()
@staticmethod
def _formulaire(corps: dict) -> bytes:
"""Encode `{racine: {champ: valeur}}` en `racine[champ]=valeur`.
POURQUOI PAS DU JSON (mesure du 2026-08-13, sur un OPNsense 24.7 neuf).
Le controleur d'OPNsense lit ses champs avec `hasPost(<racine>)`. Quand le
corps arrive en `application/json` et que le boitier ne le decompose pas en
variables de POST, ce test est FAUX : le controleur renvoie `{"result":
"failed"}` NU — sans la moindre validation, donc sans dire ce qui manque.
Toute ecriture echouait ainsi : alias, regles, NAT, routes. Le meme corps en
formulaire encode passe du premier coup.
C'est la forme que poste l'interface web elle-meme : aucune version
d'OPNsense ne peut la refuser, alors que le JSON depend du boitier.
"""
champs: dict[str, str] = {}
# IMBRIQUE A TOUTE PROFONDEUR (2026-09-16). La redirection d'OPNsense 26.7 porte
# `rule[destination][network]` : un seul niveau aurait envoye le dictionnaire comme
# texte, et le boitier aurait garde une destination vide.
def poser(prefixe: str, valeur) -> None:
if isinstance(valeur, dict):
for k, v in valeur.items():
poser(f"{prefixe}[{k}]", v)
else:
champs[prefixe] = "" if valeur is None else str(valeur)
for racine, valeurs in corps.items():
poser(racine, valeurs)
return urllib.parse.urlencode(champs).encode()
def __call__(self, chemin: str, corps: dict | None = None) -> dict:
data = self._formulaire(corps) if corps is not None else None
h = {"Authorization": self.auth}
if data is not None:
h["Content-Type"] = "application/x-www-form-urlencoded"
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 ["*"]))
# L'ACTION N'ENTRE DANS LA CLE QUE SI ELLE N'EST PAS `pass` (2026-08-27). Les regles
# de SILENCE — des `block` non consignes — ont besoin d'une identite distincte, mais
# l'ajout naif d'un champ aurait change la cle des 117 regles deja posees : le
# rapprochement les aurait toutes detruites pour les recreer a l'identique, sur la
# frontiere, en production. Une marque vide pour le cas majoritaire garde ces cles
# octet pour octet, et n'en donne une nouvelle qu'a ce qui est nouveau.
marque = "" if str(r.get("action") or "pass") == "pass" else f"{r['action']}:"
return (f"setops:{marque}{r['tenant']}:{r['interface']}:{r['sens']}:{r['protocole']}:"
f"{r['source']}->{r['destination']}:{ports}")
def cle_nat(n: dict) -> str:
"""Identite d'une regle de NAT sortant, meme principe que `cle_regle`."""
return f"setopsnat:{n['tenant']}:{n['interface']}:{n['source']}->{n['destination']}:{n['cible']}"
def cle_redirection(d: dict) -> str:
"""Identite d'une redirection publique : ce que l'Internet frappe, et ou ca aboutit."""
return (f"setopsrdr:{d['interface']}:{d['protocole']}:{d['port_public']}"
f"->{d['cible']}:{d['port_local']}")
def _corps_redirection(d: dict, k: str) -> dict:
"""Une redirection d'OPNsense 26.7 (`d_nat`), eprouvee le 2026-09-16.
`pass: ""` : AUCUNE regle associee. La regle de filtrage vient du devis, comme toutes les
autres — une regle creee en douce par le boitier n'aurait ni identite ni retrait.
`natreflection: disable` : l'interieur n'a aucune raison de passer par l'adresse publique
pour joindre un serveur qu'il atteint directement.
"""
return {
"disabled": "0",
"interface": d["interface"],
"ipprotocol": "inet",
"protocol": d["protocole"],
"source": {"network": "any", "port": "", "not": "0"},
"destination": {"network": "wanip", "port": str(d["port_public"]), "not": "0"},
"target": d["cible"],
"local-port": str(d["port_local"]),
"natreflection": "disable",
"pass": "",
"log": "0",
"descr": f"{k} — {d.get('role', '')}"[:255],
}
def cle_route(r: dict) -> str:
"""Identite d'une route statique, meme principe que `cle_regle`.
Les routes ont longtemps ete posees A LA MAIN a cote de ce script. Elles
fonctionnaient, mais rien ne les reconciliait : leur disparition n'aurait ete vue
par personne, et un devis qui change les laissait derriere lui. C'est exactement le
defaut que ce script existe pour empecher — il ne pouvait pas s'appliquer a
lui-meme tant qu'il ignorait les routes.
"""
return f"setopsroute:{r['tenant']}:{r['reseau']}->{r['prochain_saut']}"
def _corps_route(r: dict, k: str, passerelle: str) -> dict:
"""Le corps d'une route — et le champ qui decide qu'elle EXISTE VRAIMENT.
`enabled`, ET NON `disabled` (mesure du 2026-09-02). Le modele de routes d'OPNsense
lit `enabled` ; on lui envoyait `disabled: "0"`, un champ qu'il ignore. `enabled`
restait donc a son defaut — c'est-a-dire ETEINT. Chaque route creee par Set-OPS
depuis l'origine l'etait desactivee.
POURQUOI PERSONNE NE L'AVAIT VU. Une route desactivee est presente dans le modele :
`frontiere-plan` la comptait « posee » et annoncait « 15 routes inchange ». Elle
n'etait simplement pas installee dans la table de routage. Tant que le boitier
n'avait pas ete recharge depuis leur creation, le noyau gardait les routes ajoutees
a la main lors de la mise en place — et tout fonctionnait.
Le jour ou la frontiere a ete rechargee completement (pour activer une nouvelle
patte), le noyau a repris sa table depuis le modele : quatorze routes sur quinze ont
disparu, et onze machines d'un locataire sont devenues injoignables. Le devis, lui,
restait vert. La panne dormait depuis des semaines dans un mot.
"""
return {
"network": r["reseau"],
"gateway": passerelle,
"enabled": "1",
"descr": k,
}
def _passerelle_pour(api: Frontiere, saut: str) -> str | None:
"""Nom de la passerelle OPNsense portant cette adresse.
Une route se declare par NOM de passerelle, le devis raisonne en ADRESSE de
prochain saut. On resout ici plutot que d'exiger un intrant de plus : le nom est
deja sur le boitier, et le demander deux fois serait une occasion de divergence.
"""
for x in (_lire(api, "/api/routes/gateway/status").get("items") or []):
if str(x.get("address") or "").strip() == str(saut).strip():
return str(x.get("name"))
return None
def _corps_nat(n: dict, k: str) -> dict:
return {
"enabled": "1",
"sequence": "100",
"interface": n["interface"],
"ipprotocol": "inet",
"protocol": "any",
"source_net": n["source"],
"destination_net": n["destination"],
"target": n["cible"],
"description": f"{k} — sortie tenant",
}
def _corps_regle(r: dict, k: str) -> dict:
# Une destination prefixee de `!` est une destination NIEE (« tout sauf »). Le devis
# porte le `!` pour qu'il compte dans l'identite de la regle ; c'est ici qu'il devient
# le champ `destination_not` d'OPNsense.
dst = str(r["destination"])
nie = dst.startswith("!")
# L'ACTION ET LA CONSIGNATION VIENNENT DU DEVIS (2026-08-27). Elles etaient en dur :
# l'outil ne savait qu'AUTORISER, et une regle qui fait taire du bruit deja refuse
# n'avait donc aucun moyen de naitre du depot. Les defauts reproduisent exactement
# l'ancien comportement — `pass`, non consigne, sequence 1 — pour que les regles
# existantes soient emises a l'identique.
#
# LA SEQUENCE EST CE QUI REND UN `block` SUR : OPNsense evalue en `quick`, donc la
# PREMIERE regle qui correspond gagne. Un blocage large pose avant les `pass` du
# devis fermerait le courrier, le web et l'acces distant. Les silences se declarent
# a 900, apres tout le reste.
corps = {
"enabled": "1",
"sequence": str(r.get("sequence") or "1"),
"action": str(r.get("action") or "pass"),
"log": "1" if r.get("journaliser") else "0",
"interface": r["interface"],
"direction": "in", # le devis raisonne en ARRIVEE sur l'interface (D-61)
"ipprotocol": "inet",
# `any` reste EN MINUSCULES : OPNsense attend ses protocoles en majuscules mais
# nomme « tout protocole » `any`. Un `ANY` majuscule est rejete a la validation.
"protocol": ("any" if r["protocole"] == "any"
else "ICMP" if r["protocole"] == "icmp"
else r["protocole"].upper()),
"source_net": r["source"],
"destination_net": dst[1:] if nie else dst,
"destination_not": "1" if nie else "0",
"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 routes_du_noyau(api: Frontiere) -> set[str] | None:
"""Les reseaux que la frontiere ROUTE VRAIMENT, lus dans sa table.
POURQUOI CE N'EST PAS REDONDANT AVEC LE MODELE (mesure du 2026-09-02).
Le reste de ce script compare le devis au MODELE de configuration : une route
presente dans `/api/routes/routes` est comptee « posee ». C'est ce qu'il faut pour
savoir quoi creer ou retirer — et ca ne dit RIEN de ce que le noyau porte.
Les deux ont diverge : un rechargement complet de la frontiere (`rc.reload_all`,
lance pour activer une nouvelle patte) a vide la table de routage de quatorze routes
sur quinze. Le modele les avait toujours ; `frontiere-plan` annoncait donc
« 15 routes inchange » pendant que ONZE MACHINES d'un locataire etaient injoignables.
Et la reconfiguration n'est declenchee que `if routes_creer or routes_retirer` —
aucun changement, donc aucune reparation : le devis etait vert et le service coupe.
Rend None si la frontiere ne sait pas repondre : on DIT qu'on n'a pas pu verifier,
on ne conclut pas que tout va bien. Une verification muette vaut moins que pas de
verification, parce qu'elle rassure.
"""
# EN GET, ET LA REPONSE EST UNE LISTE NUE (mesure du 2026-09-02). Les endpoints de
# diagnostic ne se lisent pas comme les modeles de configuration : pas de corps de
# requete — un POST rend un objet VIDE, sans erreur — et pas d'enveloppe `rows`, mais
# directement la liste des routes. Sondees en POST puis en `{"rows": ...}`, les deux
# tentatives precedentes rendaient « impossible de lire » sur une frontiere qui
# repondait parfaitement.
for chemin in ("/api/diagnostics/interface/getRoutes",
"/api/diagnostics/interface/get_routes"):
try:
rep = api(chemin)
except Exception:
continue
lignes = rep if isinstance(rep, list) else (rep.get("rows") or rep.get("items"))
if not lignes:
continue
return {str(r.get("destination") or "").strip() for r in lignes
if isinstance(r, dict)}
return None
def _lire(api: Frontiere, chemin: str, corps: dict | None = None) -> dict:
"""Une lecture du boitier — ou un REFUS. Jamais un silence pris pour du vide.
POURQUOI (mesure du 2026-08-22, trouvee par l'exploitant). L'intrant `opnsense_api_url`
pointait encore sur `10.0.0.1`, l'adresse d'avant la migration du boitier. Chaque
lecture echouait donc et rendait `{"_erreur": ...}` ; le plan lisait `.get("rows")`,
n'y trouvait rien, et concluait que la frontiere etait VIDE : « 89 objets a creer ».
J'en avais tire une conclusion fausse — « les regles heritees sont invisibles a
l'API » — et, avec CONFIRMER, on aurait pousse une politique entiere en double sur un
boitier qui la portait deja. La verite, une fois la bonne adresse posee : 53 objets et
15 routes, tous conformes, rien a faire.
UN BOITIER INJOIGNABLE N'EST PAS UN BOITIER VIDE. C'est la troisieme fois en une
soiree que cette confusion se paie — apres le devis de placement qui iterait une
erreur comme une liste, et la reconnaissance de l'underlay qui declarait morts les
reseaux qu'elle ne joignait pas. On refuse donc, en nommant l'hote et la cause.
"""
rep = api(chemin, corps)
if isinstance(rep, dict) and rep.get("_erreur"):
raise SystemExit(
f"La frontiere ne repond pas sur `{chemin}` : {rep['_erreur']}\n"
f" boitier interroge : {api.base if hasattr(api, 'base') else '?'}\n"
f" a verifier : `opnsense_api_url` designe-t-il l'adresse ACTUELLE du "
f"boitier, le VPN est-il monte, la cle d'API est-elle encore valide ?\n"
f" (un boitier injoignable serait lu comme un boitier VIDE, et le plan "
f"proposerait de tout recreer)")
return rep if isinstance(rep, dict) else {}
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 (_lire(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 (_lire(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()}
# `lstrip('!')` : une destination niee reference le MEME alias. L'oublier ferait
# passer `SETOPS_INTERNES` pour un orphelin, et le script tenterait de supprimer
# l'alias que ses propres regles utilisent.
survivants |= {str(r["destination"]).lstrip("!") for r in devis["regles"]}
survivants |= {n["source"] for n in devis.get("nat") or []} # references par le NAT
nat_voulus = {cle_nat(n): n for n in devis.get("nat") or []}
nat_poses = {}
for x in (_lire(api, "/api/firewall/source_nat/search_rule/",
{"current": 1, "rowCount": 1000}).get("rows") or []):
d = str(x.get("description") or "")
if d.startswith("setopsnat:"):
nat_poses[d.split(" — ")[0]] = x
rdr_voulues = {cle_redirection(d): d for d in devis.get("redirections") or []}
rdr_posees = {}
for x in (_lire(api, "/api/firewall/d_nat/search_rule/",
{"current": 1, "rowCount": 1000}).get("rows") or []):
d = str(x.get("descr") or x.get("description") or "")
if d.startswith("setopsrdr:"):
rdr_posees[d.split(" — ")[0]] = x
rt_voulues = {cle_route(r): r for r in devis.get("routes") or []}
rt_posees = {}
for x in (_lire(api, "/api/routes/routes/searchroute/",
{"current": 1, "rowCount": 1000}).get("rows") or []):
d = str(x.get("descr") or "")
if d.startswith("setopsroute:"):
rt_posees[d] = x
# Ce que le NOYAU porte vraiment — calcule ici parce que c'est ici qu'on a l'API,
# et transporte dans le plan pour que l'affichage puisse le dire.
_reelles = routes_du_noyau(api)
return {
"routes_noyau_lisible": _reelles is not None,
# Presente dans le modele mais ETEINTE : elle compte « posee » partout ailleurs,
# et n'est installee nulle part. C'est la forme exacte du defaut du 2026-09-02.
"routes_eteintes": sorted(
str(x.get("network") or "") for x in rt_posees.values()
if str(x.get("enabled", "1")) not in ("1", "True", "true")),
"routes_absentes_du_noyau": sorted(
r["reseau"] for k, r in rt_voulues.items()
if _reelles is not None and k in rt_posees and r["reseau"] not in _reelles),
"routes_creer": {k: r for k, r in rt_voulues.items() if k not in rt_posees},
"routes_garder": {k for k in rt_voulues if k in rt_posees},
"routes_retirer": {k: x for k, x in rt_posees.items() if k not in rt_voulues},
"rdr_creer": {k: d for k, d in rdr_voulues.items() if k not in rdr_posees},
"rdr_garder": {k for k in rdr_voulues if k in rdr_posees},
"rdr_retirer": {k: x for k, x in rdr_posees.items() if k not in rdr_voulues},
"nat_creer": {k: n for k, n in nat_voulus.items() if k not in nat_poses},
"nat_garder": {k for k in nat_voulus if k in nat_poses},
"nat_retirer": {k: x for k, x in nat_poses.items() if k not in nat_voulus},
"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, n in sorted(p["nat_creer"].items()):
print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} "
f"-> {n['cible']}")
for k, d in sorted(p["rdr_creer"].items()):
print(f" + REDIRECTION PUBLIQUE {d['interface']:<5} {d['protocole']:<4} "
f"{d['port_public']:<9} -> {d['cible']}:{d['port_local']} ({d.get('role', '')})")
for k, r in sorted(p["routes_creer"].items()):
print(f" + route {r['reseau']:<18} -> {r['prochain_saut']:<14} "
f"{r['tenant']}")
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 k, x in sorted(p["nat_retirer"].items()):
print(f" - NAT PERIME {str(x.get('interface')):<5} "
f"{str(x.get('source_net'))[:30]:<30} -> {str(x.get('target'))}")
for k, x in sorted(p["rdr_retirer"].items()):
print(f" - REDIRECTION PERIMEE {k}")
for k, x in sorted(p["routes_retirer"].items()):
print(f" - route PERIMEE {str(x.get('network')):<18} "
f"-> {str(x.get('gateway'))}")
for n in sorted(p["alias_retirer"]):
print(f" - alias ORPHELIN {n}")
# ECRIRE, PUIS RELIRE — SUR LE NOYAU, PAS SUR LE MODELE (D-68, applique aux routes
# le 2026-09-02). Tout ce qui precede compare le devis au MODELE de configuration.
# Le noyau peut avoir perdu ce que le modele affirme, et le devis reste vert : c'est
# arrive, quatorze routes sur quinze, et onze machines d'un locataire injoignables
# pendant que `frontiere-plan` annoncait « 15 routes inchange ».
if not p.get("routes_noyau_lisible"):
print("\n ROUTES : impossible de lire la table du noyau — verification NON FAITE.")
else:
eteintes = p.get("routes_eteintes") or []
if eteintes:
print(f"\n ROUTES PRESENTES MAIS ETEINTES ({len(eteintes)}) — le modele les "
"porte, `enabled` est a 0, le noyau ne les installera pas :")
for r in eteintes:
print(f" ! {r}")
absentes = p.get("routes_absentes_du_noyau") or []
if absentes:
print(f"\n ROUTES DECLAREES MAIS ABSENTES DU NOYAU ({len(absentes)}) — "
"le modele les a, la table de routage non :")
for r in absentes:
print(f" ! {r}")
print(" Les reseaux ci-dessus ne sont PAS routes. Appliquer force leur "
"reinstallation.")
creer = len(p["alias_creer"]) + len(p["regles_creer"]) + len(p["nat_creer"]) \
+ len(p["routes_creer"]) + len(p["rdr_creer"])
retirer = len(p["regles_retirer"]) + len(p["alias_retirer"]) + len(p["nat_retirer"]) \
+ len(p["routes_retirer"]) + len(p["rdr_retirer"])
print(f"\n a creer : {creer} | a retirer : {retirer}"
f" | inchange : {len(p['regles_garder']) + len(p['nat_garder']) + len(p['rdr_garder'])}"
f" + {len(p['routes_garder'])} routes")
return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer",
"regles_creer", "regles_retirer",
"nat_creer", "nat_retirer", "rdr_creer", "rdr_retirer",
"routes_creer", "routes_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}")
for k, n in sorted(p["nat_creer"].items()):
_fait(api("/api/firewall/source_nat/add_rule/", {"rule": _corps_nat(n, k)}), f"nat {k}")
# LA REDIRECTION APRES SA REGLE DE FILTRAGE : redirigee sans regle, la requete serait
# jetee ; la regle sans redirection ne correspond a rien. Le premier ordre ne laisse
# jamais passer ce que le devis n'a pas dit.
for k, d in sorted(p["rdr_creer"].items()):
_fait(api("/api/firewall/d_nat/add_rule/", {"rule": _corps_redirection(d, k)}),
f"redirection {k}")
# 2bis. Routes, meme ordre et pour une raison plus forte encore : une route
# manquante coupe la flotte, une route en trop ne fait qu'acheminer vers un
# VRF qui la jettera. En cas de doute, on reste large.
for k, r in sorted(p["routes_creer"].items()):
gw = _passerelle_pour(api, r["prochain_saut"])
if gw is None:
echecs += 1
print(f" ! ECHEC route {k} : aucune passerelle ne porte {r['prochain_saut']}")
continue
_fait(api("/api/routes/routes/addroute/", {"route": _corps_route(r, k, gw)}),
f"route {k}")
for k, x in sorted(p["routes_retirer"].items()):
_fait(api(f"/api/routes/routes/delroute/{x['uuid']}", {}), f"retrait route {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 k, x in sorted(p["nat_retirer"].items()):
_fait(api(f"/api/firewall/source_nat/del_rule/{x['uuid']}", {}), f"retrait nat {k}")
for k, x in sorted(p["rdr_retirer"].items()):
_fait(api(f"/api/firewall/d_nat/del_rule/{x['uuid']}", {}), f"retrait redirection {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 CHARGE : les objets deja ecrits restent dans la")
print(" configuration, INACTIFS. Rejouer avec CONFIRMER=true terminera le chargement —")
print(" meme si le plan dit alors qu'il n'y a plus rien a creer.")
return 1
return _charger(api, p)
def _charger(api: Frontiere, p: dict) -> int:
"""Charge dans le pare-feu ce que la configuration porte. Idempotent.
ECRIRE N'EST PAS CHARGER (mesure du 2026-09-16). OPNsense garde les regles et les alias
ecrits par l'API dans sa CONFIGURATION ; ils ne s'appliquent qu'a `filter/apply` et
`alias/reconfigure`. Une application interrompue par deux coupures reseau avait ecrit
ses neuf objets et n'en avait charge aucun : `pfctl` montrait zero regle du DNS public,
pendant que le plan, qui lit la configuration, rendait « a creer : 0 ».
"""
# La reconfiguration ne se declenchait QUE sur un changement du modele. Le jour ou
# le noyau perd des routes que le modele a toujours, il n'y a aucun changement —
# donc aucune reparation, et `appliquer` rendait « OK » sur un routage casse.
if p["routes_creer"] or p["routes_retirer"] or p.get("routes_absentes_du_noyau"):
print(" routes :", api("/api/routes/routes/reconfigure/", {}).get("status", "?"))
print(" alias :", api("/api/firewall/alias/reconfigure/", {}).get("status", "?"))
print(" regles :", str(api("/api/firewall/filter/apply/", {}).get("status", "?")).strip())
print(" nat :", str(api("/api/firewall/source_nat/apply/", {}).get("status", "?")).strip())
if p.get("rdr_creer") or p.get("rdr_retirer") or p.get("rdr_garder"):
print(" rdr :", str(api("/api/firewall/d_nat/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")))
# NE PAS FORCER SETOPS_INSTANCE VERS L'HEBERGEUR (2026-08-22). Le devis a besoin d'un
# TENANT — il lit son inventaire pour resoudre les flux — et l'hebergeur n'en est pas
# un : depuis que le monde physique a son propre depot (SITE-<nom>), il ne porte ni
# plan ni inventaire. Ce qui vient de l'hebergeur (underlay, proxmox-hebergeur, voute)
# est DERIVE du symlink `underlay.yml`, jamais de cette variable.
#
# L'hypothese « le depot de l'hebergeur est aussi une instance » n'a tenu que tant que
# les deux etaient confondus. La separation l'a revelee, ce qui est son interet.
env_devis = {k: v for k, v in os.environ.items() if k != "SETOPS_INVENTAIRE"}
sortie = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_opnsense.py"),
"--json"], cwd=RACINE, capture_output=True, text=True,
env=env_devis)
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
(_lire(api, "/api/firewall/alias/searchItem/", {"rowCount": 1000}).get("rows") or [])}
# UNE ROUTE ABSENTE DU NOYAU EST DU TRAVAIL, MEME SI LE MODELE EST A JOUR.
# `afficher` rend « rien a faire » quand le devis et le modele s'accordent. Ils
# s'accordaient le jour ou quatorze routes sur quinze manquaient dans la table de
# routage : le message disait vrai du modele et faux du service.
if not afficher(p) and not p.get("routes_absentes_du_noyau") \
and not p.get("routes_eteintes"):
# LE MODELE A JOUR N'EST PAS UNE CONFIGURATION CHARGEE (2026-09-16).
#
# Ce chemin rendait « Rien a faire » sans jamais charger. Or une application
# interrompue laisse EXACTEMENT cet etat : les objets ecrits, le modele d'accord avec
# le devis — et rien d'actif. Relancer ne l'aurait jamais repare : le plan disait vrai
# de la configuration, et faux du pare-feu. Meme defaut que les routes absentes du
# noyau, corrige plus haut pour elles seules.
if os.environ.get("CONFIRMER") != "true":
print("\n La frontiere dit deja ce que le devis dit — dans sa CONFIGURATION.")
print(" Ce qui est CHARGE n'est pas verifie ici : une application interrompue laisse")
print(" des objets ecrits et inactifs. CONFIRMER=true recharge sans rien creer.")
return 0
print("\n Rien a creer ni a retirer. Chargement de la configuration en place :")
return _charger(api, p)
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())