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

36 KiB

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-virtlxc), 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).

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.

-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.