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) }