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:
@@ -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
|
||||||
|
}
|
||||||
@@ -306,6 +306,8 @@ Commands:
|
|||||||
rethread Thread-IDs rückwirkend aus In-Reply-To/References befüllen
|
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)
|
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)
|
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)
|
update Auf neueste Version aktualisieren (führt update.sh aus)
|
||||||
status Healthcheck für DB, Manticore und Storage
|
status Healthcheck für DB, Manticore und Storage
|
||||||
version Version anzeigen
|
version Version anzeigen
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -59,6 +59,12 @@ func main() {
|
|||||||
case "migrate-tenant-dirs":
|
case "migrate-tenant-dirs":
|
||||||
runMigrateTenantDirs(os.Args[2:])
|
runMigrateTenantDirs(os.Args[2:])
|
||||||
return
|
return
|
||||||
|
case "backup":
|
||||||
|
runBackup(os.Args[2:])
|
||||||
|
return
|
||||||
|
case "restore":
|
||||||
|
runRestore(os.Args[2:])
|
||||||
|
return
|
||||||
case "reindex":
|
case "reindex":
|
||||||
runReindex(os.Args[2:])
|
runReindex(os.Args[2:])
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -72,5 +72,16 @@
|
|||||||
# 04:10 Uhr, also nach Purge (03:40) und außerhalb der OCR-Pause.
|
# 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
|
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) ────────────────────
|
# ── Weitere Jobs (geplant, noch nicht implementiert) ────────────────────
|
||||||
# 30 2 * * * archivmail /opt/archivmail/archivmail reindex # nächtlicher Voll-Reindex
|
# 30 2 * * * archivmail /opt/archivmail/archivmail reindex # nächtlicher Voll-Reindex
|
||||||
|
|||||||
+1
-1
@@ -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-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-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-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 -->
|
<!-- Add features above this line -->
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PROJ-66: Backup-Strategie für archivmail (Produktiv + Teilproduktiv)
|
# PROJ-66: Backup-Strategie für archivmail (Produktiv + Teilproduktiv)
|
||||||
|
|
||||||
**Status:** Planned
|
**Status:** In Review
|
||||||
**Erstellt:** 2026-07-04
|
**Erstellt:** 2026-07-04
|
||||||
|
|
||||||
## Problem / Ausgangslage
|
## Problem / Ausgangslage
|
||||||
@@ -451,9 +451,63 @@ Test-Container für den Restore-Test zur Verfügung (nicht 131/132 selbst)?
|
|||||||
|
|
||||||
## Nicht Teil dieses Tickets
|
## 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
|
- Konfiguration/Änderung der PBS-Jobs selbst (liegt außerhalb des
|
||||||
archivmail-Deploy-Scopes, eigener Verantwortungsbereich).
|
archivmail-Deploy-Scopes, eigener Verantwortungsbereich).
|
||||||
- Proxmox-Host-seitige ZFS-Snapshot-Konfiguration (separates Thema, anderer
|
- Proxmox-Host-seitige ZFS-Snapshot-Konfiguration (separates Thema, anderer
|
||||||
Verantwortungsbereich/Zugriff).
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user