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 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
+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": 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
+11
View File
@@ -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
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-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 -->
+57 -3
View File
@@ -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.