diff --git a/CHANGELOG.md b/CHANGELOG.md index 585d220..16ef2fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # CHANGELOG — Set-OPS +## 2026-08-23 — Un boîtier injoignable n'est pas un boîtier vide + +L'exploitant lance `make frontiere-appliquer` dans son propre terminal. Résultat : + +``` +Frontiere https://10.17.0.1 — 50 regles au devis + a creer : 0 | a retirer : 0 | inchange : 53 + 15 routes + La frontiere dit deja ce que le devis dit. Rien a faire. +``` + +**La frontière était déjà conforme.** Or j'avais annoncé, quelques heures plus tôt, +« 89 objets à créer, 0 inchangé — donc les règles héritées sont invisibles à l'API ». +C'était faux, et la cause était ailleurs. + +### Ce qui s'était réellement passé + +L'intrant `opnsense_api_url` pointait encore sur `10.0.0.1`, l'adresse d'avant la +migration du boîtier. Chaque lecture échouait donc et rendait `{"_erreur": …}`. Le plan +lisait `.get("rows")`, n'y trouvait rien — et concluait que **la frontière était vide**. + +Avec `CONFIRMER=true`, on aurait poussé une politique entière **en double** sur un boîtier +qui la portait déjà. + +Et l'adresse avait pu rester périmée parce qu'elle était rangée au mauvais endroit : dans +les `group_vars` d'un tenant, alors qu'elle décrit un boîtier que ce tenant ne possède +pas. `opnsense.yml` vit désormais dans le dépôt de site, avec `underlay.yml`. + +### La troisième fois en une soirée + +| | | +|---|---| +| `devis_placement` | itérait un dict d'erreur comme une liste → trace Python illisible | +| `devis_underlay` | déclarait morts les réseaux qu'il ne joignait pas depuis le poste | +| `appliquer_opnsense` | lisait un boîtier injoignable comme un boîtier vide | + +Trois formes d'une même confusion : **« pas de réponse » pris pour « rien »**. Toutes les +lectures de la frontière passent maintenant par une garde qui refuse en nommant l'hôte, la +cause, et le piège qu'elle évite. Éprouvée contre l'ancienne adresse : elle refuse. + +### Ce que ça dit de la méthode + +Ce n'est pas le harnais qui a trouvé le défaut, ni moi. C'est l'exploitant, en lançant la +commande **dans son terminal**, où il voit la sortie en direct. Mes commandes s'exécutent +dans ma session : il n'en voit rien. Une commande lente ressemble alors à un blocage, et +un blocage à une commande lente — j'ai conclu deux fois à tort avant qu'il ne regarde +lui-même. + +> **Pour toute écriture longue sur du matériel, c'est à l'exploitant de lancer la +> commande.** Non par prudence formelle : parce qu'il est le seul à voir ce qui se passe. + + ## 2026-08-22 — Le moteur supposait l'écosystème complet Troisième manifestation de la même hypothèse en cinq jours, et cette fois elle **bloquait diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index 517dd87..eb5fcb3 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -148,7 +148,7 @@ def _passerelle_pour(api: Frontiere, saut: str) -> str | None: 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 (api("/api/routes/gateway/status").get("items") or []): + 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 @@ -198,19 +198,49 @@ def _contenu(x) -> set[str]: return {v.strip() for v in str(x or "").replace(",", "\n").split("\n") if v.strip()} +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 (api("/api/firewall/filter/search_rule/", - {"current": 1, "rowCount": 1000}).get("rows") or []): + 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 (api("/api/firewall/alias/searchItem/", - {"rowCount": 1000}).get("rows") or []) + 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 @@ -224,7 +254,7 @@ def plan(api: Frontiere, devis: dict) -> dict: nat_voulus = {cle_nat(n): n for n in devis.get("nat") or []} nat_poses = {} - for x in (api("/api/firewall/source_nat/search_rule/", + 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:"): @@ -232,7 +262,7 @@ def plan(api: Frontiere, devis: dict) -> dict: rt_voulues = {cle_route(r): r for r in devis.get("routes") or []} rt_posees = {} - for x in (api("/api/routes/routes/searchroute/", + 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:"): @@ -400,7 +430,7 @@ def main() -> int: print(f"Frontiere {url} — {len(devis['regles'])} regles au devis\n") p = plan(api, devis) p["_uuid_alias"] = {x["name"]: x["uuid"] for x in - (api("/api/firewall/alias/searchItem/", {"rowCount": 1000}).get("rows") or [])} + (_lire(api, "/api/firewall/alias/searchItem/", {"rowCount": 1000}).get("rows") or [])} if not afficher(p): print("\n La frontiere dit deja ce que le devis dit. Rien a faire.") return 0