Files
management/docs/issues/0030-der-restore-ist-nie-geprobt-sicherungen-sind.md
Thore CimbalandClaude Opus 4.8 f537d9c817 docs(issues): #0030 — alerting chain complete and verified end to end
Records the deployed state: fallback expressions, directory mounts (which proved
themselves on the very next rollout, where a SIGHUP reload was genuinely enough),
and alertmanager now scraped so delivery failures are visible. Also notes the
README correction — delivery had not been muted since gitops#51, and docs saying
otherwise would have made a missing alert look expected.

Left open deliberately: TrivyScanStale has the same missing-series gap but no
natural equivalent to kube_cronjob_created, so it needs a decision rather than a
reflex; and phase A/B of the restore drill still needs a throwaway environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:00:00 +00:00

19 KiB
Raw Permalink Blame History

type, id, status, created, milestone, priority, area, gitlab_iid, related
type id status created milestone priority area gitlab_iid related
issue 0030 in-progress 2026-08-06 M1 medium security 30
docs/adr/0016-notfallhandbuch-nicht-spiegeln.md

Der Restore ist nie geprobt — Sicherungen sind bisher eine Vermutung

Import aus management#30 (2026-08-11). Kommentare und Verlauf bleiben dort; kanonisch ist ab jetzt diese Datei (ADR-0012).

Es wird gesichert: jeden Sonntag, alle Dienste, drei Versionen vorgehalten, GitLab auf Overmind mit Datenbank und Volumes nach MinIO auf dem DSM. Das ist mehr, als die meisten haben.

Was fehlt, ist der Beweis, dass sich daraus etwas wiederherstellen lässt. Es gibt kein dokumentiertes Verfahren und keinen je durchgespielten Versuch. Eine Suche über docs/ und verfahren/ findet nur Erwähnungen in install.md — keine Anleitung, keine Protokolle.

Warum das der wichtigste der offenen Punkte ist

Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. Die typischen Fehler zeigen sich ausschließlich beim Zurückspielen und nie beim Sichern:

  • die Datenbank ist gesichert, aber ohne das Volume mit den Uploads ist sie wertlos
  • der Dump ist da, aber der Verschlüsselungsschlüssel lag nur auf dem Host, der weg ist
  • es liegen drei Versionen, aber alle drei sind seit Wochen leer, weil ein Pfad umgezogen ist und keiner es gemerkt hat
  • niemand weiß, in welcher Reihenfolge die Dienste hochkommen müssen

Der letzte Punkt ist hier besonders relevant: Der SOPS-age-Schlüssel entschlüsselt alle Secrets im Cluster. Wenn der nur an einer Stelle liegt, ist die Frage nicht, ob die Sicherung funktioniert, sondern ob sie überhaupt etwas nützt.

Was zu tun ist

  1. Zuerst das Billigste: stichprobenartig in die aktuellen Sicherungen hineinschauen. Sind sie plausibel groß? Enthalten sie, was sie sollen? Das findet stille Ausfälle sofort.
  2. Ein echtes Wiederherstellungsverfahren schreiben — als Ablauf, nicht als Prosa: welcher Dienst zuerst, woher der SOPS-Schlüssel, woher der kubeconfig.
  3. Einmal wirklich durchspielen, gegen eine Wegwerf-Umgebung, nicht gegen die Produktion. Was dabei fehlt, ist das Ergebnis.
  4. Ergebnis als Verfahren in verfahren/ ablegen und danach in bekanntem Abstand wiederholen.

⚠️ Bewusst nicht vorschlagen: die Sicherung erweitern, bevor die vorhandene geprüft ist. Mehr zu sichern, ohne zu wissen, ob das Vorhandene trägt, verschiebt das Problem nur.

Grenzen dieses Issues

Ich kenne den Sicherungsaufbau nur aus deiner Beschreibung und einem Screenshot, nicht aus eigener Anschauung. Der erste Schritt ist deshalb Bestandsaufnahme, nicht Bewertung.

