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

382 lines
14 KiB
Go

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, "..")
}