2026-08-11 12:00:00 +00:00
---
type : issue
id : "0030"
2026-08-14 12:00:00 +00:00
status : in-progress
2026-08-11 12:00:00 +00:00
created : 2026-08-06
milestone : M1
priority : medium
area : security
gitlab_iid : "30"
2026-08-14 12:00:00 +00:00
related : [ docs/adr/0016-notfallhandbuch-nicht-spiegeln.md]
2026-08-11 12:00:00 +00:00
---
# 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.*
2026-08-14 12:00:00 +00:00
## 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.
2026-08-14 12:00:00 +00:00
## 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.
2026-08-14 12:00:00 +00:00
## 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.
2026-08-14 12:00:00 +00:00
## 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.
2026-08-14 12:00:00 +00:00
**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.
2026-08-14 12:00:00 +00:00
Das ist eine **dauerhafte Ausnahme von der Spiegel-Topologie** (ADR-0001) und daher als
**ADR-0016** festgehalten.
2026-08-14 12:00:00 +00:00
## 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).