130 lines
5.1 KiB
Python
130 lines
5.1 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Devis des certificats : ce que le disque porte contre ce que la mémoire sert.
|
||
|
|
|
||
|
|
Lit les relevés déposés par `playbooks/maintenance/devis-certificats.yml`. Aucun accès
|
||
|
|
réseau ici. Sort en code 1 s'il y a un écart.
|
||
|
|
|
||
|
|
Le défaut traqué est connu et récurrent (voir `docs/autorisation.md` et le journal) : un
|
||
|
|
certificat renouvelé SUR DISQUE mais toujours servi PÉRIMÉ depuis la mémoire du service,
|
||
|
|
tant que nginx / postfix / dovecot / slapd n'ont pas été rechargés. Rien ne le signale —
|
||
|
|
le fichier est bon, `step` est content, et le client voit un certificat expiré.
|
||
|
|
|
||
|
|
**Ce que ce devis a appris en se construisant.** Les certificats step-ca vivent 24 h et le
|
||
|
|
minuteur les renouvelle toutes les ~14 min. Une empreinte servie DIFFÉRENTE de l'empreinte
|
||
|
|
sur disque est donc l'état NORMAL, pas un défaut : comparer les empreintes aurait produit
|
||
|
|
un vérificateur qui crie en permanence. Le signal utile est ailleurs :
|
||
|
|
|
||
|
|
1. l'échéance de ce qui est RÉELLEMENT SERVI — c'est ça qui tombe ;
|
||
|
|
2. un hôte qui sert un certificat sans déclarer aucun service à recharger : rien ne
|
||
|
|
rafraîchira jamais sa copie en mémoire.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import glob
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
RACINE = Path(__file__).resolve().parent.parent
|
||
|
|
MOTIF = str(RACINE / "instance" / "devis-certificats.json.*")
|
||
|
|
|
||
|
|
# Un certificat servi qui expire dans moins que ça n'a plus de marge : le service doit
|
||
|
|
# etre recharge avant. Genereux par rapport au renouvellement (~14 min), serre par
|
||
|
|
# rapport a la duree de vie (24 h).
|
||
|
|
SEUIL_HEURES = 6
|
||
|
|
|
||
|
|
|
||
|
|
def _champ(texte: str, cle: str) -> str:
|
||
|
|
m = re.search(rf"^{re.escape(cle)}=(.*)$", texte, re.M)
|
||
|
|
return m.group(1).strip() if m else ""
|
||
|
|
|
||
|
|
|
||
|
|
def _echeance(valeur: str) -> datetime | None:
|
||
|
|
try:
|
||
|
|
return datetime.strptime(valeur, "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
|
||
|
|
except ValueError:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _servis(texte: str) -> list[dict]:
|
||
|
|
blocs, courant = [], None
|
||
|
|
for ligne in texte.splitlines():
|
||
|
|
if ligne.startswith("PORT="):
|
||
|
|
courant = {"port": ligne[5:], "brut": ""}
|
||
|
|
blocs.append(courant)
|
||
|
|
elif courant is not None:
|
||
|
|
courant["brut"] += ligne + "\n"
|
||
|
|
for b in blocs:
|
||
|
|
b["subject"] = _champ(b["brut"], "subject")
|
||
|
|
b["fin"] = _echeance(_champ(b["brut"], "notAfter"))
|
||
|
|
m = re.search(r"Fingerprint=([0-9A-F:]+)", b["brut"])
|
||
|
|
b["empreinte"] = m.group(1) if m else ""
|
||
|
|
return blocs
|
||
|
|
|
||
|
|
|
||
|
|
def analyser(releves: list[dict]) -> tuple[list[str], list[str]]:
|
||
|
|
ecarts, notes = [], []
|
||
|
|
maintenant = datetime.now(timezone.utc)
|
||
|
|
|
||
|
|
for r in sorted(releves, key=lambda x: x["hote"]):
|
||
|
|
hote, recharges = r["hote"], r.get("recharges_declares") or []
|
||
|
|
|
||
|
|
if r.get("disque_rc", 1) != 0 or not r.get("disque"):
|
||
|
|
ecarts.append(f"{hote}: aucun certificat lisible en {r.get('chemin', '?')}")
|
||
|
|
continue
|
||
|
|
|
||
|
|
fin_disque = _echeance(_champ(r["disque"], "notAfter"))
|
||
|
|
emp_disque = (re.search(r"Fingerprint=([0-9A-F:]+)", r["disque"]) or [None, ""])[1]
|
||
|
|
if fin_disque and (fin_disque - maintenant).total_seconds() < SEUIL_HEURES * 3600:
|
||
|
|
reste = (fin_disque - maintenant).total_seconds() / 3600
|
||
|
|
ecarts.append(f"{hote}: le certificat SUR DISQUE expire dans {reste:.1f} h "
|
||
|
|
f"— le renouvellement automatique ne fait plus son travail")
|
||
|
|
|
||
|
|
for s in _servis(r["servis"]):
|
||
|
|
if not s["fin"]:
|
||
|
|
continue
|
||
|
|
reste = (s["fin"] - maintenant).total_seconds() / 3600
|
||
|
|
perime = s["empreinte"] != emp_disque
|
||
|
|
if reste < SEUIL_HEURES:
|
||
|
|
ecarts.append(
|
||
|
|
f"{hote}:{s['port']} sert un certificat qui expire dans {reste:.1f} h"
|
||
|
|
+ (f" — recharger {', '.join(recharges)}" if recharges
|
||
|
|
else " — et AUCUN service a recharger n'est declare"))
|
||
|
|
elif perime and not recharges:
|
||
|
|
notes.append(
|
||
|
|
f"{hote}:{s['port']} sert une copie plus ancienne que le disque, et "
|
||
|
|
f"aucun `client_pki_reload_services` n'est declare : rien ne la "
|
||
|
|
f"rafraichira jamais")
|
||
|
|
return ecarts, notes
|
||
|
|
|
||
|
|
|
||
|
|
def main(argv: list[str] | None = None) -> int:
|
||
|
|
fichiers = sorted(glob.glob(argv[0] if argv else MOTIF))
|
||
|
|
if not fichiers:
|
||
|
|
print("Relevés absents. Lancer d'abord : make certificats-plan", file=sys.stderr)
|
||
|
|
return 2
|
||
|
|
|
||
|
|
releves = [json.loads(Path(f).read_text(encoding="utf-8")) for f in fichiers]
|
||
|
|
ecarts, notes = analyser(releves)
|
||
|
|
print(f"Devis des certificats — {len(releves)} hôte(s), seuil {SEUIL_HEURES} h\n")
|
||
|
|
|
||
|
|
for n in notes:
|
||
|
|
print(f" [latent] {n}")
|
||
|
|
if notes and ecarts:
|
||
|
|
print()
|
||
|
|
for e in ecarts:
|
||
|
|
print(f" [écart] {e}")
|
||
|
|
|
||
|
|
if not ecarts:
|
||
|
|
print(("\n" if notes else "") + "CONFORME : aucun certificat servi n'est en fin de vie.")
|
||
|
|
return 0
|
||
|
|
print(f"\nÉCART : {len(ecarts)} certificat(s) en difficulté.")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main(sys.argv[1:]))
|