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

360 lines
12 KiB
Go

package verification
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/syncova/syncova/packages/agent"
"github.com/syncova/syncova/packages/backupengine"
"github.com/syncova/syncova/packages/platform/crypto"
"github.com/syncova/syncova/packages/platform/logging"
"github.com/syncova/syncova/packages/repository"
)
// RestoreTestOptions steuern einen Wiederherstellungstest.
type RestoreTestOptions struct {
// BackupID ist das zu pruefende Backup im Repository.
BackupID string
// PathPrefix beschraenkt den Test auf einen Teilbaum.
//
// Bei sehr grossen Backups ist ein Teiltest besser als keiner — er wird im
// Bericht aber als solcher ausgewiesen. Ein Teiltest belegt nicht die
// Wiederherstellbarkeit des ganzen Backups.
PathPrefix string
// WorkingDirectory ist der Ort des Wegwerfziels.
//
// Leer waehlt das Temporaerverzeichnis des Systems. Bei grossen Backups
// gehoert hier ein Datentraeger mit genug Platz hin — das Temporaer-
// verzeichnis liegt oft im Arbeitsspeicher.
WorkingDirectory string
// KeepRestoredData behaelt die zurueckgeschriebenen Daten.
//
// Nur zur Fehlersuche. Im Regelfall wird alles geloescht: Ein Test, der
// Platz belegt, wird bald abgeschaltet.
KeepRestoredData bool
}
// RestoreTestReport ist das Ergebnis eines Wiederherstellungstests.
type RestoreTestReport struct {
// Report ist der allgemeine Pruefbericht.
Report
// FilesRestored ist die Zahl zurueckgeschriebener Dateien.
FilesRestored int `json:"files_restored"`
// FilesCompared ist die Zahl gegen ihre Pruefsumme verglichener Dateien.
FilesCompared int `json:"files_compared"`
// FilesMismatched ist die Zahl abweichender Dateien.
FilesMismatched int `json:"files_mismatched"`
// BytesRestored ist die zurueckgeschriebene Datenmenge.
BytesRestored int64 `json:"bytes_restored"`
// WasPartialTest meldet einen auf einen Teilbaum beschraenkten Test.
WasPartialTest bool `json:"was_partial_test"`
// TestedPathPrefix ist der geprueft Teilbaum.
TestedPathPrefix string `json:"tested_path_prefix,omitempty"`
}
// Succeeded meldet einen bestandenen Test.
func (report *RestoreTestReport) Succeeded() bool {
return report.IsClean() && report.FilesMismatched == 0
}
// RestoreTester fuehrt Wiederherstellungstests aus.
//
// Der Test ist der einzige Nachweis, der zaehlt. Eine Blockpruefung sagt, dass
// die Bytes da sind; sie sagt nicht, dass Entschluesselung, Dekompression,
// Zusammensetzung und Dateisystem zusammen ein brauchbares Ergebnis liefern.
// Genau dort sitzen die Fehler, die man erst im Ernstfall bemerkt.
type RestoreTester struct {
// sourceRepository ist das Repository mit dem Backup.
sourceRepository *repository.LocalRepository
// secretStore entschluesselt die Datenschluessel.
secretStore crypto.SecretStore
// logger protokolliert den Verlauf.
logger *slog.Logger
}
// NewRestoreTester erzeugt den Wiederherstellungstest.
func NewRestoreTester(sourceRepository *repository.LocalRepository, secretStore crypto.SecretStore, baseLogger *slog.Logger) *RestoreTester {
return &RestoreTester{
sourceRepository: sourceRepository,
secretStore: secretStore,
logger: logging.WithComponent(baseLogger, "restore-test"),
}
}
// RunTest stellt ein Backup an einen Wegwerfort wieder her und vergleicht es.
//
// Der Ablauf: Wegwerfziel anlegen → wiederherstellen → jede Datei gegen die
// Pruefsumme im Manifest vergleichen → alles loeschen. Was bleibt, ist die
// Aussage — nicht die Daten.
func (tester *RestoreTester) RunTest(testContext context.Context, testOptions RestoreTestOptions) (*RestoreTestReport, error) {
startTime := time.Now()
report := &RestoreTestReport{
Report: Report{
BackupID: testOptions.BackupID,
VerificationType: TypeRestoreTest,
Findings: make([]Finding, 0, 2),
StartedAt: time.Now().UTC(),
},
WasPartialTest: testOptions.PathPrefix != "",
TestedPathPrefix: testOptions.PathPrefix,
}
workingRoot := testOptions.WorkingDirectory
if workingRoot == "" {
workingRoot = os.TempDir()
}
// Ein eigenes Verzeichnis je Lauf: Zwei gleichzeitige Tests duerfen sich
// nicht ins Gehege kommen, und das Aufraeumen bleibt eindeutig.
testDirectory, createError := os.MkdirTemp(workingRoot, "syncova-restoretest-")
if createError != nil {
return nil, fmt.Errorf("das wegwerfziel konnte nicht angelegt werden: %w", createError)
}
if !testOptions.KeepRestoredData {
// Das Aufraeumen laeuft auch bei einem Abbruch: Ein Test, der Platz
// belegt, wird bald abgeschaltet.
defer func() {
if removeError := os.RemoveAll(testDirectory); removeError != nil {
tester.logger.Error("das wegwerfziel konnte nicht geraeumt werden",
slog.String("pfad", testDirectory),
slog.String("grund", removeError.Error()))
}
}()
}
backupManifest, readError := tester.sourceRepository.ReadManifest(testContext, testOptions.BackupID)
if readError != nil {
report.Findings = append(report.Findings, Finding{
Code: "MANIFEST_UNREADABLE",
Severity: SeverityMissing,
Message: "Das Manifest ist nicht lesbar; ein Test ist nicht moeglich.",
Detail: readError.Error(),
})
finalizeReport(&report.Report, startTime)
return report, nil
}
engine := backupengine.NewEngine(tester.sourceRepository, tester.secretStore, tester.logger)
restoreRunner := agent.NewRestoreRunner(engine, tester.logger)
tester.logger.Info("wiederherstellungstest gestartet",
slog.String("backup_id", testOptions.BackupID),
slog.String("wegwerfziel", testDirectory),
slog.Bool("teiltest", report.WasPartialTest))
runResult, restoreError := restoreRunner.RunRestore(testContext, agent.RestoreRunOptions{
BackupID: testOptions.BackupID,
TargetPath: testDirectory,
PathPrefix: testOptions.PathPrefix,
// Die Rechte gehoeren zum Ergebnis: Eine Datei mit falschen Rechten ist
// nicht richtig wiederhergestellt.
RestorePermissions: true,
// Das Wegwerfziel ist frisch angelegt und damit leer; ueberschrieben
// wird nichts.
OverwriteExisting: false,
})
if restoreError != nil {
report.Findings = append(report.Findings, Finding{
Code: "RESTORE_FAILED",
Severity: SeverityCorruption,
Message: "Die Wiederherstellung schlug fehl. Das Backup laesst sich nicht zurueckholen — " +
"unabhaengig davon, was Manifest und Blockpruefung sagen.",
Detail: restoreError.Error(),
})
finalizeReport(&report.Report, startTime)
return report, nil
}
report.FilesRestored = runResult.FilesRestored
report.BytesRestored = runResult.BytesRestored
tester.compareRestoredFiles(testContext, backupManifest, testDirectory, testOptions.PathPrefix, report)
finalizeReport(&report.Report, startTime)
if report.Succeeded() {
tester.logger.Info("wiederherstellungstest bestanden",
slog.String("backup_id", testOptions.BackupID),
slog.Int("dateien", report.FilesCompared),
slog.String("dauer", time.Since(startTime).Round(time.Millisecond).String()))
} else {
tester.logger.Error("wiederherstellungstest NICHT bestanden",
slog.String("backup_id", testOptions.BackupID),
slog.Int("abweichend", report.FilesMismatched))
}
return report, nil
}
// compareRestoredFiles vergleicht die zurueckgeschriebenen Dateien.
//
// Verglichen wird gegen den Inhaltshash im Manifest — nicht gegen die
// Blockpruefsummen. Der Unterschied ist wesentlich: Die Blockpruefsummen
// belegen, dass die Bloecke unversehrt sind; der Inhaltshash belegt, dass ihre
// **Zusammensetzung** die urspruengliche Datei ergibt. Ein Fehler in der
// Reihenfolge fiele nur hier auf.
func (tester *RestoreTester) compareRestoredFiles(compareContext context.Context, backupManifest *repository.Manifest, testDirectory string, pathPrefix string, report *RestoreTestReport) {
mismatchedPaths := make([]string, 0, maximumNamedObjects)
missingPaths := make([]string, 0, maximumNamedObjects)
for _, manifestEntry := range backupManifest.Entries {
if compareContext.Err() != nil {
report.Findings = append(report.Findings, Finding{
Code: "TEST_CANCELLED",
Severity: SeverityWarning,
Message: "Der Test wurde abgebrochen und ist damit ohne Aussage.",
})
return
}
if manifestEntry.EntryType != "file" {
continue
}
if !matchesPrefix(manifestEntry.Path, pathPrefix) {
continue
}
// Ohne Inhaltshash im Manifest laesst sich nichts vergleichen. Das ist
// keine Beanstandung, aber eine Luecke im Nachweis.
if manifestEntry.ContentHash == "" {
continue
}
// Die Wiederherstellung behaelt den vollen Pfad unterhalb des Ziels —
// auch bei einem Teilbaum. Den Praefix abzuschneiden waere eine
// Annahme ueber das Verhalten des Restores statt einer Beobachtung.
restoredPath := filepath.Join(testDirectory, filepath.FromSlash(manifestEntry.Path))
actualHash, hashError := hashFileContent(restoredPath)
if hashError != nil {
report.ChunksMissing++
if len(missingPaths) < maximumNamedObjects {
missingPaths = append(missingPaths, manifestEntry.Path)
}
continue
}
report.FilesCompared++
if actualHash != manifestEntry.ContentHash {
report.FilesMismatched++
if len(mismatchedPaths) < maximumNamedObjects {
mismatchedPaths = append(mismatchedPaths, manifestEntry.Path)
}
}
}
if len(missingPaths) > 0 || report.ChunksMissing > 0 {
report.Findings = append(report.Findings, Finding{
Code: "RESTORED_FILE_MISSING",
Severity: SeverityMissing,
Message: fmt.Sprintf("%d Dateien fehlen nach der Wiederherstellung, obwohl sie im Manifest "+
"stehen.", report.ChunksMissing),
Detail: formatAffectedPaths(missingPaths, report.ChunksMissing),
})
}
if report.FilesMismatched > 0 {
report.Findings = append(report.Findings, Finding{
Code: "CONTENT_MISMATCH",
Severity: SeverityCorruption,
Message: fmt.Sprintf("%d von %d Dateien weichen nach der Wiederherstellung von ihrem "+
"urspruenglichen Inhalt ab. Das Backup ist nicht vertrauenswuerdig.",
report.FilesMismatched, report.FilesCompared),
Detail: formatAffectedPaths(mismatchedPaths, report.FilesMismatched),
})
}
if report.FilesCompared == 0 && report.FilesRestored > 0 {
// Wiederhergestellt, aber nichts vergleichbar: Der Test lief durch,
// belegt aber nichts.
report.Findings = append(report.Findings, Finding{
Code: "NO_CONTENT_HASHES",
Severity: SeverityWarning,
Message: "Das Manifest enthaelt keine Inhaltspruefsummen. Die Wiederherstellung lief durch, " +
"ihr Ergebnis liess sich aber nicht gegen das Original vergleichen.",
})
}
if report.WasPartialTest {
report.Findings = append(report.Findings, Finding{
Code: "PARTIAL_TEST",
Severity: SeverityInformation,
Message: fmt.Sprintf("Geprueft wurde nur der Teilbaum %q. Ein Teiltest belegt nicht die "+
"Wiederherstellbarkeit des gesamten Backups.", report.TestedPathPrefix),
})
}
}
// matchesPrefix prueft, ob ein Pfad in einem Teilbaum liegt.
func matchesPrefix(entryPath string, pathPrefix string) bool {
if pathPrefix == "" {
return true
}
trimmedPrefix := trimSlashes(pathPrefix)
return entryPath == trimmedPrefix || hasPathPrefix(entryPath, trimmedPrefix)
}
// hasPathPrefix prueft einen Praefix an der Verzeichnisgrenze.
//
// „dokumente" darf nicht auch „dokumentation" treffen.
func hasPathPrefix(entryPath string, pathPrefix string) bool {
return len(entryPath) > len(pathPrefix) &&
entryPath[:len(pathPrefix)] == pathPrefix &&
entryPath[len(pathPrefix)] == '/'
}
// trimSlashes entfernt fuehrende und abschliessende Schraegstriche.
func trimSlashes(pathValue string) string {
startIndex := 0
endIndex := len(pathValue)
for startIndex < endIndex && pathValue[startIndex] == '/' {
startIndex++
}
for endIndex > startIndex && pathValue[endIndex-1] == '/' {
endIndex--
}
return pathValue[startIndex:endIndex]
}
// hashFileContent berechnet die Pruefsumme einer Datei.
func hashFileContent(filePath string) (string, error) {
openedFile, openError := os.Open(filePath)
if openError != nil {
return "", openError
}
defer func() { _ = openedFile.Close() }()
contentDigest := sha256.New()
if _, copyError := io.Copy(contentDigest, openedFile); copyError != nil {
return "", copyError
}
return hex.EncodeToString(contentDigest.Sum(nil)), nil
}