feat(PROJ-66): CLI-Kommandos archivmail backup/restore

Sichert Store (Hardlinks erhalten, PROJ-65-tauglich ohne Abhängigkeit von
rsync -H), Keyfile, config.yml und PostgreSQL (pg_dump -Fc) konsistent in
ein Zielverzeichnis. Rotation läuft nur nach erfolgreichem Lauf, ein
fehlgeschlagener Backup rotiert nie ein gutes altes Backup weg.

restore ist bewusst konservativ: bricht bei nicht-leerem Store/Keyfile ohne
-force ab, stoppt/startet den Dienst nicht selbst, gibt am Ende die
Pflicht-Verifikationsschritte (reconcile, reindex, Stichprobe) aus.

Ergänzt die vorhandene PBS+Sync-Infrastruktur um einen App-eigenen,
selektiven Restore-Weg. Kein automatischer Cron-Eintrag aktiv (Zielpfad
noch offen), nur als Vorlage in deploy/cron.d/archivmail auskommentiert.
Kein lokaler go build möglich, QA folgt auf Testserver.
This commit is contained in:
sysops
2026-07-04 14:09:37 +02:00
parent ebab716006
commit f3a7dea3cc
7 changed files with 428 additions and 4 deletions
+227
View File
@@ -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_<id>/…) 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
}
+2
View File
@@ -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
+124
View File
@@ -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/<timestamp> [-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
}
+6
View File
@@ -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
+11
View File
@@ -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
+1 -1
View File
@@ -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 |
<!-- Add features above this line -->
+57 -3
View File
@@ -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 <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.