syncova-backup/apps/agent/cmd/syncova-agent/backup_commands.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

408 lines
15 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/syncova/syncova/packages/agent"
"github.com/syncova/syncova/packages/backupengine"
"github.com/syncova/syncova/packages/platform/crypto"
"github.com/syncova/syncova/packages/platform/logging"
"github.com/syncova/syncova/packages/platform/ratelimit"
"github.com/syncova/syncova/packages/repository"
)
// encryptionKeysVariable ist die Umgebungsvariable mit dem Schlüsselmaterial.
//
// Der Schlüssel steht bewusst nicht als Kommandozeilenoption zur Verfügung:
// Argumente sind auf den meisten Systemen für andere Prozesse sichtbar
// (PROMPT.md §45: keine Secrets in URLs oder Argumenten).
const encryptionKeysVariable = "SYNCOVA_ENCRYPTION_KEYS"
// openRepositoryWithEngine öffnet ein Repository und baut die Backup Engine.
func openRepositoryWithEngine(openContext context.Context, repositoryPath string, requireWriteAccess bool) (*repository.LocalRepository, *backupengine.Engine, error) {
commandLogger := logging.New(os.Stderr, logging.Options{
ServiceName: serviceName, Level: "warn", Format: "text",
})
openedRepository, openError := repository.Open(openContext, repositoryPath,
repository.OpenOptions{ReadOnly: !requireWriteAccess}, commandLogger)
if openError != nil {
return nil, nil, openError
}
// Ohne Schlüsselmaterial sind ausschließlich unverschlüsselte Backups
// möglich. Das wird nicht stillschweigend angenommen, sondern beim Aufruf
// geprüft.
var secretStore crypto.SecretStore
rawKeySet := os.Getenv(encryptionKeysVariable)
if strings.TrimSpace(rawKeySet) != "" {
parsedKeys, parseError := crypto.ParseKeySet(rawKeySet)
if parseError != nil {
_ = openedRepository.Close()
return nil, nil, fmt.Errorf("%s: %w", encryptionKeysVariable, parseError)
}
// Ohne ausdrückliche Angabe wird der einzige vorhandene Schlüssel verwendet.
currentKeyVersion := os.Getenv("SYNCOVA_ENCRYPTION_CURRENT_KEY")
if currentKeyVersion == "" {
if len(parsedKeys) != 1 {
_ = openedRepository.Close()
return nil, nil, errors.New("bei mehreren Schlüsseln ist SYNCOVA_ENCRYPTION_CURRENT_KEY erforderlich")
}
for keyVersion := range parsedKeys {
currentKeyVersion = keyVersion
}
}
builtStore, storeError := crypto.NewLocalSecretStore(parsedKeys, currentKeyVersion)
if storeError != nil {
_ = openedRepository.Close()
return nil, nil, storeError
}
secretStore = builtStore
}
return openedRepository, backupengine.NewEngine(openedRepository, secretStore, commandLogger), nil
}
// runBackupCommand sichert ein Verzeichnis in ein Repository.
func runBackupCommand(commandArguments []string) error {
commandFlags := flag.NewFlagSet("backup", flag.ContinueOnError)
repositoryPath := commandFlags.String("repository", "", "Pfad des Repositorys")
sourcePath := commandFlags.String("path", "", "Zu sicherndes Verzeichnis")
backupID := commandFlags.String("id", "", "Kennung des Backups; leer erzeugt eine aus Zeitstempel")
sourceName := commandFlags.String("name", "", "Sprechende Bezeichnung der Quelle")
excludeList := commandFlags.String("exclude", "", "Auszuschließende Muster, kommasepariert")
includeList := commandFlags.String("include", "", "Einzuschließende Muster, kommasepariert")
compressionName := commandFlags.String("compression", "balanced", "Kompression: off, fast, balanced, maximum")
disableEncryption := commandFlags.Bool("no-encryption", false, "Verschlüsselung abschalten (nicht empfohlen)")
abortOnProblems := commandFlags.Bool("strict", false, "Bei nicht lesbaren Objekten abbrechen")
incrementalMode := commandFlags.Bool("incremental", false, "Nur Geändertes lesen; unveränderte Objekte aus dem Elternbackup übernehmen")
parentBackupID := commandFlags.String("parent", "", "Kennung des Elternbackups; leer wählt das jüngste Backup derselben Quelle")
bandwidthLimit := commandFlags.String("bandwidth", "", "Lesedurchsatz begrenzen, etwa 50MB oder 400Mbit; leer bedeutet unbegrenzt")
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
return parseError
}
if *repositoryPath == "" || *sourcePath == "" {
return errors.New("--repository und --path sind erforderlich")
}
// Ein Elternbackup ohne --incremental wäre ein stiller Widerspruch: der
// Aufrufer erwartete eine Zusatzsicherung und bekäme eine vollständige.
if *parentBackupID != "" && !*incrementalMode {
return errors.New("--parent ergibt nur zusammen mit --incremental einen Sinn")
}
compressionLevel, compressionError := parseCompressionLevel(*compressionName)
if compressionError != nil {
return compressionError
}
// Die Angabe wird vor dem Öffnen des Repositorys geprüft: Ein Tippfehler in
// der Bandbreite soll nicht erst nach dem Sperren auffallen.
bandwidthBytesPerSecond, bandwidthError := ratelimit.ParseBandwidthLimit(*bandwidthLimit)
if bandwidthError != nil {
return bandwidthError
}
bandwidthLimiter, limiterError := ratelimit.NewLimiter(bandwidthBytesPerSecond)
if limiterError != nil {
return limiterError
}
// Verschlüsselung ist der Standard; sie abzuschalten muss ausdrücklich
// geschehen und wird deutlich benannt (PROMPT.md §119, §120).
encryptionEnabled := !*disableEncryption
if encryptionEnabled && strings.TrimSpace(os.Getenv(encryptionKeysVariable)) == "" {
return fmt.Errorf("für ein verschlüsseltes Backup muss %s gesetzt sein.\n"+
"Einen Schlüssel erzeugt 'syncova-admin generate-key'.\n"+
"Ein unverschlüsseltes Backup verlangt ausdrücklich --no-encryption", encryptionKeysVariable)
}
effectiveBackupID := *backupID
if effectiveBackupID == "" {
effectiveBackupID = "backup-" + time.Now().UTC().Format("20060102-150405")
}
backupContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stopSignalListener()
openedRepository, engine, openError := openRepositoryWithEngine(backupContext, *repositoryPath, true)
if openError != nil {
return openError
}
defer func() { _ = openedRepository.Close() }()
commandLogger := logging.New(os.Stderr, logging.Options{
ServiceName: serviceName, Level: "warn", Format: "text",
})
backupRunner := agent.NewBackupRunner(engine, commandLogger)
if !encryptionEnabled {
fmt.Fprintln(os.Stderr, "Hinweis: Dieses Backup wird UNVERSCHLÜSSELT abgelegt.")
}
fmt.Printf("Sichere %s nach %s ...\n\n", *sourcePath, *repositoryPath)
backupResult, backupError := backupRunner.RunBackup(backupContext, agent.BackupRunOptions{
BackupID: effectiveBackupID,
SourcePath: *sourcePath,
SourceName: *sourceName,
DiscoveryOptions: agent.DiscoveryOptions{
IncludePatterns: splitPatternList(*includeList),
ExcludePatterns: splitPatternList(*excludeList),
},
CompressionLevel: compressionLevel,
EncryptionEnabled: encryptionEnabled,
AbortOnProblems: *abortOnProblems,
BandwidthLimiter: bandwidthLimiter,
Incremental: *incrementalMode,
ParentBackupID: *parentBackupID,
CreatedByVersion: buildVersion,
ProgressCallback: func(progress backupengine.Progress) {
fmt.Printf("\r %s verarbeitet | %.0f MiB/s | %d Blöcke neu, %d wiederverwendet ",
formatBytes(progress.BytesProcessed),
progress.ThroughputBytesPerSecond/(1024*1024),
progress.ChunksWritten, progress.ChunksDeduplicated)
},
})
if backupError != nil {
fmt.Println()
return backupError
}
fmt.Printf("\r%-78s\r", "")
fmt.Printf(" Backup: %s\n", backupResult.BackupID)
fmt.Printf(" Art: %s\n", describeBackupType(backupResult.BackupType))
fmt.Printf(" Dateien: %d\n", backupResult.FilesBackedUp)
fmt.Printf(" Verzeichnisse: %d\n", backupResult.DirectoriesRecorded)
if backupResult.SymlinksRecorded > 0 {
fmt.Printf(" Verweise: %d\n", backupResult.SymlinksRecorded)
}
fmt.Printf(" Verarbeitet: %s\n", formatBytes(backupResult.Progress.BytesProcessed))
fmt.Printf(" Abgelegt: %s\n", formatBytes(backupResult.Progress.BytesWritten))
fmt.Printf(" Ersparnis: %.1f %%\n", backupResult.Progress.SavingsPercentage())
fmt.Printf(" Dauer: %s\n", backupResult.Duration.Round(time.Millisecond))
fmt.Printf(" Verschlüsselt: %s\n", formatBoolean(encryptionEnabled))
if !bandwidthLimiter.IsUnlimited() {
fmt.Printf(" Bandbreite: %s (Lesen von der Quelle)\n",
ratelimit.FormatBandwidthLimit(bandwidthLimiter.BytesPerSecond()))
}
printChangeSummary(backupResult.Changes)
fmt.Printf("\n%s\n", backupResult.Summary())
// Ein Teilfehler wird nicht beschönigt und liefert einen Fehlerstatus,
// damit ein Skript oder Monitoring daran anschlägt (PROMPT.md §140).
if backupResult.IsPartialFailure() {
fmt.Println("\nNicht gesicherte Objekte:")
for problemIndex, backupProblem := range backupResult.Problems {
if problemIndex >= 10 {
fmt.Printf(" ... und %d weitere\n", len(backupResult.Problems)-10)
break
}
fmt.Printf(" %s\n %s\n", backupProblem.Path, backupProblem.Reason)
}
return errors.New("die sicherung ist unvollständig")
}
return nil
}
// runRestoreCommand stellt ein Backup wieder her.
func runRestoreCommand(commandArguments []string) error {
commandFlags := flag.NewFlagSet("restore", flag.ContinueOnError)
repositoryPath := commandFlags.String("repository", "", "Pfad des Repositorys")
backupID := commandFlags.String("id", "", "Kennung des wiederherzustellenden Backups")
targetPath := commandFlags.String("target", "", "Zielverzeichnis")
pathPrefix := commandFlags.String("subtree", "", "Nur diesen Teilbaum wiederherstellen")
overwriteExisting := commandFlags.Bool("overwrite", false, "Vorhandene Dateien überschreiben")
skipPermissions := commandFlags.Bool("no-permissions", false, "Ursprüngliche Rechte nicht setzen")
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
return parseError
}
if *repositoryPath == "" || *backupID == "" || *targetPath == "" {
return errors.New("--repository, --id und --target sind erforderlich")
}
restoreContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stopSignalListener()
openedRepository, engine, openError := openRepositoryWithEngine(restoreContext, *repositoryPath, false)
if openError != nil {
return openError
}
defer func() { _ = openedRepository.Close() }()
commandLogger := logging.New(os.Stderr, logging.Options{
ServiceName: serviceName, Level: "warn", Format: "text",
})
restoreRunner := agent.NewRestoreRunner(engine, commandLogger)
// Das Überschreiben vorhandener Daten wird deutlich angekündigt.
if *overwriteExisting {
fmt.Fprintf(os.Stderr, "Achtung: Vorhandene Dateien in %s werden überschrieben.\n\n", *targetPath)
}
fmt.Printf("Stelle %s nach %s wieder her ...\n\n", *backupID, *targetPath)
restoreResult, restoreError := restoreRunner.RunRestore(restoreContext, agent.RestoreRunOptions{
BackupID: *backupID,
TargetPath: *targetPath,
PathPrefix: *pathPrefix,
OverwriteExisting: *overwriteExisting,
RestorePermissions: !*skipPermissions,
})
if restoreError != nil {
return restoreError
}
fmt.Printf(" Dateien: %d\n", restoreResult.FilesRestored)
fmt.Printf(" Verzeichnisse: %d\n", restoreResult.DirectoriesCreated)
if restoreResult.SymlinksCreated > 0 {
fmt.Printf(" Verweise: %d\n", restoreResult.SymlinksCreated)
}
fmt.Printf(" Zurückgeschrieben: %s\n", formatBytes(restoreResult.BytesRestored))
fmt.Printf(" Geprüft: %d Dateien gegen ihre Prüfsumme\n", restoreResult.VerifiedFiles)
fmt.Printf(" Dauer: %s\n", restoreResult.Duration.Round(time.Millisecond))
fmt.Printf("\n%s\n", restoreResult.Summary())
return nil
}
// parseCompressionLevel liest eine Kompressionsstufe.
func parseCompressionLevel(levelName string) (backupengine.CompressionLevel, error) {
switch strings.ToLower(strings.TrimSpace(levelName)) {
case "off":
return backupengine.CompressionOff, nil
case "fast":
return backupengine.CompressionFast, nil
case "balanced", "":
return backupengine.CompressionBalanced, nil
case "maximum":
return backupengine.CompressionMaximum, nil
default:
return "", fmt.Errorf("unbekannte Kompressionsstufe %q (erlaubt: off, fast, balanced, maximum)", levelName)
}
}
// formatBoolean stellt einen Schalter in Worten dar.
func formatBoolean(flagValue bool) string {
if flagValue {
return "ja"
}
return "nein"
}
// describeBackupType benennt die Art eines Backups verständlich.
func describeBackupType(backupType repository.BackupType) string {
switch backupType {
case repository.BackupTypeIncremental:
return "Zusatzsicherung"
case repository.BackupTypeSyntheticFull:
return "zusammengesetzte Vollsicherung"
default:
return "Vollsicherung"
}
}
// printChangeSummary gibt das Ergebnis der Änderungserkennung aus.
//
// Gelöschte Objekte werden ausdrücklich genannt. Ein Backup, das eine
// verschwundene Datei stillschweigend nicht mehr enthält, verwehrt genau die
// Beobachtung, für die man Backups anlegt.
func printChangeSummary(changeSet *agent.ChangeSet) {
if changeSet == nil {
return
}
fmt.Printf("\n Verglichen mit: %s\n", changeSet.ParentBackupID)
fmt.Printf(" Neu: %d\n", changeSet.CountOf(agent.ChangeKindAdded))
fmt.Printf(" Geändert: %d\n", changeSet.CountOf(agent.ChangeKindModified))
fmt.Printf(" Übernommen: %d Dateien ohne erneutes Lesen\n", changeSet.ReusedFileCount())
fmt.Printf(" Nur Rechte: %d\n", changeSet.CountOf(agent.ChangeKindMetadataOnly))
if len(changeSet.DeletedPaths) == 0 {
return
}
fmt.Printf(" Gelöscht: %d (nicht mehr im Backup enthalten)\n", len(changeSet.DeletedPaths))
// Bei vielen Löschungen wird die Ausgabe gekürzt; die Zahl darüber bleibt
// vollständig.
const maximumListedDeletions = 10
for deletionIndex, deletedPath := range changeSet.DeletedPaths {
if deletionIndex >= maximumListedDeletions {
fmt.Printf(" ... und %d weitere\n", len(changeSet.DeletedPaths)-maximumListedDeletions)
break
}
fmt.Printf(" - %s\n", deletedPath)
}
}
// buildSecretStoreFromEnvironment liest das Schlüsselmaterial aus der Umgebung.
//
// Dieselbe Quelle wie beim Kommandozeilenweg: Ein Agent, der als Dienst läuft,
// bekommt seinen Schlüssel über die Diensteinstellungen — und ein zweiter
// Einleseweg wäre ein zweiter Ort, an dem eine Prüfung fehlen kann.
//
// Fehlt die Variable, ist das kein Fehler: Der Agent kann unverschlüsselte
// Repositories bedienen. Ein Auftrag mit verlangter Verschlüsselung wird dann
// abgelehnt.
func buildSecretStoreFromEnvironment() (crypto.SecretStore, error) {
rawKeySet := os.Getenv(encryptionKeysVariable)
if strings.TrimSpace(rawKeySet) == "" {
return nil, nil
}
parsedKeys, parseError := crypto.ParseKeySet(rawKeySet)
if parseError != nil {
return nil, fmt.Errorf("%s: %w", encryptionKeysVariable, parseError)
}
currentKeyVersion := os.Getenv("SYNCOVA_ENCRYPTION_CURRENT_KEY")
if currentKeyVersion == "" {
if len(parsedKeys) != 1 {
return nil, errors.New("bei mehreren Schlüsseln ist SYNCOVA_ENCRYPTION_CURRENT_KEY erforderlich")
}
for keyVersion := range parsedKeys {
currentKeyVersion = keyVersion
}
}
return crypto.NewLocalSecretStore(parsedKeys, currentKeyVersion)
}