# 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_/`) 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/ /store/` — **`-H` ist Pflicht**, sonst werden Tenant-Hardlinks aus PROJ-65 zu Vollkopien aufgelöst 4. `cp /etc/archivmail/keyfile /keyfile` (getrennt verschlüsselt/ abgesichert, siehe unten) 5. `cp /etc/archivmail/config.yml /config.yml` 6. `cp /var/log/archivmail/audit.log /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 /store/ /var/archivmail/store/` (Hardlinks bleiben erhalten). 3. Keyfile wiederherstellen: `cp /keyfile /etc/archivmail/keyfile`, Rechte prüfen (0600, root:root). 4. Config wiederherstellen: `cp /config.yml /etc/archivmail/config.yml`. 5. PostgreSQL wiederherstellen: `pg_restore -d archivmail /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_/`) 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 [-config ...] [-keep N]`. Schreibt in `//`: `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 [-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.