310 lines
13 KiB
Python
310 lines
13 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, 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())
|