#!/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, clé DKIM, `instanceid` de Nextcloud, mot de passe d'une entrée de l'annuaire. 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 hashlib 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 == "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], int]]: """(bases, rôles, {(base, table) : lignes}) 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)) lignes: dict[tuple[str, str], int] = {} base, table, n = "", None, 0 for ligne in texte.splitlines(): if table is not None: if ligne == "\\.": lignes[(base, table)] = n table = None else: n += 1 elif ligne.startswith("\\connect "): base = ligne.split()[-1] else: m = re.match(r"^COPY (\S+) .*FROM stdin;$", ligne) if m: table, n = m.group(1), 0 return bases, roles, lignes 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) return r.stdout.splitlines() if r.returncode == 0 else None def jeu_postgresql(dump: Path, interroger=psql) -> Rapport: 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, lignes = lire_dump(dump.read_text(errors="replace")) vivantes = set(interroger("postgres", "select datname from pg_database where not datistemplate") or []) roles_vivants = set(interroger("postgres", "select rolname from pg_roles") or []) r.compte = len(lignes) 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}") for (b, t), n in sorted(lignes.items()): if b not in vivantes: continue compte = interroger(b, f"select count(*) from {t}") if compte is None: r.ecarts.append(f"table perdue : {b}.{t}") elif int(compte[0]) != n: r.changes.append(f"{b}.{t} : {n} -> {compte[0]} ligne(s)") r.nouveaux.extend(f"base {b}" for b in sorted(vivantes - bases - {"postgres"})) 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())