Files
archivmail/features/PROJ-66-backup-strategie.md
T
sysops 55131de81d fix(PROJ-66): Build-Fehler in printHelp() + -force überschreibt keine Hardlinks
BUG-1 (QA): Backticks in der restore-Hilfezeile schlossen das Raw-String-
Literal von printHelp() vorzeitig, Build brach komplett. Backticks durch
einfache Anführungszeichen ersetzt.

BUG-2 (QA): -force bei restore scheiterte an bereits vorhandenen
Hardlink-Zieldateien (os.Link: file exists) — für den Normalfall (Restore
gegen einen PROJ-65-Tenant-Hardlink-Bestand) war -force damit funktionslos.
copyTreePreservingHardlinks() entfernt jetzt eine vorhandene Zieldatei vor
os.Link.
2026-07-04 15:15:54 +02:00

623 lines
36 KiB
Markdown

# PROJ-66: Backup-Strategie für archivmail (Produktiv + Teilproduktiv)
**Status:** In Review (BUG-1/BUG-2 aus QA-Runde 1 gefixt, Re-Test steht aus)
**Erstellt:** 2026-07-04
## Problem / Ausgangslage
**Nachtrag (2026-07-04, nach Nutzer-Rückmeldung):** Auf Infrastruktur-Ebene
existiert bereits eine Sicherung: Die Container werden per **Snapshot auf
einen zweiten Server synchronisiert** und zusätzlich **per Proxmox Backup
Server (PBS) gezogen**. Das ist von innerhalb des Containers nicht sichtbar
(läuft vom Proxmox-Host aus) — deshalb kam die ursprüngliche Prüfung unten
("kein Backup-Prozess") zu einem unvollständigen Bild. Das entschärft die
zuvor als Blocker geführte Frage nach einem Off-Site-Ziel erheblich: PBS +
Sync auf zweiten Server ist bereits eine physisch getrennte Sicherung.
Was das ändert und was offen bleibt, siehe Abschnitt "Bewertung der
Infra-Ebene" weiter unten. Der ursprüngliche Befund (App-Ebene: kein
`pg_dump`/rsync/Cron für archivmail selbst) bleibt als Kontext stehen, ist
aber durch die Infra-Sicherung teilweise bereits abgedeckt.
### Verifizierter Ist-Stand (geprüft am 2026-07-04, beide Hosts)
Beide Server sind **LXC-Container** (`systemd-detect-virt``lxc`), das
zugrunde liegende Storage ist ein ZFS-Dataset auf dem Proxmox-Host
(`zfs/data/subvol-1131-disk-0` bzw. `-1132-disk-0`), aber **aus dem Container
heraus nicht als ZFS ansprechbar** (kein `zfs`/`zpool`-Binary im Container,
kein Zugriff auf Host-Snapshots). Zff-Snapshots wären nur vom Proxmox-Host aus
möglich — das ist eine separate Maßnahme außerhalb der Kontrolle des
archivmail-Deploy-Prozesses und wird hier als Zusatzoption erwähnt, aber nicht
als alleinige Lösung behandelt (siehe Optionen unten).
Geprüft und **nicht vorhanden**:
- `crontab -l` (root): leer auf beiden Hosts
- `/etc/cron.d/`: nur `archivmail` (App-eigener Cron, PROJ-56c Purge/Reindex/OCR), `e2scrub_all`, `sysstat` — kein Backup-Eintrag
- `/etc/cron.{daily,hourly,weekly}`: nur Standard-Debian-Pakete (apt, dpkg, logrotate, man-db) — kein Backup-Skript
- `systemctl list-timers`: nur Standard-systemd/apt-Timer, kein Backup-Timer
- LVM: kein `lvs`/`vgs` im Container
- Borg/Restic/rsnapshot/Bacula: nicht installiert
- `/var/backups/`: enthält nur dpkg/apt-Housekeeping-Dateien, keine archivmail-Daten
Vorhanden, aber **ungenutzt/nicht automatisiert**:
- `pg_basebackup`, `pg_backupcluster` (Debian-PostgreSQL-Standardtools) — installiert, aber kein Aufruf irgendwo
- `manticore-backup` — installiert, kein Aufruf irgendwo
**Fazit:** Bei Totalausfall eines der beiden Container (Hardware, Filesystem-
Korruption, versehentliches `rm`, Ransomware, fehlerhaftes Update) sind Store,
Keyfile und Datenbank **unwiederbringlich verloren**. Das betrifft aktuell
sowohl 131 (Produktiv) als auch 132 (teilproduktiv, ~51.000 Mails).
### Aktuelle Datenmengen (Referenz für Aufwandsschätzung)
| Server | Store-Größe | DB | Config |
|---|---|---|---|
| 131 | 3,8 MB (frisch, PROJ-65-Migration) | n/a (Verbindungsproblem bei Check — Peer-Auth) | 23 KB |
| 132 | 6,9 GB (~51.000 Mails) | n/a (nicht separat geprüft) | — |
Store-Wachstum ist also unterschiedlich weit fortgeschritten; 132 hat den
größeren produktiven Datenbestand trotz "Test"-Label.
## Bewertung der Infra-Ebene (PBS + Server-Sync)
Container-/Snapshot-Backup auf Proxmox-Ebene sichert typischerweise das
komplette Root-Filesystem des LXC — d.h. `/var/archivmail/store`,
`/etc/archivmail/keyfile`, `/etc/archivmail/config.yml`, PostgreSQL-Datendir
und `/var/log/archivmail/audit.log` **gemeinsam als ein Snapshot**. Das löst
das in "Warum das kritisch ist" beschriebene Kern-Risiko (Store/Keyfile/DB
müssen zusammen gesichert werden) potenziell bereits vollständig, da alles im
selben Container liegt und in einem Snapshot landet — vorausgesetzt
PostgreSQL läuft ebenfalls im selben Container wie Store/Keyfile (zu
verifizieren, siehe offene Punkte unten).
**Was dadurch (voraussichtlich) bereits erfüllt ist:**
- Physische Trennung (AC 3) — zweiter Server + PBS sind eigene Infrastruktur.
- Gemeinsame Konsistenz von Store+DB+Keyfile+Config (AC 1) — ein
Filesystem-Snapshot enthält zwangsläufig alle Dateien vom selben Zeitpunkt.
- Hardlink-Erhalt (AC 2) — ein Block-/Filesystem-Snapshot (ZFS/PBS) kopiert
keine Dateien einzeln, sondern sichert die Filesystem-Struktur inklusive
Inode-Referenzen — Hardlinks aus PROJ-65 bleiben dabei erhalten (anders als
bei einem naiven `cp -r`, das dieses Ticket ursprünglich als Risiko für ein
noch zu bauendes App-Backup benannt hatte).
**Was weiterhin offen bleibt, auch mit PBS+Sync:**
1. **PostgreSQL-Crash-Konsistenz:** Ein Snapshot mitten im Betrieb ist für
PostgreSQL kein sauberes Backup wie `pg_dump`, sondern entspricht einem
Stromausfall-Szenario — PostgreSQL kommt damit über WAL-Replay beim
nächsten Start i.d.R. wieder in einen konsistenten Zustand (das ist der
Normalfall, für den WAL gebaut ist), aber das ist **nicht dasselbe wie
ein verifizierter, anwendungskonsistenter Dump**. Sollte einmal getestet
werden (Restore-Test, siehe unten), nicht nur angenommen.
2. **Restore ist ungetestet** — ein Backup, das nie zurückgespielt wurde,
ist nicht verifiziert (gilt für PBS-Snapshots genauso wie für ein
selbstgebautes rsync-Skript). Das ist unabhängig davon, wer das Backup
zieht, weiterhin ein offener Punkt.
3. **Retention/DSGVO-Abgleich:** Wie lange hält PBS die Snapshots vor, und
ist das mit der DSGVO-Löschkonzeption (PROJ-50) abgestimmt? Muss geklärt
werden, ist aber eine reine Konfigurationsfrage im PBS, kein neuer Code.
4. **Monitoring/Alerting:** Läuft der PBS-Job zuverlässig durch, und gibt es
eine sichtbare Warnung bei Ausfall? (Vermutlich ja, PBS hat eigenes
Job-Monitoring — zu verifizieren, nicht Teil des archivmail-Codes.)
5. **Keyfile-Einzelkopie (Bitwarden):** Zusätzlich zur PBS-Sicherung soll das
Keyfile separat in Bitwarden hinterlegt werden (Nutzer-Entscheidung,
2026-07-04) — als unabhängiger Wiederherstellungsweg, falls PBS/Snapshot-
Infrastruktur selbst nicht verfügbar ist. Details siehe eigener Abschnitt
unten.
**Auswirkung auf den Rest dieser Spec:** Die unten stehenden Optionen A/B/C
und der App-eigene rsync+cron-Ansatz sind jetzt **Ergänzung, nicht mehr
alleinige Lösung** — vor allem für den Fall, dass ein anwendungskonsistenter
`pg_dump` zusätzlich zur PBS-Snapshot-Sicherung gewünscht wird (schnellerer,
selektiver Restore einzelner Tabellen, unabhängig von Snapshot-Zeitpunkten).
Die **kritischste verbleibende Lücke ist nicht mehr "kein Backup-Ziel"**,
sondern **"Restore nie getestet"** — das bleibt so oder so zu tun, egal
welche Backup-Quelle (PBS oder App-Skript) im Ernstfall genutzt wird.
## Warum das kritisch ist
1. **Datenverlust ohne Backup ist bei einem Mail-Archivsystem ein
Compliance-Vorfall**, nicht nur ein IT-Vorfall — GoBD verlangt
Verfügbarkeit archivierter Mails über die Aufbewahrungsfrist.
2. Store, Keyfile und PostgreSQL-Metadaten sind **nur gemeinsam** nutzbar:
- Store ohne DB-Metadaten: Dateien vorhanden, aber keine Zuordnung zu
Mail/Tenant/Betreff/Datum → praktisch nicht auffindbar.
- Keyfile ohne Store: irrelevant, nichts zu entschlüsseln.
- DB ohne Keyfile: Metadaten vorhanden, aber Mail-Inhalte nicht mehr
entschlüsselbar → GoBD-Anforderung "Lesbarmachung" verletzt.
- Ein Backup, das nur eine dieser drei Komponenten sichert, ist wertlos.
3. **PROJ-65** hat gerade Tenant-Hardlink-Verzeichnisse (`store/tenant_<id>/`)
eingeführt. Ein Backup-Tool, das Hardlinks nicht erhält (z.B. `cp -r`,
naives `scp -r`), würde bei jedem Backup den Speicherbedarf im Backup-Ziel
vervielfachen (pro Tenant eine Vollkopie statt eines Hardlinks) — kein
Datenverlust, aber Speicherplatz-Explosion und potenziell Backup-Jobs, die
wegen vollem Zielverzeichnis fehlschlagen.
## Backup-Strategie-Optionen
### Option A — rsync + cron, konsistenter Snapshot via kurzem Stop (empfohlen als erster Schritt)
Ablauf pro Lauf (nachts, Zeitfenster mit wenig Traffic wählen, ggf. an
PROJ-56-Last-Entzerrung orientieren):
1. `systemctl stop archivmail` (Backend stoppen — Store/DB werden während
des Kopiervorgangs nicht verändert; Frontend kann laufen bleiben, da es
nur liest, aber optional auch stoppen für saubere Konsistenz)
2. `pg_dump -Fc archivmail > backup.dump` (Custom-Format, komprimiert,
erlaubt selektiven Restore einzelner Tabellen)
3. `rsync -aH --delete /var/archivmail/store/ <ziel>/store/`**`-H` ist
Pflicht**, sonst werden Tenant-Hardlinks aus PROJ-65 zu Vollkopien
aufgelöst
4. `cp /etc/archivmail/keyfile <ziel>/keyfile` (getrennt verschlüsselt/
abgesichert, siehe unten)
5. `cp /etc/archivmail/config.yml <ziel>/config.yml`
6. `cp /var/log/archivmail/audit.log <ziel>/audit.log.$(date +%F)` (Append-
Only-Charakter erhalten — Datei nicht überschreiben, sondern datiert
kopieren, damit historische Audit-Nachweise auch bei Log-Rotation auf dem
Quellsystem nicht verloren gehen)
7. `systemctl start archivmail`
8. Health-Check nach Neustart (Backend ✓ läuft, wie im Standard-Deploy-Workflow)
9. Manticore-Index: **niedrige Priorität**, aus Store+DB per
`archivmail reindex` rekonstruierbar — kein Backup nötig, ggf. optional
`manticore-backup` für schnelleren Recovery-Pfad (Reindex von 51k Mails
kann je nach OCR-Last dauern)
Vorteil: Downtime ist kurz (Stop → Kopieren der geänderten Blöcke via rsync
inkrementell → Start), einfach zu verstehen, kein neues Tooling nötig außer
rsync/cron (beide bereits vorhanden). Nachteil: Downtime existiert (wenn auch
kurz bei inkrementellem rsync nach dem ersten vollen Lauf).
### Option B — PITR / kontinuierliche Konsistenz ohne Stop (vollständige Lösung, später)
- PostgreSQL: WAL-Archiving + Basisbackup (`pg_basebackup`) für
Point-in-Time-Recovery statt Downtime beim Dump.
- Store: Da Store append-only/content-adressiert ist (Dateien werden nach
Schreiben nie verändert, nur neue hinzugefügt oder per GoBD-Purge gelöscht),
ist ein Live-rsync ohne Stop grundsätzlich risikoarm — die einzige
Inkonsistenz-Gefahr ist ein Mail-Datensatz, der in der DB bereits committed
ist, aber dessen Store-Datei noch nicht vollständig geschrieben wurde (Race
zwischen Storage-Write und DB-Commit). Das lässt sich durch Reihenfolge
Store-zuerst-dann-DB im Schreibpfad (falls nicht schon so implementiert —
bei mailarchiv-architect erfragen) und/oder einen Reconciliation-Check nach
dem Restore (PROJ-52 `archivmail reconcile`) abfedern statt durch Downtime.
- Aufwand deutlich höher (WAL-Archiv-Ziel, Retention der WAL-Segmente, mehr
bewegliche Teile) — als Phase 2 vorschlagen, nicht als ersten Schritt.
### Option C — Proxmox-Host-Snapshots (ZFS)
Da beide Hosts LXC-Container auf Proxmox mit ZFS-Backing sind, sind
Host-seitige ZFS-Snapshots oder Proxmox-Backup-Server-Jobs eine sinnvolle
**Ergänzung** (schnelle Rollback-Möglichkeit bei OS-/Config-Fehlern), aber
kein Ersatz für anwendungskonsistente Backups: Ein ZFS-Snapshot mitten im
`pg_dump`-losen Normalbetrieb sichert PostgreSQL nicht transaktionskonsistent
(außer Postgres nutzt selbst WAL + fsync korrekt, was bei ZFS-Snapshots auf
Blockebene in der Praxis meist funktioniert, aber nicht ausdrücklich
validiert wurde). Empfehlung: als Zusatzschicht einplanen, aber **erfordert
Zugriff auf den Proxmox-Host**, der nicht Teil dieses Deploy-Agenten-Scopes
ist — separates Ticket/Absprache mit dem Infrastruktur-Verantwortlichen für
den Proxmox-Host nötig.
### Empfehlung
**Phase 1 (dieses Ticket, pragmatisch):** Option A — rsync+cron mit kurzem
Stop, tägliches Backup auf ein Off-Container-Ziel (siehe Off-Site unten).
Deckt das eigentliche Risiko (Totalverlust ohne jegliches Backup) sofort ab.
**Phase 2 (späteres Ticket):** Option B (PITR) wenn Downtime-Fenster zum
Problem wird (z.B. wegen wachsendem Store auf 132), plus Option C als
zusätzliche Sicherheitsschicht in Absprache mit Proxmox-Host-Admin.
## Backup-Ziel: On-Site vs. Off-Site
- **On-Site-only ist nicht ausreichend**: Beide Produktivsysteme (131, 132)
laufen vermutlich auf derselben Proxmox-Host-Infrastruktur — ein
Host-Ausfall (Hardware, Ransomware, Fehlkonfiguration) könnte beide Systeme
UND ein On-Site-Backup-Ziel gleichzeitig treffen, wenn es auf demselben
Host/Storage liegt.
- Empfehlung: Backup-Ziel auf physisch getrennter Infrastruktur (zweiter
Host, NAS außerhalb des Proxmox-Clusters, oder Cloud-Storage mit
Verschlüsselung — Cloud nur wenn DSGVO-konform vertraglich abgesichert,
siehe Verschlüsselung unten).
- Mindestens: 3-2-1-Prinzip anstreben (3 Kopien, 2 verschiedene Medien, 1
Off-Site) — für den pragmatischen ersten Schritt reicht 2 Kopien (Produktiv
+ 1 Backup-Ziel off-site) mit klarer Dokumentation, dass das noch nicht
vollständig 3-2-1 ist.
## Verschlüsselung des Backups selbst
Das Backup enthält **Keyfile UND verschlüsselte Mails** zusammen — sobald
beide am selben Ort liegen, ist der Verschlüsselungsschutz des Stores
faktisch aufgehoben, wenn das Backup-Ziel kompromittiert wird. Anforderungen:
1. **Transport:** rsync über SSH (ohnehin Standard) oder explizit
`rsync -e ssh`, niemals unverschlüsseltes Protokoll (FTP, unverschlüsseltes
NFS über öffentliches Netz).
2. **At-Rest am Backup-Ziel:** Wenn das Backup-Ziel als weniger vertrauenswürdig
gilt als der Produktivserver (z.B. NAS mit mehr Admin-Zugriffsberechtigten,
Cloud-Storage), muss das Backup-Ziel selbst verschlüsselt sein — entweder
Ziel-Filesystem-Verschlüsselung (LUKS) oder Backup-Tool mit eingebauter
Verschlüsselung (z.B. `restic` mit Repository-Passwort — als Phase-2-Option
evaluieren, da es außerdem Deduplizierung/Versionierung mitbringt).
3. **Keyfile separat behandeln:** Empfehlung, das Keyfile-Backup zusätzlich
getrennt vom Store-Backup aufzubewahren (anderer Zugriffspfad/anderes
Passwort) — erschwert Angreifern, die nur das Backup-Ziel kompromittieren,
den vollen Zugriff. Mindestens: restriktive Dateirechte (0600, root-only)
auch im Backup.
4. Backup-Skript selbst darf Zugangsdaten (SSH-Key fürs Backup-Ziel) nicht im
Klartext in world-readable Cron-Dateien ablegen (analog zu PROJ-64
Datei-Permissions-Härtung).
### Keyfile-Escrow in Bitwarden (Nutzer-Entscheidung, 2026-07-04)
Zusätzlich zur PBS-/Sync-Sicherung wird das Keyfile als unabhängige Kopie in
Bitwarden hinterlegt — für den Fall, dass die Snapshot-/PBS-Infrastruktur
selbst nicht verfügbar oder kompromittiert ist.
- **Zugriffsbeschränkung:** Eigene Bitwarden-Collection/Ordner, nur für
Personen mit Restore-Berechtigung sichtbar — nicht die allgemeine
Organisations-Vault. Das Keyfile entschlüsselt das **gesamte Archiv aller
Mandanten**, ist der höchstwertige Secret im System.
- **Format:** Secure Note mit Base64-kodiertem Keyfile-Inhalt (32 Byte roh
→ base64 fürs Notizfeld), plus Metadaten (welcher Server/welche
Installation, Erstellungsdatum, wer hat's zuletzt geprüft).
- **Kein Ersatz für Zugriffskontrolle:** Bitwarden-Zugriff muss genauso
ernst genommen werden wie root-Zugriff auf den Produktivserver (2FA
Pflicht, Zugriffsprotokoll wenn Bitwarden das bietet).
- **Rotation/Prüfung dokumentieren:** Wer Zugriff hat und wann zuletzt
verifiziert wurde, dass der Bitwarden-Eintrag noch mit dem aktiven Keyfile
übereinstimmt (relevant falls das Keyfile jemals rotiert wird — aktuell
nicht vorgesehen, aber für die Zukunft dokumentieren).
- Kein Blocker für die restlichen ACs — unabhängig von PBS/App-Backup
umsetzbar, kleiner Zusatzschritt (Bitwarden-Eintrag anlegen, kein Code).
**Umgesetzt (2026-07-04):** `install.sh` gibt das frisch generierte Keyfile
direkt nach der Erzeugung einmalig auf der Konsole aus (`print_keyfile_escrow_warning()`,
beide Installationspfade — Docker- und Native-Modus), mit deutlichem Hinweis,
den Wert sofort in einen Passwort-Safe zu verschieben. Verhindert, dass der
Escrow-Schritt vom Bequemlichkeitsgrund "mach ich später" verschluckt wird —
der Wert steht sonst nirgends mehr sichtbar zur Verfügung, sobald die
Installations-Shell geschlossen ist (Keyfile-Datei selbst ist `chmod 400`,
nicht mehr casual auslesbar). Bereits bestehende Installationen (Keyfile
existiert schon) bekommen die Ausgabe NICHT erneut — dafür manuell
`cat /etc/archivmail/keyfile` einmalig ausführen und in Bitwarden nachtragen.
## Aufbewahrungsdauer / Rotation
- Vorschlag: Tägliche Backups, Rotation nach Generationen-Prinzip:
- 7 tägliche Backups
- 4 wöchentliche Backups (z.B. jeweils Sonntag)
- 6 monatliche Backups
- Wichtig: Rotation der **Backup-Kopien** ist unabhängig von der
GoBD-Aufbewahrungsfrist der Mails selbst (PROJ-51 Retention-Kategorien) —
eine gelöschte/abgelaufene Mail in Produktion kann in einem älteren Backup
noch enthalten sein. Das ist im Rahmen der GoBD-Löschkonzeption (PROJ-50,
DSGVO-Löschersuchen) zu dokumentieren: Backups mit personenbezogenen Daten,
die in Produktion bereits DSGVO-gelöscht wurden, dürfen nicht unbegrenzt
aufbewahrt werden → Backup-Rotation muss kurz genug sein, dass gelöschte
Daten nach spätestens N Rotationszyklen auch aus alten Backups verschwinden
(Abstimmung mit Datenschutzbeauftragtem/Nutzer nötig, kein rein technisches
Thema).
## Restore-Prozess (muss dokumentiert UND getestet sein)
Kein Backup ohne verifizierten Restore-Prozess. Vorschlag Runbook-Struktur
(Stil analog `docs/MIGRATION_RUNBOOK.md`):
1. Zielserver vorbereiten (frisches Debian oder vorhandener Server im
Wartungsmodus, archivmail-Dienste gestoppt).
2. Store wiederherstellen: `rsync -aH <backup>/store/ /var/archivmail/store/`
(Hardlinks bleiben erhalten).
3. Keyfile wiederherstellen: `cp <backup>/keyfile /etc/archivmail/keyfile`,
Rechte prüfen (0600, root:root).
4. Config wiederherstellen: `cp <backup>/config.yml /etc/archivmail/config.yml`.
5. PostgreSQL wiederherstellen: `pg_restore -d archivmail <backup>/backup.dump`
(auf leere, frisch angelegte DB).
6. Audit-Log wiederherstellen (informativ, kein Funktionsbestandteil):
`/var/log/archivmail/audit.log` aus dem letzten Backup-Stand kopieren.
7. Dienste starten, Health-Check (Backend ✓, Frontend ✓ wie im
Standard-Deploy-Workflow).
8. **Verifikation (Pflicht, nicht optional):**
- `archivmail reconcile` laufen lassen (PROJ-52) — Zählvergleich
Store-Dateien vs. DB-Einträge muss aufgehen.
- Stichprobe: 3-5 zufällige Mails über die UI öffnen und lesbar
entschlüsseln lassen (beweist Keyfile+Store+DB passen zusammen).
- `archivmail reindex` ausführen, Suche testen (Manticore wird aus 1+3
rekonstruiert).
9. Restore-Ergebnis (Mail-Zahl, Zeitstempel, Dauer) dokumentieren — analog zum
Migrations-Runbook-Prinzip "Zählen, Prüfen, Belegen".
**Regelmäßiger Restore-Test:** Ein Backup, das nie zurückgespielt wurde, ist
nicht verifiziert. Empfehlung: mindestens vierteljährlich einen Restore auf
einen isolierten Test-Container durchführen (nicht auf 131/132!) und Schritt
8 durchlaufen.
## Monitoring / Alerting für den Backup-Job selbst
Größtes Risiko bei Cron-Backups: Job schlägt still fehl, niemand merkt es,
bis der Ernstfall eintritt (siehe PROJ-58-Lehre: "Cron-Zeilen fehlten
wochenlang trotz aktivem Code, weil es niemand automatisch prüfte").
Anforderungen:
- Backup-Skript muss Exit-Code aller Teilschritte prüfen (rsync, pg_dump,
cp) und bei Fehler **nicht** die Rotation der alten (guten) Backups
anstoßen (sonst wird ein gutes altes Backup durch ein fehlgeschlagenes
überschrieben).
- Log-Datei pro Lauf mit Zeitstempel, Ergebnis, Dauer, Größe.
- Alerting: minimal ein "Backup-Alter"-Check, der z.B. vom bestehenden
Health-Check-Mechanismus oder Dashboard (PROJ-17-Metriken-Anlehnung)
mitgeprüft wird — "letztes erfolgreiches Backup älter als 26h" →
sichtbarer Warnzustand (Dashboard-Badge oder E-Mail/Webhook, je nachdem was
im System bereits an Alerting existiert; falls nichts existiert, ist das
ein Teil dieses Tickets: minimal ein Cron-Check-Skript, das eine
Markerdatei mit Zeitstempel nach erfolgreichem Lauf schreibt und ein
zweiter Cron-Job/Health-Endpoint das Alter dieser Datei prüft).
## User Stories
- **Als Betreiber** möchte ich, dass Store, Keyfile und DB-Metadaten täglich
automatisiert und konsistent gesichert werden, damit ein Server-Totalausfall
nicht zu unwiederbringlichem Mail-Verlust führt.
- **Als Betreiber** möchte ich, dass ein fehlgeschlagener Backup-Lauf sichtbar
wird (nicht erst beim Restore-Versuch auffällt), damit ich rechtzeitig
eingreifen kann.
- **Als GoBD-Verantwortlicher** möchte ich, dass ich im Audit-Fall belegen
kann, dass Backups vorhanden, aktuell und restorebar sind.
- **Als Datenschutzbeauftragter** möchte ich, dass DSGVO-Löschungen (PROJ-50)
nicht durch unbegrenzt aufbewahrte Backups unterlaufen werden.
## Acceptance Criteria
1. Es existiert ein dokumentiertes, automatisiert laufendes Backup (Cron
oder systemd-Timer) auf 131 UND 132, das Store, Keyfile, PostgreSQL-DB und
config.yml konsistent zueinander sichert.
2. Das Backup-Tool erhält nachweislich Hardlinks im Store (Test: Backup-Größe
nach PROJ-65-Tenant-Hardlinks entspricht nicht der Summe aller
Tenant-Kopien, sondern der tatsächlichen Store-Größe — Stichprobe via
`du --apparent-size` vs. `du` bzw. `stat` Link-Count-Check nach Restore).
3. Backup liegt auf einem vom Produktivsystem physisch getrennten Ziel
(nicht im selben Proxmox-Host/-Storage wie 131/132, sofern das ohne
zusätzliche Host-Beschaffung machbar ist — sonst als bekannte Einschränkung
dokumentieren).
4. Backup-Daten (insbesondere Keyfile-Kopie und Store-Kopie zusammen) sind am
Zielort verschlüsselt oder das Ziel ist gleichwertig abgesichert wie der
Produktivserver.
5. Rotation/Retention ist implementiert und lässt Backups nicht unbegrenzt
wachsen; Retention ist kurz genug, um DSGVO-Löschungen nicht dauerhaft zu
unterlaufen (Zeitraum mit Nutzer/Datenschutzbeauftragtem abgestimmt).
6. Ein vollständiger Restore wurde mindestens einmal auf einem isolierten
Testsystem durchgeführt und mit `archivmail reconcile` + Stichproben-
Entschlüsselung verifiziert; Ergebnis ist dokumentiert.
7. Es gibt einen sichtbaren Alarm-/Warnmechanismus, wenn der letzte
erfolgreiche Backup-Lauf älter als ein definiertes Schwellenwert-Fenster
ist (z.B. 26h bei täglichem Backup).
8. Ein Backup-Runbook (analog `docs/MIGRATION_RUNBOOK.md`) existiert unter
`docs/BACKUP_RESTORE_RUNBOOK.md` mit Schritt-für-Schritt-Anleitung für
Backup, Restore und Verifikation.
9. `update.sh`-Vollständigkeits-Check: Falls das Backup-Skript/der Cron-Job
als Datei unter `deploy/` im Repo gepflegt wird, synct `update.sh` diese
Datei nachweislich mit aus (Lehre aus PROJ-58, siehe Deploy-Vollständigkeits-
Check in den devops-deploy-Regeln) — sonst existiert das Skript nur lokal
und geht beim nächsten Server-Neuaufsatz verloren.
10. **Keyfile-Kopie liegt zugriffsbeschränkt in Bitwarden** (Secure Note,
eigene Collection, nur Restore-Berechtigte) — unabhängig von PBS/App-Backup.
11. **PBS-/Sync-Sicherung verifiziert:** Dokumentiert ist, was genau gesichert
wird (welcher Container/welche Pfade), wie lange PBS die Snapshots vorhält,
und dass mindestens ein Restore aus einem PBS-Snapshot erfolgreich getestet
wurde (nicht nur aus einem App-eigenen Backup, falls beide existieren).
## Edge Cases
- **Server-Totalausfall (131 oder 132):** Restore auf neuem/wiederhergestelltem
Container muss laut Runbook in angemessener Zeit möglich sein (Ziel-RTO
definieren, z.B. < 4h für 132 mit 51k Mails — abhängig von Reindex-Dauer).
- **Backup-Job schlägt fehl, ohne dass es auffällt:** durch Monitoring/
Alerting (siehe oben) abgedeckt — Testfall: Backup-Skript künstlich zum
Scheitern bringen (z.B. Zielverzeichnis vollschreiben) und prüfen, ob der
Alarm tatsächlich auslöst.
- **Restore-Test schlägt fehl:** Reconcile zeigt Lücken → Ursache muss vor
Produktivnutzung des Backups geklärt werden (z.B. Backup lief während
aktiven Schreibvorgangs ohne Stop-Schritt → Konsistenzproblem im
Backup-Skript selbst beheben).
- **Backup-Ziel läuft voll:** Rotation muss dies verhindern; zusätzlich
Disk-Space-Check vor jedem Lauf (analog `df -h`-Diagnose-Befehl im
devops-deploy-Skill) mit Abbruch + Alarm statt partiellem/korruptem Backup.
- **PROJ-65-Hardlinks nach Restore:** Nach Restore prüfen, dass
Tenant-Hardlink-Struktur (`store/tenant_<id>/`) wiederhergestellt ist und
nicht zu doppeltem Speicherverbrauch auf dem Zielsystem führt.
- **Gleichzeitiger Schreibzugriff während Backup (Option A mit Stop):** Kurzer
Downtime-Fenster einplanen und in Betriebszeiten kommunizieren (SMTP-
Empfang während Stop puffert sich ggf. beim Absender/Relay, sollte mit
mailarchiv-architect abgeklärt werden, ob eingehende Mails während Backend-
Stop verloren gehen könnten oder ob es einen Retry-Mechanismus gibt).
- **132 ist "teilproduktiv":** Muss von Anfang an mit derselben Priorität wie
131 behandelt werden, nicht nachrangig als "nur Testserver" — bereits im
Ticket-Titel und in den ACs berücksichtigt (beide Server, nicht nur 131).
## Aufwandseinschätzung
Durch die vorhandene PBS+Sync-Infrastruktur verschiebt sich der Schwerpunkt
von "Backup-Ziel schaffen" zu "vorhandenes Backup verifizieren + gezielt
ergänzen":
- **Phase 0 (klein, zuerst):** Bitwarden-Keyfile-Escrow anlegen (kein Code,
<1h). Dokumentieren, was PBS/Sync konkret sichert und mit welcher
Retention (Abstimmung mit demjenigen, der PBS betreibt — vermutlich kein
archivmail-Code-Zugriff nötig, eher ein Gespräch/Screenshot der
PBS-Job-Konfiguration). Geschätzt **1-2h**.
- **Phase 1 (Restore-Verifikation, wichtigster verbleibender Schritt):** Ein
echter Restore-Test aus einem PBS-Snapshot auf einen isolierten
Test-Container, durchlaufen mit `archivmail reconcile` + Stichproben-
Entschlüsselung (siehe Restore-Prozess oben). Deckt auf, ob die
PostgreSQL-Crash-Konsistenz in der Praxis reicht oder ob doch ein
zusätzlicher `pg_dump` nötig ist. Geschätzt **2-4h** (abhängig davon, wie
schnell ein Testsystem für den Restore verfügbar ist).
- **Phase 2 (nur falls Phase 1 Lücken zeigt):** App-eigenes rsync+cron+pg_dump
(Option A) als Ergänzung, falls PBS-Snapshots allein nicht ausreichen
(z.B. weil Retention zu kurz für Compliance ist, oder PITR-Granularität
gebraucht wird). Aufwand wie ursprünglich geschätzt, **4-6h**.
- **Phase 3 (optional, später):** PITR/WAL-Archiving, restic/borg-Migration —
nur falls Phase 1/2 einen konkreten Bedarf dafür aufzeigen.
**Offene Entscheidung vor Implementierung von Phase 1:** Steht ein isolierter
Test-Container für den Restore-Test zur Verfügung (nicht 131/132 selbst)?
## Nicht Teil dieses Tickets
- Konfiguration/Änderung der PBS-Jobs selbst (liegt außerhalb des
archivmail-Deploy-Scopes, eigener Verantwortungsbereich).
- Proxmox-Host-seitige ZFS-Snapshot-Konfiguration (separates Thema, anderer
Verantwortungsbereich/Zugriff).
## Implementation Notes (2026-07-04) — App-eigenes `archivmail backup`/`restore`
Nutzer-Entscheidung: App-eigene Backup-CLI zusätzlich zur PBS-Sicherung
bauen (Phase 2 vorgezogen), statt nur auf Infra-Ebene zu verlassen — gibt
einen von PBS unabhängigen, selektiven Restore-Weg (einzelne Tabellen via
`pg_restore`, ohne ganzen Container zurückspielen zu müssen).
### Neue Dateien
- `cmd/archivmail/cmd_backup.go`: `archivmail backup -dest <dir> [-config ...] [-keep N]`.
Schreibt in `<dest>/<timestamp>/`: `postgres.dump` (`pg_dump -Fc`, shell-out),
`store/` (Hardlink-erhaltende Kopie, siehe unten), `keyfile`, `config.yml`,
`audit.log` (best-effort). Rotation (`-keep`, Default 7) läuft NUR nach
erfolgreichem Durchlauf — ein fehlgeschlagener Lauf lässt den partiellen
Ordner stehen und rotiert nichts weg (Lehre aus PROJ-58: ein Job darf beim
Scheitern nie gute alte Stände zerstören).
- `cmd/archivmail/cmd_restore.go`: `archivmail restore -source <dir> [-force] [-skip-db]`.
Bewusst konservativ: bricht ab, wenn `store_path`/Keyfile bereits Inhalt
haben, außer `-force` ist gesetzt — ein versehentlicher Restore gegen ein
laufendes System soll nicht kommentarlos Produktivdaten überschreiben.
Stoppt/startet den Dienst NICHT selbst (Restore ist für einen frischen oder
bewusst leergeräumten Zielserver gedacht, kein Live-Overlay). Gibt am Ende
die Pflicht-Verifikationsschritte aus dem Runbook aus (`reconcile`,
`reindex`, Stichproben-Entschlüsselung).
### Hardlink-Erhalt ohne externe Tools
`copyTreePreservingHardlinks()` (in `cmd_backup.go`, von `cmd_restore.go`
mitgenutzt) läuft den Store-Baum ab, merkt sich pro Datei die Inode-Nummer
(`syscall.Stat_t.Ino`, Linux) und legt beim zweiten Auftreten derselben Inode
einen Hardlink statt einer Kopie an. Das macht die Backup-CLI unabhängig von
`rsync -H` (kein zusätzliches Tool-Dependency) und funktioniert identisch für
Backup wie Restore — PROJ-65s Tenant-Hardlink-Struktur bleibt dadurch sowohl
im Backup-Ziel als auch nach einem Restore verlustfrei erhalten (kein
Speicherplatz-Mehrverbrauch).
### Bewusst nicht gebaut
- Kein automatischer Cron-Eintrag aktiv — `deploy/cron.d/archivmail` enthält
ihn nur auskommentiert als Vorlage, da `-dest` ein konkretes, vom Host
getrenntes Ziel braucht, das noch nicht feststeht (siehe offene Entscheidung
oben).
- Kein automatisches Stop/Start der Dienste im Restore-Kommando (siehe oben).
- Keine Backup-Verschlüsselung im Tool selbst (Abschnitt "Verschlüsselung des
Backups selbst" bleibt gültig — Transport/Ziel-Absicherung ist
Infrastruktur-Aufgabe, nicht Teil dieses CLI-Kommandos).
- Kein Alerting bei Backup-Alter/-Ausfall im Code selbst (AC 7 bleibt offen,
bräuchte einen zweiten Cron-Job/Health-Check, der die letzte
Backup-Verzeichnis-Zeit prüft — noch nicht gebaut).
### Offen / Handoff
- Kein lokaler `go build` möglich — QA auf Testserver 132 nötig, insbesondere:
Backup+Restore-Roundtrip (Backup ziehen, auf leeren Store restoren,
`reconcile`+Stichprobe), Hardlink-Erhalt verifizieren (Inode-Vergleich wie
bei PROJ-65-QA), Verhalten bei vollem `-dest`-Ziel, `-force`-Schutz wirklich
blockierend bei nicht-leerem Store.
- `-dest`-Ziel für einen produktiven Cron-Eintrag muss noch vom Nutzer
festgelegt werden, bevor die auskommentierte Cron-Zeile aktiviert wird.
## QA Test Results (2026-07-04, Testserver 132)
**Getestet gegen Commit f3a7dea (lokal, nicht gepusht/deployt).** Build und
Funktionstests auf 132 in isoliertem Testbereich (synthetischer Store mit
PROJ-65-Hardlinks unter `/tmp/qa66-*`, eigene Test-DB `archivmail_qa66_test`).
Produktion (60.595 Store-Dateien, DB `archivmail`) nachweislich unberührt —
kein Zugriff auf echte Store-Dateien oder die echte DB, nach dem Test verifiziert.
**Gesamtergebnis: QA NICHT BESTANDEN** — 1 Critical (build-breaking) + 1 High
(-force funktionslos). Nach Fix beider Bugs erneut testen.
| # | Testpunkt | Ergebnis |
|---|-----------|----------|
| 1 | `go build ./cmd/archivmail/` | **FAIL** (BUG-1, build-breaking) |
| 2 | Backup-Lauf gegen Test-Config | PASS (nur mit QA-Workaround-Patch) |
| 3 | postgres.dump/store/keyfile/config.yml vorhanden + Store-Hardlinks (Inode-Gleichheit, link count 2) | PASS |
| 4 | Restore-Roundtrip auf isoliertes Ziel: Store + Hardlinks + pg_restore (DB-Zeilen korrekt), Keyfile 0600 | PASS |
| 5 | `-force`-Schutz: ohne `-force` blockiert (5a) / mit `-force` läuft durch (5b) | 5a PASS / **5b FAIL** (BUG-2) |
| 6 | Rotation `-keep 2` über 3 Läufe → nur 2 Verzeichnisse | PASS |
| 7 | Ungültiges/nicht beschreibbares `-dest` → sauberer Abbruch, keine Rotation guter Backups | PASS |
| 8 | `backup` ohne `-dest` → Fehlerabbruch | PASS |
Zusätzlich verifiziert: pg_dump-Fehlerfall (falsche DB) lässt partiellen
Backup-Ordner stehen und rotiert **keine** guten alten Backups weg (Lehre aus
PROJ-58) — PASS.
### BUG-1 (Severity: CRITICAL, Priorität: sofort) — Binary kompiliert nicht
`cmd/archivmail/cmd_import.go:310` bricht den Build. Die Hilfetext-Zeile für
`restore` wurde im Commit f3a7dea in den mit Backticks begrenzten
Raw-String-Literal von `printHelp()` (`fmt.Printf` mit Backtick-String, geschlossen
mit `` `, AppVersion) ``) eingefügt, enthält aber selbst Backticks um
`` `archivmail backup` ``. Diese beenden das Raw-String-Literal vorzeitig:
```
cmd/archivmail/cmd_import.go:310:30: syntax error: unexpected name archivmail in argument list; possibly missing comma or )
```
Repro: `CGO_ENABLED=0 go build -buildvcs=false -o /tmp/x ./cmd/archivmail/`
→ scheitert. **Das gesamte Backend (nicht nur backup/restore) ist nicht
baubar/deploybar, solange dieser Fehler besteht.** Für die restlichen QA-Punkte
wurde in einem Wegwerf-Build-Verzeichnis die Zeile behelfsweise auf einfache
Anführungszeichen geändert (kein Fix am Repo/Commit — Bug besteht unverändert).
Fix-Vorschlag (Backend Developer): Backticks in Zeile 310 durch einfache
Anführungszeichen ersetzen oder die Zeile ohne Inline-Code-Markup formulieren.
### BUG-2 (Severity: HIGH, Priorität: hoch) — `restore -force` schlägt bei nicht-leerem Store mit Hardlinks fehl
`-force` soll laut Spec/AC einen bereits befüllten Store überschreiben. Tatsächlich
bricht der Restore ab, sobald der Store Hardlinks enthält (PROJ-65-Tenant-Struktur —
der Normalfall):
```
restore: store restore failed: hardlink /…/ab/abcdef123.bin -> /…/tenant_1/abcdef123.bin:
link …: file exists
```
Ursache: `copyTreePreservingHardlinks()` (`cmd_backup.go`) ruft `os.Link()` für die
zweite Inode-Referenz auf, ohne dass das Zielverzeichnis vorher geleert wird. Bei
`-force` überschreibt `copyFile()` reguläre Dateien zwar via `O_TRUNC`, aber `os.Link()`
scheitert an einer bereits existierenden Zieldatei (`EEXIST`). Damit ist `-force`
für genau den Anwendungsfall funktionslos, für den es gedacht ist (Restore über
einen vorhandenen, mit Tenant-Hardlinks befüllten Store). Repro: einmal restoren,
dann erneut mit `-force` auf dasselbe Ziel → Fehler.
Fix-Vorschlag (Backend Developer): bei `-force` das Zielverzeichnis vor dem
Restore leeren, oder in `copyTreePreservingHardlinks()` vor `os.Link()`/`copyFile()`
ein vorhandenes Ziel entfernen (`os.Remove(target)`, `ErrNotExist` ignorieren).
Hinweis: `-force` ohne Hardlinks (nur reguläre Dateien) läuft dank `O_TRUNC` durch —
der Fehler tritt nur bei der zweiten+ Inode-Referenz auf.
### Nicht getestet / Hinweise
- Nur die CLI-Kommandos wurden getestet, nicht die offenen ACs (Cron/Timer AC 1,
physische Trennung AC 3, Verschlüsselung AC 4, Restore-Verifikation via echtem
`reconcile` AC 6, Alerting AC 7, Runbook AC 8, Bitwarden AC 10, PBS AC 11) —
diese sind laut Implementation Notes bewusst noch offen.
- `pg_restore --clean --if-exists` wurde nur gegen eine leere Test-DB geprüft;
Idempotenz über eine bereits befüllte DB nicht separat getestet.
- Testartefakte (`/tmp/qa66-*`, Test-DB `archivmail_qa66_test`, Wegwerf-Build
`/root/archivmail-qa66`) nach Testende entfernt und Entfernung verifiziert.
## Fixes nach QA-Runde 1 (2026-07-04)
- **BUG-1 (Critical, build-breaking):** `cmd/archivmail/cmd_import.go:310`
enthielt Backticks um `` `archivmail backup` `` innerhalb des Raw-String-
Literals von `printHelp()`, was den String vorzeitig schloss
(`syntax error: unexpected name archivmail in argument list`). Fix:
Backticks durch einfache Anführungszeichen ersetzt.
- **BUG-2 (High):** `-force` bei `restore` scheiterte an bereits vorhandenen
Hardlink-Zieldateien (`os.Link: file exists`) — für den Normalfall (Restore
gegen einen PROJ-65-Tenant-Hardlink-Bestand) war `-force` damit
funktionslos. Fix in `copyTreePreservingHardlinks()`: vor `os.Link` wird
eine bereits vorhandene Zieldatei entfernt (`os.Remove`, `os.IsNotExist`
wird ignoriert — im Normalfall ist das Ziel leer und der Remove ist ein
No-Op).
Re-Test von Punkt 1 (Build) und Punkt 5b (`-force` gegen befüllten Store)
steht aus.