Applicateur de frontiere : ecrire n est pas charger, le chemin rien a faire charge desormais. site-verifier compare enfin playbooks/site.yml, regenere avec le groupe. Base TSIG dans un repertoire a pdns et pdnsutil en pdns (journal SQLite). Flux UDP 5300 : le secondaire demande le SOA avant de transferer, garde dans P82. socket-dir de pdns@public, et le failed_when qui taisait la notification est retire. Eprouve sur les machines : 2 zones sur 2 tirees automatiquement, NOTIFY compte, zone interne refusee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
595 lines
30 KiB
Python
595 lines
30 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] = {}
|
|
for racine, valeurs in corps.items():
|
|
if isinstance(valeurs, dict):
|
|
for k, v in valeurs.items():
|
|
champs[f"{racine}[{k}]"] = "" if v is None else str(v)
|
|
else:
|
|
champs[racine] = "" if valeurs is None else str(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_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
|
|
|
|
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},
|
|
"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, 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["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"])
|
|
retirer = len(p["regles_retirer"]) + len(p["alias_retirer"]) + len(p["nat_retirer"]) \
|
|
+ len(p["routes_retirer"])
|
|
print(f"\n a creer : {creer} | a retirer : {retirer}"
|
|
f" | inchange : {len(p['regles_garder']) + len(p['nat_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",
|
|
"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}")
|
|
|
|
# 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 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())
|
|
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())
|