#!/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 RACINE = Path(__file__).resolve().parent.parent RELEVE = RACINE / "instance" / "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:]))