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>
382 lines
14 KiB
Go
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, "..")
|
|
}
|