syncova-backup/packages/agent/backup.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

512 lines
19 KiB
Go

package agent
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/syncova/syncova/packages/backupengine"
"github.com/syncova/syncova/packages/platform/logging"
"github.com/syncova/syncova/packages/platform/ratelimit"
"github.com/syncova/syncova/packages/repository"
)
// BackupRunOptions steuern einen Sicherungslauf des Agents.
type BackupRunOptions struct {
// BackupID ist die Kennung des zu erzeugenden Backups.
BackupID string
// SourcePath ist das zu sichernde Verzeichnis.
SourcePath string
// SourceName ist die sprechende Bezeichnung der Quelle.
SourceName string
// DiscoveryOptions steuern die Erfassung.
DiscoveryOptions DiscoveryOptions
// CompressionLevel ist die gewünschte Kompressionsstufe.
CompressionLevel backupengine.CompressionLevel
// EncryptionEnabled schaltet die Verschlüsselung ein.
EncryptionEnabled bool
// ChainID verbindet das Backup mit seiner Kette.
ChainID string
// ParentBackupID benennt das Elternbackup einer Zusatzsicherung.
ParentBackupID string
// ProgressCallback meldet den Fortschritt.
ProgressCallback backupengine.ProgressCallback
// CreatedByVersion ist die erzeugende Programmversion.
CreatedByVersion string
// BandwidthLimiter begrenzt den Lesedurchsatz von der Quelle.
//
// Er wird durchgereicht statt hier gebaut: So teilen sich mehrere Läufe
// desselben Auftrags — ein Auftrag mit mehreren Quellen erzeugt je Quelle
// eine eigene Sicherung — denselben Begrenzer. Je Quelle einen neuen zu
// bauen ergäbe ein Vielfaches der vereinbarten Rate.
BandwidthLimiter *ratelimit.Limiter
// Incremental sichert nur, was sich seit dem Elternbackup geändert hat.
//
// Unveränderte Objekte werden nicht gelesen; ihre Blockverweise stammen
// aus dem Elternmanifest. Das entstehende Manifest beschreibt trotzdem den
// vollständigen Bestand.
Incremental bool
// AbortOnProblems bricht bei Erfassungsproblemen ab, statt sie zu vermerken.
//
// Standardmäßig läuft die Sicherung weiter: eine einzelne gesperrte Datei
// darf nicht verhindern, dass die übrigen gesichert werden. Das Ergebnis
// weist die Probleme dann aber aus.
AbortOnProblems bool
}
// BackupRunResult beschreibt einen abgeschlossenen Sicherungslauf.
type BackupRunResult struct {
// BackupID ist die Kennung des erzeugten Backups.
BackupID string `json:"backup_id"`
// FilesBackedUp ist die Zahl gesicherter Dateien.
FilesBackedUp int `json:"files_backed_up"`
// DirectoriesRecorded ist die Zahl vermerkter Verzeichnisse.
DirectoriesRecorded int `json:"directories_recorded"`
// SymlinksRecorded ist die Zahl vermerkter symbolischer Verweise.
SymlinksRecorded int `json:"symlinks_recorded"`
// Progress sind die Kennzahlen der Pipeline.
Progress backupengine.Progress `json:"progress"`
// Problems sind die beim Erfassen aufgetretenen Probleme.
//
// Ist die Liste nicht leer, ist das Backup unvollständig — auch wenn es
// technisch abgeschlossen wurde (PROMPT.md §140).
Problems []DiscoveryProblem `json:"problems"`
// Duration ist die Gesamtdauer.
Duration time.Duration `json:"duration"`
// BackupType ist die Art des erzeugten Backups.
BackupType repository.BackupType `json:"backup_type"`
// Changes ist das Ergebnis der Änderungserkennung.
//
// Bei einer vollständigen Sicherung bleibt es nil.
Changes *ChangeSet `json:"changes,omitempty"`
// ExtensionDistribution zählt die Dateiendungen der erfassten Objekte.
//
// Eines der sechs Signale der Ransomware-Erkennung (Phase 16). Sie entsteht
// hier, weil die Einträge ohnehin durchlaufen werden — eine nachträgliche
// Auswertung müsste das Manifest erneut lesen.
ExtensionDistribution map[string]int64 `json:"extension_distribution,omitempty"`
}
// IsPartialFailure meldet, ob Daten aus dem Backup fehlen.
//
// Ein solcher Lauf ist ein Teilfehler und darf niemals als voller Erfolg
// dargestellt werden (PROMPT.md §140).
//
// Gezählt werden ausschliesslich Probleme, die **Daten fernhalten** — nicht
// jeder Vermerk. Ein Socket oder eine benannte Pipe fehlt nicht im Backup, sie
// gehoert nicht hinein. Zaehlte man sie mit, waere jede Sicherung eines
// Linux-Systems ein Teilfehler: In /var/run und /tmp liegen staendig Sockets.
// Nach einer Woche klickt niemand mehr einen Teilfehler an — und dann faellt
// auch der echte nicht mehr auf.
func (backupRunResult *BackupRunResult) IsPartialFailure() bool {
return backupRunResult.DataLossProblemCount() > 0
}
// DataLossProblemCount zaehlt die Probleme, die Daten fernhalten.
func (backupRunResult *BackupRunResult) DataLossProblemCount() int {
dataLossCount := 0
for _, problem := range backupRunResult.Problems {
if problem.IsDataLoss() {
dataLossCount++
}
}
return dataLossCount
}
// SkippedObjectCount zaehlt die Objekte ohne sicherbaren Inhalt.
//
// Sie werden ausgewiesen, aber nicht als Fehler gewertet.
func (backupRunResult *BackupRunResult) SkippedObjectCount() int {
return len(backupRunResult.Problems) - backupRunResult.DataLossProblemCount()
}
// Summary fasst das Ergebnis in einem Satz zusammen.
func (backupRunResult *BackupRunResult) Summary() string {
// Objekte ohne sicherbaren Inhalt werden **immer** genannt, auch im
// Erfolgsfall. Sie machen den Lauf nicht zum Teilfehler — verschwiegen
// werden duerfen sie trotzdem nicht: Wer einen Socket im Quellverzeichnis
// hat, soll erfahren, dass er nicht im Backup ist.
skippedSuffix := ""
if skippedCount := backupRunResult.SkippedObjectCount(); skippedCount > 0 {
skippedSuffix = fmt.Sprintf(" %d Objekt(e) ohne sicherbaren Inhalt (Sockets, Pipes, "+
"Gerätedateien) wurden übergangen; das ist kein Fehler.", skippedCount)
}
if backupRunResult.IsPartialFailure() {
return fmt.Sprintf("TEILWEISE FEHLGESCHLAGEN: %d Dateien gesichert, %d Objekte konnten nicht gelesen werden.",
backupRunResult.FilesBackedUp, backupRunResult.DataLossProblemCount()) + skippedSuffix
}
if backupRunResult.Changes != nil {
return fmt.Sprintf("Erfolgreich: %d Dateien gesichert, davon %d neu gelesen und %d aus %s übernommen.",
backupRunResult.FilesBackedUp,
backupRunResult.Changes.ChangedFileCount(),
backupRunResult.Changes.ReusedFileCount(),
backupRunResult.Changes.ParentBackupID) + skippedSuffix
}
return fmt.Sprintf("Erfolgreich: %d Dateien, %d Verzeichnisse gesichert.",
backupRunResult.FilesBackedUp, backupRunResult.DirectoriesRecorded) + skippedSuffix
}
// ErrBackupIncompleteDueToProblems meldet einen wegen Erfassungsproblemen
// abgebrochenen Lauf.
var ErrBackupIncompleteDueToProblems = errors.New("die sicherung wurde abgebrochen, weil objekte nicht gelesen werden konnten")
// BackupRunner führt Sicherungsläufe aus.
type BackupRunner struct {
// engine ist die Backup Engine.
engine *backupengine.Engine
// logger protokolliert den Verlauf.
logger *slog.Logger
}
// NewBackupRunner erzeugt den Sicherungslauf.
func NewBackupRunner(engine *backupengine.Engine, baseLogger *slog.Logger) *BackupRunner {
return &BackupRunner{
engine: engine,
logger: logging.WithComponent(baseLogger, "agent-backup"),
}
}
// resolveParentManifest ermittelt das Elternbackup einer Zusatzsicherung.
//
// Ist eine Kennung angegeben, gilt sie; sonst wird das jüngste abgeschlossene
// Backup derselben Quelle gesucht. Ein ausdrücklich benanntes Elternbackup
// wird trotzdem geprüft: eine Zusatzsicherung auf ein unvollständiges Backup
// aufzubauen ergäbe ein Backup, das vollständig aussieht und es nicht ist.
func (backupRunner *BackupRunner) resolveParentManifest(resolveContext context.Context, runOptions BackupRunOptions) (*repository.Manifest, error) {
sourceRepository := backupRunner.engine.Repository()
if runOptions.ParentBackupID == "" {
return FindParentBackup(resolveContext, sourceRepository, runOptions.SourcePath)
}
parentManifest, readError := sourceRepository.ReadManifest(resolveContext, runOptions.ParentBackupID)
if readError != nil {
return nil, fmt.Errorf("das angegebene elternbackup %s konnte nicht gelesen werden: %w",
runOptions.ParentBackupID, readError)
}
if !parentManifest.Complete {
return nil, fmt.Errorf("%w: das angegebene backup %s ist unvollständig",
ErrNoParentBackup, runOptions.ParentBackupID)
}
return parentManifest, nil
}
// RunBackup sichert einen Verzeichnisbaum.
//
// Der Ablauf verbindet die Erfassung mit der Pipeline: erst wird ermittelt, was
// gesichert werden soll, dann wandert jede Datei als eigener Datenstrom durch
// Chunking, Deduplizierung, Kompression und Verschlüsselung.
//
// Verzeichnisse und symbolische Verweise tragen keine Daten, gehören aber ins
// Manifest: ohne ihre Rechte und Ziele wäre eine Wiederherstellung unvollständig.
func (backupRunner *BackupRunner) RunBackup(backupContext context.Context, runOptions BackupRunOptions) (*BackupRunResult, error) {
startTime := time.Now()
// Verzeichnisse werden immer mit erfasst: ihre Rechte gehen sonst verloren.
discoveryOptions := runOptions.DiscoveryOptions
discoveryOptions.IncludeDirectories = true
discoveryResult, discoveryError := Discover(runOptions.SourcePath, discoveryOptions)
if discoveryError != nil {
return nil, discoveryError
}
// Auch der strenge Modus bricht nur bei echtem Datenverlust ab: Ein Socket
// im Quellverzeichnis darf keine Sicherung verhindern.
if runOptions.AbortOnProblems && discoveryResult.DataLossProblemCount() > 0 {
return nil, fmt.Errorf("%w (%d objekte betroffen)",
ErrBackupIncompleteDueToProblems, discoveryResult.DataLossProblemCount())
}
if len(discoveryResult.Entries) == 0 {
// Ein leeres Backup wäre ein Gebilde, das sich als erfolgreiche
// Sicherung ausgibt, ohne etwas zu enthalten.
return nil, errors.New("die quelle enthält keine sicherbaren objekte")
}
// Bei einer Zusatzsicherung entscheidet der Vergleich mit dem
// Elternmanifest, was überhaupt gelesen werden muss.
backupType := repository.BackupTypeFull
chainIdentifier := runOptions.ChainID
parentBackupIdentifier := runOptions.ParentBackupID
var changeSet *ChangeSet
var decisionsByPath map[string]ChangeDecision
if runOptions.Incremental {
parentManifest, parentError := backupRunner.resolveParentManifest(backupContext, runOptions)
if parentError != nil {
return nil, parentError
}
changeSet = DetectChanges(discoveryResult.Entries, parentManifest)
decisionsByPath = make(map[string]ChangeDecision, len(changeSet.Decisions))
for _, changeDecision := range changeSet.Decisions {
decisionsByPath[changeDecision.Entry.RelativePath] = changeDecision
}
backupType = repository.BackupTypeIncremental
parentBackupIdentifier = parentManifest.BackupID
// Die Kette wird vom Elternbackup übernommen. Eine eigene Kette je
// Zusatzsicherung machte die Zusammengehörigkeit unauffindbar.
chainIdentifier = parentManifest.ChainID
if chainIdentifier == "" {
chainIdentifier = parentManifest.BackupID
}
}
backupRunner.logger.Info("sicherung gestartet",
slog.String("backup_id", runOptions.BackupID),
slog.String("quelle", runOptions.SourcePath),
slog.String("art", string(backupType)),
slog.Int("objekte", len(discoveryResult.Entries)),
slog.Int64("bytes", discoveryResult.TotalBytes))
if changeSet != nil {
backupRunner.logger.Info("änderungserkennung abgeschlossen",
slog.String("elternbackup", changeSet.ParentBackupID),
slog.Int("geaenderte_dateien", changeSet.ChangedFileCount()),
slog.Int("uebernommene_dateien", changeSet.ReusedFileCount()),
slog.Int("geloeschte_objekte", len(changeSet.DeletedPaths)),
slog.Int64("zu_lesende_bytes", changeSet.BytesToRead()))
}
// Die geöffneten Dateien werden nach dem Lauf geschlossen. Sie müssen bis
// zum Ende offen bleiben, weil die Pipeline sie als Datenströme liest.
openedFiles := make([]*os.File, 0, discoveryResult.FileCount())
defer func() {
for _, openedFile := range openedFiles {
_ = openedFile.Close()
}
}()
backupSources := make([]backupengine.BackupSource, 0, len(discoveryResult.Entries))
runResult := &BackupRunResult{
BackupID: runOptions.BackupID,
Problems: discoveryResult.Problems,
// Die Endungsverteilung entsteht hier, weil die Einträge ohnehin
// vorliegen — eine nachträgliche Auswertung müsste das Manifest erneut
// lesen (Phase 16).
ExtensionDistribution: countFileExtensions(discoveryResult.Entries),
}
for _, discoveredEntry := range discoveryResult.Entries {
switch discoveredEntry.EntryType {
case EntryTypeFile:
// Ein unverändertes Objekt wird nicht einmal geöffnet — darin
// besteht der Gewinn der Zusatzsicherung.
if changeDecision, hasDecision := decisionsByPath[discoveredEntry.RelativePath]; hasDecision && changeDecision.ParentEntry != nil {
backupSources = append(backupSources, backupengine.BackupSource{
Path: discoveredEntry.RelativePath,
EntryType: string(EntryTypeFile),
SizeBytes: discoveredEntry.SizeBytes,
ModifiedAt: discoveredEntry.ModifiedAt,
Mode: discoveredEntry.Mode,
ReusedChunks: changeDecision.ParentEntry.Chunks,
ContentHash: changeDecision.ParentEntry.ContentHash,
})
runResult.FilesBackedUp++
continue
}
sourceFile, openError := os.Open(discoveredEntry.AbsolutePath)
if openError != nil {
// Eine zwischen Erfassung und Sicherung verschwundene oder
// gesperrte Datei wird vermerkt, nicht verschwiegen.
runResult.Problems = append(runResult.Problems, DiscoveryProblem{
Path: discoveredEntry.AbsolutePath,
Reason: "Die Datei konnte beim Sichern nicht geöffnet werden: " + openError.Error(),
IsPermissionDenied: errors.Is(openError, os.ErrPermission),
})
continue
}
openedFiles = append(openedFiles, sourceFile)
backupSources = append(backupSources, backupengine.BackupSource{
Path: discoveredEntry.RelativePath,
EntryType: string(EntryTypeFile),
SizeBytes: discoveredEntry.SizeBytes,
ModifiedAt: discoveredEntry.ModifiedAt,
Reader: sourceFile,
Mode: discoveredEntry.Mode,
})
runResult.FilesBackedUp++
case EntryTypeDirectory:
// Ein Verzeichnis trägt keine Daten, aber seine Rechte.
backupSources = append(backupSources, backupengine.BackupSource{
Path: discoveredEntry.RelativePath,
EntryType: string(EntryTypeDirectory),
ModifiedAt: discoveredEntry.ModifiedAt,
Mode: discoveredEntry.Mode,
})
runResult.DirectoriesRecorded++
case EntryTypeSymlink:
backupSources = append(backupSources, backupengine.BackupSource{
Path: discoveredEntry.RelativePath,
EntryType: string(EntryTypeSymlink),
ModifiedAt: discoveredEntry.ModifiedAt,
Mode: discoveredEntry.Mode,
LinkTarget: discoveredEntry.LinkTarget,
})
runResult.SymlinksRecorded++
}
}
backupResult, backupError := backupRunner.engine.Backup(backupContext, backupengine.BackupOptions{
BackupID: runOptions.BackupID,
ChainID: chainIdentifier,
ParentBackupID: parentBackupIdentifier,
BackupType: backupType,
Source: repository.SourceInformation{
SourceType: "filesystem",
SourceID: runOptions.SourcePath,
SourceName: runOptions.SourceName,
Hostname: CollectSystemInformation().Hostname,
OperatingSystem: CollectSystemInformation().Platform,
},
CompressionLevel: runOptions.CompressionLevel,
EncryptionEnabled: runOptions.EncryptionEnabled,
ProgressCallback: runOptions.ProgressCallback,
CreatedByVersion: runOptions.CreatedByVersion,
BandwidthLimiter: runOptions.BandwidthLimiter,
}, backupSources)
if backupError != nil {
return nil, backupError
}
runResult.Progress = backupResult.Progress
runResult.Duration = time.Since(startTime)
runResult.BackupType = backupType
runResult.Changes = changeSet
// Der Ausgang wird ehrlich benannt: ein Lauf mit übergangenen Objekten ist
// ein Teilfehler, kein Erfolg.
if runResult.IsPartialFailure() {
backupRunner.logger.Warn("sicherung teilweise fehlgeschlagen",
slog.String("backup_id", runOptions.BackupID),
slog.Int("gesicherte_dateien", runResult.FilesBackedUp),
slog.Int("uebergangene_objekte", runResult.DataLossProblemCount()),
slog.Int("ohne_sicherbaren_inhalt", runResult.SkippedObjectCount()))
} else {
backupRunner.logger.Info("sicherung abgeschlossen",
slog.String("backup_id", runOptions.BackupID),
slog.Int("dateien", runResult.FilesBackedUp),
slog.Int64("bytes", runResult.Progress.BytesProcessed),
slog.String("dauer", runResult.Duration.String()))
}
return runResult, nil
}
// maximumTrackedExtensions begrenzt die Zahl gemeldeter Dateiendungen.
//
// Eine Quelle mit tausend verschiedenen Endungen ergäbe eine Verteilung, die
// niemand liest und die in jeder Zeile der Datenbank Platz kostet. Für die
// Erkennung zählt ohnehin nur, was häufig ist — und eine plötzlich auftauchende
// neue Endung erscheint bei massenhafter Verschlüsselung sofort ganz oben.
const maximumTrackedExtensions = 20
// countFileExtensions zählt die Dateiendungen der erfassten Objekte.
//
// Eines der sechs Signale der Ransomware-Erkennung (Phase 16). Verzeichnisse
// und Verweise bleiben aussen vor: Sie tragen keine Endung, die etwas über den
// Inhalt sagt.
//
// Dateien ohne Endung werden unter „(ohne)" geführt statt weggelassen — ihr
// Anteil ist selbst eine Aussage.
func countFileExtensions(discoveredEntries []DiscoveredEntry) map[string]int64 {
extensionCounts := make(map[string]int64, 16)
for _, discoveredEntry := range discoveredEntries {
if discoveredEntry.EntryType != EntryTypeFile {
continue
}
fileExtension := strings.ToLower(filepath.Ext(discoveredEntry.RelativePath))
if fileExtension == "" {
fileExtension = "(ohne)"
}
extensionCounts[fileExtension]++
}
if len(extensionCounts) <= maximumTrackedExtensions {
return extensionCounts
}
return keepMostFrequentExtensions(extensionCounts)
}
// keepMostFrequentExtensions behält die häufigsten Endungen.
//
// Der Rest wird nicht verworfen, sondern unter „(weitere)" zusammengefasst: Eine
// Verteilung, deren Summe nicht mehr der Zahl der Dateien entspricht, wäre in
// jeder Auswertung irreführend.
func keepMostFrequentExtensions(extensionCounts map[string]int64) map[string]int64 {
type extensionCount struct {
extension string
count int64
}
sortedCounts := make([]extensionCount, 0, len(extensionCounts))
for extension, count := range extensionCounts {
sortedCounts = append(sortedCounts, extensionCount{extension: extension, count: count})
}
sort.Slice(sortedCounts, func(firstIndex int, secondIndex int) bool {
if sortedCounts[firstIndex].count != sortedCounts[secondIndex].count {
return sortedCounts[firstIndex].count > sortedCounts[secondIndex].count
}
// Bei Gleichstand alphabetisch, damit dieselbe Quelle stets dieselbe
// Verteilung ergibt — sonst schwankte der Vergleich zweier Läufe
// zufällig.
return sortedCounts[firstIndex].extension < sortedCounts[secondIndex].extension
})
reducedCounts := make(map[string]int64, maximumTrackedExtensions+1)
var remainingCount int64
for countIndex, entry := range sortedCounts {
if countIndex < maximumTrackedExtensions {
reducedCounts[entry.extension] = entry.count
continue
}
remainingCount += entry.count
}
if remainingCount > 0 {
reducedCounts["(weitere)"] = remainingCount
}
return reducedCounts
}