Aufgenommen am 2026-08-06 bei einer Bestandsaufnahme der Sicherheitslage.

Schritt 1 erledigt 2026-08-14 — Bestandsaufnahme (Cluster-Seite)

Wie im Issue gefordert zuerst „das Billigste": in die vorhandenen Sicherungen hineingeschaut, nichts erweitert. Geprüft wurde die Matrix-Cluster-Seite (Hetzner/K3s); die Homelab-Seite (GitLab auf Overmind → MinIO/DSM) ist von hier nicht erreichbar und weiterhin ungeprüft.

Befund: die Sicherungen selbst sind besser als vermutet. Drei nächtliche CronJobs, alle Complete, keiner suspendiert, alle off-host per Borg auf eine Hetzner Storage Box (u641795@…your-storagebox.de:23) — also genau das Muster, das #0010 für Gitea erst plant:

Job Zeit Ziel-Repo Volumen (letzter Lauf) Bewertung
synapse-backup (matrix) 03:00 /./synapse-backup 199,5 MB, 247 Dateien, Dedup-Delta 3,7 MB plausibel
authentik-backup (authentik) 03:15 /./authentik-backup ~150 MB gesamt, Delta ~15 MB plausibel
wikijs-backup (matrix) 03:30 /./wikijs-backup 223 kB plausibel (nur Postgres; die Wiki-Inhalte liegen per git-storage in Gitea/git.lab)

