Set-OPS-Public/scripts/devis_expositions.py
Daniel Allaire 0cc04177fb devis des expositions : une vraie requete, depuis deux points de vue
Troisieme devis de service. Chaque expose: du plan repond-il, et si non, ou
ca casse.

- requete HTTPS complete avec la racine de l'AC, jamais un connect() : a
  travers l'OPNsense (anti-spoofing) toute connexion TCP reussit, et en TLS
  le silence apres connect() ne distingue pas un service sain d'un trou.
- deux points de vue : depuis l'edge (edge + dorsal) et depuis le poste
  (DNS + frontiere + edge + dorsal). Leur difference diagnostique.
- un code n'est pas un verdict : mon premier comparateur laissait passer un
  502 des deux cotes. Trouve par le test negatif, pas par la relecture.

Etat : les 6 expositions repondent des deux cotes. Test negatif (frontiere
qui bloque + dorsal tombe) : 2 ecarts nommes distinctement, code 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 07:33:49 -04:00

103 lines
4.1 KiB
Python
Executable file

#!/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:]))