Deuxieme application du patron devis/applicateur aux services. Trouve a la premiere execution : sur infra-pki-01 — l'autorite elle-meme — le certificat etait expire depuis plus de 8 h et le renouvellement echouait toutes les 14 minutes sur « 'step ca renew' requires the '--ca-url' flag ». Rien ne le signalait. Cause : sur l'hote de l'AC, /etc/step est le STEPPATH du SERVEUR, pas un amorcage client — pas de defaults.json, et l'unite de renouvellement en dependait. La lecon etait deja ecrite dans le commentaire de la tache d'emission (« l'autorite ne bootstrape pas »), jamais reportee sur l'unite. Le role ne pouvait pas non plus se soigner : la re-emission ne regardait que la FORME (cert absent ou SAN manquant), jamais la validite. client_pki verifie desormais l'echeance (client_pki_marge_renouvellement). Ce qu'il a fallu desapprendre : les certificats vivent 24 h et se renouvellent toutes les ~14 min ; « empreinte servie != empreinte disque » est l'etat NORMAL. Comparer les empreintes aurait donne un verificateur qui crie en permanence. Le signal est l'echeance de ce qui est SERVI, plus l'absence de client_pki_reload_services. Le devis a d'abord menti, du defaut meme qu'il traque : include_vars au niveau du play prime sur les group_vars. Et le premier correctif a PARU marcher — set_fact accepte un dictionnaire entier en argument libre sans erreur et n'en fait rien. Il faut reimposer cle par cle. Les deux devis sont corriges et le piege est consigne dans docs/devis-services.md avant d'ecrire le prochain. Verifie dans les deux sens : CONFORME sur 14 hotes ; sur un releve ou l'on rejoue une copie perimee en memoire, 2 ecarts et code de sortie 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
5.1 KiB
Python
Executable file
129 lines
5.1 KiB
Python
Executable file
#!/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:]))
|