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>
360 lines
12 KiB
Go
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
|
|
}
|