2026-08-06 14:51:51 -04:00
|
|
|
#!/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
|
frontiere : poster en formulaire encode — independant de la version du boitier
Mesure du 2026-08-13 sur un OPNsense 24.7 (version ANCIENNE, en cours de mise a
jour depuis) : toute ecriture du moteur y echouait. Alias, regles, NAT, routes —
`make frontiere-appliquer` n'aurait rien pose sur ce boitier.
Ce n'est donc PAS un defaut universel du moteur : il ecrit correctement sur la
frontiere de Chezlepro, plus recente. C'est un probleme de COMPATIBILITE, et le
correctif vaut surtout comme garantie de portabilite — le jour ou l'on arrive sur
un site dont on ne choisit pas le firmware, ce qui est exactement le cas ici.
LE SYMPTOME MERITE D'ETRE RETENU, lui, quelle que soit la version. Le controleur
d'OPNsense lit ses champs avec `hasPost(<racine>)`. Si le corps arrive en
`application/json` et que le boitier ne le decompose pas en variables de POST, ce
test est FAUX : reponse `{"result":"failed"}` NUE — HTTP 200, aucune redirection,
et surtout AUCUNE validation. Le controleur ne dit pas quel champ manque, parce que
de son point de vue il n'y avait aucun champ.
Trois fausses pistes avant la bonne : valeur invalide (un corps VIDE echouait
pareil), racine de payload erronee (elle etait juste), privileges de la cle (la
lecture passait). Ce qui a tranche : un corps vide aurait DU produire des
validations. Leur absence disait que le controleur n'avait rien recu.
CORRECTIF : `Frontiere` poste desormais `racine[champ]=valeur`. C'est la forme que
poste l'interface web elle-meme — aucune version d'OPNsense ne la refuse, alors que
le JSON depend du boitier. Tous les corps du moteur sont des dicts plats de chaines
(alias, rule, route) : un seul niveau d'imbrication suffit.
EPROUVE SUR LE BOITIER, dans les deux sens : addItem d'un alias sonde -> `saved`,
delItem -> `deleted`, aucune trace laissee, lecture intacte (11 alias). A re-eprouver
apres la mise a jour, pour verifier que le formulaire reste bon sur la version
recente — c'est le seul point qui reste ouvert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:47:09 -04:00
|
|
|
import urllib.parse
|
2026-08-06 14:51:51 -04:00
|
|
|
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
|
voutes : le monde physique a la sienne, les tenants n'en portent plus les cles
Demande de l'exploitant : « l'underlay et son tenant doivent avoir chacun sa voute ».
CE QUI ETAIT FAUX. Le jeton d'API du cluster et la cle d'API de la frontiere vivaient dans
la voute de CHAQUE tenant. Patient 0 a du les recopier pour exister. Consequence : on ne
pouvait plus revoquer l'acces d'un locataire sans le revoquer pour tous — la faute des
neuf copies, appliquee aux secrets.
CE QUI EST POSE :
- `underlay.vault.yml`, chez l'hebergeur, a cote d'underlay.yml. Quatre secrets deplaces
(jeton Proxmox, cle et secret d'API OPNsense), retires des deux voutes de tenants.
- `proxmox_api.voute()` lit l'underlay APRES le tenant, donc l'hebergeur fait foi ; un site
non encore migre continue de fonctionner sur son ancienne voute.
- `appliquer_opnsense._voute()` n'a plus sa propre lecture : elle appelle celle du cluster.
La reecrire aurait fait une dixieme copie le jour ou l'on refermait les neuf autres.
- Le playbook de clonage charge la voute de l'underlay APRES celle du tenant, par la meme
derivation que proxmox-hebergeur.yml : le symlink designe deja l'hebergeur.
- `voute.py` sait que ces secrets ne sont plus attendus chez un tenant (P18).
EPROUVE SUR LE REEL, apres retrait des cles chez les deux tenants : l'API du cluster
repond, le devis de placement est conforme pour les deux ecosystemes, le devis de
frontiere se genere (86 objets), le SDN est convergent.
UNE PRECAUTION APPRISE EN CHEMIN : le premier essai a ecrit la voute EN CLAIR avant de la
chiffrer, et le chiffrement a echoue — il a fallu detruire le fichier. La sequence est
desormais l'inverse : chiffrer dans un dossier de travail, ne deposer que le resultat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:13:48 -04:00
|
|
|
from proxmox_api import voute as voute_proxmox # noqa: E402
|
2026-08-06 14:51:51 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _voute(base: Path) -> dict:
|
voutes : le monde physique a la sienne, les tenants n'en portent plus les cles
Demande de l'exploitant : « l'underlay et son tenant doivent avoir chacun sa voute ».
CE QUI ETAIT FAUX. Le jeton d'API du cluster et la cle d'API de la frontiere vivaient dans
la voute de CHAQUE tenant. Patient 0 a du les recopier pour exister. Consequence : on ne
pouvait plus revoquer l'acces d'un locataire sans le revoquer pour tous — la faute des
neuf copies, appliquee aux secrets.
CE QUI EST POSE :
- `underlay.vault.yml`, chez l'hebergeur, a cote d'underlay.yml. Quatre secrets deplaces
(jeton Proxmox, cle et secret d'API OPNsense), retires des deux voutes de tenants.
- `proxmox_api.voute()` lit l'underlay APRES le tenant, donc l'hebergeur fait foi ; un site
non encore migre continue de fonctionner sur son ancienne voute.
- `appliquer_opnsense._voute()` n'a plus sa propre lecture : elle appelle celle du cluster.
La reecrire aurait fait une dixieme copie le jour ou l'on refermait les neuf autres.
- Le playbook de clonage charge la voute de l'underlay APRES celle du tenant, par la meme
derivation que proxmox-hebergeur.yml : le symlink designe deja l'hebergeur.
- `voute.py` sait que ces secrets ne sont plus attendus chez un tenant (P18).
EPROUVE SUR LE REEL, apres retrait des cles chez les deux tenants : l'API du cluster
repond, le devis de placement est conforme pour les deux ecosystemes, le devis de
frontiere se genere (86 objets), le SDN est convergent.
UNE PRECAUTION APPRISE EN CHEMIN : le premier essai a ecrit la voute EN CLAIR avant de la
chiffrer, et le chiffrement a echoue — il a fallu detruire le fichier. La sequence est
desormais l'inverse : chiffrer dans un dossier de travail, ne deposer que le resultat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:13:48 -04:00
|
|
|
"""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)
|
2026-08-06 14:51:51 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
frontiere : poster en formulaire encode — independant de la version du boitier
Mesure du 2026-08-13 sur un OPNsense 24.7 (version ANCIENNE, en cours de mise a
jour depuis) : toute ecriture du moteur y echouait. Alias, regles, NAT, routes —
`make frontiere-appliquer` n'aurait rien pose sur ce boitier.
Ce n'est donc PAS un defaut universel du moteur : il ecrit correctement sur la
frontiere de Chezlepro, plus recente. C'est un probleme de COMPATIBILITE, et le
correctif vaut surtout comme garantie de portabilite — le jour ou l'on arrive sur
un site dont on ne choisit pas le firmware, ce qui est exactement le cas ici.
LE SYMPTOME MERITE D'ETRE RETENU, lui, quelle que soit la version. Le controleur
d'OPNsense lit ses champs avec `hasPost(<racine>)`. Si le corps arrive en
`application/json` et que le boitier ne le decompose pas en variables de POST, ce
test est FAUX : reponse `{"result":"failed"}` NUE — HTTP 200, aucune redirection,
et surtout AUCUNE validation. Le controleur ne dit pas quel champ manque, parce que
de son point de vue il n'y avait aucun champ.
Trois fausses pistes avant la bonne : valeur invalide (un corps VIDE echouait
pareil), racine de payload erronee (elle etait juste), privileges de la cle (la
lecture passait). Ce qui a tranche : un corps vide aurait DU produire des
validations. Leur absence disait que le controleur n'avait rien recu.
CORRECTIF : `Frontiere` poste desormais `racine[champ]=valeur`. C'est la forme que
poste l'interface web elle-meme — aucune version d'OPNsense ne la refuse, alors que
le JSON depend du boitier. Tous les corps du moteur sont des dicts plats de chaines
(alias, rule, route) : un seul niveau d'imbrication suffit.
EPROUVE SUR LE BOITIER, dans les deux sens : addItem d'un alias sonde -> `saved`,
delItem -> `deleted`, aucune trace laissee, lecture intacte (11 alias). A re-eprouver
apres la mise a jour, pour verifier que le formulaire reste bon sur la version
recente — c'est le seul point qui reste ouvert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:47:09 -04:00
|
|
|
@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()
|
|
|
|
|
|
2026-08-06 14:51:51 -04:00
|
|
|
def __call__(self, chemin: str, corps: dict | None = None) -> dict:
|
frontiere : poster en formulaire encode — independant de la version du boitier
Mesure du 2026-08-13 sur un OPNsense 24.7 (version ANCIENNE, en cours de mise a
jour depuis) : toute ecriture du moteur y echouait. Alias, regles, NAT, routes —
`make frontiere-appliquer` n'aurait rien pose sur ce boitier.
Ce n'est donc PAS un defaut universel du moteur : il ecrit correctement sur la
frontiere de Chezlepro, plus recente. C'est un probleme de COMPATIBILITE, et le
correctif vaut surtout comme garantie de portabilite — le jour ou l'on arrive sur
un site dont on ne choisit pas le firmware, ce qui est exactement le cas ici.
LE SYMPTOME MERITE D'ETRE RETENU, lui, quelle que soit la version. Le controleur
d'OPNsense lit ses champs avec `hasPost(<racine>)`. Si le corps arrive en
`application/json` et que le boitier ne le decompose pas en variables de POST, ce
test est FAUX : reponse `{"result":"failed"}` NUE — HTTP 200, aucune redirection,
et surtout AUCUNE validation. Le controleur ne dit pas quel champ manque, parce que
de son point de vue il n'y avait aucun champ.
Trois fausses pistes avant la bonne : valeur invalide (un corps VIDE echouait
pareil), racine de payload erronee (elle etait juste), privileges de la cle (la
lecture passait). Ce qui a tranche : un corps vide aurait DU produire des
validations. Leur absence disait que le controleur n'avait rien recu.
CORRECTIF : `Frontiere` poste desormais `racine[champ]=valeur`. C'est la forme que
poste l'interface web elle-meme — aucune version d'OPNsense ne la refuse, alors que
le JSON depend du boitier. Tous les corps du moteur sont des dicts plats de chaines
(alias, rule, route) : un seul niveau d'imbrication suffit.
EPROUVE SUR LE BOITIER, dans les deux sens : addItem d'un alias sonde -> `saved`,
delItem -> `deleted`, aucune trace laissee, lecture intacte (11 alias). A re-eprouver
apres la mise a jour, pour verifier que le formulaire reste bon sur la version
recente — c'est le seul point qui reste ouvert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:47:09 -04:00
|
|
|
data = self._formulaire(corps) if corps is not None else None
|
2026-08-06 14:51:51 -04:00
|
|
|
h = {"Authorization": self.auth}
|
frontiere : poster en formulaire encode — independant de la version du boitier
Mesure du 2026-08-13 sur un OPNsense 24.7 (version ANCIENNE, en cours de mise a
jour depuis) : toute ecriture du moteur y echouait. Alias, regles, NAT, routes —
`make frontiere-appliquer` n'aurait rien pose sur ce boitier.
Ce n'est donc PAS un defaut universel du moteur : il ecrit correctement sur la
frontiere de Chezlepro, plus recente. C'est un probleme de COMPATIBILITE, et le
correctif vaut surtout comme garantie de portabilite — le jour ou l'on arrive sur
un site dont on ne choisit pas le firmware, ce qui est exactement le cas ici.
LE SYMPTOME MERITE D'ETRE RETENU, lui, quelle que soit la version. Le controleur
d'OPNsense lit ses champs avec `hasPost(<racine>)`. Si le corps arrive en
`application/json` et que le boitier ne le decompose pas en variables de POST, ce
test est FAUX : reponse `{"result":"failed"}` NUE — HTTP 200, aucune redirection,
et surtout AUCUNE validation. Le controleur ne dit pas quel champ manque, parce que
de son point de vue il n'y avait aucun champ.
Trois fausses pistes avant la bonne : valeur invalide (un corps VIDE echouait
pareil), racine de payload erronee (elle etait juste), privileges de la cle (la
lecture passait). Ce qui a tranche : un corps vide aurait DU produire des
validations. Leur absence disait que le controleur n'avait rien recu.
CORRECTIF : `Frontiere` poste desormais `racine[champ]=valeur`. C'est la forme que
poste l'interface web elle-meme — aucune version d'OPNsense ne la refuse, alors que
le JSON depend du boitier. Tous les corps du moteur sont des dicts plats de chaines
(alias, rule, route) : un seul niveau d'imbrication suffit.
EPROUVE SUR LE BOITIER, dans les deux sens : addItem d'un alias sonde -> `saved`,
delItem -> `deleted`, aucune trace laissee, lecture intacte (11 alias). A re-eprouver
apres la mise a jour, pour verifier que le formulaire reste bon sur la version
recente — c'est le seul point qui reste ouvert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:47:09 -04:00
|
|
|
if data is not None:
|
|
|
|
|
h["Content-Type"] = "application/x-www-form-urlencoded"
|
2026-08-06 14:51:51 -04:00
|
|
|
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 ["*"]))
|
frontiere : le devis sait desormais refuser SANS consigner
L'outil ne savait qu'AUTORISER — `"action": "pass"` etait en dur dans l'emetteur.
Une regle de silence ne pouvait donc pas naitre du depot, et j'en avais pose deux
a la main sur le boitier : exactement ce que ce projet refuse.
CE QUI L'A MOTIVE. Le journal de la frontiere ecrivait 982 000 entrees par jour,
dont 82 % un balayage Internet contre le port VNC et le reste du bavardage de
decouverte du reseau local. Sa fenetre utile etait tombee a QUARANTE-QUATRE
SECONDES. J'y ai cherche la trace d'un flux du site vers les hyperviseurs, je n'ai
rien trouve, et j'en ai conclu a tort qu'aucune regle ne bloquait. Un journal noye
ment aussi surement qu'un journal mort. Mesure apres declaration : ~20 700/jour.
Une regle de silence ne change AUCUN comportement : ce qu'elle vise etait deja
refuse par le defaut. Elle ne supprime qu'une trace que personne ne lira.
TROIS PIECES.
`cle_regle` accepte une action sans changer d'un octet la cle des regles `pass`
deja posees. L'ajout naif d'un champ les aurait toutes detruites pour les recreer
a l'identique, sur la frontiere, en production. Le plan l'a confirme : 7 a creer,
0 a retirer, 121 inchangees.
`_corps_regle` lit l'action, la consignation et la SEQUENCE depuis le devis. La
sequence est ce qui rend un `block` sur : OPNsense evalue en `quick`, donc un
blocage large emis avant les `pass` fermerait courrier, web et acces distant.
`devis_opnsense` lit `opnsense_silences` et en fabrique regles et alias, motif
compris — une regle `block` muette sans raison ecrite est indiscernable d'un oubli.
P50 garde ces deux dangers. Controles negatifs verifies : un silence en sequence 1
echoue, un silence sans motif echoue.
Carte : 28 pieces d'audit (P48 l'avait vu juste).
make prouver : CONFORME, 50 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:10:39 -04:00
|
|
|
# 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']}:"
|
2026-08-06 14:51:51 -04:00
|
|
|
f"{r['source']}->{r['destination']}:{ports}")
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 15:59:11 -04:00
|
|
|
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']}"
|
|
|
|
|
|
|
|
|
|
|
2026-08-09 16:44:58 -04:00
|
|
|
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:
|
supervision du site, et la panne qui dormait dans un mot
Le site a son temoin : site-mon-01 (VLAN 36) porte PostgreSQL, Icinga et un
relais. Le depot lui rapporte, et son premier verdict fut un vrai defaut —
site-mon-01 n avait jamais depose son propre etat. Les trois sont au vert.
serveur_backup_verification_locale repasse a true sur le depot : le drapeau ne
dit plus on renonce mais verifie ce que tu peux ouvrir . La liste des noeuds
attendus derive de l inventaire ou tourne le role, donc du site seul.
serveur_postfix gagne un mode relais : un site n heberge aucune boite, il
expedie. Directement par sa frontiere — emprunter le MTA d un locataire ferait
dependre l hebergeur d un ecosysteme qu il peut outvivre.
LA PANNE DE FOND : le modele de routes OPNsense lit enabled ; on lui envoyait
disabled=0, un champ ignore. enabled restait a son defaut, ETEINT. Chaque route
creee par Set-OPS depuis l origine l etait desactivee — invisible, parce qu une
route eteinte EXISTE dans le modele et compte posee . Le rechargement lance
pour activer la nouvelle patte a fait reprendre au noyau sa table depuis le
modele : 14 routes sur 15 disparues, 11 machines de Chezlepro injoignables le
lendemain de sa reconstruction, et le devis toujours vert.
Trois corrections empilees : la cause (enabled), la cecite (le plan lit la table
du NOYAU et signale absent ou eteint), l inaction (la reconfiguration ne se
declenchait que sur un changement du modele).
Aussi : un role du site peut enfin en appeler un autre a travers la frontiere —
le devis traitait tout pair nomme comme l exterieur . Et nftables_admin_ssh se
DERIVE de la carte : recopie a la main, il retardait d une zone, et m a
verrouille dehors de la machine que je venais de creer.
Reste ouvert : le destinataire des alertes est encore root@localhost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 17:34:01 -04:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-08-09 16:44:58 -04:00
|
|
|
return {
|
|
|
|
|
"network": r["reseau"],
|
|
|
|
|
"gateway": passerelle,
|
supervision du site, et la panne qui dormait dans un mot
Le site a son temoin : site-mon-01 (VLAN 36) porte PostgreSQL, Icinga et un
relais. Le depot lui rapporte, et son premier verdict fut un vrai defaut —
site-mon-01 n avait jamais depose son propre etat. Les trois sont au vert.
serveur_backup_verification_locale repasse a true sur le depot : le drapeau ne
dit plus on renonce mais verifie ce que tu peux ouvrir . La liste des noeuds
attendus derive de l inventaire ou tourne le role, donc du site seul.
serveur_postfix gagne un mode relais : un site n heberge aucune boite, il
expedie. Directement par sa frontiere — emprunter le MTA d un locataire ferait
dependre l hebergeur d un ecosysteme qu il peut outvivre.
LA PANNE DE FOND : le modele de routes OPNsense lit enabled ; on lui envoyait
disabled=0, un champ ignore. enabled restait a son defaut, ETEINT. Chaque route
creee par Set-OPS depuis l origine l etait desactivee — invisible, parce qu une
route eteinte EXISTE dans le modele et compte posee . Le rechargement lance
pour activer la nouvelle patte a fait reprendre au noyau sa table depuis le
modele : 14 routes sur 15 disparues, 11 machines de Chezlepro injoignables le
lendemain de sa reconstruction, et le devis toujours vert.
Trois corrections empilees : la cause (enabled), la cecite (le plan lit la table
du NOYAU et signale absent ou eteint), l inaction (la reconfiguration ne se
declenchait que sur un changement du modele).
Aussi : un role du site peut enfin en appeler un autre a travers la frontiere —
le devis traitait tout pair nomme comme l exterieur . Et nftables_admin_ssh se
DERIVE de la carte : recopie a la main, il retardait d une zone, et m a
verrouille dehors de la machine que je venais de creer.
Reste ouvert : le destinataire des alertes est encore root@localhost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 17:34:01 -04:00
|
|
|
"enabled": "1",
|
2026-08-09 16:44:58 -04:00
|
|
|
"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.
|
|
|
|
|
"""
|
frontiere : un boitier injoignable etait lu comme un boitier vide
L'exploitant lance `make frontiere-appliquer` dans son terminal : « a creer 0, inchange
53 + 15 routes — la frontiere dit deja ce que le devis dit ». Elle etait DEJA conforme.
J'avais annonce quelques heures plus tot « 89 a creer, 0 inchange, donc les regles
heritees sont invisibles a l'API ». FAUX. L'intrant `opnsense_api_url` pointait sur
10.0.0.1, l'adresse d'avant la migration du boitier : chaque lecture rendait
{"_erreur": ...}, le plan lisait `.get("rows")`, n'y trouvait rien, et concluait au vide.
Avec CONFIRMER, on aurait pousse une politique entiere EN DOUBLE.
TROISIEME FOIS EN UNE SOIREE, meme confusion — « pas de reponse » pris pour « rien » :
devis_placement iterait un dict d'erreur comme une liste
devis_underlay declarait morts les reseaux qu'il ne joignait pas
appliquer_opnsense lisait un boitier injoignable comme un boitier vide
Toutes les lectures de la frontiere passent desormais par une garde qui refuse en nommant
l'hote, la cause et le piege evite. Eprouvee contre l'ancienne adresse : elle refuse.
CE QUE CA DIT DE LA METHODE : ce n'est ni le harnais ni moi qui avons trouve, c'est
l'exploitant, en lancant la commande la ou il VOIT la sortie. Mes commandes s'executent
dans ma session ; il n'en voit rien. Une commande lente ressemble a un blocage, et un
blocage a une commande lente — j'ai conclu deux fois a tort avant qu'il ne regarde.
make verifier 41/41 ; le plan reel reste « rien a faire ».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:56:30 -04:00
|
|
|
for x in (_lire(api, "/api/routes/gateway/status").get("items") or []):
|
2026-08-09 16:44:58 -04:00
|
|
|
if str(x.get("address") or "").strip() == str(saut).strip():
|
|
|
|
|
return str(x.get("name"))
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 15:59:11 -04:00
|
|
|
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",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 14:51:51 -04:00
|
|
|
def _corps_regle(r: dict, k: str) -> dict:
|
frontiere : « vers Internet » n'est plus « vers n'importe ou »
Validation a l'instrument de l'exigence « aucun trafic impertinent », par de
vraies requetes applicatives contre des destinations interdites.
Le transit tenant etait deja correct : hyperviseur, frontiere, poste et
Proxmox tous muets ; le 25 sortant passe depuis edge-mta-01 (banniere
220 mx.google.com) et est refuse depuis infra-dns-01.
Trou 1, a nous : les flux sortants visaient `any`, donc n'excluaient ni le
plan de gestion ni le voisin — https://10.0.0.1/ repondait depuis une VM.
Ils visent desormais !SETOPS_INTERNES, destination NIEE valant les trois
blocs prives RFC 1918. Pas la liste de nos reseaux : elle laissait dehors
192.168.11.0/24, le plan de gestion herite.
Trou 2, pas a nous : la regle d'usine « Default allow LAN to any » privait
notre defaut-deny de tout effet (mesure : le 443 d'un nginx repondait depuis
le poste alors que seul le 22 est declare). Aucune API ne l'expose. Set-OPS
declare donc les flux d'administration legitimes vers les services publies,
pour que la desactiver ne coupe pas l'exploitant de ses consoles web.
Verifie apres application : 10.0.0.1:443 bloque depuis le tenant, sortie web
+ DNS + SMTP public toujours passants, curl vers le nginx du tenant -> 302,
flotte 14/14, frontiere-plan sans ecart, prouver.py 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:00:08 -04:00
|
|
|
# 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("!")
|
frontiere : le devis sait desormais refuser SANS consigner
L'outil ne savait qu'AUTORISER — `"action": "pass"` etait en dur dans l'emetteur.
Une regle de silence ne pouvait donc pas naitre du depot, et j'en avais pose deux
a la main sur le boitier : exactement ce que ce projet refuse.
CE QUI L'A MOTIVE. Le journal de la frontiere ecrivait 982 000 entrees par jour,
dont 82 % un balayage Internet contre le port VNC et le reste du bavardage de
decouverte du reseau local. Sa fenetre utile etait tombee a QUARANTE-QUATRE
SECONDES. J'y ai cherche la trace d'un flux du site vers les hyperviseurs, je n'ai
rien trouve, et j'en ai conclu a tort qu'aucune regle ne bloquait. Un journal noye
ment aussi surement qu'un journal mort. Mesure apres declaration : ~20 700/jour.
Une regle de silence ne change AUCUN comportement : ce qu'elle vise etait deja
refuse par le defaut. Elle ne supprime qu'une trace que personne ne lira.
TROIS PIECES.
`cle_regle` accepte une action sans changer d'un octet la cle des regles `pass`
deja posees. L'ajout naif d'un champ les aurait toutes detruites pour les recreer
a l'identique, sur la frontiere, en production. Le plan l'a confirme : 7 a creer,
0 a retirer, 121 inchangees.
`_corps_regle` lit l'action, la consignation et la SEQUENCE depuis le devis. La
sequence est ce qui rend un `block` sur : OPNsense evalue en `quick`, donc un
blocage large emis avant les `pass` fermerait courrier, web et acces distant.
`devis_opnsense` lit `opnsense_silences` et en fabrique regles et alias, motif
compris — une regle `block` muette sans raison ecrite est indiscernable d'un oubli.
P50 garde ces deux dangers. Controles negatifs verifies : un silence en sequence 1
echoue, un silence sans motif echoue.
Carte : 28 pieces d'audit (P48 l'avait vu juste).
make prouver : CONFORME, 50 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:10:39 -04:00
|
|
|
# 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.
|
2026-08-06 14:51:51 -04:00
|
|
|
corps = {
|
|
|
|
|
"enabled": "1",
|
frontiere : le devis sait desormais refuser SANS consigner
L'outil ne savait qu'AUTORISER — `"action": "pass"` etait en dur dans l'emetteur.
Une regle de silence ne pouvait donc pas naitre du depot, et j'en avais pose deux
a la main sur le boitier : exactement ce que ce projet refuse.
CE QUI L'A MOTIVE. Le journal de la frontiere ecrivait 982 000 entrees par jour,
dont 82 % un balayage Internet contre le port VNC et le reste du bavardage de
decouverte du reseau local. Sa fenetre utile etait tombee a QUARANTE-QUATRE
SECONDES. J'y ai cherche la trace d'un flux du site vers les hyperviseurs, je n'ai
rien trouve, et j'en ai conclu a tort qu'aucune regle ne bloquait. Un journal noye
ment aussi surement qu'un journal mort. Mesure apres declaration : ~20 700/jour.
Une regle de silence ne change AUCUN comportement : ce qu'elle vise etait deja
refuse par le defaut. Elle ne supprime qu'une trace que personne ne lira.
TROIS PIECES.
`cle_regle` accepte une action sans changer d'un octet la cle des regles `pass`
deja posees. L'ajout naif d'un champ les aurait toutes detruites pour les recreer
a l'identique, sur la frontiere, en production. Le plan l'a confirme : 7 a creer,
0 a retirer, 121 inchangees.
`_corps_regle` lit l'action, la consignation et la SEQUENCE depuis le devis. La
sequence est ce qui rend un `block` sur : OPNsense evalue en `quick`, donc un
blocage large emis avant les `pass` fermerait courrier, web et acces distant.
`devis_opnsense` lit `opnsense_silences` et en fabrique regles et alias, motif
compris — une regle `block` muette sans raison ecrite est indiscernable d'un oubli.
P50 garde ces deux dangers. Controles negatifs verifies : un silence en sequence 1
echoue, un silence sans motif echoue.
Carte : 28 pieces d'audit (P48 l'avait vu juste).
make prouver : CONFORME, 50 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:10:39 -04:00
|
|
|
"sequence": str(r.get("sequence") or "1"),
|
|
|
|
|
"action": str(r.get("action") or "pass"),
|
|
|
|
|
"log": "1" if r.get("journaliser") else "0",
|
2026-08-06 14:51:51 -04:00
|
|
|
"interface": r["interface"],
|
|
|
|
|
"direction": "in", # le devis raisonne en ARRIVEE sur l'interface (D-61)
|
|
|
|
|
"ipprotocol": "inet",
|
frontiere : le devis sait desormais refuser SANS consigner
L'outil ne savait qu'AUTORISER — `"action": "pass"` etait en dur dans l'emetteur.
Une regle de silence ne pouvait donc pas naitre du depot, et j'en avais pose deux
a la main sur le boitier : exactement ce que ce projet refuse.
CE QUI L'A MOTIVE. Le journal de la frontiere ecrivait 982 000 entrees par jour,
dont 82 % un balayage Internet contre le port VNC et le reste du bavardage de
decouverte du reseau local. Sa fenetre utile etait tombee a QUARANTE-QUATRE
SECONDES. J'y ai cherche la trace d'un flux du site vers les hyperviseurs, je n'ai
rien trouve, et j'en ai conclu a tort qu'aucune regle ne bloquait. Un journal noye
ment aussi surement qu'un journal mort. Mesure apres declaration : ~20 700/jour.
Une regle de silence ne change AUCUN comportement : ce qu'elle vise etait deja
refuse par le defaut. Elle ne supprime qu'une trace que personne ne lira.
TROIS PIECES.
`cle_regle` accepte une action sans changer d'un octet la cle des regles `pass`
deja posees. L'ajout naif d'un champ les aurait toutes detruites pour les recreer
a l'identique, sur la frontiere, en production. Le plan l'a confirme : 7 a creer,
0 a retirer, 121 inchangees.
`_corps_regle` lit l'action, la consignation et la SEQUENCE depuis le devis. La
sequence est ce qui rend un `block` sur : OPNsense evalue en `quick`, donc un
blocage large emis avant les `pass` fermerait courrier, web et acces distant.
`devis_opnsense` lit `opnsense_silences` et en fabrique regles et alias, motif
compris — une regle `block` muette sans raison ecrite est indiscernable d'un oubli.
P50 garde ces deux dangers. Controles negatifs verifies : un silence en sequence 1
echoue, un silence sans motif echoue.
Carte : 28 pieces d'audit (P48 l'avait vu juste).
make prouver : CONFORME, 50 OK, 0 echec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:10:39 -04:00
|
|
|
# `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()),
|
2026-08-06 14:51:51 -04:00
|
|
|
"source_net": r["source"],
|
frontiere : « vers Internet » n'est plus « vers n'importe ou »
Validation a l'instrument de l'exigence « aucun trafic impertinent », par de
vraies requetes applicatives contre des destinations interdites.
Le transit tenant etait deja correct : hyperviseur, frontiere, poste et
Proxmox tous muets ; le 25 sortant passe depuis edge-mta-01 (banniere
220 mx.google.com) et est refuse depuis infra-dns-01.
Trou 1, a nous : les flux sortants visaient `any`, donc n'excluaient ni le
plan de gestion ni le voisin — https://10.0.0.1/ repondait depuis une VM.
Ils visent desormais !SETOPS_INTERNES, destination NIEE valant les trois
blocs prives RFC 1918. Pas la liste de nos reseaux : elle laissait dehors
192.168.11.0/24, le plan de gestion herite.
Trou 2, pas a nous : la regle d'usine « Default allow LAN to any » privait
notre defaut-deny de tout effet (mesure : le 443 d'un nginx repondait depuis
le poste alors que seul le 22 est declare). Aucune API ne l'expose. Set-OPS
declare donc les flux d'administration legitimes vers les services publies,
pour que la desactiver ne coupe pas l'exploitant de ses consoles web.
Verifie apres application : 10.0.0.1:443 bloque depuis le tenant, sortie web
+ DNS + SMTP public toujours passants, curl vers le nginx du tenant -> 302,
flotte 14/14, frontiere-plan sans ecart, prouver.py 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:00:08 -04:00
|
|
|
"destination_net": dst[1:] if nie else dst,
|
|
|
|
|
"destination_not": "1" if nie else "0",
|
2026-08-06 14:51:51 -04:00
|
|
|
"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()}
|
|
|
|
|
|
|
|
|
|
|
supervision du site, et la panne qui dormait dans un mot
Le site a son temoin : site-mon-01 (VLAN 36) porte PostgreSQL, Icinga et un
relais. Le depot lui rapporte, et son premier verdict fut un vrai defaut —
site-mon-01 n avait jamais depose son propre etat. Les trois sont au vert.
serveur_backup_verification_locale repasse a true sur le depot : le drapeau ne
dit plus on renonce mais verifie ce que tu peux ouvrir . La liste des noeuds
attendus derive de l inventaire ou tourne le role, donc du site seul.
serveur_postfix gagne un mode relais : un site n heberge aucune boite, il
expedie. Directement par sa frontiere — emprunter le MTA d un locataire ferait
dependre l hebergeur d un ecosysteme qu il peut outvivre.
LA PANNE DE FOND : le modele de routes OPNsense lit enabled ; on lui envoyait
disabled=0, un champ ignore. enabled restait a son defaut, ETEINT. Chaque route
creee par Set-OPS depuis l origine l etait desactivee — invisible, parce qu une
route eteinte EXISTE dans le modele et compte posee . Le rechargement lance
pour activer la nouvelle patte a fait reprendre au noyau sa table depuis le
modele : 14 routes sur 15 disparues, 11 machines de Chezlepro injoignables le
lendemain de sa reconstruction, et le devis toujours vert.
Trois corrections empilees : la cause (enabled), la cecite (le plan lit la table
du NOYAU et signale absent ou eteint), l inaction (la reconfiguration ne se
declenchait que sur un changement du modele).
Aussi : un role du site peut enfin en appeler un autre a travers la frontiere —
le devis traitait tout pair nomme comme l exterieur . Et nftables_admin_ssh se
DERIVE de la carte : recopie a la main, il retardait d une zone, et m a
verrouille dehors de la machine que je venais de creer.
Reste ouvert : le destinataire des alertes est encore root@localhost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 17:34:01 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
frontiere : un boitier injoignable etait lu comme un boitier vide
L'exploitant lance `make frontiere-appliquer` dans son terminal : « a creer 0, inchange
53 + 15 routes — la frontiere dit deja ce que le devis dit ». Elle etait DEJA conforme.
J'avais annonce quelques heures plus tot « 89 a creer, 0 inchange, donc les regles
heritees sont invisibles a l'API ». FAUX. L'intrant `opnsense_api_url` pointait sur
10.0.0.1, l'adresse d'avant la migration du boitier : chaque lecture rendait
{"_erreur": ...}, le plan lisait `.get("rows")`, n'y trouvait rien, et concluait au vide.
Avec CONFIRMER, on aurait pousse une politique entiere EN DOUBLE.
TROISIEME FOIS EN UNE SOIREE, meme confusion — « pas de reponse » pris pour « rien » :
devis_placement iterait un dict d'erreur comme une liste
devis_underlay declarait morts les reseaux qu'il ne joignait pas
appliquer_opnsense lisait un boitier injoignable comme un boitier vide
Toutes les lectures de la frontiere passent desormais par une garde qui refuse en nommant
l'hote, la cause et le piege evite. Eprouvee contre l'ancienne adresse : elle refuse.
CE QUE CA DIT DE LA METHODE : ce n'est ni le harnais ni moi qui avons trouve, c'est
l'exploitant, en lancant la commande la ou il VOIT la sortie. Mes commandes s'executent
dans ma session ; il n'en voit rien. Une commande lente ressemble a un blocage, et un
blocage a une commande lente — j'ai conclu deux fois a tort avant qu'il ne regarde.
make verifier 41/41 ; le plan reel reste « rien a faire ».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:56:30 -04:00
|
|
|
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 {}
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 14:51:51 -04:00
|
|
|
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 = {}
|
frontiere : un boitier injoignable etait lu comme un boitier vide
L'exploitant lance `make frontiere-appliquer` dans son terminal : « a creer 0, inchange
53 + 15 routes — la frontiere dit deja ce que le devis dit ». Elle etait DEJA conforme.
J'avais annonce quelques heures plus tot « 89 a creer, 0 inchange, donc les regles
heritees sont invisibles a l'API ». FAUX. L'intrant `opnsense_api_url` pointait sur
10.0.0.1, l'adresse d'avant la migration du boitier : chaque lecture rendait
{"_erreur": ...}, le plan lisait `.get("rows")`, n'y trouvait rien, et concluait au vide.
Avec CONFIRMER, on aurait pousse une politique entiere EN DOUBLE.
TROISIEME FOIS EN UNE SOIREE, meme confusion — « pas de reponse » pris pour « rien » :
devis_placement iterait un dict d'erreur comme une liste
devis_underlay declarait morts les reseaux qu'il ne joignait pas
appliquer_opnsense lisait un boitier injoignable comme un boitier vide
Toutes les lectures de la frontiere passent desormais par une garde qui refuse en nommant
l'hote, la cause et le piege evite. Eprouvee contre l'ancienne adresse : elle refuse.
CE QUE CA DIT DE LA METHODE : ce n'est ni le harnais ni moi qui avons trouve, c'est
l'exploitant, en lancant la commande la ou il VOIT la sortie. Mes commandes s'executent
dans ma session ; il n'en voit rien. Une commande lente ressemble a un blocage, et un
blocage a une commande lente — j'ai conclu deux fois a tort avant qu'il ne regarde.
make verifier 41/41 ; le plan reel reste « rien a faire ».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:56:30 -04:00
|
|
|
for x in (_lire(api, "/api/firewall/filter/search_rule/",
|
|
|
|
|
{"current": 1, "rowCount": 1000}).get("rows") or []):
|
2026-08-06 14:51:51 -04:00
|
|
|
d = str(x.get("description") or "")
|
|
|
|
|
if d.startswith("setops:"):
|
|
|
|
|
posees[d.split(" — ")[0]] = x
|
|
|
|
|
|
|
|
|
|
al_voulus = devis["alias"]
|
frontiere : un boitier injoignable etait lu comme un boitier vide
L'exploitant lance `make frontiere-appliquer` dans son terminal : « a creer 0, inchange
53 + 15 routes — la frontiere dit deja ce que le devis dit ». Elle etait DEJA conforme.
J'avais annonce quelques heures plus tot « 89 a creer, 0 inchange, donc les regles
heritees sont invisibles a l'API ». FAUX. L'intrant `opnsense_api_url` pointait sur
10.0.0.1, l'adresse d'avant la migration du boitier : chaque lecture rendait
{"_erreur": ...}, le plan lisait `.get("rows")`, n'y trouvait rien, et concluait au vide.
Avec CONFIRMER, on aurait pousse une politique entiere EN DOUBLE.
TROISIEME FOIS EN UNE SOIREE, meme confusion — « pas de reponse » pris pour « rien » :
devis_placement iterait un dict d'erreur comme une liste
devis_underlay declarait morts les reseaux qu'il ne joignait pas
appliquer_opnsense lisait un boitier injoignable comme un boitier vide
Toutes les lectures de la frontiere passent desormais par une garde qui refuse en nommant
l'hote, la cause et le piege evite. Eprouvee contre l'ancienne adresse : elle refuse.
CE QUE CA DIT DE LA METHODE : ce n'est ni le harnais ni moi qui avons trouve, c'est
l'exploitant, en lancant la commande la ou il VOIT la sortie. Mes commandes s'executent
dans ma session ; il n'en voit rien. Une commande lente ressemble a un blocage, et un
blocage a une commande lente — j'ai conclu deux fois a tort avant qu'il ne regarde.
make verifier 41/41 ; le plan reel reste « rien a faire ».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:56:30 -04:00
|
|
|
al_poses = {x["name"]: x for x in (_lire(api, "/api/firewall/alias/searchItem/",
|
|
|
|
|
{"rowCount": 1000}).get("rows") or [])
|
2026-08-06 14:51:51 -04:00
|
|
|
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()}
|
frontiere : « vers Internet » n'est plus « vers n'importe ou »
Validation a l'instrument de l'exigence « aucun trafic impertinent », par de
vraies requetes applicatives contre des destinations interdites.
Le transit tenant etait deja correct : hyperviseur, frontiere, poste et
Proxmox tous muets ; le 25 sortant passe depuis edge-mta-01 (banniere
220 mx.google.com) et est refuse depuis infra-dns-01.
Trou 1, a nous : les flux sortants visaient `any`, donc n'excluaient ni le
plan de gestion ni le voisin — https://10.0.0.1/ repondait depuis une VM.
Ils visent desormais !SETOPS_INTERNES, destination NIEE valant les trois
blocs prives RFC 1918. Pas la liste de nos reseaux : elle laissait dehors
192.168.11.0/24, le plan de gestion herite.
Trou 2, pas a nous : la regle d'usine « Default allow LAN to any » privait
notre defaut-deny de tout effet (mesure : le 443 d'un nginx repondait depuis
le poste alors que seul le 22 est declare). Aucune API ne l'expose. Set-OPS
declare donc les flux d'administration legitimes vers les services publies,
pour que la desactiver ne coupe pas l'exploitant de ses consoles web.
Verifie apres application : 10.0.0.1:443 bloque depuis le tenant, sortie web
+ DNS + SMTP public toujours passants, curl vers le nginx du tenant -> 302,
flotte 14/14, frontiere-plan sans ecart, prouver.py 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:00:08 -04:00
|
|
|
# `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"]}
|
2026-08-06 15:59:11 -04:00
|
|
|
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 = {}
|
frontiere : un boitier injoignable etait lu comme un boitier vide
L'exploitant lance `make frontiere-appliquer` dans son terminal : « a creer 0, inchange
53 + 15 routes — la frontiere dit deja ce que le devis dit ». Elle etait DEJA conforme.
J'avais annonce quelques heures plus tot « 89 a creer, 0 inchange, donc les regles
heritees sont invisibles a l'API ». FAUX. L'intrant `opnsense_api_url` pointait sur
10.0.0.1, l'adresse d'avant la migration du boitier : chaque lecture rendait
{"_erreur": ...}, le plan lisait `.get("rows")`, n'y trouvait rien, et concluait au vide.
Avec CONFIRMER, on aurait pousse une politique entiere EN DOUBLE.
TROISIEME FOIS EN UNE SOIREE, meme confusion — « pas de reponse » pris pour « rien » :
devis_placement iterait un dict d'erreur comme une liste
devis_underlay declarait morts les reseaux qu'il ne joignait pas
appliquer_opnsense lisait un boitier injoignable comme un boitier vide
Toutes les lectures de la frontiere passent desormais par une garde qui refuse en nommant
l'hote, la cause et le piege evite. Eprouvee contre l'ancienne adresse : elle refuse.
CE QUE CA DIT DE LA METHODE : ce n'est ni le harnais ni moi qui avons trouve, c'est
l'exploitant, en lancant la commande la ou il VOIT la sortie. Mes commandes s'executent
dans ma session ; il n'en voit rien. Une commande lente ressemble a un blocage, et un
blocage a une commande lente — j'ai conclu deux fois a tort avant qu'il ne regarde.
make verifier 41/41 ; le plan reel reste « rien a faire ».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:56:30 -04:00
|
|
|
for x in (_lire(api, "/api/firewall/source_nat/search_rule/",
|
2026-08-06 15:59:11 -04:00
|
|
|
{"current": 1, "rowCount": 1000}).get("rows") or []):
|
|
|
|
|
d = str(x.get("description") or "")
|
|
|
|
|
if d.startswith("setopsnat:"):
|
|
|
|
|
nat_poses[d.split(" — ")[0]] = x
|
2026-08-06 14:51:51 -04:00
|
|
|
|
2026-08-09 16:44:58 -04:00
|
|
|
rt_voulues = {cle_route(r): r for r in devis.get("routes") or []}
|
|
|
|
|
rt_posees = {}
|
frontiere : un boitier injoignable etait lu comme un boitier vide
L'exploitant lance `make frontiere-appliquer` dans son terminal : « a creer 0, inchange
53 + 15 routes — la frontiere dit deja ce que le devis dit ». Elle etait DEJA conforme.
J'avais annonce quelques heures plus tot « 89 a creer, 0 inchange, donc les regles
heritees sont invisibles a l'API ». FAUX. L'intrant `opnsense_api_url` pointait sur
10.0.0.1, l'adresse d'avant la migration du boitier : chaque lecture rendait
{"_erreur": ...}, le plan lisait `.get("rows")`, n'y trouvait rien, et concluait au vide.
Avec CONFIRMER, on aurait pousse une politique entiere EN DOUBLE.
TROISIEME FOIS EN UNE SOIREE, meme confusion — « pas de reponse » pris pour « rien » :
devis_placement iterait un dict d'erreur comme une liste
devis_underlay declarait morts les reseaux qu'il ne joignait pas
appliquer_opnsense lisait un boitier injoignable comme un boitier vide
Toutes les lectures de la frontiere passent desormais par une garde qui refuse en nommant
l'hote, la cause et le piege evite. Eprouvee contre l'ancienne adresse : elle refuse.
CE QUE CA DIT DE LA METHODE : ce n'est ni le harnais ni moi qui avons trouve, c'est
l'exploitant, en lancant la commande la ou il VOIT la sortie. Mes commandes s'executent
dans ma session ; il n'en voit rien. Une commande lente ressemble a un blocage, et un
blocage a une commande lente — j'ai conclu deux fois a tort avant qu'il ne regarde.
make verifier 41/41 ; le plan reel reste « rien a faire ».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:56:30 -04:00
|
|
|
for x in (_lire(api, "/api/routes/routes/searchroute/",
|
2026-08-09 16:44:58 -04:00
|
|
|
{"current": 1, "rowCount": 1000}).get("rows") or []):
|
|
|
|
|
d = str(x.get("descr") or "")
|
|
|
|
|
if d.startswith("setopsroute:"):
|
|
|
|
|
rt_posees[d] = x
|
|
|
|
|
|
supervision du site, et la panne qui dormait dans un mot
Le site a son temoin : site-mon-01 (VLAN 36) porte PostgreSQL, Icinga et un
relais. Le depot lui rapporte, et son premier verdict fut un vrai defaut —
site-mon-01 n avait jamais depose son propre etat. Les trois sont au vert.
serveur_backup_verification_locale repasse a true sur le depot : le drapeau ne
dit plus on renonce mais verifie ce que tu peux ouvrir . La liste des noeuds
attendus derive de l inventaire ou tourne le role, donc du site seul.
serveur_postfix gagne un mode relais : un site n heberge aucune boite, il
expedie. Directement par sa frontiere — emprunter le MTA d un locataire ferait
dependre l hebergeur d un ecosysteme qu il peut outvivre.
LA PANNE DE FOND : le modele de routes OPNsense lit enabled ; on lui envoyait
disabled=0, un champ ignore. enabled restait a son defaut, ETEINT. Chaque route
creee par Set-OPS depuis l origine l etait desactivee — invisible, parce qu une
route eteinte EXISTE dans le modele et compte posee . Le rechargement lance
pour activer la nouvelle patte a fait reprendre au noyau sa table depuis le
modele : 14 routes sur 15 disparues, 11 machines de Chezlepro injoignables le
lendemain de sa reconstruction, et le devis toujours vert.
Trois corrections empilees : la cause (enabled), la cecite (le plan lit la table
du NOYAU et signale absent ou eteint), l inaction (la reconfiguration ne se
declenchait que sur un changement du modele).
Aussi : un role du site peut enfin en appeler un autre a travers la frontiere —
le devis traitait tout pair nomme comme l exterieur . Et nftables_admin_ssh se
DERIVE de la carte : recopie a la main, il retardait d une zone, et m a
verrouille dehors de la machine que je venais de creer.
Reste ouvert : le destinataire des alertes est encore root@localhost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 17:34:01 -04:00
|
|
|
# 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)
|
2026-08-06 14:51:51 -04:00
|
|
|
return {
|
supervision du site, et la panne qui dormait dans un mot
Le site a son temoin : site-mon-01 (VLAN 36) porte PostgreSQL, Icinga et un
relais. Le depot lui rapporte, et son premier verdict fut un vrai defaut —
site-mon-01 n avait jamais depose son propre etat. Les trois sont au vert.
serveur_backup_verification_locale repasse a true sur le depot : le drapeau ne
dit plus on renonce mais verifie ce que tu peux ouvrir . La liste des noeuds
attendus derive de l inventaire ou tourne le role, donc du site seul.
serveur_postfix gagne un mode relais : un site n heberge aucune boite, il
expedie. Directement par sa frontiere — emprunter le MTA d un locataire ferait
dependre l hebergeur d un ecosysteme qu il peut outvivre.
LA PANNE DE FOND : le modele de routes OPNsense lit enabled ; on lui envoyait
disabled=0, un champ ignore. enabled restait a son defaut, ETEINT. Chaque route
creee par Set-OPS depuis l origine l etait desactivee — invisible, parce qu une
route eteinte EXISTE dans le modele et compte posee . Le rechargement lance
pour activer la nouvelle patte a fait reprendre au noyau sa table depuis le
modele : 14 routes sur 15 disparues, 11 machines de Chezlepro injoignables le
lendemain de sa reconstruction, et le devis toujours vert.
Trois corrections empilees : la cause (enabled), la cecite (le plan lit la table
du NOYAU et signale absent ou eteint), l inaction (la reconfiguration ne se
declenchait que sur un changement du modele).
Aussi : un role du site peut enfin en appeler un autre a travers la frontiere —
le devis traitait tout pair nomme comme l exterieur . Et nftables_admin_ssh se
DERIVE de la carte : recopie a la main, il retardait d une zone, et m a
verrouille dehors de la machine que je venais de creer.
Reste ouvert : le destinataire des alertes est encore root@localhost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 17:34:01 -04:00
|
|
|
"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),
|
2026-08-09 16:44:58 -04:00
|
|
|
"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},
|
2026-08-06 15:59:11 -04:00
|
|
|
"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},
|
2026-08-06 14:51:51 -04:00
|
|
|
"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]}")
|
2026-08-06 15:59:11 -04:00
|
|
|
for k, n in sorted(p["nat_creer"].items()):
|
|
|
|
|
print(f" + NAT sortant {n['interface']:<5} {n['source'][:30]:<30} "
|
|
|
|
|
f"-> {n['cible']}")
|
2026-08-09 16:44:58 -04:00
|
|
|
for k, r in sorted(p["routes_creer"].items()):
|
|
|
|
|
print(f" + route {r['reseau']:<18} -> {r['prochain_saut']:<14} "
|
|
|
|
|
f"{r['tenant']}")
|
2026-08-06 14:51:51 -04:00
|
|
|
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]}")
|
2026-08-06 15:59:11 -04:00
|
|
|
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'))}")
|
2026-08-09 16:44:58 -04:00
|
|
|
for k, x in sorted(p["routes_retirer"].items()):
|
|
|
|
|
print(f" - route PERIMEE {str(x.get('network')):<18} "
|
|
|
|
|
f"-> {str(x.get('gateway'))}")
|
2026-08-06 14:51:51 -04:00
|
|
|
for n in sorted(p["alias_retirer"]):
|
|
|
|
|
print(f" - alias ORPHELIN {n}")
|
supervision du site, et la panne qui dormait dans un mot
Le site a son temoin : site-mon-01 (VLAN 36) porte PostgreSQL, Icinga et un
relais. Le depot lui rapporte, et son premier verdict fut un vrai defaut —
site-mon-01 n avait jamais depose son propre etat. Les trois sont au vert.
serveur_backup_verification_locale repasse a true sur le depot : le drapeau ne
dit plus on renonce mais verifie ce que tu peux ouvrir . La liste des noeuds
attendus derive de l inventaire ou tourne le role, donc du site seul.
serveur_postfix gagne un mode relais : un site n heberge aucune boite, il
expedie. Directement par sa frontiere — emprunter le MTA d un locataire ferait
dependre l hebergeur d un ecosysteme qu il peut outvivre.
LA PANNE DE FOND : le modele de routes OPNsense lit enabled ; on lui envoyait
disabled=0, un champ ignore. enabled restait a son defaut, ETEINT. Chaque route
creee par Set-OPS depuis l origine l etait desactivee — invisible, parce qu une
route eteinte EXISTE dans le modele et compte posee . Le rechargement lance
pour activer la nouvelle patte a fait reprendre au noyau sa table depuis le
modele : 14 routes sur 15 disparues, 11 machines de Chezlepro injoignables le
lendemain de sa reconstruction, et le devis toujours vert.
Trois corrections empilees : la cause (enabled), la cecite (le plan lit la table
du NOYAU et signale absent ou eteint), l inaction (la reconfiguration ne se
declenchait que sur un changement du modele).
Aussi : un role du site peut enfin en appeler un autre a travers la frontiere —
le devis traitait tout pair nomme comme l exterieur . Et nftables_admin_ssh se
DERIVE de la carte : recopie a la main, il retardait d une zone, et m a
verrouille dehors de la machine que je venais de creer.
Reste ouvert : le destinataire des alertes est encore root@localhost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 17:34:01 -04:00
|
|
|
|
|
|
|
|
# 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.")
|
2026-08-09 16:44:58 -04:00
|
|
|
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"])
|
2026-08-06 15:59:11 -04:00
|
|
|
print(f"\n a creer : {creer} | a retirer : {retirer}"
|
2026-08-09 16:44:58 -04:00
|
|
|
f" | inchange : {len(p['regles_garder']) + len(p['nat_garder'])}"
|
|
|
|
|
f" + {len(p['routes_garder'])} routes")
|
2026-08-06 14:51:51 -04:00
|
|
|
return any(p[c] for c in ("alias_creer", "alias_majer", "alias_retirer",
|
2026-08-06 15:59:11 -04:00
|
|
|
"regles_creer", "regles_retirer",
|
2026-08-09 16:44:58 -04:00
|
|
|
"nat_creer", "nat_retirer",
|
|
|
|
|
"routes_creer", "routes_retirer"))
|
2026-08-06 14:51:51 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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}")
|
2026-08-06 15:59:11 -04:00
|
|
|
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}")
|
2026-08-06 14:51:51 -04:00
|
|
|
|
2026-08-09 16:44:58 -04:00
|
|
|
# 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}")
|
|
|
|
|
|
2026-08-06 14:51:51 -04:00
|
|
|
# 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}")
|
2026-08-06 15:59:11 -04:00
|
|
|
for k, x in sorted(p["nat_retirer"].items()):
|
|
|
|
|
_fait(api(f"/api/firewall/source_nat/del_rule/{x['uuid']}", {}), f"retrait nat {k}")
|
2026-08-06 14:51:51 -04:00
|
|
|
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
|
supervision du site, et la panne qui dormait dans un mot
Le site a son temoin : site-mon-01 (VLAN 36) porte PostgreSQL, Icinga et un
relais. Le depot lui rapporte, et son premier verdict fut un vrai defaut —
site-mon-01 n avait jamais depose son propre etat. Les trois sont au vert.
serveur_backup_verification_locale repasse a true sur le depot : le drapeau ne
dit plus on renonce mais verifie ce que tu peux ouvrir . La liste des noeuds
attendus derive de l inventaire ou tourne le role, donc du site seul.
serveur_postfix gagne un mode relais : un site n heberge aucune boite, il
expedie. Directement par sa frontiere — emprunter le MTA d un locataire ferait
dependre l hebergeur d un ecosysteme qu il peut outvivre.
LA PANNE DE FOND : le modele de routes OPNsense lit enabled ; on lui envoyait
disabled=0, un champ ignore. enabled restait a son defaut, ETEINT. Chaque route
creee par Set-OPS depuis l origine l etait desactivee — invisible, parce qu une
route eteinte EXISTE dans le modele et compte posee . Le rechargement lance
pour activer la nouvelle patte a fait reprendre au noyau sa table depuis le
modele : 14 routes sur 15 disparues, 11 machines de Chezlepro injoignables le
lendemain de sa reconstruction, et le devis toujours vert.
Trois corrections empilees : la cause (enabled), la cecite (le plan lit la table
du NOYAU et signale absent ou eteint), l inaction (la reconfiguration ne se
declenchait que sur un changement du modele).
Aussi : un role du site peut enfin en appeler un autre a travers la frontiere —
le devis traitait tout pair nomme comme l exterieur . Et nftables_admin_ssh se
DERIVE de la carte : recopie a la main, il retardait d une zone, et m a
verrouille dehors de la machine que je venais de creer.
Reste ouvert : le destinataire des alertes est encore root@localhost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 17:34:01 -04:00
|
|
|
# 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"):
|
2026-08-09 16:44:58 -04:00
|
|
|
print(" routes :", api("/api/routes/routes/reconfigure/", {}).get("status", "?"))
|
2026-08-06 14:51:51 -04:00
|
|
|
print(" alias :", api("/api/firewall/alias/reconfigure/", {}).get("status", "?"))
|
|
|
|
|
print(" regles :", str(api("/api/firewall/filter/apply/", {}).get("status", "?")).strip())
|
2026-08-06 15:59:11 -04:00
|
|
|
print(" nat :", str(api("/api/firewall/source_nat/apply/", {}).get("status", "?")).strip())
|
2026-08-06 14:51:51 -04:00
|
|
|
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")))
|
|
|
|
|
|
2026-08-23 00:47:32 -04:00
|
|
|
# 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"}
|
2026-08-06 14:51:51 -04:00
|
|
|
sortie = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_opnsense.py"),
|
|
|
|
|
"--json"], cwd=RACINE, capture_output=True, text=True,
|
2026-08-23 00:47:32 -04:00
|
|
|
env=env_devis)
|
2026-08-06 14:51:51 -04:00
|
|
|
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
|
frontiere : un boitier injoignable etait lu comme un boitier vide
L'exploitant lance `make frontiere-appliquer` dans son terminal : « a creer 0, inchange
53 + 15 routes — la frontiere dit deja ce que le devis dit ». Elle etait DEJA conforme.
J'avais annonce quelques heures plus tot « 89 a creer, 0 inchange, donc les regles
heritees sont invisibles a l'API ». FAUX. L'intrant `opnsense_api_url` pointait sur
10.0.0.1, l'adresse d'avant la migration du boitier : chaque lecture rendait
{"_erreur": ...}, le plan lisait `.get("rows")`, n'y trouvait rien, et concluait au vide.
Avec CONFIRMER, on aurait pousse une politique entiere EN DOUBLE.
TROISIEME FOIS EN UNE SOIREE, meme confusion — « pas de reponse » pris pour « rien » :
devis_placement iterait un dict d'erreur comme une liste
devis_underlay declarait morts les reseaux qu'il ne joignait pas
appliquer_opnsense lisait un boitier injoignable comme un boitier vide
Toutes les lectures de la frontiere passent desormais par une garde qui refuse en nommant
l'hote, la cause et le piege evite. Eprouvee contre l'ancienne adresse : elle refuse.
CE QUE CA DIT DE LA METHODE : ce n'est ni le harnais ni moi qui avons trouve, c'est
l'exploitant, en lancant la commande la ou il VOIT la sortie. Mes commandes s'executent
dans ma session ; il n'en voit rien. Une commande lente ressemble a un blocage, et un
blocage a une commande lente — j'ai conclu deux fois a tort avant qu'il ne regarde.
make verifier 41/41 ; le plan reel reste « rien a faire ».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:56:30 -04:00
|
|
|
(_lire(api, "/api/firewall/alias/searchItem/", {"rowCount": 1000}).get("rows") or [])}
|
supervision du site, et la panne qui dormait dans un mot
Le site a son temoin : site-mon-01 (VLAN 36) porte PostgreSQL, Icinga et un
relais. Le depot lui rapporte, et son premier verdict fut un vrai defaut —
site-mon-01 n avait jamais depose son propre etat. Les trois sont au vert.
serveur_backup_verification_locale repasse a true sur le depot : le drapeau ne
dit plus on renonce mais verifie ce que tu peux ouvrir . La liste des noeuds
attendus derive de l inventaire ou tourne le role, donc du site seul.
serveur_postfix gagne un mode relais : un site n heberge aucune boite, il
expedie. Directement par sa frontiere — emprunter le MTA d un locataire ferait
dependre l hebergeur d un ecosysteme qu il peut outvivre.
LA PANNE DE FOND : le modele de routes OPNsense lit enabled ; on lui envoyait
disabled=0, un champ ignore. enabled restait a son defaut, ETEINT. Chaque route
creee par Set-OPS depuis l origine l etait desactivee — invisible, parce qu une
route eteinte EXISTE dans le modele et compte posee . Le rechargement lance
pour activer la nouvelle patte a fait reprendre au noyau sa table depuis le
modele : 14 routes sur 15 disparues, 11 machines de Chezlepro injoignables le
lendemain de sa reconstruction, et le devis toujours vert.
Trois corrections empilees : la cause (enabled), la cecite (le plan lit la table
du NOYAU et signale absent ou eteint), l inaction (la reconfiguration ne se
declenchait que sur un changement du modele).
Aussi : un role du site peut enfin en appeler un autre a travers la frontiere —
le devis traitait tout pair nomme comme l exterieur . Et nftables_admin_ssh se
DERIVE de la carte : recopie a la main, il retardait d une zone, et m a
verrouille dehors de la machine que je venais de creer.
Reste ouvert : le destinataire des alertes est encore root@localhost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 17:34:01 -04:00
|
|
|
# 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"):
|
2026-08-06 14:51:51 -04:00
|
|
|
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())
|