Set-OPS-Public/scripts/devis_expositions.py

104 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 aucune machine n'existe. Et « lire
des données après connexion » ne vaut rien en TLS, 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:]))