package agent import ( "context" "fmt" "log/slog" "path/filepath" "strings" "time" "github.com/syncova/syncova/packages/backupengine" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/platform/ratelimit" "github.com/syncova/syncova/packages/repository" ) // TaskExecutor führt Aufträge des Control-Servers aus (Phase 5). // // Er benutzt dieselbe Engine wie der Kommandozeilenweg. Ein eigener // Ausführungspfad für Aufträge wäre ein zweiter Ort, an dem eine Regel fehlen // kann — und der Unterschied fiele erst auf, wenn eine über den Server // gesteuerte Sicherung anders ausfällt als eine von Hand angestoßene. type TaskExecutor struct { // secretStore entschlüsselt den Datenschlüssel des Repositorys. // // Er darf nil sein; dann sind nur unverschlüsselte Repositories nutzbar, // und ein Auftrag mit verlangter Verschlüsselung wird abgelehnt statt // unverschlüsselt ausgeführt. secretStore crypto.SecretStore // progressReporter meldet Fortschritt an den Server. progressReporter ProgressReporter // logger protokolliert den Verlauf. logger *slog.Logger } // ProgressReporter meldet den Fortschritt eines Auftrags. // // Eine Schnittstelle statt eines festen Clients: So lässt sich die Ausführung // prüfen, ohne einen Server zu betreiben. type ProgressReporter interface { // ReportProgress meldet Fortschritt und Lebenszeichen. // // Die zweite Zahl ist die der geschriebenen Bloecke, nicht die der Dateien: // Die Engine zaehlt waehrend des Laufs Bloecke, und eine Dateizahl liesse // sich daraus nur schaetzen. Eine geschaetzte Fortschrittsangabe ist // schlimmer als eine unvollstaendige. ReportProgress(progressContext context.Context, taskIdentifier string, bytesProcessed int64, chunksWritten int64) error } // NewTaskExecutor erzeugt die Auftragsausführung. func NewTaskExecutor(secretStore crypto.SecretStore, progressReporter ProgressReporter, baseLogger *slog.Logger) *TaskExecutor { return &TaskExecutor{ secretStore: secretStore, progressReporter: progressReporter, logger: baseLogger, } } // progressReportInterval ist der Abstand zweier Fortschrittsmeldungen. // // Fünf Sekunden: Häufiger belastete den Server ohne Erkenntnisgewinn, seltener // näherte sich die Meldung der Frist, nach der der Server den Auftrag als // verwaist freigibt. const progressReportInterval = 5 * time.Second // ExecuteBackupTask führt einen Sicherungsauftrag aus. // // Das Ergebnis wird **immer** zurückgegeben, auch im Fehlerfall: Der Server // wartet darauf, und ein Agent, der bei einem Fehler schweigt, lässt den Lauf // bis zur Frist hängen. Ein Fehler ist eine Antwort. func (executor *TaskExecutor) ExecuteBackupTask(taskContext context.Context, claimedTask *ClaimedTask) TaskResultPayload { taskLogger := executor.logger.With( slog.String("auftrag", claimedTask.TaskID), slog.String("quelle", claimedTask.Backup.SourcePath)) taskLogger.Info("auftrag wird ausgefuehrt", slog.String("repository", claimedTask.Backup.RepositoryPath), slog.Bool("inkrementell", claimedTask.Backup.Incremental)) openedRepository, openError := repository.Open(taskContext, claimedTask.Backup.RepositoryPath, repository.OpenOptions{}, executor.logger) if openError != nil { // Der häufigste Fall in einer echten Anlage: Der Agent erreicht das // Repository nicht, weil die Freigabe fehlt oder nicht eingehängt ist. // Die Meldung sagt das, statt einen technischen Pfadfehler // weiterzureichen. return TaskResultPayload{ Status: "failed", ErrorCode: "REPOSITORY_UNREACHABLE", ErrorMessage: fmt.Sprintf("Das Repository unter %q ist von diesem Agenten aus nicht "+ "erreichbar: %v. Prüfen Sie, ob die Freigabe eingehängt ist.", claimedTask.Backup.RepositoryPath, openError), FailureClass: "repository", } } defer func() { _ = openedRepository.Close() }() if claimedTask.Backup.EncryptionEnabled && executor.secretStore == nil { // Niemals unverschlüsselt ausführen, wenn Verschlüsselung verlangt war. // Ein Backup, das der Server für verschlüsselt hält und das es nicht // ist, wäre eine stille Zusicherung ins Leere. return TaskResultPayload{ Status: "failed", ErrorCode: "ENCRYPTION_KEY_MISSING", ErrorMessage: "Der Auftrag verlangt Verschlüsselung, aber auf diesem Agenten ist " + "kein Schlüsselmaterial eingerichtet.", FailureClass: "configuration", } } backupEngine := backupengine.NewEngine(openedRepository, executor.secretStore, executor.logger) backupRunner := NewBackupRunner(backupEngine, executor.logger) bandwidthLimiter, limiterError := ratelimit.NewLimiter( claimedTask.Backup.BandwidthLimitBytesPerSecond) if limiterError != nil { return TaskResultPayload{ Status: "failed", ErrorCode: "INVALID_BANDWIDTH_LIMIT", ErrorMessage: limiterError.Error(), FailureClass: "configuration", } } runOptions := BackupRunOptions{ BackupID: claimedTask.Backup.BackupID, SourcePath: claimedTask.Backup.SourcePath, SourceName: claimedTask.Backup.SourceName, ChainID: claimedTask.Backup.ChainID, ParentBackupID: claimedTask.Backup.ParentBackupID, Incremental: claimedTask.Backup.Incremental, CompressionLevel: resolveCompressionLevel(claimedTask.Backup.CompressionLevel), EncryptionEnabled: claimedTask.Backup.EncryptionEnabled, BandwidthLimiter: bandwidthLimiter, CreatedByVersion: "syncova-agent", DiscoveryOptions: DiscoveryOptions{ IncludePatterns: claimedTask.Backup.IncludePatterns, ExcludePatterns: claimedTask.Backup.ExcludePatterns, }, ProgressCallback: executor.buildProgressCallback(taskContext, claimedTask.TaskID), } runResult, runError := backupRunner.RunBackup(taskContext, runOptions) if runError != nil { taskLogger.Error("der auftrag ist gescheitert", slog.String("grund", runError.Error())) return TaskResultPayload{ Status: "failed", ErrorCode: "AGENT_BACKUP_FAILED", ErrorMessage: runError.Error(), FailureClass: "source", } } taskResult := TaskResultPayload{ Status: "succeeded", BytesProcessed: runResult.Progress.BytesProcessed, BytesWritten: runResult.Progress.BytesWritten, FilesProcessed: int64(runResult.FilesBackedUp + runResult.DirectoriesRecorded + runResult.SymlinksRecorded), FilesSkipped: int64(runResult.DataLossProblemCount()), BackupIDInRepository: claimedTask.Backup.BackupID, } // Ein Lauf mit übergangenen Objekten ist ein Teilfehler, niemals ein // Erfolg (verbindliche Regel 1). Der Server berichtigt das zusätzlich, und // die Datenbank lehnte es ohnehin ab — aber der Agent soll gar nicht erst // die Unwahrheit melden. if taskResult.FilesSkipped > 0 { taskResult.Status = "partial_failure" } taskLogger.Info("auftrag abgeschlossen", slog.String("ergebnis", taskResult.Status), slog.Int64("bytes", taskResult.BytesProcessed), slog.Int64("objekte", taskResult.FilesProcessed), slog.Int64("uebergangen", taskResult.FilesSkipped)) return taskResult } // buildProgressCallback erzeugt die Fortschrittsmeldung an den Server. // // Die Meldung wird **gedrosselt**: Die Engine ruft den Rückruf sehr häufig auf, // und jede Meldung ist eine HTTP-Anfrage samt Datenbankschreibvorgang. Ohne // Drosselung erzeugte eine Sicherung mit einer Million Dateien eine Million // Anfragen — dieselbe Überlegung wie beim Prüfpunkt der Wiederherstellung // (Phase 9). func (executor *TaskExecutor) buildProgressCallback(taskContext context.Context, taskIdentifier string) backupengine.ProgressCallback { if executor.progressReporter == nil { return nil } lastReportTime := time.Now() return func(currentProgress backupengine.Progress) { if time.Since(lastReportTime) < progressReportInterval { return } lastReportTime = time.Now() reportError := executor.progressReporter.ReportProgress(taskContext, taskIdentifier, currentProgress.BytesProcessed, currentProgress.ChunksWritten) if reportError != nil { // Eine ausgefallene Fortschrittsmeldung bricht die Sicherung nicht // ab: Die Arbeit ist wertvoller als die Meldung. Verschwiegen wird // sie trotzdem nicht — bleibt sie dauerhaft aus, gibt der Server // den Auftrag als verwaist frei. executor.logger.Warn("der fortschritt liess sich nicht melden", slog.String("auftrag", taskIdentifier), slog.String("grund", reportError.Error())) } } } // resolveCompressionLevel liest die Kompressionsstufe eines Auftrags. // // Eine unbekannte Angabe ergibt die ausgewogene Stufe, nicht einen Fehler: Ein // neuerer Server könnte eine Stufe kennen, die dieser Agent noch nicht hat, und // deswegen die Sicherung ausfallen zu lassen wäre unverhältnismäßig. func resolveCompressionLevel(rawLevel string) backupengine.CompressionLevel { switch rawLevel { case "off": return backupengine.CompressionOff case "fast": return backupengine.CompressionFast case "maximum": return backupengine.CompressionMaximum default: return backupengine.CompressionBalanced } } // ExecuteRestoreTask führt einen Wiederherstellungsauftrag aus. // // Der Agent schreibt hier auf **sein eigenes** System. Die Entscheidung, ob das // gewollt ist, hat der Server bereits getroffen: Recht und wörtliche // Bestätigung des Zielpfads lagen vor, bevor der Auftrag entstand. Der Agent // prüft das nicht noch einmal — er könnte es nicht, ihm fehlt der Benutzer. // // Was er sehr wohl prüft, ist der **Zielpfad**: Ein Auftrag, der nach /etc // zurückschreiben will, wird abgelehnt. Diese Prüfung gehört auf das System, // das die Daten empfängt, denn nur dort ist bekannt, welche Verzeichnisse zum // laufenden Betriebssystem gehören. func (executor *TaskExecutor) ExecuteRestoreTask(taskContext context.Context, claimedTask *ClaimedTask) TaskResultPayload { taskLogger := executor.logger.With( slog.String("auftrag", claimedTask.TaskID), slog.String("ziel", claimedTask.Restore.TargetPath)) taskLogger.Info("wiederherstellung wird ausgefuehrt", slog.String("backup", claimedTask.Restore.BackupID), slog.Bool("ueberschreiben", claimedTask.Restore.OverwriteExisting)) if guardError := validateRestoreTarget(claimedTask.Restore.TargetPath); guardError != nil { return TaskResultPayload{ Status: "failed", ErrorCode: "RESTORE_TARGET_FORBIDDEN", ErrorMessage: guardError.Error(), FailureClass: "configuration", } } openedRepository, openError := repository.Open(taskContext, claimedTask.Restore.RepositoryPath, repository.OpenOptions{ReadOnly: true}, executor.logger) if openError != nil { return TaskResultPayload{ Status: "failed", ErrorCode: "REPOSITORY_UNREACHABLE", ErrorMessage: fmt.Sprintf("Das Repository unter %q ist von diesem Agenten aus nicht "+ "erreichbar: %v. Prüfen Sie, ob die Freigabe eingehängt ist.", claimedTask.Restore.RepositoryPath, openError), FailureClass: "repository", } } defer func() { _ = openedRepository.Close() }() backupEngine := backupengine.NewEngine(openedRepository, executor.secretStore, executor.logger) restoreRunner := NewRestoreRunner(backupEngine, executor.logger) runResult, runError := restoreRunner.RunRestore(taskContext, RestoreRunOptions{ BackupID: claimedTask.Restore.BackupID, TargetPath: claimedTask.Restore.TargetPath, PathPrefix: claimedTask.Restore.PathPrefix, OverwriteExisting: claimedTask.Restore.OverwriteExisting, RestorePermissions: claimedTask.Restore.RestorePermissions, }) if runError != nil { taskLogger.Error("die wiederherstellung ist gescheitert", slog.String("grund", runError.Error())) return TaskResultPayload{ Status: "failed", ErrorCode: "AGENT_RESTORE_FAILED", ErrorMessage: runError.Error(), FailureClass: "permanent", } } taskResult := TaskResultPayload{ Status: "succeeded", BytesProcessed: runResult.BytesRestored, FilesProcessed: int64(runResult.FilesRestored + runResult.DirectoriesCreated + runResult.SymlinksCreated), FilesSkipped: int64(runResult.SkippedExisting), } // Übergangene Objekte machen auch eine Wiederherstellung zum Teilfehler. // Ein „erfolgreich" mit ausgelassenen Dateien wäre die gefährlichste // Meldung der ganzen Anlage: Der Betreiber hielte sein System für // vollständig zurückgeholt. if taskResult.FilesSkipped > 0 { taskResult.Status = "partial_failure" } taskLogger.Info("wiederherstellung abgeschlossen", slog.String("ergebnis", taskResult.Status), slog.Int64("objekte", taskResult.FilesProcessed), slog.Int64("uebergangen", taskResult.FilesSkipped)) return taskResult } // forbiddenRestoreRoots sind die Systemverzeichnisse des laufenden Systems. // // Dieselbe Liste wie im TargetGuard des Servers (Phase 19) — hier ein zweites // Mal, weil sie auf dem **empfangenden** System gelten muss: Der Server kennt // die Verzeichnisstruktur eines fremden Rechners nicht, und ein Windows-Agent // hat andere Systempfade als ein Linux-Server. var forbiddenRestoreRoots = []string{ "/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/libx32", "/proc", "/root", "/sbin", "/sys", "/usr", "/var/lib", "/var/run", "/System", "/Library", "/private/etc", "/private/var/db", `C:\Windows`, `C:\Program Files`, `C:\Program Files (x86)`, } // validateRestoreTarget prüft das Zielverzeichnis einer Wiederherstellung. func validateRestoreTarget(targetPath string) error { absoluteTarget, absoluteError := filepath.Abs(strings.TrimSpace(targetPath)) if absoluteError != nil { return fmt.Errorf("der zielpfad ist unlesbar: %w", absoluteError) } absoluteTarget = filepath.Clean(absoluteTarget) if absoluteTarget == string(filepath.Separator) { return fmt.Errorf("das wurzelverzeichnis ist als ziel nicht zulaessig") } for _, forbiddenRoot := range forbiddenRestoreRoots { if isWithinRestoreRoot(absoluteTarget, filepath.Clean(forbiddenRoot)) { return fmt.Errorf("das ziel %q liegt in einem systemverzeichnis (%s). eine "+ "wiederherstellung dorthin ueberschriebe das laufende system dieses agenten", absoluteTarget, forbiddenRoot) } } return nil } // isWithinRestoreRoot meldet, ob ein Pfad in einem Verzeichnis liegt. // // Über die Pfadtrennung, nicht über strings.HasPrefix: Sonst gälte "/etchen" // als Teil von "/etc" (derselbe Fund wie in Phase 19). func isWithinRestoreRoot(candidatePath string, directoryPath string) bool { if candidatePath == directoryPath { return true } relativePath, relativeError := filepath.Rel(directoryPath, candidatePath) if relativeError != nil { return false } return relativePath == "." || !strings.HasPrefix(relativePath, "..") }