Retention greift nachweislich (borg prune, 7 daily / 4 weekly / 6 monthly, „Deleted data" in den Logs). Kein stiller Ausfall, keine leeren Archive — die Sicherung ist keine bloße Vermutung mehr, zumindest was Existenz und Inhalt angeht.

⚠️ Kritischer Befund: Zirkelabhängigkeit beim age-Schlüssel

Genau das vom Issue vorhergesagte Muster („der Dump ist da, aber der Schlüssel lag nur auf dem Host, der weg ist") liegt real vor:

  1. Zum Lesen der Borg-Repos braucht man Borg-Passphrase und SSH-Key.
  2. Beide liegen in synapse-backup-secret.yaml / authentik-backup-secret.yamlSOPS-verschlüsselt.
  3. Entschlüsselbar ist das nur mit einem einzigen age-Schlüssel (Empfänger age14l0hw…, siehe .sops.yaml).
  4. Dieser private Schlüssel existiert an genau zwei Orten, und beide sind „heiß":
    • sops-age-Secret in flux-systemim Cluster, den das Backup schützen soll
    • ~/.age/keys.txt auf sorbs Mac — ein einzelnes Gerät

Folge: Gehen Cluster und Mac zusammen verloren (Totalschaden Hetzner + Laptop weg/defekt), sind alle drei Borg-Repos dauerhaft unlesbar. Die Sicherungen wären technisch einwandfrei und trotzdem wertlos. Eine Suche über docs/ und hosts/ findet keine dokumentierte Auslagerung (Escrow, Offline-Kopie, Passwort-Manager) des Schlüssels.

Billigste wirksame Gegenmaßnahme (vor jedem Restore-Test): eine kalte Kopie des age-Schlüssels außerhalb von Cluster und Mac anlegen — Passwort-Manager und/oder Ausdruck an sicherem Ort — und die Fundstelle in hosts/ dokumentieren (nur wo, nie der Wert). Erst danach lohnt der eigentliche Restore-Durchlauf, sonst probt man einen Ablauf, dessen Voraussetzung selbst ungesichert ist.

Nächste Schritte (unverändert nach Issue-Plan)

  1. Restore-Verfahren schreiben (Reihenfolge, Herkunft von age-Key und kubeconfig).
  2. Einmal gegen eine Wegwerf-Umgebung durchspielen.
  3. Ergebnis nach verfahren/ und Wiederholungsrhythmus festlegen.

Update 2026-08-14 — age-Schlüssel ist ausgelagert (Befund entschärft)

sorb bestätigt: der private age-Schlüssel liegt zusätzlich im Passwort-Vault, also außerhalb von Cluster und Mac. Damit ist die oben beschriebene Zirkelabhängigkeit aufgelöst — ein gleichzeitiger Verlust von Hetzner-Host und Laptop macht die Borg-Repos nicht mehr unlesbar. Der Befund bleibt hier stehen, weil die Kette (Backup lesen → Borg-Passphrase → SOPS → ein age-Schlüssel) beim Schreiben des Restore-Verfahrens explizit auftauchen muss: der Vault ist Teil des Wiederherstellungswegs, nicht nur Nebensache.

Zu ergänzen beim Verfahren (Schritt 2): Fundort des Schlüssels benennen (nur wo, nie der Wert) und im Restore-Ablauf als ersten Schritt führen — ohne ihn ist kein weiterer Schritt möglich.

Restaufwand nach heutiger Bestandsaufnahme

Schritt 1 (Bestandsaufnahme) ist erledigt und positiv ausgefallen; Schritt 24 stehen aus: Restore-Verfahren schreiben, einmal gegen eine Wegwerf-Umgebung durchspielen, Ergebnis nach verfahren/ und Wiederholungsrhythmus. Die Homelab-Seite (GitLab auf Overmind → MinIO/DSM) ist weiterhin ungeprüft — von der Hetzner-Seite aus nicht erreichbar.

Schritt 2 erledigt 2026-08-14 — Restore-Verfahren geschrieben

docs/wiki/deployment/restore.md — als Ablauf, nicht als Prosa: Voraussetzungen (age-Key aus dem Vault zuerst), Fundort und Layout der drei Borg-Repos, Bootstrap-Reihenfolge (Host/K3s → die zwei manuellen Secrets sops-age + flux-system → Flux → infra-apps → production/authentik/monitoring), Daten-Rückspielung und Verifikation.

Abweichung vom Issue-Wortlaut: abgelegt unter docs/wiki/deployment/ statt verfahren/ — dort liegt bereits die Deploy-Übergabe, nur docs/** wird von validate.py geprüft, und die Seite ist über den Wiki-Index auffindbar. verfahren/ enthält bislang ausschließlich Skripte.

Zwei Erkenntnisse beim Schreiben, die vorher nicht dokumentiert waren:

  1. Flux zieht aus Gitea (rohana), nicht aus git.lab. Ist rohana beim Ausfall ebenfalls weg, hängt der Wiederanlauf an einem Host, der gar nicht Teil des Backup-Konzepts ist — dann muss zuerst eine erreichbare Git-Quelle hergestellt und gotk-sync.yaml umgebogen werden. Im Verfahren als Warnung vermerkt.
  2. Konsumenten müssen vor dem pg_restore heruntergefahren werden. Synapse/MAS/Authentik/ Wiki.js legen beim Start ein leeres Schema an, gegen das ein Restore kollidiert.

Ebenfalls festgehalten: Borg nutzt repokey-blake2, die Passphrase allein genügt (keine separate Schlüsseldatei), und Passphrase/SSH-Key sind ohne Cluster per sops -d aus einem lokalen Clone lesbar — der age-Key aus dem Vault ist damit tatsächlich der einzige harte Startpunkt.

Offen: Schritt 3 (einmal gegen eine Wegwerf-Umgebung durchspielen) und Schritt 4 (Wiederholungsrhythmus). Das Verfahren ist bis dahin abgeleitet, aber unerprobt.

Schritt 3 (teilweise) erledigt 2026-08-14 — Restore-Probe bestanden

Eigenes Repo git.lab/axion1337.chat/notfallhandbuch angelegt (Wunsch sorb): Einstiegs- README, das Verfahren (restore-matrix.md) und ein menügeführtes Werkzeug (notfall.sh). Das Verfahren wurde aus docs/wiki/deployment/ dorthin verschoben — hier steht nur noch ein Zeiger. Begründung: im Ernstfall will man einen Clone, nicht drei Repos absuchen.

Der Beweis, den dieses Issue verlangt, ist für die Datenbanken erbracht. notfall.sh Stufe 3 spielt die Sicherungen in eine Wegwerf-Postgres im Pod zurück — isoliert, die Produktion bleibt unberührt, beliebig wiederholbar:

Repo Datenbank zurückgespielte Zeilen
synapse-backup synapse 31.908
synapse-backup matrixauthenticationservice 16.085
authentik-backup authentik 325.149
wikijs-backup wiki 251

Die Sicherungen sind damit keine Vermutung mehr — sie wurden tatsächlich zurückgespielt.

Entwurfsentscheidungen des Werkzeugs (bewusst, nicht beiläufig):

  • Arbeit läuft im Cluster, nicht auf dem Laptop: axion-backup:v2 bringt borg + pg-Tools mit, die Backup-Secrets verlassen den Cluster nicht, lokal genügt kubectl.
  • whiptail wird genutzt falls vorhanden, sonst Textmenü — ein Notfallwerkzeug darf nicht mit „installier erst ein Paket" beginnen (auf dem Mac fehlt whiptail).
  • Bestehenskriterium ist die Zeilenzahl, nicht der Exitcode. Die erste Fassung meldete bei fehlgeschlagenem pg_restore fälschlich „OK" (Pipe verschluckte den Code) — genau der stille Ausfall, den dieses Issue beschreibt, nur im Prüfwerkzeug selbst. Behoben und gegengetestet.

Weiterhin offen (Rest von Schritt 3 + Schritt 4):

  • Phase A/B nie durchgespielt: Wiederanlauf auf leerem Host (K3s, die zwei Bootstrap- Secrets, Flux) ist abgeleitet, nicht getestet — das braucht eine Wegwerf-Umgebung.
  • Synapse-Medien (media_store → PVC) nicht erprobt; ohne sie sind Bilder in Räumen tote Links.
  • Wiederholungsrhythmus festlegen (Stufe 3 ist gefahrlos → z.B. monatlich).
  • Homelab-Seite (GitLab/Overmind → MinIO/DSM) weiterhin ungeprüft.

Keine Spiegelung — bewusste Ausnahme (Entscheidung sorb 2026-08-14): Anders als alle übrigen Repos wird das Notfallhandbuch nicht nach Gitea gespiegelt. Es beschreibt die Infrastruktur, den Ablageort der Sicherungen und wo die Schlüssel liegen; auf dem öffentlich erreichbaren Gitea wäre es bei einer Kompromittierung des Stacks genau die Landkarte, die ein Angreifer braucht. Vertraulichkeit vor Verfügbarkeit. (Meine ursprüngliche Empfehlung, zu spiegeln, war damit falsch — sie hatte nur die Verfügbarkeit im Blick.) Die Verfügbarkeitslücke — git.lab ist nur im Lab erreichbar — wird über einen lokalen Clone abgedeckt, nicht über einen Mirror. Begründung steht im README des Notfallhandbuchs, damit sie nicht erneut "wegoptimiert" wird.

Das ist eine dauerhafte Ausnahme von der Spiegel-Topologie (ADR-0001) und daher als ADR-0016 festgehalten.

Schritt 4 erledigt 2026-08-14 — Rhythmus festgelegt (automatisiert + überwacht)

Monatlich, 4. um 04:20, als CronJob restore-drill (gitops:apps/production/restore-drill.yaml, Commit b61dfd9) — nach den nächtlichen Backups, damit er den frischen Stand zieht. Er spielt die Sicherungen in eine Wegwerf-Postgres im Pod zurück und besteht nur, wenn Zeilen ankommen. Vor dem Commit manuell ausgelöst und bestanden (synapse 31.908, MAS 16.085, wiki 251).

Automatisiert statt dokumentiert: ein Prüfrhythmus, den niemand ausführt, ist derselbe Fehler wie ein ungetestetes Backup — nur eine Ebene höher.

Abdeckung: synapse + matrixauthenticationservice + wiki (die unersetzlichen Daten). Authentik ist bewusst nicht im automatischen Lauf: Flows/Provider liegen als Blueprints deklarativ im Repo, die DB ist also weitgehend reproduzierbar — und der Job müsste sonst wegen der namespace-gebundenen Credentials dupliziert werden. Auf Zuruf über notfall.sh Stufe 3 (deckt alle drei Repos ab) jederzeit prüfbar.

Nebenbefund, der wichtiger war als der Rhythmus selbst

Es gab überhaupt keine Alarmregel zu Backups. Ein fehlgeschlagenes nächtliches Backup wäre unbemerkt geblieben — exakt der stille Ausfall, den dieses Issue beschreibt, nur an der Stelle, die ihn hätte melden sollen. Behoben in threadnet-operating:monitoring/prometheus/alerts.yml (Commit 1bbff5e), neue Gruppe axion-backup:

Alarm feuert wenn
BackupJobFailed ein Backup- oder Probe-Job fehlschlägt
BackupNotRunning ein CronJob seit >26h nicht mehr geplant hat
RestoreDrillStale die Probe seit >40 Tagen nicht lief

Der letzte ist Absicht: auch das Ausbleiben der Prüfung ist ein Alarm. Metrikweg verifiziert (kube-state-metrics → Alloy → remote_write; der Filter verwirft nur go_.*|process_.*, kube_job_*/kube_cronjob_* kommen an).

⚠️ Deploy offen: Die Alarmregeln liegen im Repo, sind aber noch nicht auf CFGMON ausgerollt (Prometheus dort, kein Zugang von hier). Bis zum Reload greifen sie nicht.

Restaufwand

Nur noch Phase A/B: Wiederanlauf auf einem leeren Host (K3s, die zwei Bootstrap-Secrets, Flux) und die Rückspielung der Synapse-Medien sind weiterhin abgeleitet, nicht erprobt — dafür braucht es eine Wegwerf-Umgebung. Ebenso ungeprüft: die Homelab-Seite (GitLab/Overmind → MinIO/DSM).

Nachtrag 2026-08-15 — Alarme ausgerollt, zwei Fehlalarme derselben Klasse gefunden

Der Rollout auf CFGMON ist durch und verifiziert: alle vier Regeln der Gruppe axion-backup sind geladen, evaluieren mit health=ok und stehen auf inactive. Die Fallback-Ausdrücke liefern echte Serien — die drei Backup-CronJobs mit last_schedule_time von heute Nacht, restore-drill greift wie vorgesehen auf kube_cronjob_created zurück, bis der erste geplante Lauf am 4.9. eine Schedule-Zeit setzt.

Beim Verifizieren kamen zwei Befunde derselben Fehlerklasse heraus — beide sind Varianten von „meldet Erfolg, ist aber blind":

1. Fehlende Serie = Stille statt Alarm. RestoreDrillStale hätte nie feuern können: ein manuell ausgelöster Job setzt keine last_schedule_time, und ein Ausdruck über eine nicht existierende Serie liefert nichts. Dieselbe Lücke in BackupNotRunning: wird ein CronJob gelöscht — exakt der Fall, den der Alarm abdecken soll — verschwindet die Serie, und der Alarm verstummt. Behoben (threadnet-operating e0808ba) durch Fallback auf kube_cronjob_created, zusammengefasst per max by (namespace, cronjob): or matcht inklusive __name__, ein blanker Fallback hätte beide Serien zurückgegeben und die nie aktualisierte created-Zeit hätte nach Fristablauf dauerhaft falsch gefeuert. Ergänzt um BackupCronJobMissing (absent()), damit ein verschwundener CronJob selbst der Alarm ist.

2. Reload meldet Erfolg und lädt den alten Stand. prometheus_config_last_reload_successful=1 nach SIGHUP — geladen waren trotzdem die alten Regeln. Ursache: Docker hängt Einzeldatei-Bindmounts am Inode auf, git pull ersetzt Dateien per Rename. Host-Datei 13 Regeln, Container-Datei 12, unterschiedliche md5-Summen. Sichtbar wurde das nur durch den Vergleich Host↔Container; ein Neustart löste es.

⚠️ Die eigentliche Lehre aus (2): Diese Falle war in monitoring/README.md bereits ausführlich dokumentiert — mit Mechanik, richtigem Kommando (--force-recreate) und sogar dem Prüfbefehl — und hat trotzdem zugeschlagen. Dokumentation hat den Fehler nicht verhindert. Deshalb strukturell beseitigt statt besser beschrieben (threadnet-operating 4cb9bfb): Prometheus und Alertmanager mounten jetzt ihr Config-Verzeichnis; Verzeichnis-Mounts lösen bei jedem Zugriff über den Pfad auf. Config-Pfade unverändert. Die verbliebenen Einzeldatei-Mounts (loki, alloy, die Skripte) sind in der README benannt.

⚠️ Deploy offen: 4cb9bfb ist gepusht, aber noch nicht auf CFGMON aktiv — die Mount-Änderung greift erst, wenn die Container neu erstellt werden (docker compose up -d genügt hier, da sich die Service-Definition ändert).

Endstand Überwachung 2026-08-15 — Kette durchgängig, verifiziert

Drei Commits in threadnet-operating, alle live und im Betrieb gegengeprüft:

Commit Was Nachweis
e0808ba Regel-Ausdrücke mit Fallback (max by über … or kube_cronjob_created) + BackupCronJobMissing 4 Regeln health=ok, Fallback liefert Serien statt Leere
4cb9bfb Prometheus/Alertmanager mounten Config-Verzeichnis statt Einzeldateien md5 Host↔Container identisch; beim nächsten Rollout genügte erstmals ein echter SIGHUP-Reload — der Fix hat sich sofort bewährt
e9c13dc operating_alertmanager-Scrape + AlertDeliveryFailing; veraltete README-Passage korrigiert up{job="operating_alertmanager"}=1, 14 Regeln health=ok, 80 Serien alertmanager_notifications_failed_total (alle 0)

Damit ist die Alarmkette lückenlos beobachtet: Metrik vorhanden (Fallback) → Regel evaluiert (health=ok) → Zustellung sichtbar (AlertDeliveryFailing) → Scrape-Ausfall gedeckt (TargetDown). AlertDeliveryFailing kann selbst nicht an fehlender Serie scheitern: der Zähler existiert ab dem ersten Scrape.

Nebenkorrektur: Die README behauptete weiterhin, die Alarm-Zustellung sei per room="security"-Null-Receiver stummgeschaltet. Diese Route existiert seit gitops#51 nicht mehr — Alarme werden zugestellt, auch die neuen Backup-Regeln (kein room-Label → Default-Route auf den matrix-Receiver). Eine Doku, die fälschlich „ist stummgeschaltet" sagt, hätte ein ausbleibendes Signal als bekannt-und-erwartet erscheinen lassen.

Offen (bewusst nicht reflexhaft gelöst)

  • TrivyScanStale hat dieselbe Lücke wie ursprünglich RestoreDrillStale: ein Image, das nie erfolgreich gescannt wurde, hat keine Serie, an der time() - … hängen könnte, und bleibt still. Anders als dort gibt es kein natürliches Pendant zu kube_cronjob_created — es bräuchte eine Soll-Liste der erwarteten Images. Das ist eine Entscheidung, kein Handgriff; gehört fachlich zu #0025/CVE-Pipeline.
  • Phase A/B des Restore-Verfahrens (Wiederanlauf auf leerem Host, Synapse-Medien) — unverändert offen, braucht eine Wegwerf-Umgebung.