L'historique d'Icinga DB etait exclu de la restauration (2026-09-30) : son environnement derive de l'AC d'Icinga, recreee a chaque reconstruction. L'AC est desormais un jeu de sauvegarde, remis avant api setup ; la base d'Icinga est restauree, et son schema n'est importe que sur une base vierge. Les temoins comparaient PostgreSQL par nombre de lignes et n'ont pas vu 412 lignes d'historique remplacees par 380 neuves. Ils comparent desormais par cle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
16 KiB
Python
346 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
# Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main.
|
|
"""Témoins d'état : l'état VIVANT de ce noeud est-il celui qu'il avait avant le rasage ?
|
|
|
|
POURQUOI (2026-10-07). Le bilan d'une reconstruction dit ce que chaque rôle a FAIT
|
|
(« restauré depuis tel instantané ») : c'est le système qui juge son propre travail. Le
|
|
2026-09-30, les empreintes avaient été relevées à la main, avant et après ; rien ne le
|
|
gardait dans le dépôt. Le 2026-10-07 (M4), l'instantané d'avant le rasage a même disparu
|
|
du dépôt dans l'heure, chassé par la rétention : il ne restait rien à quoi comparer.
|
|
|
|
Ce script compare l'instantané extrait par `setops-restaurer temoins` (sous `--avant`) à ce
|
|
qui vit sur le noeud. Il ne lit que ; il n'écrit rien.
|
|
|
|
VERDICTS, PAR JEU :
|
|
IDENTIQUE rien ne diffère ;
|
|
CONFORME rien de perdu, l'identité est intacte ; des changements ordinaires, listés
|
|
(un courriel reçu depuis, une table de sessions, le bayes de rspamd) ;
|
|
ECART une PERTE (fichier, entrée, base, table), ou un objet d'IDENTITÉ changé :
|
|
clés et certificats de l'AC, AC d'Icinga et son environnement, clé DKIM,
|
|
`instanceid` de Nextcloud, mot de passe d'une entrée de l'annuaire ; une
|
|
base dont plus aucune ligne d'avant ne subsiste ; une ligne d'historique
|
|
perdue. Exactement ce que la reconstruction du
|
|
2026-09-30 avait perdu sans que rien ne le dise.
|
|
|
|
Code de sortie : 0 sans écart, 1 avec au moins un écart, 2 erreur d'usage.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import collections
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
LISTE_MAX = 8 # détails affichés par catégorie ; le compte, lui, est toujours entier
|
|
|
|
# Attributs que l'annuaire tient lui-même : ils changent à chaque écriture, ou à chaque
|
|
# rejeu (`slapadd` les repose), sans que la donnée ait bougé.
|
|
OPERATIONNELS = {"entrycsn", "modifytimestamp", "modifiersname", "createtimestamp",
|
|
"creatorsname", "contextcsn"}
|
|
|
|
|
|
class Rapport:
|
|
def __init__(self, jeu: str) -> None:
|
|
self.jeu = jeu
|
|
self.ecarts: list[str] = [] # pertes et identités changées
|
|
self.changes: list[str] = [] # changements ordinaires
|
|
self.retires: list[str] = [] # disparitions ordinaires (un cache, une base compactee)
|
|
self.nouveaux: list[str] = []
|
|
self.compte = 0 # objets comparés
|
|
|
|
def verdict(self) -> str:
|
|
if self.ecarts:
|
|
return "ECART"
|
|
return "CONFORME" if (self.changes or self.retires or self.nouveaux) else "IDENTIQUE"
|
|
|
|
def lignes(self, unite: str) -> list[str]:
|
|
out = [f" {self.jeu:<12} {self.verdict():<10} {self.compte} {unite}"]
|
|
for titre, liste in (("ECART", self.ecarts), ("modifie", self.changes),
|
|
("retire", self.retires), ("nouveau", self.nouveaux)):
|
|
for x in liste[:LISTE_MAX]:
|
|
out.append(f" {titre:<8} {x}")
|
|
if len(liste) > LISTE_MAX:
|
|
out.append(f" {titre:<8} ... et {len(liste) - LISTE_MAX} autre(s)")
|
|
return out
|
|
|
|
|
|
def empreinte(p: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with p.open("rb") as f:
|
|
for bloc in iter(lambda: f.read(1 << 20), b""):
|
|
h.update(bloc)
|
|
return h.hexdigest()
|
|
|
|
|
|
def arbre(racine: Path, normaliser=None) -> dict[str, str]:
|
|
"""{chemin relatif (normalisé) : sha256} des fichiers réguliers sous `racine`."""
|
|
if racine.is_file():
|
|
return {racine.name: empreinte(racine)}
|
|
out: dict[str, str] = {}
|
|
if not racine.is_dir():
|
|
return out
|
|
for p in sorted(racine.rglob("*")):
|
|
if p.is_file() and not p.is_symlink():
|
|
rel = p.relative_to(racine).as_posix()
|
|
out[normaliser(rel) if normaliser else rel] = empreinte(p)
|
|
return out
|
|
|
|
|
|
def comparer_arbres(r: Rapport, avant: dict[str, str], vivant: dict[str, str], *,
|
|
strict, perte_stricte) -> None:
|
|
"""`strict(rel)` : un changement est un écart ; `perte_stricte(rel)` : une perte aussi."""
|
|
r.compte += len(avant)
|
|
for rel, h in sorted(avant.items()):
|
|
if rel not in vivant:
|
|
if perte_stricte(rel):
|
|
r.ecarts.append(f"perdu : {rel}")
|
|
else:
|
|
r.retires.append(rel)
|
|
elif vivant[rel] != h:
|
|
(r.ecarts if strict(rel) else r.changes).append(f"{'IDENTITE ' if strict(rel) else ''}{rel}")
|
|
r.nouveaux.extend(sorted(set(vivant) - set(avant)))
|
|
|
|
|
|
def jamais(_rel: str) -> bool:
|
|
return False
|
|
|
|
|
|
def toujours(_rel: str) -> bool:
|
|
return True
|
|
|
|
|
|
# --- Les jeux de fichiers ------------------------------------------------------------
|
|
|
|
def maildir(rel: str) -> str:
|
|
"""Un message garde son nom de base : `new/` -> `cur/` à la lecture, `:2,S` aux drapeaux."""
|
|
rel = re.sub(r"(^|/)(new|cur)/", r"\1msg/", rel)
|
|
return re.sub(r":2,[A-Za-z]*$", "", rel)
|
|
|
|
|
|
def est_message(rel: str) -> bool:
|
|
return "/msg/" in f"/{rel}"
|
|
|
|
|
|
def jeu_fichiers(jeu: str, chemins: list[str], avant: Path) -> Rapport:
|
|
r = Rapport(jeu)
|
|
for c in chemins:
|
|
a_avant, a_vivant = avant / c.lstrip("/"), Path(c)
|
|
if jeu == "step_ca":
|
|
# Les clés et les certificats SONT l'AC : un octet de différence et plus rien
|
|
# de ce qu'elle a signé ne se vérifie. Sa base (badger) se compacte d'elle-même.
|
|
identite = lambda rel: rel.startswith(("certs/", "secrets/")) # noqa: E731
|
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=identite,
|
|
perte_stricte=identite)
|
|
elif jeu == "icinga":
|
|
# L'AC d'Icinga et l'environnement qu'elle derive : changes, chaque identifiant
|
|
# d'Icinga DB change, et tout l'historique restaure devient orphelin.
|
|
identite = lambda rel: rel in ("ca.crt", "ca.key", "icingadb.env") # noqa: E731
|
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=identite,
|
|
perte_stricte=identite)
|
|
elif jeu == "courriel":
|
|
comparer_arbres(r, arbre(a_avant, maildir), arbre(a_vivant, maildir),
|
|
strict=jamais, perte_stricte=est_message)
|
|
elif jeu == "rspamd":
|
|
# La clé DKIM est l'identité du domaine ; le bayes, lui, apprend tous les jours.
|
|
dkim = lambda rel: rel.startswith("dkim/") # noqa: E731
|
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=dkim, perte_stricte=dkim)
|
|
elif jeu == "nextcloud":
|
|
if c.rstrip("/").endswith("/config"):
|
|
instance_id(r, a_avant / "config.php", a_vivant / "config.php")
|
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
|
|
perte_stricte=jamais)
|
|
else:
|
|
# Les fichiers des PERSONNES ; `appdata_*` est un cache que Nextcloud refait.
|
|
fichier = lambda rel: re.match(r"^(?!appdata_)[^/]+/files/", rel) is not None # noqa: E731
|
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
|
|
perte_stricte=fichier)
|
|
else:
|
|
comparer_arbres(r, arbre(a_avant), arbre(a_vivant), strict=jamais,
|
|
perte_stricte=toujours)
|
|
return r
|
|
|
|
|
|
def instance_id(r: Rapport, avant: Path, vivant: Path) -> None:
|
|
def lire(p: Path) -> str:
|
|
m = re.search(r"'instanceid'\s*=>\s*'([^']+)'", p.read_text(errors="replace")) if p.is_file() else None
|
|
return m.group(1) if m else ""
|
|
a, v = lire(avant), lire(vivant)
|
|
if a and a != v:
|
|
r.ecarts.append(f"IDENTITE instanceid : {a} -> {v or '(absent)'}")
|
|
|
|
|
|
# --- L'annuaire ----------------------------------------------------------------------
|
|
|
|
def entrees_ldif(texte: str) -> dict[str, dict[str, list[str]]]:
|
|
"""{dn : {attribut : [valeurs triées]}}, attributs opérationnels exclus."""
|
|
deplie = re.sub(r"\n ", "", texte.replace("\r\n", "\n"))
|
|
out: dict[str, dict[str, list[str]]] = {}
|
|
for bloc in deplie.split("\n\n"):
|
|
attrs: dict[str, list[str]] = {}
|
|
dn = ""
|
|
for ligne in bloc.splitlines():
|
|
if not ligne or ligne.startswith("#") or ":" not in ligne:
|
|
continue
|
|
nom, _, val = ligne.partition(":")
|
|
if val.startswith(":"):
|
|
val = base64.b64decode(val[1:].strip()).decode("utf-8", "replace")
|
|
else:
|
|
val = val.strip()
|
|
if nom.lower() == "dn":
|
|
dn = val.lower()
|
|
elif nom.lower() not in OPERATIONNELS:
|
|
attrs.setdefault(nom.lower(), []).append(val)
|
|
if dn:
|
|
out[dn] = {k: sorted(v) for k, v in attrs.items()}
|
|
return out
|
|
|
|
|
|
def jeu_annuaire(avant_ldif: Path, vivant_ldif: Path) -> Rapport:
|
|
r = Rapport("openldap")
|
|
if not avant_ldif.is_file():
|
|
r.ecarts.append(f"l'instantane n'emporte pas d'annuaire ({avant_ldif.name})")
|
|
return r
|
|
a = entrees_ldif(avant_ldif.read_text(errors="replace"))
|
|
v = entrees_ldif(vivant_ldif.read_text(errors="replace")) if vivant_ldif.is_file() else {}
|
|
r.compte = len(a)
|
|
for dn, attrs in sorted(a.items()):
|
|
if dn not in v:
|
|
r.ecarts.append(f"perdue : {dn}")
|
|
continue
|
|
if attrs == v[dn]:
|
|
continue
|
|
# Le mot de passe d'une entrée, c'est ce que la reconstruction du 2026-09-30 avait
|
|
# remis à la valeur d'amorçage sans que rien ne le dise.
|
|
if attrs.get("userpassword") != v[dn].get("userpassword"):
|
|
r.ecarts.append(f"IDENTITE userPassword : {dn}")
|
|
diff = sorted(k for k in set(attrs) | set(v[dn])
|
|
if k != "userpassword" and attrs.get(k) != v[dn].get(k))
|
|
if diff:
|
|
r.changes.append(f"{dn} ({', '.join(diff)})")
|
|
r.nouveaux.extend(sorted(set(v) - set(a)))
|
|
return r
|
|
|
|
|
|
# --- PostgreSQL ----------------------------------------------------------------------
|
|
|
|
def lire_dump(texte: str) -> tuple[set[str], set[str], dict[tuple[str, str, str], list[str]]]:
|
|
"""(bases, rôles, {(base, table, 1re colonne) : [valeurs]}) d'après un `pg_dumpall`."""
|
|
bases = set(re.findall(r"^CREATE DATABASE (\S+) ", texte, re.M))
|
|
roles = set(re.findall(r"^CREATE ROLE (\S+);", texte, re.M))
|
|
cles: dict[tuple[str, str, str], list[str]] = {}
|
|
base, table = "", None
|
|
for ligne in texte.splitlines():
|
|
if table is not None:
|
|
if ligne == "\\.":
|
|
table = None
|
|
else:
|
|
cles[table].append(ligne.split("\t", 1)[0])
|
|
elif ligne.startswith("\\connect "):
|
|
base = ligne.split()[-1]
|
|
else:
|
|
m = re.match(r"^COPY (\S+) \(([^,)]+)[,)].*FROM stdin;$", ligne)
|
|
if m:
|
|
table = (base, m.group(1), m.group(2).strip())
|
|
cles[table] = []
|
|
return bases, roles, cles
|
|
|
|
|
|
def psql(base: str, sql: str) -> list[str] | None:
|
|
r = subprocess.run(["runuser", "-u", "postgres", "--", "psql", "-tAX", "-d", base, "-c", sql],
|
|
capture_output=True, text=True, env=dict(os.environ, PAGER="cat"))
|
|
return r.stdout.splitlines() if r.returncode == 0 else None
|
|
|
|
|
|
def jeu_postgresql(dump: Path, interroger=psql) -> Rapport:
|
|
"""Par CLÉ, pas par nombre de lignes (2026-10-07).
|
|
|
|
Compter ne voyait rien : l'historique d'Icinga DB, entièrement remplacé (412 lignes
|
|
d'avant, toutes perdues ; 380 neuves), passait pour « 412 -> 380 ». On compare donc la
|
|
première colonne de chaque table, lue au même format que le dump (`COPY ... TO STDOUT`).
|
|
|
|
Ce qui est un ÉCART, d'après la mesure sur Chezlepro (avant-raser contre vivant) :
|
|
- une base dont AUCUNE ligne d'avant ne subsiste, toutes tables confondues : elle n'a
|
|
pas été restaurée (c'était icingadb, ses 30 tables renouvelées) ;
|
|
- une ligne perdue dans une table d'HISTORIQUE (`*history*`) : par nature, on y
|
|
ajoute, on n'y retire pas.
|
|
Le reste est listé : la découverte de grappe de Keycloak (`jgroups_ping`), ses sessions,
|
|
une configuration de composant régénérée renouvellent leurs clés dans la vie ordinaire.
|
|
"""
|
|
r = Rapport("postgresql")
|
|
if not dump.is_file():
|
|
r.ecarts.append(f"l'instantane n'emporte pas de dump ({dump.name})")
|
|
return r
|
|
bases, roles, cles = lire_dump(dump.read_text(errors="replace"))
|
|
vivantes = set(interroger("postgres", "select datname from pg_database") or [])
|
|
roles_vivants = set(interroger("postgres", "select rolname from pg_roles") or [])
|
|
for b in sorted(bases - vivantes):
|
|
r.ecarts.append(f"base perdue : {b}")
|
|
for x in sorted(roles - roles_vivants):
|
|
r.ecarts.append(f"role perdu : {x}")
|
|
par_base: dict[str, list[int]] = collections.defaultdict(lambda: [0, 0])
|
|
for (b, t, col), avant in sorted(cles.items()):
|
|
if b not in vivantes or not avant:
|
|
continue
|
|
r.compte += 1
|
|
vivant = interroger(b, f"COPY (select {col} from {t}) TO STDOUT")
|
|
if vivant is None:
|
|
r.ecarts.append(f"table perdue : {b}.{t}")
|
|
continue
|
|
a, v = set(avant), set(vivant)
|
|
communes = len(a & v)
|
|
par_base[b][0] += len(a)
|
|
par_base[b][1] += communes
|
|
perdues, nouvelles = len(a) - communes, len(v - a)
|
|
if perdues and "history" in t:
|
|
r.ecarts.append(f"historique perdu : {b}.{t} : {perdues} ligne(s) d'avant sur {len(a)}")
|
|
elif perdues or nouvelles:
|
|
r.changes.append(f"{b}.{t} : {perdues} retiree(s), {nouvelles} nouvelle(s) (avant {len(a)})")
|
|
for b, (n, communes) in sorted(par_base.items()):
|
|
if n and not communes:
|
|
r.ecarts.insert(0, f"base {b} : aucune des {n} ligne(s) d'avant ne subsiste — non restauree")
|
|
# `template0` n'est jamais dans un `pg_dumpall` : il n'est pas « nouveau », il est hors jeu.
|
|
r.nouveaux.extend(f"base {b}" for b in sorted(vivantes - bases - {"postgres", "template0"}))
|
|
return r
|
|
|
|
|
|
# --- Programme -----------------------------------------------------------------------
|
|
|
|
def main(argv: list[str] | None = None, interroger=psql) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--avant", required=True, type=Path, help="racine où l'instantané est extrait")
|
|
ap.add_argument("--instantane", required=True)
|
|
ap.add_argument("--heure", default="")
|
|
ap.add_argument("--hote", default="")
|
|
ap.add_argument("--jeu", action="append", default=[], help="NOM=CHEMIN[:CHEMIN...]")
|
|
ap.add_argument("--ldif", default="", help="chemin du LDIF dans l'instantané")
|
|
ap.add_argument("--ldif-vivant", type=Path, default=None)
|
|
ap.add_argument("--dump", default="", help="chemin du pg_dumpall dans l'instantané")
|
|
a = ap.parse_args(argv)
|
|
|
|
print(f"TEMOINS de {a.hote} : instantane {a.instantane} ({a.heure}) contre l'etat vivant")
|
|
if not a.jeu:
|
|
print(" SANS OBJET : ce noeud ne detient aucun etat.")
|
|
return 0
|
|
ecart = False
|
|
for spec in a.jeu:
|
|
nom, _, chemins = spec.partition("=")
|
|
if nom == "openldap":
|
|
r, unite = jeu_annuaire(a.avant / a.ldif.lstrip("/"), a.ldif_vivant or Path("/nonexistent")), "entree(s)"
|
|
elif nom == "postgresql":
|
|
r, unite = jeu_postgresql(a.avant / a.dump.lstrip("/"), interroger), "table(s)"
|
|
else:
|
|
r, unite = jeu_fichiers(nom, [c for c in chemins.split(":") if c], a.avant), "fichier(s)"
|
|
print("\n".join(r.lignes(unite)))
|
|
ecart = ecart or bool(r.ecarts)
|
|
print("ECART : une perte, ou une identite qui a change." if ecart
|
|
else "Aucun ecart : rien de perdu, l'identite est intacte.")
|
|
return 1 if ecart else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|