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