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 }