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>
408 lines
15 KiB
Go
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)
|
|
}
|