diff --git a/cmd/archivmail/cmd_backup.go b/cmd/archivmail/cmd_backup.go new file mode 100644 index 0000000..5a5d6d8 --- /dev/null +++ b/cmd/archivmail/cmd_backup.go @@ -0,0 +1,227 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "syscall" + "time" + + "archivmail/config" +) + +// runBackup performs an application-level backup (PROJ-66 Phase 2): a +// consistent snapshot of PostgreSQL, the mail store (hardlinks preserved, +// see copyTreePreservingHardlinks), the AES keyfile, config.yml and the +// audit-log flat file, all under one timestamped directory. +// +// This is a complement to, not a replacement for, infrastructure-level +// backups (Proxmox Backup Server / snapshot sync onto a second host) — see +// features/PROJ-66-backup-strategie.md. Store+DB+Keyfile are only useful +// together; a partial backup (e.g. DB dump without the matching store +// snapshot) is worse than no backup because it looks complete but isn't. +// +// Usage: archivmail backup -dest /path/to/backups [-config /etc/archivmail/config.yml] [-keep 7] +func runBackup(args []string) { + fs := flag.NewFlagSet("backup", flag.ExitOnError) + configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file") + dest := fs.String("dest", "", "backup destination base directory (required)") + keep := fs.Int("keep", 7, "number of most recent backups to retain (0 = keep all)") + fs.Parse(args) + + if *dest == "" { + fmt.Fprintln(os.Stderr, "backup: -dest is required") + os.Exit(1) + } + + cfg, err := config.Load(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "backup: load config: %v\n", err) + os.Exit(1) + } + + timestamp := time.Now().Format("20060102-150405") + backupDir := filepath.Join(*dest, timestamp) + if err := os.MkdirAll(backupDir, 0o700); err != nil { + fmt.Fprintf(os.Stderr, "backup: mkdir %s: %v\n", backupDir, err) + os.Exit(1) + } + + fmt.Printf("backup: writing to %s\n", backupDir) + + // PostgreSQL — custom format, allows selective pg_restore later. + dumpPath := filepath.Join(backupDir, "postgres.dump") + if err := runCmd("pg_dump", "-Fc", "-f", dumpPath, cfg.Database.DSN()); err != nil { + fmt.Fprintf(os.Stderr, "backup: pg_dump failed: %v\n", err) + fmt.Fprintln(os.Stderr, "backup: aborting — leaving partial backup dir for inspection, NOT rotating old backups") + os.Exit(1) + } + fmt.Println("backup: postgres.dump written") + + // Mail store — hardlinks preserved (PROJ-65 tenant directories share + // inodes with the canonical content-addressed files; a naive copy would + // silently multiply disk usage in the backup). + storeDst := filepath.Join(backupDir, "store") + linked, copied, err := copyTreePreservingHardlinks(cfg.Storage.StorePath, storeDst) + if err != nil { + fmt.Fprintf(os.Stderr, "backup: store copy failed: %v\n", err) + fmt.Fprintln(os.Stderr, "backup: aborting — leaving partial backup dir for inspection, NOT rotating old backups") + os.Exit(1) + } + fmt.Printf("backup: store copied — %d files, %d hardlinked (dedup preserved)\n", copied, linked) + + // Keyfile — without it the store copy above is undecryptable ciphertext. + if cfg.Storage.Keyfile != "" { + if err := copyFile(cfg.Storage.Keyfile, filepath.Join(backupDir, "keyfile"), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "backup: keyfile copy failed: %v\n", err) + os.Exit(1) + } + fmt.Println("backup: keyfile copied") + } else { + fmt.Println("backup: WARNUNG — kein storage.keyfile konfiguriert, Store ist unverschlüsselt") + } + + // Config — needed to stand the system back up with the same settings. + if err := copyFile(*configPath, filepath.Join(backupDir, "config.yml"), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "backup: config copy failed: %v\n", err) + os.Exit(1) + } + + // Audit log — best-effort, informational. A missing/rotated log must + // not fail the backup; the DB-side audit_log table (dual-write, PROJ-48) + // remains the authoritative copy. + auditPath := cfg.Audit.ResolvedLogPath() + if auditPath != "" { + if err := copyFile(auditPath, filepath.Join(backupDir, "audit.log"), 0o640); err != nil { + fmt.Printf("backup: audit log copy skipped (%v)\n", err) + } else { + fmt.Println("backup: audit.log copied") + } + } + + fmt.Printf("backup: complete — %s\n", backupDir) + + if *keep > 0 { + removed, err := rotateBackups(*dest, *keep) + if err != nil { + fmt.Fprintf(os.Stderr, "backup: rotation failed (backup itself succeeded): %v\n", err) + return + } + if len(removed) > 0 { + fmt.Printf("backup: rotation removed %d old backup(s): %v\n", len(removed), removed) + } + } +} + +// rotateBackups keeps only the `keep` most recent timestamped backup +// directories under base (lexicographic == chronological since the +// timestamp format is YYYYMMDD-HHMMSS) and removes older ones. Only called +// after a backup run has fully succeeded, so a failed run never causes a +// good older backup to be deleted. +func rotateBackups(base string, keep int) ([]string, error) { + entries, err := os.ReadDir(base) + if err != nil { + return nil, fmt.Errorf("read backup base dir: %w", err) + } + var names []string + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + sort.Strings(names) + if len(names) <= keep { + return nil, nil + } + toRemove := names[:len(names)-keep] + var removed []string + for _, n := range toRemove { + if err := os.RemoveAll(filepath.Join(base, n)); err != nil { + return removed, fmt.Errorf("remove %s: %w", n, err) + } + removed = append(removed, n) + } + return removed, nil +} + +func runCmd(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +// copyTreePreservingHardlinks copies src to dst, replicating the hardlink +// structure instead of duplicating content: the first time an inode is seen +// its content is copied, every subsequent path with the same inode becomes +// a hardlink to that already-copied file. This is what makes PROJ-65's +// per-tenant hardlink directories (store/tenant_/…) backup-safe without +// depending on `rsync -H` or any other external tool being available. +func copyTreePreservingHardlinks(src, dst string) (linked, copied int, err error) { + seen := make(map[uint64]string) // inode -> already-copied destination path + + walkErr := filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + + if info.IsDir() { + return os.MkdirAll(target, 0o700) + } + if !info.Mode().IsRegular() { + return nil // skip symlinks/sockets/etc — the store only contains regular files + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if ok { + if existing, dup := seen[stat.Ino]; dup { + if err := os.Link(existing, target); err != nil { + return fmt.Errorf("hardlink %s -> %s: %w", existing, target, err) + } + linked++ + return nil + } + if err := copyFile(path, target, info.Mode().Perm()); err != nil { + return err + } + seen[stat.Ino] = target + copied++ + return nil + } + + // Non-Linux fallback (no inode info available): plain copy, dedup lost. + if err := copyFile(path, target, info.Mode().Perm()); err != nil { + return err + } + copied++ + return nil + }) + return linked, copied, walkErr +} diff --git a/cmd/archivmail/cmd_import.go b/cmd/archivmail/cmd_import.go index e245df4..909c95d 100644 --- a/cmd/archivmail/cmd_import.go +++ b/cmd/archivmail/cmd_import.go @@ -306,6 +306,8 @@ Commands: rethread Thread-IDs rückwirkend aus In-Reply-To/References befüllen ocr-reprocess OCR für Anhänge nachholen (alle oder pro Mandant/Status) index-pending Ungeindexte Mails nachindexieren (cron-fähig, PROJ-58 batch_mode) + backup Store, Keyfile, PostgreSQL und Config konsistent sichern (PROJ-66) + restore Backup aus `archivmail backup` zurückspielen (PROJ-66) update Auf neueste Version aktualisieren (führt update.sh aus) status Healthcheck für DB, Manticore und Storage version Version anzeigen diff --git a/cmd/archivmail/cmd_restore.go b/cmd/archivmail/cmd_restore.go new file mode 100644 index 0000000..dc68268 --- /dev/null +++ b/cmd/archivmail/cmd_restore.go @@ -0,0 +1,124 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "archivmail/config" +) + +// runRestore restores a backup created by `archivmail backup` (PROJ-66). +// Deliberately conservative: refuses to overwrite a non-empty store +// directory or an existing keyfile/config unless -force is given, since a +// restore run by mistake against a live system would otherwise silently +// clobber production data. Does not stop/start systemd units itself — +// the operator is expected to have the daemon stopped already (the backup +// is meant to be restored onto a fresh or intentionally-wiped system, not +// layered live onto a running one). +// +// Usage: archivmail restore -source /path/to/backups/ [-config /etc/archivmail/config.yml] [-force] +func runRestore(args []string) { + fs := flag.NewFlagSet("restore", flag.ExitOnError) + configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file") + source := fs.String("source", "", "backup directory to restore from (required, e.g. /backups/20260704-034000)") + force := fs.Bool("force", false, "overwrite an existing, non-empty store/keyfile/config") + skipDB := fs.Bool("skip-db", false, "skip pg_restore (e.g. when DB was already restored separately)") + fs.Parse(args) + + if *source == "" { + fmt.Fprintln(os.Stderr, "restore: -source is required") + os.Exit(1) + } + if _, err := os.Stat(*source); err != nil { + fmt.Fprintf(os.Stderr, "restore: source %s not accessible: %v\n", *source, err) + os.Exit(1) + } + + cfg, err := config.Load(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "restore: load config: %v\n", err) + os.Exit(1) + } + + fmt.Println("restore: archivmail daemon (systemctl stop archivmail) must already be stopped — this command does not stop it for you.") + + // Store — refuse if the destination already has content, unless -force. + if !*force { + if nonEmpty, _ := dirHasEntries(cfg.Storage.StorePath); nonEmpty { + fmt.Fprintf(os.Stderr, "restore: %s is not empty — pass -force to overwrite (this can destroy data, make sure this is really what you want)\n", cfg.Storage.StorePath) + os.Exit(1) + } + } + storeSrc := filepath.Join(*source, "store") + linked, copied, err := copyTreePreservingHardlinks(storeSrc, cfg.Storage.StorePath) + if err != nil { + fmt.Fprintf(os.Stderr, "restore: store restore failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("restore: store restored — %d files, %d hardlinked\n", copied, linked) + + // Keyfile. + keySrc := filepath.Join(*source, "keyfile") + if _, err := os.Stat(keySrc); err == nil { + if cfg.Storage.Keyfile == "" { + fmt.Println("restore: WARNUNG — kein storage.keyfile in der Ziel-Config konfiguriert, Keyfile wird nicht restored. Store bleibt unentschlüsselbar bis das Keyfile manuell eingespielt wird.") + } else { + if !*force { + if _, err := os.Stat(cfg.Storage.Keyfile); err == nil { + fmt.Fprintf(os.Stderr, "restore: %s existiert bereits — pass -force to overwrite\n", cfg.Storage.Keyfile) + os.Exit(1) + } + } + if err := copyFile(keySrc, cfg.Storage.Keyfile, 0o600); err != nil { + fmt.Fprintf(os.Stderr, "restore: keyfile restore failed: %v\n", err) + os.Exit(1) + } + fmt.Println("restore: keyfile restored") + } + } + + // PostgreSQL. + if *skipDB { + fmt.Println("restore: -skip-db gesetzt, pg_restore übersprungen") + } else { + dumpPath := filepath.Join(*source, "postgres.dump") + if _, err := os.Stat(dumpPath); err != nil { + fmt.Printf("restore: kein postgres.dump in %s gefunden, überspringe DB-Restore\n", *source) + } else { + // --clean --if-exists: drop existing objects before recreating them, + // so restoring onto a non-empty DB (e.g. a retried restore) doesn't + // fail on "relation already exists". + if err := runCmd("pg_restore", "--clean", "--if-exists", "-d", cfg.Database.DSN(), dumpPath); err != nil { + fmt.Fprintf(os.Stderr, "restore: pg_restore failed: %v\n", err) + os.Exit(1) + } + fmt.Println("restore: postgres.dump restored") + } + } + + fmt.Println() + fmt.Println("restore: Dateien wiederhergestellt. Nächste Schritte (Pflicht, siehe docs/MIGRATION_RUNBOOK.md):") + fmt.Println(" 1. Config prüfen/übernehmen: diff", filepath.Join(*source, "config.yml"), *configPath) + fmt.Println(" 2. systemctl start archivmail archivmail-web") + fmt.Println(" 3. archivmail reconcile — Zählvergleich Store vs. DB muss aufgehen") + fmt.Println(" 4. archivmail reindex — Manticore-Index aus Store+DB neu aufbauen") + fmt.Println(" 5. Stichprobe: 3-5 zufällige Mails über die UI öffnen und lesbar entschlüsseln lassen") +} + +func dirHasEntries(dir string) (bool, error) { + f, err := os.Open(dir) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + defer f.Close() + _, err = f.Readdirnames(1) + if err == nil { + return true, nil + } + return false, nil +} diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index e074c6e..3d96784 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -59,6 +59,12 @@ func main() { case "migrate-tenant-dirs": runMigrateTenantDirs(os.Args[2:]) return + case "backup": + runBackup(os.Args[2:]) + return + case "restore": + runRestore(os.Args[2:]) + return case "reindex": runReindex(os.Args[2:]) return diff --git a/deploy/cron.d/archivmail b/deploy/cron.d/archivmail index 6b8b297..829b357 100644 --- a/deploy/cron.d/archivmail +++ b/deploy/cron.d/archivmail @@ -72,5 +72,16 @@ # 04:10 Uhr, also nach Purge (03:40) und außerhalb der OCR-Pause. 10 4 * * * root /opt/archivmail/archivmail reconcile --config /etc/archivmail/config.yml >> /var/log/archivmail/reconcile.log 2>&1 +# ── App-Backup (PROJ-66, optional — Ergänzung zur PBS-/Snapshot-Sicherung) ── +# `archivmail backup` sichert Store (Hardlinks erhalten), Keyfile, Config und +# PostgreSQL-Dump konsistent in ein Zielverzeichnis. Standardmäßig NICHT +# aktiviert — auskommentiert lassen, bis ein konkretes -dest-Ziel (idealerweise +# ein von diesem Host getrenntes Mount, z.B. NFS/rsync-Ziel) feststeht, siehe +# features/PROJ-66-backup-strategie.md. -keep begrenzt die Anzahl vorgehaltener +# Backup-Verzeichnisse (Rotation läuft nur nach einem erfolgreichen Lauf, ein +# fehlgeschlagener Backup löscht nie ein älteres gutes Backup). +# +# 0 3 * * * root /opt/archivmail/archivmail backup --config /etc/archivmail/config.yml --dest /mnt/backup/archivmail --keep 14 >> /var/log/archivmail/backup.log 2>&1 + # ── Weitere Jobs (geplant, noch nicht implementiert) ──────────────────── # 30 2 * * * archivmail /opt/archivmail/archivmail reindex # nächtlicher Voll-Reindex diff --git a/features/INDEX.md b/features/INDEX.md index b32f619..7ad4c36 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -81,7 +81,7 @@ | PROJ-63 | Defensive Tenant-Scope-Härtung der Tenant-Verwaltungs-Endpunkte (FUND-2) | Deployed | [PROJ-63](PROJ-63-harden-tenant-admin-scope.md) | 2026-06-25 | | PROJ-64 | Session-Invalidation bei Passwort-Change + Datei-Permissions-Härtung (Security-Audit) | Deployed | [PROJ-64](PROJ-64-session-invalidation-file-permissions.md) | 2026-07-03 | | PROJ-65 | Physische Tenant-Trennung im Storage-Layer | Deployed | [PROJ-65](PROJ-65-physische-tenant-trennung.md) | 2026-07-04 | -| PROJ-66 | Backup-Strategie für Store, Keyfile, PostgreSQL (Produktiv + Teilproduktiv) | Planned | [PROJ-66](PROJ-66-backup-strategie.md) | 2026-07-04 | +| PROJ-66 | Backup-Strategie für Store, Keyfile, PostgreSQL (Produktiv + Teilproduktiv) | In Review | [PROJ-66](PROJ-66-backup-strategie.md) | 2026-07-04 | diff --git a/features/PROJ-66-backup-strategie.md b/features/PROJ-66-backup-strategie.md index 1f18747..d80f453 100644 --- a/features/PROJ-66-backup-strategie.md +++ b/features/PROJ-66-backup-strategie.md @@ -1,6 +1,6 @@ # PROJ-66: Backup-Strategie für archivmail (Produktiv + Teilproduktiv) -**Status:** Planned +**Status:** In Review **Erstellt:** 2026-07-04 ## Problem / Ausgangslage @@ -451,9 +451,63 @@ Test-Container für den Restore-Test zur Verfügung (nicht 131/132 selbst)? ## Nicht Teil dieses Tickets -- Implementierung selbst (dieses Ticket liefert nur die Spec, Status - "Planned" — Implementierung erst nach Freigabe). - 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.