Set-OPS-Public/scripts/devis_expositions.py

106 lines
4.1 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Devis des expositions web : chaque `expose:` du plan répond-il vraiment ?
Lit le relevé déposé par `playbooks/maintenance/devis-expositions.yml`. Aucun accès réseau
ici. Sort en code 1 s'il y a un écart.
**Une vraie requête, jamais un `connect()`.** À travers l'OPNsense (anti-spoofing), toute
connexion TCP réussit — y compris vers une adresse où aucune machine n'existe. Et « lire
des données après connexion » ne vaut rien en TLS, où c'est le *client* qui parle en
premier : le port 443 d'un edge sain se comporte exactement comme un port mort. Seul le
code de retour d'une requête HTTPS complète tranche.
**Deux points de vue, et c'est leur différence qui diagnostique :**
depuis l'edge — edge + dorsal, sans la frontière
depuis le poste — DNS + frontière + edge + dorsal, le chemin d'un vrai utilisateur
L'un répond et pas l'autre : ce n'est pas le service, c'est le chemin.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
resolution d'instance : une seule, partagee — au lieu de neuf copies Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? » Neuf modules portaient chacun leur reponse. - 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ; - 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et _frontiere_absente lisaient le symlink au lieu de la variable ; - 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ; - 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme. Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier. LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(), dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve — c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut. Vingt-huit modules y sont branches. CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure : 17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose. P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees : instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de la resolution. make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent
resolution d'instance : une seule, partagee — au lieu de neuf copies Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? » Neuf modules portaient chacun leur reponse. - 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ; - 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et _frontiere_absente lisaient le symlink au lieu de la variable ; - 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ; - 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme. Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier. LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(), dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve — c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut. Vingt-huit modules y sont branches. CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure : 17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose. P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees : instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de la resolution. make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
RELEVE = instance_courante() / "devis-expositions.json"
# Un service VIVANT peut legitimement repondre autre chose que 200 : une redirection vers
# l'IdP, ou un 401/403 quand l'authentification est exigee. Ce qui compte est qu'il ait
# repondu quelque chose d'intelligible.
VIVANT = {200, 301, 302, 303, 307, 401, 403}
def _lire(paires: list) -> dict[str, dict]:
"""Le relevé associe chaque résultat à son FQDN (zip côté Ansible)."""
out = {}
for resultat, fqdn in paires:
out[fqdn] = resultat
return out
def _verdict(r: dict) -> tuple[int, bool, str]:
"""Rend (code affiché, vivant ?, message).
Un code n'est pas un verdict : un 502 ou un 504 est une reponse, et pourtant le
service est mort derriere. Le premier jet ne testait que la PRESENCE d'un code et
laissait donc passer un dorsal tombe — trouve par le test negatif, pas par la
lecture. Le 2026-08-08.
"""
code = r.get("status", 0)
if not isinstance(code, int) or code <= 0:
return 0, False, (r.get("msg") or "aucune réponse")[:90]
return code, code in VIVANT, (r.get("msg") or "")[:90]
def analyser(releve: dict) -> list[str]:
edge, poste, ecarts = _lire(releve["depuis_edge"]), _lire(releve["poste"]), []
largeur = max((len(f) for f in releve["expositions"]), default=10)
print(f" {'exposition':<{largeur}} edge poste")
for fqdn in releve["expositions"]:
ce, ve, me = _verdict(edge.get(fqdn, {}))
cp, vp, mp = _verdict(poste.get(fqdn, {}))
print(f" {fqdn:<{largeur}} {ce or '---':<6} {cp or '---'}")
if not ve and not vp:
ecarts.append(f"{fqdn} : en echec des DEUX cotes ({ce or 'muet'}) — le service "
f"lui-meme ne repond pas. {me}")
elif ve and not vp:
ecarts.append(f"{fqdn} : repond depuis l'edge ({ce}) mais PAS depuis le poste "
f"({cp or 'muet'}) — ce n'est pas le service, c'est le chemin "
f"(DNS ou frontiere). {mp}")
elif vp and not ve:
ecarts.append(f"{fqdn} : repond depuis le poste ({cp}) mais pas depuis l'edge "
f"({ce or 'muet'}) — la resolution interne diverge de la publique. {me}")
return ecarts
def main(argv: list[str] | None = None) -> int:
chemin = Path(argv[0]) if argv else RELEVE
if not chemin.exists():
print("Relevé absent. Lancer d'abord : make expositions-plan", file=sys.stderr)
return 2
releve = json.loads(chemin.read_text(encoding="utf-8"))
print(f"Devis des expositions — {len(releve['expositions'])} déclarée(s) au plan, "
f"edge {releve['edge']}\n")
ecarts = analyser(releve)
if not ecarts:
print("\nCONFORME : toute exposition déclarée répond, des deux points de vue.")
return 0
print()
for e in ecarts:
print(f" [écart] {e}")
print(f"\nÉCART : {len(ecarts)} exposition(s) en difficulté.")
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))