--- type: issue id: "0030" status: in-progress created: 2026-08-06 milestone: M1 priority: medium area: security gitlab_iid: "30" related: [docs/adr/0016-notfallhandbuch-nicht-spiegeln.md] --- # Der Restore ist nie geprobt — Sicherungen sind bisher eine Vermutung > Import aus [management#30](https://git.lab/axion1337.chat/management/-/issues/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.yaml` — **SOPS-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-system` — **im 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) 2. Restore-Verfahren schreiben (Reihenfolge, Herkunft von age-Key und kubeconfig). 3. Einmal gegen eine Wegwerf-Umgebung durchspielen. 4. 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 2–4 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`](../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.